From 20f789af30c5820b12ae63b5de403b201943bf47 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:15:13 +0000 Subject: [PATCH 01/11] chore: use ^0.8.27 caret pragma and bump solc to 0.8.34 Normalize all Solidity pragmas to caret form (^0.8.27) for forward compatibility, and bump the compiler version from 0.8.33 to 0.8.34 for the issuance and subgraph-service packages. Archive the now- obsolete CompilerUpgrade0833.md doc. --- docs/{ => archive}/CompilerUpgrade0833.md | 0 packages/contracts/contracts/governance/Controller.sol | 2 +- packages/contracts/contracts/governance/Governed.sol | 2 +- packages/contracts/contracts/governance/Pausable.sol | 2 +- packages/contracts/contracts/rewards/RewardsManager.sol | 2 +- .../contracts/contracts/rewards/RewardsManagerStorage.sol | 2 +- packages/contracts/contracts/tests/MockERC165.sol | 2 +- packages/contracts/contracts/tests/MockIssuanceAllocator.sol | 2 +- .../contracts/tests/MockRewardsEligibilityOracle.sol | 2 +- packages/contracts/contracts/tests/MockSubgraphService.sol | 2 +- packages/contracts/contracts/upgrades/GraphProxy.sol | 2 +- packages/contracts/contracts/upgrades/GraphProxyAdmin.sol | 2 +- packages/contracts/contracts/upgrades/GraphProxyStorage.sol | 2 +- packages/contracts/contracts/upgrades/GraphUpgradeable.sol | 2 +- packages/contracts/contracts/utils/TokenUtils.sol | 2 +- packages/deployment/lib/issuance-deploy-utils.ts | 4 ++-- packages/horizon/contracts/data-service/DataService.sol | 2 +- .../horizon/contracts/data-service/DataServiceStorage.sol | 2 +- .../contracts/data-service/extensions/DataServiceFees.sol | 2 +- .../data-service/extensions/DataServiceFeesStorage.sol | 2 +- .../contracts/data-service/extensions/DataServicePausable.sol | 2 +- .../extensions/DataServicePausableUpgradeable.sol | 2 +- .../contracts/data-service/libraries/ProvisionTracker.sol | 2 +- .../contracts/data-service/utilities/ProvisionManager.sol | 2 +- .../data-service/utilities/ProvisionManagerStorage.sol | 2 +- packages/horizon/contracts/libraries/LibFixedMath.sol | 2 +- packages/horizon/contracts/libraries/LinkedList.sol | 2 +- packages/horizon/contracts/libraries/MathUtils.sol | 2 +- packages/horizon/contracts/libraries/PPMMath.sol | 2 +- packages/horizon/contracts/libraries/UintRange.sol | 2 +- packages/horizon/contracts/payments/GraphPayments.sol | 2 +- packages/horizon/contracts/payments/PaymentsEscrow.sol | 2 +- .../contracts/payments/collectors/GraphTallyCollector.sol | 2 +- packages/horizon/contracts/staking/HorizonStaking.sol | 2 +- packages/horizon/contracts/staking/HorizonStakingBase.sol | 2 +- .../horizon/contracts/staking/HorizonStakingExtension.sol | 2 +- packages/horizon/contracts/staking/HorizonStakingStorage.sol | 2 +- .../contracts/staking/libraries/ExponentialRebates.sol | 2 +- packages/horizon/contracts/staking/utilities/Managed.sol | 2 +- packages/horizon/contracts/utilities/Authorizable.sol | 2 +- packages/horizon/contracts/utilities/GraphDirectory.sol | 2 +- packages/horizon/test/unit/GraphBase.t.sol | 2 +- packages/horizon/test/unit/data-service/DataService.t.sol | 2 +- .../test/unit/data-service/DataServiceUpgradeable.t.sol | 2 +- .../test/unit/data-service/extensions/DataServiceFees.t.sol | 2 +- .../unit/data-service/extensions/DataServicePausable.t.sol | 2 +- .../extensions/DataServicePausableUpgradeable.t.sol | 2 +- .../unit/data-service/implementations/DataServiceBase.sol | 2 +- .../implementations/DataServiceBaseUpgradeable.sol | 2 +- .../unit/data-service/implementations/DataServiceImpFees.sol | 2 +- .../data-service/implementations/DataServiceImpPausable.sol | 2 +- .../implementations/DataServiceImpPausableUpgradeable.sol | 2 +- .../unit/data-service/implementations/DataServiceOverride.sol | 2 +- .../test/unit/data-service/libraries/ProvisionTracker.t.sol | 2 +- .../data-service/libraries/ProvisionTrackerImplementation.sol | 2 +- packages/horizon/test/unit/escrow/GraphEscrow.t.sol | 2 +- packages/horizon/test/unit/escrow/collect.t.sol | 2 +- packages/horizon/test/unit/escrow/deposit.t.sol | 2 +- packages/horizon/test/unit/escrow/getters.t.sol | 2 +- packages/horizon/test/unit/escrow/paused.t.sol | 2 +- packages/horizon/test/unit/escrow/thaw.t.sol | 2 +- packages/horizon/test/unit/escrow/withdraw.t.sol | 2 +- packages/horizon/test/unit/libraries/LinkedList.t.sol | 2 +- packages/horizon/test/unit/libraries/ListImplementation.sol | 2 +- packages/horizon/test/unit/libraries/PPMMath.t.sol | 2 +- packages/horizon/test/unit/payments/GraphPayments.t.sol | 2 +- .../payments/graph-tally-collector/GraphTallyCollector.t.sol | 2 +- .../unit/payments/graph-tally-collector/collect/collect.t.sol | 2 +- .../graph-tally-collector/signer/authorizeSigner.t.sol | 2 +- .../graph-tally-collector/signer/cancelThawSigner.t.sol | 2 +- .../payments/graph-tally-collector/signer/revokeSigner.t.sol | 2 +- .../payments/graph-tally-collector/signer/thawSigner.t.sol | 2 +- .../unit/shared/horizon-staking/HorizonStakingShared.t.sol | 2 +- .../unit/shared/payments-escrow/PaymentsEscrowShared.t.sol | 2 +- packages/horizon/test/unit/staking/HorizonStaking.t.sol | 2 +- .../horizon/test/unit/staking/allocation/allocation.t.sol | 2 +- packages/horizon/test/unit/staking/allocation/close.t.sol | 2 +- packages/horizon/test/unit/staking/allocation/collect.t.sol | 2 +- packages/horizon/test/unit/staking/delegation/addToPool.t.sol | 2 +- packages/horizon/test/unit/staking/delegation/delegate.t.sol | 2 +- .../horizon/test/unit/staking/delegation/legacyWithdraw.t.sol | 2 +- .../horizon/test/unit/staking/delegation/redelegate.t.sol | 2 +- .../horizon/test/unit/staking/delegation/undelegate.t.sol | 2 +- packages/horizon/test/unit/staking/delegation/withdraw.t.sol | 2 +- .../horizon/test/unit/staking/governance/governance.t.sol | 2 +- packages/horizon/test/unit/staking/operator/locked.t.sol | 2 +- packages/horizon/test/unit/staking/operator/operator.t.sol | 2 +- .../horizon/test/unit/staking/provision/deprovision.t.sol | 2 +- packages/horizon/test/unit/staking/provision/locked.t.sol | 2 +- packages/horizon/test/unit/staking/provision/parameters.t.sol | 2 +- packages/horizon/test/unit/staking/provision/provision.t.sol | 2 +- .../horizon/test/unit/staking/provision/reprovision.t.sol | 2 +- packages/horizon/test/unit/staking/provision/thaw.t.sol | 2 +- .../test/unit/staking/serviceProvider/serviceProvider.t.sol | 2 +- packages/horizon/test/unit/staking/slash/legacySlash.t.sol | 2 +- packages/horizon/test/unit/staking/slash/slash.t.sol | 2 +- packages/horizon/test/unit/staking/stake/stake.t.sol | 2 +- packages/horizon/test/unit/staking/stake/unstake.t.sol | 2 +- packages/horizon/test/unit/staking/stake/withdraw.t.sol | 2 +- packages/horizon/test/unit/utilities/Authorizable.t.sol | 2 +- packages/horizon/test/unit/utilities/GraphDirectory.t.sol | 2 +- .../test/unit/utilities/GraphDirectoryImplementation.sol | 2 +- packages/horizon/test/unit/utils/Bounder.t.sol | 2 +- packages/horizon/test/unit/utils/Constants.sol | 2 +- packages/horizon/test/unit/utils/Users.sol | 2 +- packages/horizon/test/unit/utils/Utils.sol | 2 +- packages/issuance/contracts/allocate/DirectAllocation.sol | 2 +- packages/issuance/contracts/allocate/IssuanceAllocator.sol | 2 +- packages/issuance/contracts/common/BaseUpgradeable.sol | 2 +- .../contracts/eligibility/RewardsEligibilityOracle.sol | 2 +- .../contracts/test/allocate/IssuanceAllocatorTestHarness.sol | 2 +- packages/issuance/foundry.toml | 2 +- packages/issuance/hardhat.base.config.ts | 2 +- packages/subgraph-service/contracts/DisputeManager.sol | 2 +- packages/subgraph-service/contracts/DisputeManagerStorage.sol | 2 +- packages/subgraph-service/contracts/SubgraphService.sol | 2 +- .../subgraph-service/contracts/SubgraphServiceStorage.sol | 2 +- packages/subgraph-service/contracts/libraries/Allocation.sol | 2 +- packages/subgraph-service/contracts/libraries/Attestation.sol | 2 +- .../subgraph-service/contracts/libraries/LegacyAllocation.sol | 2 +- .../contracts/utilities/AllocationManager.sol | 2 +- .../contracts/utilities/AllocationManagerStorage.sol | 2 +- .../contracts/utilities/AttestationManager.sol | 2 +- .../contracts/utilities/AttestationManagerStorage.sol | 2 +- packages/subgraph-service/contracts/utilities/Directory.sol | 2 +- packages/subgraph-service/hardhat.config.ts | 2 +- 126 files changed, 126 insertions(+), 126 deletions(-) rename docs/{ => archive}/CompilerUpgrade0833.md (100%) diff --git a/docs/CompilerUpgrade0833.md b/docs/archive/CompilerUpgrade0833.md similarity index 100% rename from docs/CompilerUpgrade0833.md rename to docs/archive/CompilerUpgrade0833.md diff --git a/packages/contracts/contracts/governance/Controller.sol b/packages/contracts/contracts/governance/Controller.sol index 3f289ca7d..af9c78bd8 100644 --- a/packages/contracts/contracts/governance/Controller.sol +++ b/packages/contracts/contracts/governance/Controller.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.7.6 || 0.8.27 || 0.8.33; +pragma solidity ^0.7.6 || ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-indexed-events, gas-small-strings diff --git a/packages/contracts/contracts/governance/Governed.sol b/packages/contracts/contracts/governance/Governed.sol index d20df43a2..6a31cffea 100644 --- a/packages/contracts/contracts/governance/Governed.sol +++ b/packages/contracts/contracts/governance/Governed.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.7.6 || 0.8.27 || 0.8.33; +pragma solidity ^0.7.6 || ^0.8.27; /* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6 diff --git a/packages/contracts/contracts/governance/Pausable.sol b/packages/contracts/contracts/governance/Pausable.sol index d7a1824f2..8f5614231 100644 --- a/packages/contracts/contracts/governance/Pausable.sol +++ b/packages/contracts/contracts/governance/Pausable.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.7.6 || 0.8.27 || 0.8.33; +pragma solidity ^0.7.6 || ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-indexed-events diff --git a/packages/contracts/contracts/rewards/RewardsManager.sol b/packages/contracts/contracts/rewards/RewardsManager.sol index 0b223429c..ffae7877b 100644 --- a/packages/contracts/contracts/rewards/RewardsManager.sol +++ b/packages/contracts/contracts/rewards/RewardsManager.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.7.6; +pragma solidity ^0.7.6; pragma abicoder v2; import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; diff --git a/packages/contracts/contracts/rewards/RewardsManagerStorage.sol b/packages/contracts/contracts/rewards/RewardsManagerStorage.sol index 14a8061b0..6e8606b2b 100644 --- a/packages/contracts/contracts/rewards/RewardsManagerStorage.sol +++ b/packages/contracts/contracts/rewards/RewardsManagerStorage.sol @@ -5,7 +5,7 @@ // TODO: Re-enable and fix issues when publishing a new version // solhint-disable named-parameters-mapping -pragma solidity ^0.7.6 || 0.8.27 || 0.8.33; +pragma solidity ^0.7.6 || ^0.8.27; import { IIssuanceAllocationDistribution } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol"; import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol"; diff --git a/packages/contracts/contracts/tests/MockERC165.sol b/packages/contracts/contracts/tests/MockERC165.sol index 056493fd3..446c752a7 100644 --- a/packages/contracts/contracts/tests/MockERC165.sol +++ b/packages/contracts/contracts/tests/MockERC165.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.7.6; +pragma solidity ^0.7.6; import { IERC165 } from "@openzeppelin/contracts/introspection/IERC165.sol"; diff --git a/packages/contracts/contracts/tests/MockIssuanceAllocator.sol b/packages/contracts/contracts/tests/MockIssuanceAllocator.sol index 6113b8bc0..24e482a55 100644 --- a/packages/contracts/contracts/tests/MockIssuanceAllocator.sol +++ b/packages/contracts/contracts/tests/MockIssuanceAllocator.sol @@ -2,7 +2,7 @@ // solhint-disable gas-increment-by-one, gas-indexed-events, named-parameters-mapping, use-natspec -pragma solidity 0.7.6; +pragma solidity ^0.7.6; pragma abicoder v2; import { IERC165 } from "@openzeppelin/contracts/introspection/IERC165.sol"; diff --git a/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol b/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol index 6b13d4d76..03d26d9e6 100644 --- a/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol +++ b/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol @@ -2,7 +2,7 @@ // solhint-disable named-parameters-mapping -pragma solidity 0.7.6; +pragma solidity ^0.7.6; import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol"; import { IERC165 } from "@openzeppelin/contracts/introspection/IERC165.sol"; diff --git a/packages/contracts/contracts/tests/MockSubgraphService.sol b/packages/contracts/contracts/tests/MockSubgraphService.sol index cdee9ab6a..1e355923b 100644 --- a/packages/contracts/contracts/tests/MockSubgraphService.sol +++ b/packages/contracts/contracts/tests/MockSubgraphService.sol @@ -2,7 +2,7 @@ // solhint-disable named-parameters-mapping -pragma solidity 0.7.6; +pragma solidity ^0.7.6; import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol"; diff --git a/packages/contracts/contracts/upgrades/GraphProxy.sol b/packages/contracts/contracts/upgrades/GraphProxy.sol index 65216a4d7..624c3a650 100644 --- a/packages/contracts/contracts/upgrades/GraphProxy.sol +++ b/packages/contracts/contracts/upgrades/GraphProxy.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.7.6 || 0.8.27 || 0.8.33; +pragma solidity ^0.7.6 || ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-small-strings diff --git a/packages/contracts/contracts/upgrades/GraphProxyAdmin.sol b/packages/contracts/contracts/upgrades/GraphProxyAdmin.sol index e72bf3626..e603a6a50 100644 --- a/packages/contracts/contracts/upgrades/GraphProxyAdmin.sol +++ b/packages/contracts/contracts/upgrades/GraphProxyAdmin.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.7.6 || 0.8.27 || 0.8.33; +pragma solidity ^0.7.6 || ^0.8.27; /* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6 diff --git a/packages/contracts/contracts/upgrades/GraphProxyStorage.sol b/packages/contracts/contracts/upgrades/GraphProxyStorage.sol index 4c3d2e4de..d550d18f0 100644 --- a/packages/contracts/contracts/upgrades/GraphProxyStorage.sol +++ b/packages/contracts/contracts/upgrades/GraphProxyStorage.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.7.6 || 0.8.27 || 0.8.33; +pragma solidity ^0.7.6 || ^0.8.27; /* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6 diff --git a/packages/contracts/contracts/upgrades/GraphUpgradeable.sol b/packages/contracts/contracts/upgrades/GraphUpgradeable.sol index 466084fba..a6cc7b8c6 100644 --- a/packages/contracts/contracts/upgrades/GraphUpgradeable.sol +++ b/packages/contracts/contracts/upgrades/GraphUpgradeable.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.7.6 || 0.8.27 || 0.8.33; +pragma solidity ^0.7.6 || ^0.8.27; /* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6 diff --git a/packages/contracts/contracts/utils/TokenUtils.sol b/packages/contracts/contracts/utils/TokenUtils.sol index 10c244e26..f4c0f58f5 100644 --- a/packages/contracts/contracts/utils/TokenUtils.sol +++ b/packages/contracts/contracts/utils/TokenUtils.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.7.6 || 0.8.27 || 0.8.33; +pragma solidity ^0.7.6 || ^0.8.27; /* solhint-disable gas-custom-errors */ // Cannot use custom errors with 0.7.6 diff --git a/packages/deployment/lib/issuance-deploy-utils.ts b/packages/deployment/lib/issuance-deploy-utils.ts index 4cf41496b..bd1b5f486 100644 --- a/packages/deployment/lib/issuance-deploy-utils.ts +++ b/packages/deployment/lib/issuance-deploy-utils.ts @@ -358,7 +358,7 @@ async function deployProxyWithOwnImpl( // Deploy OZ v5 TransparentUpgradeableProxy // Constructor: (address _logic, address initialOwner, bytes memory _data) // The proxy creates its own ProxyAdmin owned by initialOwner (governor) - // Use issuance-compiled proxy artifact (0.8.33) for consistent verification + // Use issuance-compiled proxy artifact (0.8.34) for consistent verification const proxyArtifact = loadTransparentProxyArtifact() const proxyResult = await deployFn( `${contract.name}_Proxy`, @@ -447,7 +447,7 @@ async function deployProxyWithSharedImpl( // Deploy OZ v5 TransparentUpgradeableProxy // Constructor: (address _logic, address initialOwner, bytes memory _data) - // Use issuance-compiled proxy artifact (0.8.33) for consistent verification + // Use issuance-compiled proxy artifact (0.8.34) for consistent verification const proxyArtifact = loadTransparentProxyArtifact() const proxyResult = await deployFn( `${contract.name}_Proxy`, diff --git a/packages/horizon/contracts/data-service/DataService.sol b/packages/horizon/contracts/data-service/DataService.sol index 8206f4924..ccdec7151 100644 --- a/packages/horizon/contracts/data-service/DataService.sol +++ b/packages/horizon/contracts/data-service/DataService.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; import { IDataService } from "@graphprotocol/interfaces/contracts/data-service/IDataService.sol"; diff --git a/packages/horizon/contracts/data-service/DataServiceStorage.sol b/packages/horizon/contracts/data-service/DataServiceStorage.sol index 3ce552a7f..4ce5a7f20 100644 --- a/packages/horizon/contracts/data-service/DataServiceStorage.sol +++ b/packages/horizon/contracts/data-service/DataServiceStorage.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; /** * @title DataServiceStorage diff --git a/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol b/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol index 0f8cf3653..fd2bbd57f 100644 --- a/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol +++ b/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; import { IDataServiceFees } from "@graphprotocol/interfaces/contracts/data-service/IDataServiceFees.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; diff --git a/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol b/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol index 384149201..b9a5253b6 100644 --- a/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol +++ b/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; import { IDataServiceFees } from "@graphprotocol/interfaces/contracts/data-service/IDataServiceFees.sol"; diff --git a/packages/horizon/contracts/data-service/extensions/DataServicePausable.sol b/packages/horizon/contracts/data-service/extensions/DataServicePausable.sol index 7d0c8c522..8eed40165 100644 --- a/packages/horizon/contracts/data-service/extensions/DataServicePausable.sol +++ b/packages/horizon/contracts/data-service/extensions/DataServicePausable.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IDataServicePausable } from "@graphprotocol/interfaces/contracts/data-service/IDataServicePausable.sol"; diff --git a/packages/horizon/contracts/data-service/extensions/DataServicePausableUpgradeable.sol b/packages/horizon/contracts/data-service/extensions/DataServicePausableUpgradeable.sol index 6dc2433ce..4770a9375 100644 --- a/packages/horizon/contracts/data-service/extensions/DataServicePausableUpgradeable.sol +++ b/packages/horizon/contracts/data-service/extensions/DataServicePausableUpgradeable.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; import { IDataServicePausable } from "@graphprotocol/interfaces/contracts/data-service/IDataServicePausable.sol"; diff --git a/packages/horizon/contracts/data-service/libraries/ProvisionTracker.sol b/packages/horizon/contracts/data-service/libraries/ProvisionTracker.sol index 8f7ddff8d..d52bf13ad 100644 --- a/packages/horizon/contracts/data-service/libraries/ProvisionTracker.sol +++ b/packages/horizon/contracts/data-service/libraries/ProvisionTracker.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-strict-inequalities diff --git a/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol b/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol index ec0be49c3..bdfae747a 100644 --- a/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol +++ b/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-indexed-events diff --git a/packages/horizon/contracts/data-service/utilities/ProvisionManagerStorage.sol b/packages/horizon/contracts/data-service/utilities/ProvisionManagerStorage.sol index 02631d866..dbfe94cc8 100644 --- a/packages/horizon/contracts/data-service/utilities/ProvisionManagerStorage.sol +++ b/packages/horizon/contracts/data-service/utilities/ProvisionManagerStorage.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; /** * @title Storage layout for the {ProvisionManager} helper contract. diff --git a/packages/horizon/contracts/libraries/LibFixedMath.sol b/packages/horizon/contracts/libraries/LibFixedMath.sol index f248a513d..4b31d1ef3 100644 --- a/packages/horizon/contracts/libraries/LibFixedMath.sol +++ b/packages/horizon/contracts/libraries/LibFixedMath.sol @@ -18,7 +18,7 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable function-max-lines, gas-strict-inequalities diff --git a/packages/horizon/contracts/libraries/LinkedList.sol b/packages/horizon/contracts/libraries/LinkedList.sol index 24e5610a0..893ea4a24 100644 --- a/packages/horizon/contracts/libraries/LinkedList.sol +++ b/packages/horizon/contracts/libraries/LinkedList.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-increment-by-one, gas-strict-inequalities diff --git a/packages/horizon/contracts/libraries/MathUtils.sol b/packages/horizon/contracts/libraries/MathUtils.sol index ec8cc8161..a1822df61 100644 --- a/packages/horizon/contracts/libraries/MathUtils.sol +++ b/packages/horizon/contracts/libraries/MathUtils.sol @@ -3,7 +3,7 @@ // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-strict-inequalities -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; /** * @title MathUtils Library diff --git a/packages/horizon/contracts/libraries/PPMMath.sol b/packages/horizon/contracts/libraries/PPMMath.sol index a3108d88b..75448a6d0 100644 --- a/packages/horizon/contracts/libraries/PPMMath.sol +++ b/packages/horizon/contracts/libraries/PPMMath.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-strict-inequalities diff --git a/packages/horizon/contracts/libraries/UintRange.sol b/packages/horizon/contracts/libraries/UintRange.sol index c96222464..3783b95ea 100644 --- a/packages/horizon/contracts/libraries/UintRange.sol +++ b/packages/horizon/contracts/libraries/UintRange.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-strict-inequalities diff --git a/packages/horizon/contracts/payments/GraphPayments.sol b/packages/horizon/contracts/payments/GraphPayments.sol index 276ce2100..ed83d4b3c 100644 --- a/packages/horizon/contracts/payments/GraphPayments.sol +++ b/packages/horizon/contracts/payments/GraphPayments.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable function-max-lines diff --git a/packages/horizon/contracts/payments/PaymentsEscrow.sol b/packages/horizon/contracts/payments/PaymentsEscrow.sol index 6af296e42..edf98627f 100644 --- a/packages/horizon/contracts/payments/PaymentsEscrow.sol +++ b/packages/horizon/contracts/payments/PaymentsEscrow.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-strict-inequalities diff --git a/packages/horizon/contracts/payments/collectors/GraphTallyCollector.sol b/packages/horizon/contracts/payments/collectors/GraphTallyCollector.sol index 9040219fc..8b8a161ee 100644 --- a/packages/horizon/contracts/payments/collectors/GraphTallyCollector.sol +++ b/packages/horizon/contracts/payments/collectors/GraphTallyCollector.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-small-strings diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index 7040ac343..f77761483 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -5,7 +5,7 @@ // solhint-disable gas-increment-by-one // solhint-disable function-max-lines -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; diff --git a/packages/horizon/contracts/staking/HorizonStakingBase.sol b/packages/horizon/contracts/staking/HorizonStakingBase.sol index 615de4994..f8ae1fa18 100644 --- a/packages/horizon/contracts/staking/HorizonStakingBase.sol +++ b/packages/horizon/contracts/staking/HorizonStakingBase.sol @@ -3,7 +3,7 @@ // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-strict-inequalities -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { IHorizonStakingBase } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol"; diff --git a/packages/horizon/contracts/staking/HorizonStakingExtension.sol b/packages/horizon/contracts/staking/HorizonStakingExtension.sol index 3258381b2..7046c0473 100644 --- a/packages/horizon/contracts/staking/HorizonStakingExtension.sol +++ b/packages/horizon/contracts/staking/HorizonStakingExtension.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable function-max-lines, gas-strict-inequalities diff --git a/packages/horizon/contracts/staking/HorizonStakingStorage.sol b/packages/horizon/contracts/staking/HorizonStakingStorage.sol index 1469d27a2..92c769a42 100644 --- a/packages/horizon/contracts/staking/HorizonStakingStorage.sol +++ b/packages/horizon/contracts/staking/HorizonStakingStorage.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // forge-lint: disable-start(mixed-case-variable) diff --git a/packages/horizon/contracts/staking/libraries/ExponentialRebates.sol b/packages/horizon/contracts/staking/libraries/ExponentialRebates.sol index 9e2544533..e06706139 100644 --- a/packages/horizon/contracts/staking/libraries/ExponentialRebates.sol +++ b/packages/horizon/contracts/staking/libraries/ExponentialRebates.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // forge-lint: disable-start(unsafe-typecast) diff --git a/packages/horizon/contracts/staking/utilities/Managed.sol b/packages/horizon/contracts/staking/utilities/Managed.sol index 8839912f5..8efec4711 100644 --- a/packages/horizon/contracts/staking/utilities/Managed.sol +++ b/packages/horizon/contracts/staking/utilities/Managed.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; import { GraphDirectory } from "../../utilities/GraphDirectory.sol"; diff --git a/packages/horizon/contracts/utilities/Authorizable.sol b/packages/horizon/contracts/utilities/Authorizable.sol index 9cbd41672..d48d2e1a3 100644 --- a/packages/horizon/contracts/utilities/Authorizable.sol +++ b/packages/horizon/contracts/utilities/Authorizable.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-strict-inequalities diff --git a/packages/horizon/contracts/utilities/GraphDirectory.sol b/packages/horizon/contracts/utilities/GraphDirectory.sol index 0534ca3c7..2dd8cdec5 100644 --- a/packages/horizon/contracts/utilities/GraphDirectory.sol +++ b/packages/horizon/contracts/utilities/GraphDirectory.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.27 || 0.8.33; +pragma solidity ^0.8.27; import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; diff --git a/packages/horizon/test/unit/GraphBase.t.sol b/packages/horizon/test/unit/GraphBase.t.sol index 7fa450295..4aa5b66f1 100644 --- a/packages/horizon/test/unit/GraphBase.t.sol +++ b/packages/horizon/test/unit/GraphBase.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; import { GraphProxyAdmin } from "@graphprotocol/contracts/contracts/upgrades/GraphProxyAdmin.sol"; diff --git a/packages/horizon/test/unit/data-service/DataService.t.sol b/packages/horizon/test/unit/data-service/DataService.t.sol index 209362767..a7fb52d58 100644 --- a/packages/horizon/test/unit/data-service/DataService.t.sol +++ b/packages/horizon/test/unit/data-service/DataService.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; import { HorizonStakingSharedTest } from "../shared/horizon-staking/HorizonStakingShared.t.sol"; diff --git a/packages/horizon/test/unit/data-service/DataServiceUpgradeable.t.sol b/packages/horizon/test/unit/data-service/DataServiceUpgradeable.t.sol index a4501242b..ac2be13ea 100644 --- a/packages/horizon/test/unit/data-service/DataServiceUpgradeable.t.sol +++ b/packages/horizon/test/unit/data-service/DataServiceUpgradeable.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { GraphBaseTest } from "../GraphBase.t.sol"; import { DataServiceBaseUpgradeable } from "./implementations/DataServiceBaseUpgradeable.sol"; diff --git a/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol b/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol index a2ae10653..28f74003f 100644 --- a/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol +++ b/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingSharedTest } from "../../shared/horizon-staking/HorizonStakingShared.t.sol"; import { DataServiceImpFees } from "../implementations/DataServiceImpFees.sol"; diff --git a/packages/horizon/test/unit/data-service/extensions/DataServicePausable.t.sol b/packages/horizon/test/unit/data-service/extensions/DataServicePausable.t.sol index 47912797b..97c6bb100 100644 --- a/packages/horizon/test/unit/data-service/extensions/DataServicePausable.t.sol +++ b/packages/horizon/test/unit/data-service/extensions/DataServicePausable.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingSharedTest } from "../../shared/horizon-staking/HorizonStakingShared.t.sol"; import { DataServiceImpPausable } from "../implementations/DataServiceImpPausable.sol"; diff --git a/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol b/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol index d5413ed5b..f85569151 100644 --- a/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol +++ b/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { GraphBaseTest } from "../../GraphBase.t.sol"; import { DataServiceImpPausableUpgradeable } from "../implementations/DataServiceImpPausableUpgradeable.sol"; diff --git a/packages/horizon/test/unit/data-service/implementations/DataServiceBase.sol b/packages/horizon/test/unit/data-service/implementations/DataServiceBase.sol index b58bbc5e0..d5286be57 100644 --- a/packages/horizon/test/unit/data-service/implementations/DataServiceBase.sol +++ b/packages/horizon/test/unit/data-service/implementations/DataServiceBase.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { DataService } from "../../../../contracts/data-service/DataService.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; diff --git a/packages/horizon/test/unit/data-service/implementations/DataServiceBaseUpgradeable.sol b/packages/horizon/test/unit/data-service/implementations/DataServiceBaseUpgradeable.sol index d328089f9..b0057e941 100644 --- a/packages/horizon/test/unit/data-service/implementations/DataServiceBaseUpgradeable.sol +++ b/packages/horizon/test/unit/data-service/implementations/DataServiceBaseUpgradeable.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { DataService } from "../../../../contracts/data-service/DataService.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; diff --git a/packages/horizon/test/unit/data-service/implementations/DataServiceImpFees.sol b/packages/horizon/test/unit/data-service/implementations/DataServiceImpFees.sol index 85c51465f..85fc23b25 100644 --- a/packages/horizon/test/unit/data-service/implementations/DataServiceImpFees.sol +++ b/packages/horizon/test/unit/data-service/implementations/DataServiceImpFees.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { DataService } from "../../../../contracts/data-service/DataService.sol"; import { DataServiceFees } from "../../../../contracts/data-service/extensions/DataServiceFees.sol"; diff --git a/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausable.sol b/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausable.sol index bba7de566..9f15584d5 100644 --- a/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausable.sol +++ b/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausable.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { DataService } from "../../../../contracts/data-service/DataService.sol"; import { DataServicePausable } from "../../../../contracts/data-service/extensions/DataServicePausable.sol"; diff --git a/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol b/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol index 71453fd19..32fb97b22 100644 --- a/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol +++ b/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { DataService } from "../../../../contracts/data-service/DataService.sol"; import { DataServicePausableUpgradeable } from "../../../../contracts/data-service/extensions/DataServicePausableUpgradeable.sol"; diff --git a/packages/horizon/test/unit/data-service/implementations/DataServiceOverride.sol b/packages/horizon/test/unit/data-service/implementations/DataServiceOverride.sol index c5d50ca74..6af527271 100644 --- a/packages/horizon/test/unit/data-service/implementations/DataServiceOverride.sol +++ b/packages/horizon/test/unit/data-service/implementations/DataServiceOverride.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { DataServiceBase } from "./DataServiceBase.sol"; diff --git a/packages/horizon/test/unit/data-service/libraries/ProvisionTracker.t.sol b/packages/horizon/test/unit/data-service/libraries/ProvisionTracker.t.sol index d3424dfc5..d56d770b0 100644 --- a/packages/horizon/test/unit/data-service/libraries/ProvisionTracker.t.sol +++ b/packages/horizon/test/unit/data-service/libraries/ProvisionTracker.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingSharedTest } from "../../shared/horizon-staking/HorizonStakingShared.t.sol"; import { ProvisionTrackerImplementation } from "./ProvisionTrackerImplementation.sol"; diff --git a/packages/horizon/test/unit/data-service/libraries/ProvisionTrackerImplementation.sol b/packages/horizon/test/unit/data-service/libraries/ProvisionTrackerImplementation.sol index abb525b91..7722df836 100644 --- a/packages/horizon/test/unit/data-service/libraries/ProvisionTrackerImplementation.sol +++ b/packages/horizon/test/unit/data-service/libraries/ProvisionTrackerImplementation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; contract ProvisionTrackerImplementation { mapping(address => uint256) public provisionTracker; diff --git a/packages/horizon/test/unit/escrow/GraphEscrow.t.sol b/packages/horizon/test/unit/escrow/GraphEscrow.t.sol index a0c3fbad1..3f88b468c 100644 --- a/packages/horizon/test/unit/escrow/GraphEscrow.t.sol +++ b/packages/horizon/test/unit/escrow/GraphEscrow.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; diff --git a/packages/horizon/test/unit/escrow/collect.t.sol b/packages/horizon/test/unit/escrow/collect.t.sol index bbd35922c..9d229e1ab 100644 --- a/packages/horizon/test/unit/escrow/collect.t.sol +++ b/packages/horizon/test/unit/escrow/collect.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; diff --git a/packages/horizon/test/unit/escrow/deposit.t.sol b/packages/horizon/test/unit/escrow/deposit.t.sol index 3f7c254c0..0f1fe450e 100644 --- a/packages/horizon/test/unit/escrow/deposit.t.sol +++ b/packages/horizon/test/unit/escrow/deposit.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { GraphEscrowTest } from "./GraphEscrow.t.sol"; diff --git a/packages/horizon/test/unit/escrow/getters.t.sol b/packages/horizon/test/unit/escrow/getters.t.sol index 23f700036..770b8b7c3 100644 --- a/packages/horizon/test/unit/escrow/getters.t.sol +++ b/packages/horizon/test/unit/escrow/getters.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; diff --git a/packages/horizon/test/unit/escrow/paused.t.sol b/packages/horizon/test/unit/escrow/paused.t.sol index ea3fce631..010268c80 100644 --- a/packages/horizon/test/unit/escrow/paused.t.sol +++ b/packages/horizon/test/unit/escrow/paused.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; diff --git a/packages/horizon/test/unit/escrow/thaw.t.sol b/packages/horizon/test/unit/escrow/thaw.t.sol index 0b71e6d1b..ca8569176 100644 --- a/packages/horizon/test/unit/escrow/thaw.t.sol +++ b/packages/horizon/test/unit/escrow/thaw.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { GraphEscrowTest } from "./GraphEscrow.t.sol"; diff --git a/packages/horizon/test/unit/escrow/withdraw.t.sol b/packages/horizon/test/unit/escrow/withdraw.t.sol index bcc116fd1..18a000af4 100644 --- a/packages/horizon/test/unit/escrow/withdraw.t.sol +++ b/packages/horizon/test/unit/escrow/withdraw.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { GraphEscrowTest } from "./GraphEscrow.t.sol"; diff --git a/packages/horizon/test/unit/libraries/LinkedList.t.sol b/packages/horizon/test/unit/libraries/LinkedList.t.sol index bdf902edf..e55469d25 100644 --- a/packages/horizon/test/unit/libraries/LinkedList.t.sol +++ b/packages/horizon/test/unit/libraries/LinkedList.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { Test } from "forge-std/Test.sol"; import { LinkedList } from "../../../contracts/libraries/LinkedList.sol"; diff --git a/packages/horizon/test/unit/libraries/ListImplementation.sol b/packages/horizon/test/unit/libraries/ListImplementation.sol index dad859f59..72577a4d7 100644 --- a/packages/horizon/test/unit/libraries/ListImplementation.sol +++ b/packages/horizon/test/unit/libraries/ListImplementation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; import { LinkedList } from "../../../contracts/libraries/LinkedList.sol"; diff --git a/packages/horizon/test/unit/libraries/PPMMath.t.sol b/packages/horizon/test/unit/libraries/PPMMath.t.sol index c760cab06..bed8438a1 100644 --- a/packages/horizon/test/unit/libraries/PPMMath.t.sol +++ b/packages/horizon/test/unit/libraries/PPMMath.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { Test } from "forge-std/Test.sol"; import { PPMMath } from "../../../contracts/libraries/PPMMath.sol"; diff --git a/packages/horizon/test/unit/payments/GraphPayments.t.sol b/packages/horizon/test/unit/payments/GraphPayments.t.sol index 62d739ba3..d4bf17153 100644 --- a/packages/horizon/test/unit/payments/GraphPayments.t.sol +++ b/packages/horizon/test/unit/payments/GraphPayments.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; diff --git a/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol b/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol index b8e569574..bd022f1d3 100644 --- a/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol +++ b/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; import { IGraphTallyCollector } from "@graphprotocol/interfaces/contracts/horizon/IGraphTallyCollector.sol"; diff --git a/packages/horizon/test/unit/payments/graph-tally-collector/collect/collect.t.sol b/packages/horizon/test/unit/payments/graph-tally-collector/collect/collect.t.sol index 2c15a930d..e9c25d6cc 100644 --- a/packages/horizon/test/unit/payments/graph-tally-collector/collect/collect.t.sol +++ b/packages/horizon/test/unit/payments/graph-tally-collector/collect/collect.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IGraphTallyCollector } from "@graphprotocol/interfaces/contracts/horizon/IGraphTallyCollector.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; diff --git a/packages/horizon/test/unit/payments/graph-tally-collector/signer/authorizeSigner.t.sol b/packages/horizon/test/unit/payments/graph-tally-collector/signer/authorizeSigner.t.sol index cbc3f2960..948a9a1c2 100644 --- a/packages/horizon/test/unit/payments/graph-tally-collector/signer/authorizeSigner.t.sol +++ b/packages/horizon/test/unit/payments/graph-tally-collector/signer/authorizeSigner.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IAuthorizable } from "@graphprotocol/interfaces/contracts/horizon/IAuthorizable.sol"; diff --git a/packages/horizon/test/unit/payments/graph-tally-collector/signer/cancelThawSigner.t.sol b/packages/horizon/test/unit/payments/graph-tally-collector/signer/cancelThawSigner.t.sol index d117cfb95..b3b1cbeb6 100644 --- a/packages/horizon/test/unit/payments/graph-tally-collector/signer/cancelThawSigner.t.sol +++ b/packages/horizon/test/unit/payments/graph-tally-collector/signer/cancelThawSigner.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IAuthorizable } from "@graphprotocol/interfaces/contracts/horizon/IAuthorizable.sol"; diff --git a/packages/horizon/test/unit/payments/graph-tally-collector/signer/revokeSigner.t.sol b/packages/horizon/test/unit/payments/graph-tally-collector/signer/revokeSigner.t.sol index 5d987cb9c..6e6b92dfb 100644 --- a/packages/horizon/test/unit/payments/graph-tally-collector/signer/revokeSigner.t.sol +++ b/packages/horizon/test/unit/payments/graph-tally-collector/signer/revokeSigner.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IAuthorizable } from "@graphprotocol/interfaces/contracts/horizon/IAuthorizable.sol"; diff --git a/packages/horizon/test/unit/payments/graph-tally-collector/signer/thawSigner.t.sol b/packages/horizon/test/unit/payments/graph-tally-collector/signer/thawSigner.t.sol index 781551f61..bf6269ee6 100644 --- a/packages/horizon/test/unit/payments/graph-tally-collector/signer/thawSigner.t.sol +++ b/packages/horizon/test/unit/payments/graph-tally-collector/signer/thawSigner.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IAuthorizable } from "@graphprotocol/interfaces/contracts/horizon/IAuthorizable.sol"; diff --git a/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol b/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol index 27b4aeca9..5861b9f27 100644 --- a/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol +++ b/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { GraphBaseTest } from "../../GraphBase.t.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; diff --git a/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol b/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol index ca62aa02b..8e51aed9f 100644 --- a/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol +++ b/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; import { GraphBaseTest } from "../../GraphBase.t.sol"; diff --git a/packages/horizon/test/unit/staking/HorizonStaking.t.sol b/packages/horizon/test/unit/staking/HorizonStaking.t.sol index 8046723f7..256fce859 100644 --- a/packages/horizon/test/unit/staking/HorizonStaking.t.sol +++ b/packages/horizon/test/unit/staking/HorizonStaking.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { stdStorage, StdStorage } from "forge-std/Test.sol"; diff --git a/packages/horizon/test/unit/staking/allocation/allocation.t.sol b/packages/horizon/test/unit/staking/allocation/allocation.t.sol index 2b7349817..e4b0e22c3 100644 --- a/packages/horizon/test/unit/staking/allocation/allocation.t.sol +++ b/packages/horizon/test/unit/staking/allocation/allocation.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; diff --git a/packages/horizon/test/unit/staking/allocation/close.t.sol b/packages/horizon/test/unit/staking/allocation/close.t.sol index 41eddfe0f..e5d222b59 100644 --- a/packages/horizon/test/unit/staking/allocation/close.t.sol +++ b/packages/horizon/test/unit/staking/allocation/close.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; import { PPMMath } from "../../../../contracts/libraries/PPMMath.sol"; diff --git a/packages/horizon/test/unit/staking/allocation/collect.t.sol b/packages/horizon/test/unit/staking/allocation/collect.t.sol index a05c55220..20fde8e91 100644 --- a/packages/horizon/test/unit/staking/allocation/collect.t.sol +++ b/packages/horizon/test/unit/staking/allocation/collect.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { console } from "forge-std/console.sol"; diff --git a/packages/horizon/test/unit/staking/delegation/addToPool.t.sol b/packages/horizon/test/unit/staking/delegation/addToPool.t.sol index 5c61b1ffc..46a86b096 100644 --- a/packages/horizon/test/unit/staking/delegation/addToPool.t.sol +++ b/packages/horizon/test/unit/staking/delegation/addToPool.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/delegation/delegate.t.sol b/packages/horizon/test/unit/staking/delegation/delegate.t.sol index 5395a8464..2209b2dff 100644 --- a/packages/horizon/test/unit/staking/delegation/delegate.t.sol +++ b/packages/horizon/test/unit/staking/delegation/delegate.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; diff --git a/packages/horizon/test/unit/staking/delegation/legacyWithdraw.t.sol b/packages/horizon/test/unit/staking/delegation/legacyWithdraw.t.sol index 59acde904..0c5db17f5 100644 --- a/packages/horizon/test/unit/staking/delegation/legacyWithdraw.t.sol +++ b/packages/horizon/test/unit/staking/delegation/legacyWithdraw.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; diff --git a/packages/horizon/test/unit/staking/delegation/redelegate.t.sol b/packages/horizon/test/unit/staking/delegation/redelegate.t.sol index 710586785..a8cd04a59 100644 --- a/packages/horizon/test/unit/staking/delegation/redelegate.t.sol +++ b/packages/horizon/test/unit/staking/delegation/redelegate.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; diff --git a/packages/horizon/test/unit/staking/delegation/undelegate.t.sol b/packages/horizon/test/unit/staking/delegation/undelegate.t.sol index 15fa5c4c1..faa8d4f30 100644 --- a/packages/horizon/test/unit/staking/delegation/undelegate.t.sol +++ b/packages/horizon/test/unit/staking/delegation/undelegate.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; diff --git a/packages/horizon/test/unit/staking/delegation/withdraw.t.sol b/packages/horizon/test/unit/staking/delegation/withdraw.t.sol index 31155cec2..e50c2ff66 100644 --- a/packages/horizon/test/unit/staking/delegation/withdraw.t.sol +++ b/packages/horizon/test/unit/staking/delegation/withdraw.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; diff --git a/packages/horizon/test/unit/staking/governance/governance.t.sol b/packages/horizon/test/unit/staking/governance/governance.t.sol index cc2a54465..068dbee6b 100644 --- a/packages/horizon/test/unit/staking/governance/governance.t.sol +++ b/packages/horizon/test/unit/staking/governance/governance.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/operator/locked.t.sol b/packages/horizon/test/unit/staking/operator/locked.t.sol index 474407692..83f753348 100644 --- a/packages/horizon/test/unit/staking/operator/locked.t.sol +++ b/packages/horizon/test/unit/staking/operator/locked.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/operator/operator.t.sol b/packages/horizon/test/unit/staking/operator/operator.t.sol index 672269aab..b52b9c6a3 100644 --- a/packages/horizon/test/unit/staking/operator/operator.t.sol +++ b/packages/horizon/test/unit/staking/operator/operator.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/provision/deprovision.t.sol b/packages/horizon/test/unit/staking/provision/deprovision.t.sol index 51725b111..c37410b8c 100644 --- a/packages/horizon/test/unit/staking/provision/deprovision.t.sol +++ b/packages/horizon/test/unit/staking/provision/deprovision.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/provision/locked.t.sol b/packages/horizon/test/unit/staking/provision/locked.t.sol index f7f95c6ac..f48ca384d 100644 --- a/packages/horizon/test/unit/staking/provision/locked.t.sol +++ b/packages/horizon/test/unit/staking/provision/locked.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/provision/parameters.t.sol b/packages/horizon/test/unit/staking/provision/parameters.t.sol index 3c3c745de..9a723e1c3 100644 --- a/packages/horizon/test/unit/staking/provision/parameters.t.sol +++ b/packages/horizon/test/unit/staking/provision/parameters.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; diff --git a/packages/horizon/test/unit/staking/provision/provision.t.sol b/packages/horizon/test/unit/staking/provision/provision.t.sol index 5149e8cf6..7862dd60c 100644 --- a/packages/horizon/test/unit/staking/provision/provision.t.sol +++ b/packages/horizon/test/unit/staking/provision/provision.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/provision/reprovision.t.sol b/packages/horizon/test/unit/staking/provision/reprovision.t.sol index 377dfa35d..f90ae56fa 100644 --- a/packages/horizon/test/unit/staking/provision/reprovision.t.sol +++ b/packages/horizon/test/unit/staking/provision/reprovision.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/provision/thaw.t.sol b/packages/horizon/test/unit/staking/provision/thaw.t.sol index 5669189e9..6703f330c 100644 --- a/packages/horizon/test/unit/staking/provision/thaw.t.sol +++ b/packages/horizon/test/unit/staking/provision/thaw.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol b/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol index 651fd662f..84008c01f 100644 --- a/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol +++ b/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; diff --git a/packages/horizon/test/unit/staking/slash/legacySlash.t.sol b/packages/horizon/test/unit/staking/slash/legacySlash.t.sol index 4e4a9bdd3..0e1724ecb 100644 --- a/packages/horizon/test/unit/staking/slash/legacySlash.t.sol +++ b/packages/horizon/test/unit/staking/slash/legacySlash.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; diff --git a/packages/horizon/test/unit/staking/slash/slash.t.sol b/packages/horizon/test/unit/staking/slash/slash.t.sol index 4572ed93f..cba33ae8a 100644 --- a/packages/horizon/test/unit/staking/slash/slash.t.sol +++ b/packages/horizon/test/unit/staking/slash/slash.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; diff --git a/packages/horizon/test/unit/staking/stake/stake.t.sol b/packages/horizon/test/unit/staking/stake/stake.t.sol index ea1425de0..db00ad7ec 100644 --- a/packages/horizon/test/unit/staking/stake/stake.t.sol +++ b/packages/horizon/test/unit/staking/stake/stake.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/stake/unstake.t.sol b/packages/horizon/test/unit/staking/stake/unstake.t.sol index 54803cc60..98d508e2a 100644 --- a/packages/horizon/test/unit/staking/stake/unstake.t.sol +++ b/packages/horizon/test/unit/staking/stake/unstake.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/stake/withdraw.t.sol b/packages/horizon/test/unit/staking/stake/withdraw.t.sol index 2d7b89382..4cd6666b9 100644 --- a/packages/horizon/test/unit/staking/stake/withdraw.t.sol +++ b/packages/horizon/test/unit/staking/stake/withdraw.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; diff --git a/packages/horizon/test/unit/utilities/Authorizable.t.sol b/packages/horizon/test/unit/utilities/Authorizable.t.sol index 33713c436..420cd01ff 100644 --- a/packages/horizon/test/unit/utilities/Authorizable.t.sol +++ b/packages/horizon/test/unit/utilities/Authorizable.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { Test } from "forge-std/Test.sol"; diff --git a/packages/horizon/test/unit/utilities/GraphDirectory.t.sol b/packages/horizon/test/unit/utilities/GraphDirectory.t.sol index 2eea04b73..a0b22f6bb 100644 --- a/packages/horizon/test/unit/utilities/GraphDirectory.t.sol +++ b/packages/horizon/test/unit/utilities/GraphDirectory.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { GraphBaseTest } from "../GraphBase.t.sol"; import { GraphDirectory } from "./../../../contracts/utilities/GraphDirectory.sol"; diff --git a/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol b/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol index 4a88bf0cd..80c7d231d 100644 --- a/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol +++ b/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; diff --git a/packages/horizon/test/unit/utils/Bounder.t.sol b/packages/horizon/test/unit/utils/Bounder.t.sol index 44e977f57..82ba2ff15 100644 --- a/packages/horizon/test/unit/utils/Bounder.t.sol +++ b/packages/horizon/test/unit/utils/Bounder.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { Test } from "forge-std/Test.sol"; diff --git a/packages/horizon/test/unit/utils/Constants.sol b/packages/horizon/test/unit/utils/Constants.sol index 51b882118..036ca43a2 100644 --- a/packages/horizon/test/unit/utils/Constants.sol +++ b/packages/horizon/test/unit/utils/Constants.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; abstract contract Constants { uint32 internal constant MAX_PPM = 1000000; // 100% in parts per million diff --git a/packages/horizon/test/unit/utils/Users.sol b/packages/horizon/test/unit/utils/Users.sol index 6213e4e82..56f67396f 100644 --- a/packages/horizon/test/unit/utils/Users.sol +++ b/packages/horizon/test/unit/utils/Users.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; struct Users { address governor; diff --git a/packages/horizon/test/unit/utils/Utils.sol b/packages/horizon/test/unit/utils/Utils.sol index 741c7367f..45da9df8c 100644 --- a/packages/horizon/test/unit/utils/Utils.sol +++ b/packages/horizon/test/unit/utils/Utils.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity ^0.8.27; import { Test } from "forge-std/Test.sol"; diff --git a/packages/issuance/contracts/allocate/DirectAllocation.sol b/packages/issuance/contracts/allocate/DirectAllocation.sol index 4c048acf2..799755256 100644 --- a/packages/issuance/contracts/allocate/DirectAllocation.sol +++ b/packages/issuance/contracts/allocate/DirectAllocation.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; import { ISendTokens } from "@graphprotocol/interfaces/contracts/issuance/allocate/ISendTokens.sol"; diff --git a/packages/issuance/contracts/allocate/IssuanceAllocator.sol b/packages/issuance/contracts/allocate/IssuanceAllocator.sol index 4b8f15291..83456daf6 100644 --- a/packages/issuance/contracts/allocate/IssuanceAllocator.sol +++ b/packages/issuance/contracts/allocate/IssuanceAllocator.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { TargetIssuancePerBlock, diff --git a/packages/issuance/contracts/common/BaseUpgradeable.sol b/packages/issuance/contracts/common/BaseUpgradeable.sol index 771d6f0a1..2141a8e20 100644 --- a/packages/issuance/contracts/common/BaseUpgradeable.sol +++ b/packages/issuance/contracts/common/BaseUpgradeable.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; diff --git a/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol b/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol index bd2591a44..06ed29e8d 100644 --- a/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol +++ b/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol"; import { IRewardsEligibilityAdministration } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityAdministration.sol"; diff --git a/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol b/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol index 586c6e677..f9b037682 100644 --- a/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol +++ b/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IssuanceAllocator } from "../../allocate/IssuanceAllocator.sol"; diff --git a/packages/issuance/foundry.toml b/packages/issuance/foundry.toml index 38d166efd..cfd6d9c04 100644 --- a/packages/issuance/foundry.toml +++ b/packages/issuance/foundry.toml @@ -13,7 +13,7 @@ fs_permissions = [{ access = "read", path = "./" }] optimizer = true optimizer_runs = 100 via_ir = true -solc_version = '0.8.33' +solc_version = '0.8.34' evm_version = 'cancun' # Exclude test files from coverage reports diff --git a/packages/issuance/hardhat.base.config.ts b/packages/issuance/hardhat.base.config.ts index 5ae490a66..d31b7d48b 100644 --- a/packages/issuance/hardhat.base.config.ts +++ b/packages/issuance/hardhat.base.config.ts @@ -7,7 +7,7 @@ const ARBITRUM_SEPOLIA_RPC = process.env.ARBITRUM_SEPOLIA_RPC || 'https://sepoli // Issuance-specific Solidity configuration with Cancun EVM version export const issuanceSolidityConfig = { - version: '0.8.33', + version: '0.8.34', settings: { optimizer: { enabled: true, diff --git a/packages/subgraph-service/contracts/DisputeManager.sol b/packages/subgraph-service/contracts/DisputeManager.sol index 130182e4b..6cbd73a22 100644 --- a/packages/subgraph-service/contracts/DisputeManager.sol +++ b/packages/subgraph-service/contracts/DisputeManager.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable function-max-lines, gas-strict-inequalities diff --git a/packages/subgraph-service/contracts/DisputeManagerStorage.sol b/packages/subgraph-service/contracts/DisputeManagerStorage.sol index cb0766023..5c2295b73 100644 --- a/packages/subgraph-service/contracts/DisputeManagerStorage.sol +++ b/packages/subgraph-service/contracts/DisputeManagerStorage.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IDisputeManager } from "@graphprotocol/interfaces/contracts/subgraph-service/IDisputeManager.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index 2eb8e0a9f..26e73084f 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; diff --git a/packages/subgraph-service/contracts/SubgraphServiceStorage.sol b/packages/subgraph-service/contracts/SubgraphServiceStorage.sol index 67accbb5a..15fc33acc 100644 --- a/packages/subgraph-service/contracts/SubgraphServiceStorage.sol +++ b/packages/subgraph-service/contracts/SubgraphServiceStorage.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; diff --git a/packages/subgraph-service/contracts/libraries/Allocation.sol b/packages/subgraph-service/contracts/libraries/Allocation.sol index d5018e482..404dc8cec 100644 --- a/packages/subgraph-service/contracts/libraries/Allocation.sol +++ b/packages/subgraph-service/contracts/libraries/Allocation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // forge-lint: disable-start(mixed-case-variable, mixed-case-function) diff --git a/packages/subgraph-service/contracts/libraries/Attestation.sol b/packages/subgraph-service/contracts/libraries/Attestation.sol index 77c3a3fc2..54bd2c2f2 100644 --- a/packages/subgraph-service/contracts/libraries/Attestation.sol +++ b/packages/subgraph-service/contracts/libraries/Attestation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-strict-inequalities diff --git a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol index 97b2be1dc..47f04c3a9 100644 --- a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol +++ b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; diff --git a/packages/subgraph-service/contracts/utilities/AllocationManager.sol b/packages/subgraph-service/contracts/utilities/AllocationManager.sol index e78fbc6f8..cbfe5e663 100644 --- a/packages/subgraph-service/contracts/utilities/AllocationManager.sol +++ b/packages/subgraph-service/contracts/utilities/AllocationManager.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; diff --git a/packages/subgraph-service/contracts/utilities/AllocationManagerStorage.sol b/packages/subgraph-service/contracts/utilities/AllocationManagerStorage.sol index 053b32a70..8f3460876 100644 --- a/packages/subgraph-service/contracts/utilities/AllocationManagerStorage.sol +++ b/packages/subgraph-service/contracts/utilities/AllocationManagerStorage.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; diff --git a/packages/subgraph-service/contracts/utilities/AttestationManager.sol b/packages/subgraph-service/contracts/utilities/AttestationManager.sol index 4ba57e639..c050786c0 100644 --- a/packages/subgraph-service/contracts/utilities/AttestationManager.sol +++ b/packages/subgraph-service/contracts/utilities/AttestationManager.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-small-strings diff --git a/packages/subgraph-service/contracts/utilities/AttestationManagerStorage.sol b/packages/subgraph-service/contracts/utilities/AttestationManagerStorage.sol index 40f4c614c..2b7be6850 100644 --- a/packages/subgraph-service/contracts/utilities/AttestationManagerStorage.sol +++ b/packages/subgraph-service/contracts/utilities/AttestationManagerStorage.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; /** * @title AttestationManagerStorage diff --git a/packages/subgraph-service/contracts/utilities/Directory.sol b/packages/subgraph-service/contracts/utilities/Directory.sol index 09d180a5d..0ba82a5b5 100644 --- a/packages/subgraph-service/contracts/utilities/Directory.sol +++ b/packages/subgraph-service/contracts/utilities/Directory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.33; +pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-indexed-events diff --git a/packages/subgraph-service/hardhat.config.ts b/packages/subgraph-service/hardhat.config.ts index aca08e03c..f6f6b387e 100644 --- a/packages/subgraph-service/hardhat.config.ts +++ b/packages/subgraph-service/hardhat.config.ts @@ -19,7 +19,7 @@ const baseConfig = hardhatBaseConfig(require) const config: HardhatUserConfig = { ...baseConfig, solidity: { - version: '0.8.33', + version: '0.8.34', settings: { optimizer: { enabled: true, runs: 100 }, evmVersion: 'cancun', From cc202344c5b5d7018ccbd9550251ca79a92a314d Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:29:12 +0000 Subject: [PATCH 02/11] feat: indexing agreements with TRST audit fixes Previously audited implementation of indexing agreements for the Graph Protocol's recurring payment system. Adds RecurringCollector, IndexingAgreement library, and integrates indexing fee collection into SubgraphService and DisputeManager. Includes all TRST audit fixes: - TRST-H-1: IndexingAgreement.collect() on CanceledByPayer - TRST-H-2: Only agreement owner can collect indexing fee - TRST-H-3: collect() checks provision - TRST-M-1: correct TYPEHASH string for RCAU - TRST-M-2: shared collection window logic + improve _getCollectionInfo - TRST-M-3: Add nonce-based replay protection - TRST-L-3: Add deterministic agreement ID - TRST-L-5: Add slippage protection - TRST-L-6: Proper agreement version check - TRST-L-7: update() documentation - TRST-L-9: Cancel agreement if over-allocated - TRST-R-1: minor fixes - TRST-R-4: CEI violation - TRST-R-5: Terms validation - TRST-R-6: Configurable indexing fees cut Re-based from indexing-payments-baseline commits a7fb8758f..a9b500a54 --- .../audits/2025-06-Indexing-Payments.pdf | Bin 0 -> 622268 bytes .../extensions/DataServiceFees.sol | 75 +- .../extensions/DataServiceFeesStorage.sol | 4 +- .../data-service/libraries/StakeClaims.sol | 221 +++++ .../utilities/ProvisionManager.sol | 36 +- packages/horizon/contracts/mocks/imports.sol | 2 +- .../collectors/RecurringCollector.sol | 662 +++++++++++++++ .../extensions/DataServiceFees.t.sol | 10 +- .../utilities/ProvisionManager.t.sol | 53 ++ .../utilities/ProvisionManagerImpl.t.sol | 15 + .../test/unit/libraries/StakeClaims.t.sol | 18 + .../test/unit/mocks/HorizonStakingMock.t.sol | 37 + .../unit/mocks/InvalidControllerMock.t.sol | 8 + .../unit/mocks/PartialControllerMock.t.sol | 33 + .../PaymentsEscrowMock.t.sol | 33 + .../RecurringCollectorAuthorizableTest.t.sol | 14 + .../RecurringCollectorHelper.t.sol | 193 +++++ .../payments/recurring-collector/accept.t.sol | 61 ++ .../payments/recurring-collector/base.t.sol | 44 + .../payments/recurring-collector/cancel.t.sol | 65 ++ .../recurring-collector/collect.t.sol | 447 ++++++++++ .../payments/recurring-collector/shared.t.sol | 264 ++++++ .../payments/recurring-collector/update.t.sol | 324 +++++++ .../test/unit/utilities/Authorizable.t.sol | 33 +- .../GraphDirectoryImplementation.sol | 1 + .../horizon/test/unit/utils/Bounder.t.sol | 29 +- .../data-service/IDataServiceFees.sol | 64 -- .../contracts/horizon/IRecurringCollector.sol | 483 +++++++++++ .../subgraph-service/IDisputeManager.sol | 65 +- .../subgraph-service/ISubgraphService.sol | 64 +- .../internal/IIndexingAgreement.sol | 39 + .../contracts/DisputeManager.sol | 84 ++ .../contracts/SubgraphService.sol | 245 +++++- .../contracts/SubgraphServiceStorage.sol | 3 + .../contracts/libraries/AllocationHandler.sol | 692 +++++++++++++++ .../contracts/libraries/IndexingAgreement.sol | 796 ++++++++++++++++++ .../libraries/IndexingAgreementDecoder.sol | 106 +++ .../libraries/IndexingAgreementDecoderRaw.sol | 70 ++ .../contracts/utilities/AllocationManager.sol | 326 ++----- .../contracts/utilities/Directory.sol | 35 +- .../test/unit/SubgraphBaseTest.t.sol | 12 +- .../unit/libraries/IndexingAgreement.t.sol | 108 +++ .../unit/shared/HorizonStakingShared.t.sol | 6 + .../subgraphService/SubgraphService.t.sol | 54 +- .../subgraphService/allocation/resize.t.sol | 10 +- .../subgraphService/allocation/start.t.sol | 6 +- .../subgraphService/collect/collect.t.sol | 12 +- .../collect/indexing/indexing.t.sol | 4 +- .../governance/maxPOIStaleness.t.sol | 4 +- .../indexing-agreement/accept.t.sol | 325 +++++++ .../indexing-agreement/base.t.sol | 64 ++ .../indexing-agreement/cancel.t.sol | 230 +++++ .../indexing-agreement/collect.t.sol | 341 ++++++++ .../indexing-agreement/integration.t.sol | 276 ++++++ .../indexing-agreement/shared.t.sol | 438 ++++++++++ .../indexing-agreement/update.t.sol | 176 ++++ 56 files changed, 7328 insertions(+), 492 deletions(-) create mode 100644 packages/horizon/audits/2025-06-Indexing-Payments.pdf create mode 100644 packages/horizon/contracts/data-service/libraries/StakeClaims.sol create mode 100644 packages/horizon/contracts/payments/collectors/RecurringCollector.sol create mode 100644 packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol create mode 100644 packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol create mode 100644 packages/horizon/test/unit/libraries/StakeClaims.t.sol create mode 100644 packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol create mode 100644 packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol create mode 100644 packages/horizon/test/unit/mocks/PartialControllerMock.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/accept.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/base.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/collect.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/shared.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/update.t.sol create mode 100644 packages/interfaces/contracts/horizon/IRecurringCollector.sol create mode 100644 packages/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol create mode 100644 packages/subgraph-service/contracts/libraries/AllocationHandler.sol create mode 100644 packages/subgraph-service/contracts/libraries/IndexingAgreement.sol create mode 100644 packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol create mode 100644 packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol create mode 100644 packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol diff --git a/packages/horizon/audits/2025-06-Indexing-Payments.pdf b/packages/horizon/audits/2025-06-Indexing-Payments.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bd5325dcac3e38d89634d356e4c0e2de2a1eb413 GIT binary patch literal 622268 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

5Lo(WE?zu5S+X`%O># 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ᗰ$S*2W7~Gul222vfR%X0udxX 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 literal 0 HcmV?d00001 diff --git a/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol b/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol index fd2bbd57f..f68852513 100644 --- a/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol +++ b/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol @@ -6,6 +6,7 @@ import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/interna import { ProvisionTracker } from "../libraries/ProvisionTracker.sol"; import { LinkedList } from "../../libraries/LinkedList.sol"; +import { StakeClaims } from "../libraries/StakeClaims.sol"; import { DataService } from "../DataService.sol"; import { DataServiceFeesV1Storage } from "./DataServiceFeesStorage.sol"; @@ -43,23 +44,17 @@ abstract contract DataServiceFees is DataService, DataServiceFeesV1Storage, IDat * @param _unlockTimestamp The timestamp when the tokens can be released */ function _lockStake(address _serviceProvider, uint256 _tokens, uint256 _unlockTimestamp) internal { - require(_tokens != 0, DataServiceFeesZeroTokens()); - feesProvisionTracker.lock(_graphStaking(), _serviceProvider, _tokens, _delegationRatio); - - ILinkedList.List storage claimsList = claimsLists[_serviceProvider]; - - // Save item and add to list - bytes32 claimId = _buildStakeClaimId(_serviceProvider, claimsList.nonce); - claims[claimId] = StakeClaim({ - tokens: _tokens, - createdAt: block.timestamp, - releasableAt: _unlockTimestamp, - nextClaim: bytes32(0) - }); - if (claimsList.count != 0) claims[claimsList.tail].nextClaim = claimId; - claimsList.addTail(claimId); - - emit StakeClaimLocked(_serviceProvider, claimId, _tokens, _unlockTimestamp); + StakeClaims.lockStake( + feesProvisionTracker, + claims, + claimsLists, + _graphStaking(), + address(this), + _delegationRatio, + _serviceProvider, + _tokens, + _unlockTimestamp + ); } /** @@ -82,7 +77,7 @@ abstract contract DataServiceFees is DataService, DataServiceFeesV1Storage, IDat _numClaimsToRelease ); - emit StakeClaimsReleased(_serviceProvider, claimsReleased, abi.decode(data, (uint256))); + emit StakeClaims.StakeClaimsReleased(_serviceProvider, claimsReleased, abi.decode(data, (uint256))); } /** @@ -94,23 +89,7 @@ abstract contract DataServiceFees is DataService, DataServiceFeesV1Storage, IDat * @return The updated accumulator data */ function _processStakeClaim(bytes32 _claimId, bytes memory _acc) private returns (bool, bytes memory) { - StakeClaim memory claim = _getStakeClaim(_claimId); - - // early exit - if (claim.releasableAt > block.timestamp) { - return (true, LinkedList.NULL_BYTES); - } - - // decode - (uint256 tokensClaimed, address serviceProvider) = abi.decode(_acc, (uint256, address)); - - // process - feesProvisionTracker.release(serviceProvider, claim.tokens); - emit StakeClaimReleased(serviceProvider, _claimId, claim.tokens, claim.releasableAt); - - // encode - _acc = abi.encode(tokensClaimed + claim.tokens, serviceProvider); - return (false, _acc); + return StakeClaims.processStakeClaim(feesProvisionTracker, claims, _claimId, _acc); } /** @@ -119,18 +98,7 @@ abstract contract DataServiceFees is DataService, DataServiceFeesV1Storage, IDat * @param _claimId The ID of the stake claim to delete */ function _deleteStakeClaim(bytes32 _claimId) private { - delete claims[_claimId]; - } - - /** - * @notice Gets the details of a stake claim - * @param _claimId The ID of the stake claim - * @return The stake claim details - */ - function _getStakeClaim(bytes32 _claimId) private view returns (StakeClaim memory) { - StakeClaim memory claim = claims[_claimId]; - require(claim.createdAt != 0, DataServiceFeesClaimNotFound(_claimId)); - return claim; + StakeClaims.deleteStakeClaim(claims, _claimId); } /** @@ -140,17 +108,6 @@ abstract contract DataServiceFees is DataService, DataServiceFeesV1Storage, IDat * @return The next stake claim ID */ function _getNextStakeClaim(bytes32 _claimId) private view returns (bytes32) { - return claims[_claimId].nextClaim; - } - - // forge-lint: disable-next-item(asm-keccak256) - /** - * @notice Builds a stake claim ID - * @param _serviceProvider The address of the service provider - * @param _nonce A nonce of the stake claim - * @return The stake claim ID - */ - function _buildStakeClaimId(address _serviceProvider, uint256 _nonce) private view returns (bytes32) { - return keccak256(abi.encodePacked(address(this), _serviceProvider, _nonce)); + return StakeClaims.getNextStakeClaim(claims, _claimId); } } diff --git a/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol b/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol index b9a5253b6..4c5b89709 100644 --- a/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol +++ b/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.27; -import { IDataServiceFees } from "@graphprotocol/interfaces/contracts/data-service/IDataServiceFees.sol"; +import { StakeClaims } from "../libraries/StakeClaims.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; @@ -17,7 +17,7 @@ abstract contract DataServiceFeesV1Storage { mapping(address serviceProvider => uint256 tokens) public feesProvisionTracker; /// @notice List of all locked stake claims to be released to service providers - mapping(bytes32 claimId => IDataServiceFees.StakeClaim claim) public claims; + mapping(bytes32 claimId => StakeClaims.StakeClaim claim) public claims; /// @notice Service providers registered in the data service mapping(address serviceProvider => ILinkedList.List list) public claimsLists; diff --git a/packages/horizon/contracts/data-service/libraries/StakeClaims.sol b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol new file mode 100644 index 000000000..00ad95348 --- /dev/null +++ b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.27; + +import { ProvisionTracker } from "./ProvisionTracker.sol"; +import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; +import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; +import { LinkedList } from "../../libraries/LinkedList.sol"; + +/** + * @title StakeClaims library + * @author Edge & Node + * @notice Manages stake claims — provisioned stake locked for release to service providers. + */ +library StakeClaims { + using ProvisionTracker for mapping(address => uint256); + using LinkedList for ILinkedList.List; + + /** + * @notice A stake claim, representing provisioned stake that gets locked + * to be released to a service provider. + * @dev StakeClaims are stored in linked lists by service provider, ordered by + * creation timestamp. + * @param tokens The amount of tokens to be locked in the claim + * @param createdAt The timestamp when the claim was created + * @param releasableAt The timestamp when the tokens can be released + * @param nextClaim The next claim in the linked list + */ + struct StakeClaim { + uint256 tokens; + uint256 createdAt; + uint256 releasableAt; + bytes32 nextClaim; + } + + /* solhint-disable gas-indexed-events */ + /** + * @notice Emitted when a stake claim is created and stake is locked. + * @param serviceProvider The address of the service provider + * @param claimId The id of the stake claim + * @param tokens The amount of tokens to lock in the claim + * @param unlockTimestamp The timestamp when the tokens can be released + */ + event StakeClaimLocked( + address indexed serviceProvider, + bytes32 indexed claimId, + uint256 tokens, + uint256 unlockTimestamp + ); + + /** + * @notice Emitted when a stake claim is released and stake is unlocked. + * @param serviceProvider The address of the service provider + * @param claimId The id of the stake claim + * @param tokens The amount of tokens released + * @param releasableAt The timestamp when the tokens were released + */ + event StakeClaimReleased( + address indexed serviceProvider, + bytes32 indexed claimId, + uint256 tokens, + uint256 releasableAt + ); + + /** + * @notice Emitted when a series of stake claims are released. + * @param serviceProvider The address of the service provider + * @param claimsCount The number of stake claims being released + * @param tokensReleased The total amount of tokens being released + */ + event StakeClaimsReleased(address indexed serviceProvider, uint256 claimsCount, uint256 tokensReleased); + /* solhint-enable gas-indexed-events */ + + /** + * @notice Thrown when attempting to get a stake claim that does not exist. + * @param claimId The id of the stake claim + */ + error StakeClaimsClaimNotFound(bytes32 claimId); + + /** + * @notice Emitted when trying to lock zero tokens in a stake claim + */ + error StakeClaimsZeroTokens(); + + /** + * @notice Locks stake for a service provider to back a payment. + * Creates a stake claim, which is stored in a linked list by service provider. + * @dev Requirements: + * - The associated provision must have enough available tokens to lock the stake. + * + * Emits a {StakeClaimLocked} event. + * + * @param feesProvisionTracker The mapping that tracks the provision tokens for each service provider + * @param claims The mapping that stores stake claims by their ID + * @param claimsLists The mapping that stores linked lists of stake claims by service provider + * @param graphStaking The Horizon staking contract used to lock the tokens + * @param _dataService The address of the data service + * @param _delegationRatio The delegation ratio to use for the stake claim + * @param _serviceProvider The address of the service provider + * @param _tokens The amount of tokens to lock in the claim + * @param _unlockTimestamp The timestamp when the tokens can be released + */ + function lockStake( + mapping(address => uint256) storage feesProvisionTracker, + mapping(bytes32 => StakeClaim) storage claims, + mapping(address serviceProvider => ILinkedList.List list) storage claimsLists, + IHorizonStaking graphStaking, + address _dataService, + uint32 _delegationRatio, + address _serviceProvider, + uint256 _tokens, + uint256 _unlockTimestamp + ) external { + require(_tokens != 0, StakeClaimsZeroTokens()); + feesProvisionTracker.lock(graphStaking, _serviceProvider, _tokens, _delegationRatio); + + ILinkedList.List storage claimsList = claimsLists[_serviceProvider]; + + // Save item and add to list + bytes32 claimId = _buildStakeClaimId(_dataService, _serviceProvider, claimsList.nonce); + claims[claimId] = StakeClaim({ + tokens: _tokens, + createdAt: block.timestamp, + releasableAt: _unlockTimestamp, + nextClaim: bytes32(0) + }); + if (claimsList.count != 0) claims[claimsList.tail].nextClaim = claimId; + claimsList.addTail(claimId); + + emit StakeClaimLocked(_serviceProvider, claimId, _tokens, _unlockTimestamp); + } + + /** + * @notice Processes a stake claim, releasing the tokens if the claim has expired. + * @dev This function is used as a callback in the stake claims linked list traversal. + * @param feesProvisionTracker The mapping that tracks the provision tokens for each service provider. + * @param claims The mapping that stores stake claims by their ID. + * @param _claimId The ID of the stake claim to process. + * @param _acc The accumulator data, which contains the total tokens claimed and the service provider address. + * @return Whether the stake claim is still locked, indicating that the traversal should continue or stop. + * @return The updated accumulator data + */ + function processStakeClaim( + mapping(address serviceProvider => uint256 tokens) storage feesProvisionTracker, + mapping(bytes32 claimId => StakeClaim claim) storage claims, + bytes32 _claimId, + bytes memory _acc + ) external returns (bool, bytes memory) { + StakeClaim memory claim = claims[_claimId]; + require(claim.createdAt != 0, StakeClaimsClaimNotFound(_claimId)); + + // early exit + if (claim.releasableAt > block.timestamp) { + return (true, LinkedList.NULL_BYTES); + } + + // decode + (uint256 tokensClaimed, address serviceProvider) = abi.decode(_acc, (uint256, address)); + + // process + feesProvisionTracker.release(serviceProvider, claim.tokens); + emit StakeClaimReleased(serviceProvider, _claimId, claim.tokens, claim.releasableAt); + + // encode + _acc = abi.encode(tokensClaimed + claim.tokens, serviceProvider); + return (false, _acc); + } + + /** + * @notice Deletes a stake claim. + * @dev This function is used as a callback in the stake claims linked list traversal. + * @param claims The mapping that stores stake claims by their ID + * @param claimId The ID of the stake claim to delete + */ + function deleteStakeClaim(mapping(bytes32 claimId => StakeClaim claim) storage claims, bytes32 claimId) external { + delete claims[claimId]; + } + + /** + * @notice Gets the next stake claim in the linked list + * @dev This function is used as a callback in the stake claims linked list traversal. + * @param claims The mapping that stores stake claims by their ID + * @param claimId The ID of the stake claim + * @return The next stake claim ID + */ + function getNextStakeClaim( + mapping(bytes32 claimId => StakeClaim claim) storage claims, + bytes32 claimId + ) external view returns (bytes32) { + return claims[claimId].nextClaim; + } + + /** + * @notice Builds a stake claim ID + * @param dataService The address of the data service + * @param serviceProvider The address of the service provider + * @param nonce A nonce of the stake claim + * @return The stake claim ID + */ + function buildStakeClaimId( + address dataService, + address serviceProvider, + uint256 nonce + ) public pure returns (bytes32) { + return _buildStakeClaimId(dataService, serviceProvider, nonce); + } + + /** + * @notice Builds a stake claim ID + * @param _dataService The address of the data service + * @param _serviceProvider The address of the service provider + * @param _nonce A nonce of the stake claim + * @return The stake claim ID + */ + function _buildStakeClaimId( + address _dataService, + address _serviceProvider, + uint256 _nonce + ) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(_dataService, _serviceProvider, _nonce)); + } +} diff --git a/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol b/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol index bdfae747a..77f495ed8 100644 --- a/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol +++ b/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol @@ -132,9 +132,7 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param serviceProvider The address of the service provider. */ modifier onlyValidProvision(address serviceProvider) virtual { - IHorizonStaking.Provision memory provision = _getProvision(serviceProvider); - _checkProvisionTokens(provision); - _checkProvisionParameters(provision, false); + _requireValidProvision(serviceProvider); _; } @@ -186,7 +184,7 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param _max The maximum allowed value for the provision tokens. */ function _setProvisionTokensRange(uint256 _min, uint256 _max) internal { - require(_min <= _max, ProvisionManagerInvalidRange(_min, _max)); + _requireLTE(_min, _max); _minimumProvisionTokens = _min; _maximumProvisionTokens = _max; emit ProvisionTokensRangeSet(_min, _max); @@ -198,7 +196,7 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param _max The maximum allowed value for the max verifier cut. */ function _setVerifierCutRange(uint32 _min, uint32 _max) internal { - require(_min <= _max, ProvisionManagerInvalidRange(_min, _max)); + _requireLTE(_min, _max); require(PPMMath.isValidPPM(_max), ProvisionManagerInvalidRange(_min, _max)); _minimumVerifierCut = _min; _maximumVerifierCut = _max; @@ -211,12 +209,23 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param _max The maximum allowed value for the thawing period. */ function _setThawingPeriodRange(uint64 _min, uint64 _max) internal { - require(_min <= _max, ProvisionManagerInvalidRange(_min, _max)); + _requireLTE(_min, _max); _minimumThawingPeriod = _min; _maximumThawingPeriod = _max; emit ThawingPeriodRangeSet(_min, _max); } + /** + * @notice Checks if a provision of a service provider is valid according + * to the parameter ranges established. + * @param _serviceProvider The address of the service provider. + */ + function _requireValidProvision(address _serviceProvider) internal view { + IHorizonStaking.Provision memory provision = _getProvision(_serviceProvider); + _checkProvisionTokens(provision); + _checkProvisionParameters(provision, false); + } + // -- checks -- /** @@ -224,8 +233,7 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param _serviceProvider The address of the service provider. */ function _checkProvisionTokens(address _serviceProvider) internal view virtual { - IHorizonStaking.Provision memory provision = _getProvision(_serviceProvider); - _checkProvisionTokens(provision); + _checkProvisionTokens(_getProvision(_serviceProvider)); } /** @@ -248,8 +256,7 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param _checkPending If true, checks the pending provision parameters. */ function _checkProvisionParameters(address _serviceProvider, bool _checkPending) internal view virtual { - IHorizonStaking.Provision memory provision = _getProvision(_serviceProvider); - _checkProvisionParameters(provision, _checkPending); + _checkProvisionParameters(_getProvision(_serviceProvider), _checkPending); } /** @@ -330,4 +337,13 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa function _checkValueInRange(uint256 _value, uint256 _min, uint256 _max, bytes memory _revertMessage) private pure { require(_value.isInRange(_min, _max), ProvisionManagerInvalidValue(_revertMessage, _value, _min, _max)); } + + /** + * @notice Requires that a value is less than or equal to another value. + * @param _a The value to check. + * @param _b The value to compare against. + */ + function _requireLTE(uint256 _a, uint256 _b) private pure { + require(_a <= _b, ProvisionManagerInvalidRange(_a, _b)); + } } diff --git a/packages/horizon/contracts/mocks/imports.sol b/packages/horizon/contracts/mocks/imports.sol index 3a05b2b4d..f153a9320 100644 --- a/packages/horizon/contracts/mocks/imports.sol +++ b/packages/horizon/contracts/mocks/imports.sol @@ -1,7 +1,7 @@ // solhint-disable no-global-import // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.7.6 || 0.8.27; +pragma solidity ^0.7.6 || ^0.8.27; // We import these here to force Hardhat to compile them. // This ensures that their artifacts are available for Hardhat Ignition to use. diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol new file mode 100644 index 000000000..b0b10b642 --- /dev/null +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -0,0 +1,662 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.27; + +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +import { Authorizable } from "../../utilities/Authorizable.sol"; +import { GraphDirectory } from "../../utilities/GraphDirectory.sol"; +// solhint-disable-next-line no-unused-import +import { IPaymentsCollector } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsCollector.sol"; // for @inheritdoc +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { PPMMath } from "../../libraries/PPMMath.sol"; + +/** + * @title RecurringCollector contract + * @author Edge & Node + * @dev Implements the {IRecurringCollector} interface. + * @notice A payments collector contract that can be used to collect payments using a RCA (Recurring Collection Agreement). + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringCollector { + using PPMMath for uint256; + + /// @notice The minimum number of seconds that must be between two collections + uint32 public constant MIN_SECONDS_COLLECTION_WINDOW = 600; + + /* solhint-disable gas-small-strings */ + /// @notice The EIP712 typehash for the RecurringCollectionAgreement struct + bytes32 public constant EIP712_RCA_TYPEHASH = + keccak256( + "RecurringCollectionAgreement(uint64 deadline,uint64 endsAt,address payer,address dataService,address serviceProvider,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,uint256 nonce,bytes metadata)" + ); + + /// @notice The EIP712 typehash for the RecurringCollectionAgreementUpdate struct + bytes32 public constant EIP712_RCAU_TYPEHASH = + keccak256( + "RecurringCollectionAgreementUpdate(bytes16 agreementId,uint64 deadline,uint64 endsAt,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,uint32 nonce,bytes metadata)" + ); + /* solhint-enable gas-small-strings */ + + /// @notice Tracks agreements + mapping(bytes16 agreementId => AgreementData data) internal agreements; + + /** + * @notice Constructs a new instance of the RecurringCollector contract. + * @param eip712Name The name of the EIP712 domain. + * @param eip712Version The version of the EIP712 domain. + * @param controller The address of the Graph controller. + * @param revokeSignerThawingPeriod The duration (in seconds) in which a signer is thawing before they can be revoked. + */ + constructor( + string memory eip712Name, + string memory eip712Version, + address controller, + uint256 revokeSignerThawingPeriod + ) EIP712(eip712Name, eip712Version) GraphDirectory(controller) Authorizable(revokeSignerThawingPeriod) {} + + /** + * @inheritdoc IPaymentsCollector + * @notice Initiate a payment collection through the payments protocol. + * See {IPaymentsCollector.collect}. + * @dev Caller must be the data service the RCA was issued to. + */ + function collect(IGraphPayments.PaymentTypes paymentType, bytes calldata data) external returns (uint256) { + try this.decodeCollectData(data) returns (CollectParams memory collectParams) { + return _collect(paymentType, collectParams); + } catch { + revert RecurringCollectorInvalidCollectData(data); + } + } + + /* solhint-disable function-max-lines */ + /** + * @inheritdoc IRecurringCollector + * @notice Accept a Recurring Collection Agreement. + * See {IRecurringCollector.accept}. + * @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, + RecurringCollectorAgreementDeadlineElapsed(block.timestamp, signedRCA.rca.deadline) + ); + /* solhint-enable gas-strict-inequalities */ + + // check that the voucher is signed by the payer (or proxy) + _requireAuthorizedRCASigner(signedRCA); + + require( + signedRCA.rca.dataService != address(0) && + signedRCA.rca.payer != address(0) && + signedRCA.rca.serviceProvider != address(0), + RecurringCollectorAgreementAddressNotSet() + ); + + _requireValidCollectionWindowParams( + signedRCA.rca.endsAt, + signedRCA.rca.minSecondsPerCollection, + signedRCA.rca.maxSecondsPerCollection + ); + + AgreementData storage agreement = _getAgreementStorage(agreementId); + // check that the agreement is not already accepted + require( + agreement.state == AgreementState.NotAccepted, + RecurringCollectorAgreementIncorrectState(agreementId, agreement.state) + ); + + // accept the agreement + agreement.acceptedAt = uint64(block.timestamp); + agreement.state = AgreementState.Accepted; + agreement.dataService = signedRCA.rca.dataService; + agreement.payer = signedRCA.rca.payer; + agreement.serviceProvider = signedRCA.rca.serviceProvider; + agreement.endsAt = signedRCA.rca.endsAt; + agreement.maxInitialTokens = signedRCA.rca.maxInitialTokens; + agreement.maxOngoingTokensPerSecond = signedRCA.rca.maxOngoingTokensPerSecond; + agreement.minSecondsPerCollection = signedRCA.rca.minSecondsPerCollection; + agreement.maxSecondsPerCollection = signedRCA.rca.maxSecondsPerCollection; + agreement.updateNonce = 0; + + emit AgreementAccepted( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + agreementId, + agreement.acceptedAt, + agreement.endsAt, + agreement.maxInitialTokens, + agreement.maxOngoingTokensPerSecond, + agreement.minSecondsPerCollection, + agreement.maxSecondsPerCollection + ); + + return agreementId; + } + /* solhint-enable function-max-lines */ + + /** + * @inheritdoc IRecurringCollector + * @notice Cancel a Recurring Collection Agreement. + * See {IRecurringCollector.cancel}. + * @dev Caller must be the data service for the agreement. + */ + function cancel(bytes16 agreementId, CancelAgreementBy by) external { + AgreementData storage agreement = _getAgreementStorage(agreementId); + require( + agreement.state == AgreementState.Accepted, + RecurringCollectorAgreementIncorrectState(agreementId, agreement.state) + ); + require( + agreement.dataService == msg.sender, + RecurringCollectorDataServiceNotAuthorized(agreementId, msg.sender) + ); + agreement.canceledAt = uint64(block.timestamp); + if (by == CancelAgreementBy.Payer) { + agreement.state = AgreementState.CanceledByPayer; + } else { + agreement.state = AgreementState.CanceledByServiceProvider; + } + + emit AgreementCanceled( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + agreementId, + agreement.canceledAt, + by + ); + } + + /* solhint-disable function-max-lines */ + /** + * @inheritdoc IRecurringCollector + * @notice Update a Recurring Collection Agreement. + * See {IRecurringCollector.update}. + * @dev Caller must be the data service for the agreement. + * @dev Note: Updated pricing terms apply immediately and will affect the next collection + * for the entire period since lastCollectionAt. + */ + function update(SignedRCAU calldata signedRCAU) external { + /* solhint-disable gas-strict-inequalities */ + require( + signedRCAU.rcau.deadline >= block.timestamp, + RecurringCollectorAgreementDeadlineElapsed(block.timestamp, signedRCAU.rcau.deadline) + ); + /* solhint-enable gas-strict-inequalities */ + + AgreementData storage agreement = _getAgreementStorage(signedRCAU.rcau.agreementId); + require( + agreement.state == AgreementState.Accepted, + RecurringCollectorAgreementIncorrectState(signedRCAU.rcau.agreementId, agreement.state) + ); + require( + agreement.dataService == msg.sender, + RecurringCollectorDataServiceNotAuthorized(signedRCAU.rcau.agreementId, msg.sender) + ); + + // check that the voucher is signed by the payer (or proxy) + _requireAuthorizedRCAUSigner(signedRCAU, agreement.payer); + + // validate nonce to prevent replay attacks + uint32 expectedNonce = agreement.updateNonce + 1; + require( + signedRCAU.rcau.nonce == expectedNonce, + RecurringCollectorInvalidUpdateNonce(signedRCAU.rcau.agreementId, expectedNonce, signedRCAU.rcau.nonce) + ); + + _requireValidCollectionWindowParams( + signedRCAU.rcau.endsAt, + signedRCAU.rcau.minSecondsPerCollection, + signedRCAU.rcau.maxSecondsPerCollection + ); + + // update the agreement + agreement.endsAt = signedRCAU.rcau.endsAt; + agreement.maxInitialTokens = signedRCAU.rcau.maxInitialTokens; + agreement.maxOngoingTokensPerSecond = signedRCAU.rcau.maxOngoingTokensPerSecond; + agreement.minSecondsPerCollection = signedRCAU.rcau.minSecondsPerCollection; + agreement.maxSecondsPerCollection = signedRCAU.rcau.maxSecondsPerCollection; + agreement.updateNonce = signedRCAU.rcau.nonce; + + emit AgreementUpdated( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + signedRCAU.rcau.agreementId, + uint64(block.timestamp), + agreement.endsAt, + agreement.maxInitialTokens, + agreement.maxOngoingTokensPerSecond, + agreement.minSecondsPerCollection, + agreement.maxSecondsPerCollection + ); + } + /* solhint-enable function-max-lines */ + + /// @inheritdoc IRecurringCollector + function recoverRCASigner(SignedRCA calldata signedRCA) external view returns (address) { + return _recoverRCASigner(signedRCA); + } + + /// @inheritdoc IRecurringCollector + function recoverRCAUSigner(SignedRCAU calldata signedRCAU) external view returns (address) { + return _recoverRCAUSigner(signedRCAU); + } + + /// @inheritdoc IRecurringCollector + function hashRCA(RecurringCollectionAgreement calldata rca) external view returns (bytes32) { + return _hashRCA(rca); + } + + /// @inheritdoc IRecurringCollector + function hashRCAU(RecurringCollectionAgreementUpdate calldata rcau) external view returns (bytes32) { + return _hashRCAU(rcau); + } + + /// @inheritdoc IRecurringCollector + function getAgreement(bytes16 agreementId) external view returns (AgreementData memory) { + return _getAgreement(agreementId); + } + + /// @inheritdoc IRecurringCollector + function getCollectionInfo( + AgreementData calldata agreement + ) external view returns (bool isCollectable, uint256 collectionSeconds, AgreementNotCollectableReason reason) { + return _getCollectionInfo(agreement); + } + + /// @inheritdoc IRecurringCollector + function generateAgreementId( + address payer, + address dataService, + address serviceProvider, + uint64 deadline, + uint256 nonce + ) external pure returns (bytes16) { + return _generateAgreementId(payer, dataService, serviceProvider, deadline, nonce); + } + + /** + * @notice Decodes the collect data. + * @param data The encoded collect parameters. + * @return The decoded collect parameters. + */ + function decodeCollectData(bytes calldata data) public pure returns (CollectParams memory) { + return abi.decode(data, (CollectParams)); + } + + /* solhint-disable function-max-lines */ + /** + * @notice Collect payment through the payments protocol. + * @dev Caller must be the data service the RCA was issued to. + * + * Emits {PaymentCollected} and {RCACollected} events. + * + * @param _paymentType The type of payment to collect + * @param _params The decoded parameters for the collection + * @return The amount of tokens collected + */ + function _collect( + IGraphPayments.PaymentTypes _paymentType, + CollectParams memory _params + ) private returns (uint256) { + AgreementData storage agreement = _getAgreementStorage(_params.agreementId); + + // Check if agreement is collectable first + (bool isCollectable, uint256 collectionSeconds, AgreementNotCollectableReason reason) = _getCollectionInfo( + agreement + ); + require(isCollectable, RecurringCollectorAgreementNotCollectable(_params.agreementId, reason)); + + require( + msg.sender == agreement.dataService, + RecurringCollectorDataServiceNotAuthorized(_params.agreementId, msg.sender) + ); + + // Check the service provider has an active provision with the data service + // This prevents an attack where the payer can deny the service provider from collecting payments + // by using a signer as data service to syphon off the tokens in the escrow to an account they control + { + uint256 tokensAvailable = _graphStaking().getProviderTokensAvailable( + agreement.serviceProvider, + agreement.dataService + ); + require(tokensAvailable > 0, RecurringCollectorUnauthorizedDataService(agreement.dataService)); + } + + uint256 tokensToCollect = 0; + if (_params.tokens != 0) { + tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens, collectionSeconds); + + uint256 slippage = _params.tokens - tokensToCollect; + /* solhint-disable gas-strict-inequalities */ + require( + slippage <= _params.maxSlippage, + RecurringCollectorExcessiveSlippage(_params.tokens, tokensToCollect, _params.maxSlippage) + ); + /* solhint-enable gas-strict-inequalities */ + } + agreement.lastCollectionAt = uint64(block.timestamp); + + if (tokensToCollect > 0) { + _graphPaymentsEscrow().collect( + _paymentType, + agreement.payer, + agreement.serviceProvider, + tokensToCollect, + agreement.dataService, + _params.dataServiceCut, + _params.receiverDestination + ); + } + + emit PaymentCollected( + _paymentType, + _params.collectionId, + agreement.payer, + agreement.serviceProvider, + agreement.dataService, + tokensToCollect + ); + + emit RCACollected( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + _params.agreementId, + _params.collectionId, + tokensToCollect, + _params.dataServiceCut + ); + + return tokensToCollect; + } + /* solhint-enable function-max-lines */ + + /** + * @notice Requires that the collection window parameters are valid. + * + * @param _endsAt The end time of the agreement + * @param _minSecondsPerCollection The minimum seconds per collection + * @param _maxSecondsPerCollection The maximum seconds per collection + */ + function _requireValidCollectionWindowParams( + uint64 _endsAt, + uint32 _minSecondsPerCollection, + uint32 _maxSecondsPerCollection + ) private view { + // Agreement needs to end in the future + require(_endsAt > block.timestamp, RecurringCollectorAgreementElapsedEndsAt(block.timestamp, _endsAt)); + + // Collection window needs to be at least MIN_SECONDS_COLLECTION_WINDOW + require( + _maxSecondsPerCollection > _minSecondsPerCollection && + // solhint-disable-next-line gas-strict-inequalities + (_maxSecondsPerCollection - _minSecondsPerCollection >= MIN_SECONDS_COLLECTION_WINDOW), + RecurringCollectorAgreementInvalidCollectionWindow( + MIN_SECONDS_COLLECTION_WINDOW, + _minSecondsPerCollection, + _maxSecondsPerCollection + ) + ); + + // Agreement needs to last at least one min collection window + require( + // solhint-disable-next-line gas-strict-inequalities + _endsAt - block.timestamp >= _minSecondsPerCollection + MIN_SECONDS_COLLECTION_WINDOW, + RecurringCollectorAgreementInvalidDuration( + _minSecondsPerCollection + MIN_SECONDS_COLLECTION_WINDOW, + _endsAt - block.timestamp + ) + ); + } + + /** + * @notice Requires that the collection params are valid. + * @param _agreement The agreement data + * @param _agreementId The ID of the agreement + * @param _tokens The number of tokens to collect + * @param _collectionSeconds Collection duration from _getCollectionInfo() + * @return The number of tokens that can be collected + */ + function _requireValidCollect( + AgreementData memory _agreement, + bytes16 _agreementId, + uint256 _tokens, + uint256 _collectionSeconds + ) private view returns (uint256) { + bool canceledOrElapsed = _agreement.state == AgreementState.CanceledByPayer || + block.timestamp > _agreement.endsAt; + if (!canceledOrElapsed) { + require( + // solhint-disable-next-line gas-strict-inequalities + _collectionSeconds >= _agreement.minSecondsPerCollection, + RecurringCollectorCollectionTooSoon( + _agreementId, + uint32(_collectionSeconds), + _agreement.minSecondsPerCollection + ) + ); + } + require( + // solhint-disable-next-line gas-strict-inequalities + _collectionSeconds <= _agreement.maxSecondsPerCollection, + RecurringCollectorCollectionTooLate( + _agreementId, + uint64(_collectionSeconds), + _agreement.maxSecondsPerCollection + ) + ); + + uint256 maxTokens = _agreement.maxOngoingTokensPerSecond * _collectionSeconds; + maxTokens += _agreement.lastCollectionAt == 0 ? _agreement.maxInitialTokens : 0; + + return Math.min(_tokens, maxTokens); + } + + /** + * @notice See {recoverRCASigner} + * @param _signedRCA The signed RCA to recover the signer from + * @return The address of the signer + */ + function _recoverRCASigner(SignedRCA memory _signedRCA) private view returns (address) { + bytes32 messageHash = _hashRCA(_signedRCA.rca); + return ECDSA.recover(messageHash, _signedRCA.signature); + } + + /** + * @notice See {recoverRCAUSigner} + * @param _signedRCAU The signed RCAU to recover the signer from + * @return The address of the signer + */ + function _recoverRCAUSigner(SignedRCAU memory _signedRCAU) private view returns (address) { + bytes32 messageHash = _hashRCAU(_signedRCAU.rcau); + return ECDSA.recover(messageHash, _signedRCAU.signature); + } + + /** + * @notice See {hashRCA} + * @param _rca The RCA to hash + * @return The EIP712 hash of the RCA + */ + function _hashRCA(RecurringCollectionAgreement memory _rca) private view returns (bytes32) { + return + _hashTypedDataV4( + keccak256( + abi.encode( + EIP712_RCA_TYPEHASH, + _rca.deadline, + _rca.endsAt, + _rca.payer, + _rca.dataService, + _rca.serviceProvider, + _rca.maxInitialTokens, + _rca.maxOngoingTokensPerSecond, + _rca.minSecondsPerCollection, + _rca.maxSecondsPerCollection, + _rca.nonce, + keccak256(_rca.metadata) + ) + ) + ); + } + + /** + * @notice See {hashRCAU} + * @param _rcau The RCAU to hash + * @return The EIP712 hash of the RCAU + */ + function _hashRCAU(RecurringCollectionAgreementUpdate memory _rcau) private view returns (bytes32) { + return + _hashTypedDataV4( + keccak256( + abi.encode( + EIP712_RCAU_TYPEHASH, + _rcau.agreementId, + _rcau.deadline, + _rcau.endsAt, + _rcau.maxInitialTokens, + _rcau.maxOngoingTokensPerSecond, + _rcau.minSecondsPerCollection, + _rcau.maxSecondsPerCollection, + _rcau.nonce, + keccak256(_rcau.metadata) + ) + ) + ); + } + + /** + * @notice Requires that the signer for the RCA is authorized + * by the payer of the RCA. + * @param _signedRCA The signed RCA to verify + * @return The address of the authorized signer + */ + function _requireAuthorizedRCASigner(SignedRCA memory _signedRCA) private view returns (address) { + address signer = _recoverRCASigner(_signedRCA); + require(_isAuthorized(_signedRCA.rca.payer, signer), RecurringCollectorInvalidSigner()); + + return signer; + } + + /** + * @notice Requires that the signer for the RCAU is authorized + * by the payer. + * @param _signedRCAU The signed RCAU to verify + * @param _payer The address of the payer + * @return The address of the authorized signer + */ + function _requireAuthorizedRCAUSigner( + SignedRCAU memory _signedRCAU, + address _payer + ) private view returns (address) { + address signer = _recoverRCAUSigner(_signedRCAU); + require(_isAuthorized(_payer, signer), RecurringCollectorInvalidSigner()); + + return signer; + } + + /** + * @notice Gets an agreement to be updated. + * @param _agreementId The ID of the agreement to get + * @return The storage reference to the agreement data + */ + function _getAgreementStorage(bytes16 _agreementId) private view returns (AgreementData storage) { + return agreements[_agreementId]; + } + + /** + * @notice See {getAgreement} + * @param _agreementId The ID of the agreement to get + * @return The agreement data + */ + function _getAgreement(bytes16 _agreementId) private view returns (AgreementData memory) { + return agreements[_agreementId]; + } + + /** + * @notice Internal function to get collection info for an agreement + * @dev This is the single source of truth for collection window logic + * @param _agreement The agreement data + * @return isCollectable Whether the agreement can be collected from + * @return collectionSeconds The valid collection duration in seconds (0 if not collectable) + * @return reason The reason why the agreement is not collectable (None if collectable) + */ + function _getCollectionInfo( + AgreementData memory _agreement + ) private view returns (bool, uint256, AgreementNotCollectableReason) { + // Check if agreement is in collectable state + bool hasValidState = _agreement.state == AgreementState.Accepted || + _agreement.state == AgreementState.CanceledByPayer; + + if (!hasValidState) { + return (false, 0, AgreementNotCollectableReason.InvalidAgreementState); + } + + bool canceledOrElapsed = _agreement.state == AgreementState.CanceledByPayer || + block.timestamp > _agreement.endsAt; + uint256 canceledOrNow = _agreement.state == AgreementState.CanceledByPayer + ? _agreement.canceledAt + : block.timestamp; + + uint256 collectionEnd = canceledOrElapsed ? Math.min(canceledOrNow, _agreement.endsAt) : block.timestamp; + uint256 collectionStart = _agreementCollectionStartAt(_agreement); + + if (collectionEnd < collectionStart) { + return (false, 0, AgreementNotCollectableReason.InvalidTemporalWindow); + } + + if (collectionStart == collectionEnd) { + return (false, 0, AgreementNotCollectableReason.ZeroCollectionSeconds); + } + + return (true, collectionEnd - collectionStart, AgreementNotCollectableReason.None); + } + + /** + * @notice Gets the start time for the collection of an agreement. + * @param _agreement The agreement data + * @return The start time for the collection of the agreement + */ + function _agreementCollectionStartAt(AgreementData memory _agreement) private pure returns (uint256) { + return _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt; + } + + /** + * @notice Internal function to generate deterministic agreement ID + * @param _payer The address of the payer + * @param _dataService The address of the data service + * @param _serviceProvider The address of the service provider + * @param _deadline The deadline for accepting the agreement + * @param _nonce A unique nonce for preventing collisions + * @return agreementId The deterministically generated agreement ID + */ + function _generateAgreementId( + address _payer, + address _dataService, + address _serviceProvider, + uint64 _deadline, + uint256 _nonce + ) private pure returns (bytes16) { + return bytes16(keccak256(abi.encode(_payer, _dataService, _serviceProvider, _deadline, _nonce))); + } +} diff --git a/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol b/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol index 28f74003f..5692dd952 100644 --- a/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol +++ b/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.27; import { HorizonStakingSharedTest } from "../../shared/horizon-staking/HorizonStakingShared.t.sol"; import { DataServiceImpFees } from "../implementations/DataServiceImpFees.sol"; -import { IDataServiceFees } from "@graphprotocol/interfaces/contracts/data-service/IDataServiceFees.sol"; +import { StakeClaims } from "../../../../contracts/data-service/libraries/StakeClaims.sol"; import { ProvisionTracker } from "../../../../contracts/data-service/libraries/ProvisionTracker.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; @@ -13,7 +13,7 @@ contract DataServiceFeesTest is HorizonStakingSharedTest { useIndexer useProvisionDataService(address(dataService), PROVISION_TOKENS, 0, 0) { - vm.expectRevert(abi.encodeWithSignature("DataServiceFeesZeroTokens()")); + vm.expectRevert(abi.encodeWithSignature("StakeClaimsZeroTokens()")); dataService.lockStake(users.indexer, 0); } @@ -145,7 +145,7 @@ contract DataServiceFeesTest is HorizonStakingSharedTest { // it should emit a an event vm.expectEmit(); - emit IDataServiceFees.StakeClaimLocked( + emit StakeClaims.StakeClaimLocked( serviceProvider, calcValues.predictedClaimId, calcValues.stakeToLock, @@ -207,14 +207,14 @@ contract DataServiceFeesTest is HorizonStakingSharedTest { break; } - emit IDataServiceFees.StakeClaimReleased(serviceProvider, calcValues.head, claimTokens, releasableAt); + emit StakeClaims.StakeClaimReleased(serviceProvider, calcValues.head, claimTokens, releasableAt); calcValues.head = nextClaim; calcValues.tokensReleased += claimTokens; calcValues.claimsCount++; } // it should emit a an event - emit IDataServiceFees.StakeClaimsReleased(serviceProvider, calcValues.claimsCount, calcValues.tokensReleased); + emit StakeClaims.StakeClaimsReleased(serviceProvider, calcValues.claimsCount, calcValues.tokensReleased); dataService.releaseStake(numClaimsToRelease); // after state diff --git a/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol b/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol new file mode 100644 index 000000000..a5c51d5bd --- /dev/null +++ b/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { ProvisionManager } from "../../../../contracts/data-service/utilities/ProvisionManager.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; +import { PartialControllerMock } from "../../mocks/PartialControllerMock.t.sol"; +import { HorizonStakingMock } from "../../mocks/HorizonStakingMock.t.sol"; +import { ProvisionManagerImpl } from "./ProvisionManagerImpl.t.sol"; + +contract ProvisionManagerTest is Test { + ProvisionManagerImpl internal _provisionManager; + HorizonStakingMock internal _horizonStakingMock; + + function setUp() public { + _horizonStakingMock = new HorizonStakingMock(); + + PartialControllerMock.Entry[] memory entries = new PartialControllerMock.Entry[](1); + entries[0] = PartialControllerMock.Entry({ name: "Staking", addr: address(_horizonStakingMock) }); + _provisionManager = new ProvisionManagerImpl(address(new PartialControllerMock(entries))); + } + + /* solhint-disable graph/func-name-mixedcase */ + + function test_OnlyValidProvision(address serviceProvider) public { + vm.expectRevert( + abi.encodeWithSelector(ProvisionManager.ProvisionManagerProvisionNotFound.selector, serviceProvider) + ); + _provisionManager.onlyValidProvision_(serviceProvider); + + IHorizonStakingTypes.Provision memory provision; + provision.createdAt = 1; + + _horizonStakingMock.setProvision(serviceProvider, address(_provisionManager), provision); + + _provisionManager.onlyValidProvision_(serviceProvider); + } + + function test_OnlyAuthorizedForProvision(address serviceProvider, address sender) public { + vm.expectRevert( + abi.encodeWithSelector(ProvisionManager.ProvisionManagerNotAuthorized.selector, serviceProvider, sender) + ); + vm.prank(sender); + _provisionManager.onlyAuthorizedForProvision_(serviceProvider); + + _horizonStakingMock.setIsAuthorized(serviceProvider, address(_provisionManager), sender, true); + vm.prank(sender); + _provisionManager.onlyAuthorizedForProvision_(serviceProvider); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol b/packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol new file mode 100644 index 000000000..97b8ecce3 --- /dev/null +++ b/packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.27; + +import { ProvisionManager } from "../../../../contracts/data-service/utilities/ProvisionManager.sol"; +import { GraphDirectory } from "../../../../contracts/utilities/GraphDirectory.sol"; + +contract ProvisionManagerImpl is GraphDirectory, ProvisionManager { + constructor(address controller) GraphDirectory(controller) {} + + function onlyValidProvision_(address serviceProvider) public view onlyValidProvision(serviceProvider) {} + + function onlyAuthorizedForProvision_( + address serviceProvider + ) public view onlyAuthorizedForProvision(serviceProvider) {} +} diff --git a/packages/horizon/test/unit/libraries/StakeClaims.t.sol b/packages/horizon/test/unit/libraries/StakeClaims.t.sol new file mode 100644 index 000000000..474949d64 --- /dev/null +++ b/packages/horizon/test/unit/libraries/StakeClaims.t.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { StakeClaims } from "../../../contracts/data-service/libraries/StakeClaims.sol"; + +contract StakeClaimsTest is Test { + /* solhint-disable graph/func-name-mixedcase */ + + function test_BuildStakeClaimId(address dataService, address serviceProvider, uint256 nonce) public pure { + bytes32 id = StakeClaims.buildStakeClaimId(dataService, serviceProvider, nonce); + bytes32 expectedId = keccak256(abi.encodePacked(dataService, serviceProvider, nonce)); + assertEq(id, expectedId, "StakeClaim ID does not match expected value"); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol b/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol new file mode 100644 index 000000000..995442388 --- /dev/null +++ b/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; + +contract HorizonStakingMock { + mapping(address => mapping(address => IHorizonStakingTypes.Provision)) public provisions; + mapping(address => mapping(address => mapping(address => bool))) public authorizations; + + function setProvision( + address serviceProvider, + address verifier, + IHorizonStakingTypes.Provision memory provision + ) external { + provisions[serviceProvider][verifier] = provision; + } + + function getProvision( + address serviceProvider, + address verifier + ) external view returns (IHorizonStakingTypes.Provision memory) { + return provisions[serviceProvider][verifier]; + } + + function isAuthorized(address serviceProvider, address verifier, address operator) external view returns (bool) { + return authorizations[serviceProvider][verifier][operator]; + } + + function setIsAuthorized(address serviceProvider, address verifier, address operator, bool authorized) external { + authorizations[serviceProvider][verifier][operator] = authorized; + } + + function getProviderTokensAvailable(address serviceProvider, address verifier) external view returns (uint256) { + IHorizonStakingTypes.Provision memory provision = provisions[serviceProvider][verifier]; + return provision.tokens - provision.tokensThawing; + } +} diff --git a/packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol b/packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol new file mode 100644 index 000000000..8005c7a01 --- /dev/null +++ b/packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { PartialControllerMock } from "./PartialControllerMock.t.sol"; + +contract InvalidControllerMock is PartialControllerMock { + constructor() PartialControllerMock(new PartialControllerMock.Entry[](0)) {} +} diff --git a/packages/horizon/test/unit/mocks/PartialControllerMock.t.sol b/packages/horizon/test/unit/mocks/PartialControllerMock.t.sol new file mode 100644 index 000000000..946ec46a2 --- /dev/null +++ b/packages/horizon/test/unit/mocks/PartialControllerMock.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { ControllerMock } from "../../../contracts/mocks/ControllerMock.sol"; + +contract PartialControllerMock is ControllerMock, Test { + struct Entry { + string name; + address addr; + } + + address private _invalidContractAddress; + + Entry[] private _contracts; + + constructor(Entry[] memory contracts) ControllerMock(address(0)) { + for (uint256 i = 0; i < contracts.length; i++) { + _contracts.push(Entry({ name: contracts[i].name, addr: contracts[i].addr })); + } + _invalidContractAddress = makeAddr("invalidContractAddress"); + } + + function getContractProxy(bytes32 data) external view override returns (address) { + for (uint256 i = 0; i < _contracts.length; i++) { + if (keccak256(abi.encodePacked(_contracts[i].name)) == data) { + return _contracts[i].addr; + } + } + return _invalidContractAddress; + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol new file mode 100644 index 000000000..99d4d47a4 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; + +contract PaymentsEscrowMock is IPaymentsEscrow { + function initialize() external {} + + function collect(IGraphPayments.PaymentTypes, address, address, uint256, address, uint256, address) external {} + + function deposit(address, address, uint256) external {} + + function depositTo(address, address, address, uint256) external {} + + function thaw(address, address, uint256) external {} + + function cancelThaw(address, address) external {} + + function withdraw(address, address) external {} + + function getBalance(address, address, address) external pure returns (uint256) { + return 0; + } + + function MAX_WAIT_PERIOD() external pure returns (uint256) { + return 0; + } + + function WITHDRAW_ESCROW_THAWING_PERIOD() external pure returns (uint256) { + return 0; + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol new file mode 100644 index 000000000..b4d109678 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IAuthorizable } from "@graphprotocol/interfaces/contracts/horizon/IAuthorizable.sol"; +import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; + +import { AuthorizableTest } from "../../../unit/utilities/Authorizable.t.sol"; +import { InvalidControllerMock } from "../../mocks/InvalidControllerMock.t.sol"; + +contract RecurringCollectorAuthorizableTest is AuthorizableTest { + function newAuthorizable(uint256 thawPeriod) public override returns (IAuthorizable) { + return new RecurringCollector("RecurringCollector", "1", address(new InvalidControllerMock()), thawPeriod); + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol new file mode 100644 index 000000000..b483413ae --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; +import { AuthorizableHelper } from "../../../unit/utilities/Authorizable.t.sol"; +import { Bounder } from "../../../unit/utils/Bounder.t.sol"; + +contract RecurringCollectorHelper is AuthorizableHelper, Bounder { + RecurringCollector public collector; + + constructor( + RecurringCollector collector_ + ) AuthorizableHelper(collector_, collector_.REVOKE_AUTHORIZATION_THAWING_PERIOD()) { + collector = collector_; + } + + function generateSignedRCA( + IRecurringCollector.RecurringCollectionAgreement memory rca, + uint256 signerPrivateKey + ) public view returns (IRecurringCollector.SignedRCA memory) { + bytes32 messageHash = collector.hashRCA(rca); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, messageHash); + bytes memory signature = abi.encodePacked(r, s, v); + IRecurringCollector.SignedRCA memory signedRCA = IRecurringCollector.SignedRCA({ + rca: rca, + signature: signature + }); + + return signedRCA; + } + + function generateSignedRCAU( + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + uint256 signerPrivateKey + ) public view returns (IRecurringCollector.SignedRCAU memory) { + bytes32 messageHash = collector.hashRCAU(rcau); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, messageHash); + bytes memory signature = abi.encodePacked(r, s, v); + IRecurringCollector.SignedRCAU memory signedRCAU = IRecurringCollector.SignedRCAU({ + rcau: rcau, + signature: signature + }); + + return signedRCAU; + } + + function generateSignedRCAUForAgreement( + bytes16 agreementId, + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + uint256 signerPrivateKey + ) public view returns (IRecurringCollector.SignedRCAU memory) { + // Automatically set the correct nonce based on current agreement state + IRecurringCollector.AgreementData memory agreement = collector.getAgreement(agreementId); + rcau.nonce = agreement.updateNonce + 1; + + return generateSignedRCAU(rcau, signerPrivateKey); + } + + function generateSignedRCAUWithCorrectNonce( + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + uint256 signerPrivateKey + ) public view returns (IRecurringCollector.SignedRCAU memory) { + // This is kept for backwards compatibility but should not be used with new interface + // since we can't determine agreementId without it being passed separately + return generateSignedRCAU(rcau, signerPrivateKey); + } + + function generateSignedRCAWithCalculatedId( + IRecurringCollector.RecurringCollectionAgreement memory rca, + uint256 signerPrivateKey + ) public view returns (IRecurringCollector.SignedRCA memory, bytes16) { + // Ensure we have sensible values + rca = sensibleRCA(rca); + + // Calculate the agreement ID + bytes16 agreementId = collector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + + IRecurringCollector.SignedRCA memory signedRCA = generateSignedRCA(rca, signerPrivateKey); + return (signedRCA, agreementId); + } + + function withElapsedAcceptDeadline( + IRecurringCollector.RecurringCollectionAgreement memory rca + ) public view returns (IRecurringCollector.RecurringCollectionAgreement memory) { + require(block.timestamp > 0, "block.timestamp can't be zero"); + require(block.timestamp <= type(uint64).max, "block.timestamp can't be huge"); + rca.deadline = uint64(bound(rca.deadline, 0, block.timestamp - 1)); + return rca; + } + + function withOKAcceptDeadline( + IRecurringCollector.RecurringCollectionAgreement memory rca + ) public view returns (IRecurringCollector.RecurringCollectionAgreement memory) { + require(block.timestamp <= type(uint64).max, "block.timestamp can't be huge"); + rca.deadline = uint64(boundTimestampMin(rca.deadline, block.timestamp)); + return rca; + } + + function sensibleRCA( + IRecurringCollector.RecurringCollectionAgreement memory rca + ) public view returns (IRecurringCollector.RecurringCollectionAgreement memory) { + vm.assume(rca.dataService != address(0)); + vm.assume(rca.payer != address(0)); + vm.assume(rca.serviceProvider != address(0)); + + // Ensure we have a nonce if it's zero + if (rca.nonce == 0) { + rca.nonce = 1; + } + + rca.minSecondsPerCollection = _sensibleMinSecondsPerCollection(rca.minSecondsPerCollection); + rca.maxSecondsPerCollection = _sensibleMaxSecondsPerCollection( + rca.maxSecondsPerCollection, + rca.minSecondsPerCollection + ); + + rca.deadline = _sensibleDeadline(rca.deadline); + rca.endsAt = _sensibleEndsAt(rca.endsAt, rca.maxSecondsPerCollection); + + rca.maxInitialTokens = _sensibleMaxInitialTokens(rca.maxInitialTokens); + rca.maxOngoingTokensPerSecond = _sensibleMaxOngoingTokensPerSecond(rca.maxOngoingTokensPerSecond); + + return rca; + } + + function sensibleRCAU( + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau + ) public view returns (IRecurringCollector.RecurringCollectionAgreementUpdate memory) { + rcau.minSecondsPerCollection = _sensibleMinSecondsPerCollection(rcau.minSecondsPerCollection); + rcau.maxSecondsPerCollection = _sensibleMaxSecondsPerCollection( + rcau.maxSecondsPerCollection, + rcau.minSecondsPerCollection + ); + + rcau.deadline = _sensibleDeadline(rcau.deadline); + rcau.endsAt = _sensibleEndsAt(rcau.endsAt, rcau.maxSecondsPerCollection); + rcau.maxInitialTokens = _sensibleMaxInitialTokens(rcau.maxInitialTokens); + rcau.maxOngoingTokensPerSecond = _sensibleMaxOngoingTokensPerSecond(rcau.maxOngoingTokensPerSecond); + + return rcau; + } + + function _sensibleDeadline(uint256 _seed) internal view returns (uint64) { + return + uint64( + bound(_seed, block.timestamp + 1, block.timestamp + uint256(collector.MIN_SECONDS_COLLECTION_WINDOW())) + ); // between now and +MIN_SECONDS_COLLECTION_WINDOW + } + + function _sensibleEndsAt(uint256 _seed, uint32 _maxSecondsPerCollection) internal view returns (uint64) { + return + uint64( + bound( + _seed, + block.timestamp + (10 * uint256(_maxSecondsPerCollection)), + block.timestamp + (1_000_000 * uint256(_maxSecondsPerCollection)) + ) + ); // between 10 and 1M max collections + } + + function _sensibleMaxSecondsPerCollection( + uint32 _seed, + uint32 _minSecondsPerCollection + ) internal view returns (uint32) { + return + uint32( + bound( + _seed, + _minSecondsPerCollection + uint256(collector.MIN_SECONDS_COLLECTION_WINDOW()), + 60 * 60 * 24 * 30 + ) // between minSecondsPerCollection + 2h and 30 days + ); + } + + function _sensibleMaxInitialTokens(uint256 _seed) internal pure returns (uint256) { + return bound(_seed, 0, 1e18 * 100_000_000); // between 0 and 100M tokens + } + + function _sensibleMaxOngoingTokensPerSecond(uint256 _seed) internal pure returns (uint256) { + return bound(_seed, 1, 1e18); // between 1 and 1e18 tokens per second + } + + function _sensibleMinSecondsPerCollection(uint32 _seed) internal pure returns (uint32) { + return uint32(bound(_seed, 10 * 60, 24 * 60 * 60)); // between 10 min and 24h + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol new file mode 100644 index 000000000..345d1a4f7 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorAcceptTest is RecurringCollectorSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + + function test_Accept(FuzzyTestAccept calldata fuzzyTestAccept) public { + _sensibleAuthorizeAndAccept(fuzzyTestAccept); + } + + function test_Accept_Revert_WhenAcceptanceDeadlineElapsed( + IRecurringCollector.SignedRCA memory fuzzySignedRCA, + uint256 unboundedSkip + ) public { + // Generate deterministic agreement ID for validation + bytes16 agreementId = _recurringCollector.generateAgreementId( + fuzzySignedRCA.rca.payer, + fuzzySignedRCA.rca.dataService, + fuzzySignedRCA.rca.serviceProvider, + fuzzySignedRCA.rca.deadline, + fuzzySignedRCA.rca.nonce + ); + vm.assume(agreementId != bytes16(0)); + skip(boundSkip(unboundedSkip, 1, type(uint64).max - block.timestamp)); + fuzzySignedRCA.rca = _recurringCollectorHelper.withElapsedAcceptDeadline(fuzzySignedRCA.rca); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementDeadlineElapsed.selector, + block.timestamp, + fuzzySignedRCA.rca.deadline + ); + vm.expectRevert(expectedErr); + vm.prank(fuzzySignedRCA.rca.dataService); + _recurringCollector.accept(fuzzySignedRCA); + } + + function test_Accept_Revert_WhenAlreadyAccepted(FuzzyTestAccept calldata fuzzyTestAccept) public { + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzyTestAccept + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + agreementId, + IRecurringCollector.AgreementState.Accepted + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.accept(accepted); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/base.t.sol b/packages/horizon/test/unit/payments/recurring-collector/base.t.sol new file mode 100644 index 000000000..d1837837a --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/base.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorBaseTest is RecurringCollectorSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + + function test_RecoverRCASigner(FuzzyTestAccept memory fuzzyTestAccept) public view { + uint256 signerKey = boundKey(fuzzyTestAccept.unboundedSignerKey); + IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA( + fuzzyTestAccept.rca, + signerKey + ); + + assertEq( + _recurringCollector.recoverRCASigner(signedRCA), + vm.addr(signerKey), + "Recovered RCA signer does not match" + ); + } + + function test_RecoverRCAUSigner(FuzzyTestUpdate memory fuzzyTestUpdate) public view { + uint256 signerKey = boundKey(fuzzyTestUpdate.fuzzyTestAccept.unboundedSignerKey); + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + fuzzyTestUpdate.rcau, + signerKey + ); + + assertEq( + _recurringCollector.recoverRCAUSigner(signedRCAU), + vm.addr(signerKey), + "Recovered RCAU signer does not match" + ); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol new file mode 100644 index 000000000..a6128a7b5 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorCancelTest is RecurringCollectorSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + + function test_Cancel(FuzzyTestAccept calldata fuzzyTestAccept, uint8 unboundedCanceler) public { + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzyTestAccept + ); + + _cancel(accepted.rca, agreementId, _fuzzyCancelAgreementBy(unboundedCanceler)); + } + + function test_Cancel_Revert_WhenNotAccepted( + IRecurringCollector.RecurringCollectionAgreement memory fuzzyRCA, + uint8 unboundedCanceler + ) public { + // Generate deterministic agreement ID + bytes16 agreementId = _recurringCollector.generateAgreementId( + fuzzyRCA.payer, + fuzzyRCA.dataService, + fuzzyRCA.serviceProvider, + fuzzyRCA.deadline, + fuzzyRCA.nonce + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + agreementId, + IRecurringCollector.AgreementState.NotAccepted + ); + vm.expectRevert(expectedErr); + vm.prank(fuzzyRCA.dataService); + _recurringCollector.cancel(agreementId, _fuzzyCancelAgreementBy(unboundedCanceler)); + } + + function test_Cancel_Revert_WhenNotDataService( + FuzzyTestAccept calldata fuzzyTestAccept, + uint8 unboundedCanceler, + address notDataService + ) public { + vm.assume(fuzzyTestAccept.rca.dataService != notDataService); + + (, , bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzyTestAccept); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorDataServiceNotAuthorized.selector, + agreementId, + notDataService + ); + vm.expectRevert(expectedErr); + vm.prank(notDataService); + _recurringCollector.cancel(agreementId, _fuzzyCancelAgreementBy(unboundedCanceler)); + } + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol new file mode 100644 index 000000000..818019277 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + + function test_Collect_Revert_WhenInvalidData(address caller, uint8 unboundedPaymentType, bytes memory data) public { + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorInvalidCollectData.selector, + data + ); + vm.expectRevert(expectedErr); + vm.prank(caller); + _recurringCollector.collect(_paymentType(unboundedPaymentType), data); + } + + function test_Collect_Revert_WhenCallerNotDataService( + FuzzyTestCollect calldata fuzzy, + address notDataService + ) public { + vm.assume(fuzzy.fuzzyTestAccept.rca.dataService != notDataService); + + (, , bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams; + + skip(1); + collectParams.agreementId = agreementId; + bytes memory data = _generateCollectData(collectParams); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorDataServiceNotAuthorized.selector, + collectParams.agreementId, + notDataService + ); + vm.expectRevert(expectedErr); + vm.prank(notDataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + } + + function test_Collect_Revert_WhenUnauthorizedDataService(FuzzyTestCollect calldata fuzzy) public { + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); + IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams; + collectParams.agreementId = agreementId; + collectParams.tokens = bound(collectParams.tokens, 1, type(uint256).max); + bytes memory data = _generateCollectData(collectParams); + + skip(1); + + // Set up the scenario where service provider has no tokens staked with data service + // This simulates an unauthorized data service attack + _horizonStaking.setProvision( + accepted.rca.serviceProvider, + accepted.rca.dataService, + IHorizonStakingTypes.Provision({ + tokens: 0, // No tokens staked - this triggers the vulnerability + tokensThawing: 0, + sharesThawing: 0, + maxVerifierCut: 100000, + thawingPeriod: 604800, + createdAt: uint64(block.timestamp), + maxVerifierCutPending: 100000, + thawingPeriodPending: 604800, + lastParametersStagedAt: 0, + thawingNonce: 0 + }) + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorUnauthorizedDataService.selector, + accepted.rca.dataService + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + } + + function test_Collect_Revert_WhenUnknownAgreement(FuzzyTestCollect memory fuzzy, address dataService) public { + bytes memory data = _generateCollectData(fuzzy.collectParams); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementNotCollectable.selector, + fuzzy.collectParams.agreementId, + IRecurringCollector.AgreementNotCollectableReason.InvalidAgreementState + ); + vm.expectRevert(expectedErr); + vm.prank(dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + } + + function test_Collect_Revert_WhenCanceledAgreementByServiceProvider(FuzzyTestCollect calldata fuzzy) public { + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); + _cancel(accepted.rca, agreementId, IRecurringCollector.CancelAgreementBy.ServiceProvider); + IRecurringCollector.CollectParams memory collectData = fuzzy.collectParams; + collectData.tokens = bound(collectData.tokens, 1, type(uint256).max); + IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( + accepted.rca, + agreementId, + collectData.collectionId, + collectData.tokens, + collectData.dataServiceCut + ); + bytes memory data = _generateCollectData(collectParams); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementNotCollectable.selector, + collectParams.agreementId, + IRecurringCollector.AgreementNotCollectableReason.InvalidAgreementState + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + } + + function test_Collect_Revert_WhenCollectingTooSoon( + FuzzyTestCollect calldata fuzzy, + uint256 unboundedCollectionSeconds + ) public { + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); + + skip(accepted.rca.minSecondsPerCollection); + bytes memory data = _generateCollectData( + _generateCollectParams( + accepted.rca, + agreementId, + fuzzy.collectParams.collectionId, + 1, + fuzzy.collectParams.dataServiceCut + ) + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + + uint256 collectionSeconds = boundSkip(unboundedCollectionSeconds, 1, accepted.rca.minSecondsPerCollection - 1); + skip(collectionSeconds); + + IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( + accepted.rca, + agreementId, + fuzzy.collectParams.collectionId, + bound(fuzzy.collectParams.tokens, 1, type(uint256).max), + fuzzy.collectParams.dataServiceCut + ); + data = _generateCollectData(collectParams); + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorCollectionTooSoon.selector, + collectParams.agreementId, + collectionSeconds, + accepted.rca.minSecondsPerCollection + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + } + + function test_Collect_Revert_WhenCollectingTooLate( + FuzzyTestCollect calldata fuzzy, + uint256 unboundedFirstCollectionSeconds, + uint256 unboundedSecondCollectionSeconds + ) public { + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); + + // skip to collectable time + + skip( + boundSkip( + unboundedFirstCollectionSeconds, + accepted.rca.minSecondsPerCollection, + accepted.rca.maxSecondsPerCollection + ) + ); + bytes memory data = _generateCollectData( + _generateCollectParams( + accepted.rca, + agreementId, + fuzzy.collectParams.collectionId, + 1, + fuzzy.collectParams.dataServiceCut + ) + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + + // skip beyond collectable time but still within the agreement endsAt + uint256 collectionSeconds = boundSkip( + unboundedSecondCollectionSeconds, + accepted.rca.maxSecondsPerCollection + 1, + accepted.rca.endsAt - block.timestamp + ); + skip(collectionSeconds); + + data = _generateCollectData( + _generateCollectParams( + accepted.rca, + agreementId, + fuzzy.collectParams.collectionId, + bound(fuzzy.collectParams.tokens, 1, type(uint256).max), + fuzzy.collectParams.dataServiceCut + ) + ); + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorCollectionTooLate.selector, + agreementId, + collectionSeconds, + accepted.rca.maxSecondsPerCollection + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + } + + function test_Collect_OK_WhenCollectingTooMuch( + FuzzyTestCollect calldata fuzzy, + uint256 unboundedInitialCollectionSeconds, + uint256 unboundedCollectionSeconds, + uint256 unboundedTokens, + bool testInitialCollection + ) public { + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); + + if (!testInitialCollection) { + // skip to collectable time + skip( + boundSkip( + unboundedInitialCollectionSeconds, + accepted.rca.minSecondsPerCollection, + accepted.rca.maxSecondsPerCollection + ) + ); + bytes memory initialData = _generateCollectData( + _generateCollectParams( + accepted.rca, + agreementId, + fuzzy.collectParams.collectionId, + 1, + fuzzy.collectParams.dataServiceCut + ) + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), initialData); + } + + // skip to collectable time + uint256 collectionSeconds = boundSkip( + unboundedCollectionSeconds, + accepted.rca.minSecondsPerCollection, + accepted.rca.maxSecondsPerCollection + ); + skip(collectionSeconds); + uint256 maxTokens = accepted.rca.maxOngoingTokensPerSecond * collectionSeconds; + maxTokens += testInitialCollection ? accepted.rca.maxInitialTokens : 0; + uint256 tokens = bound(unboundedTokens, maxTokens + 1, type(uint256).max); + IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( + accepted.rca, + agreementId, + fuzzy.collectParams.collectionId, + tokens, + fuzzy.collectParams.dataServiceCut + ); + bytes memory data = _generateCollectData(collectParams); + vm.prank(accepted.rca.dataService); + uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + assertEq(collected, maxTokens); + } + + function test_Collect_OK( + FuzzyTestCollect calldata fuzzy, + uint256 unboundedCollectionSeconds, + uint256 unboundedTokens + ) public { + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); + + (bytes memory data, uint256 collectionSeconds, uint256 tokens) = _generateValidCollection( + accepted.rca, + fuzzy.collectParams, + unboundedCollectionSeconds, + unboundedTokens + ); + + skip(collectionSeconds); + _expectCollectCallAndEmit( + accepted.rca, + agreementId, + _paymentType(fuzzy.unboundedPaymentType), + fuzzy.collectParams, + tokens + ); + vm.prank(accepted.rca.dataService); + uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + assertEq(collected, tokens); + } + + function test_Collect_RevertWhen_ExceedsMaxSlippage() public { + // Setup: Create agreement with known parameters + IRecurringCollector.RecurringCollectionAgreement memory rca; + rca.deadline = uint64(block.timestamp + 1000); + rca.endsAt = uint64(block.timestamp + 2000); + rca.payer = address(0x123); + rca.dataService = address(0x456); + rca.serviceProvider = address(0x789); + rca.maxInitialTokens = 0; // No initial tokens to keep calculation simple + rca.maxOngoingTokensPerSecond = 1 ether; // 1 token per second + rca.minSecondsPerCollection = 60; // 1 minute + rca.maxSecondsPerCollection = 3600; // 1 hour + rca.nonce = 1; + rca.metadata = ""; + + // Accept the agreement + _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, 1); + IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(rca, 1); + bytes16 agreementId = _accept(signedRCA); + + // Do a first collection to use up initial tokens allowance + skip(rca.minSecondsPerCollection); + IRecurringCollector.CollectParams memory firstCollection = IRecurringCollector.CollectParams({ + agreementId: agreementId, + collectionId: keccak256("first"), + tokens: 1 ether, // Small amount + dataServiceCut: 0, + receiverDestination: rca.serviceProvider, + maxSlippage: type(uint256).max + }); + vm.prank(rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, _generateCollectData(firstCollection)); + + // Wait minimum collection time again for second collection + skip(rca.minSecondsPerCollection); + + // Calculate expected narrowing: max allowed is 60 tokens (60 seconds * 1 token/second) + uint256 maxAllowed = rca.maxOngoingTokensPerSecond * rca.minSecondsPerCollection; // 60 tokens + uint256 requested = maxAllowed + 50 ether; // Request 110 tokens + uint256 expectedSlippage = requested - maxAllowed; // 50 tokens + uint256 maxSlippage = expectedSlippage - 1; // Allow up to 49 tokens slippage + + // Create collect params with slippage protection + IRecurringCollector.CollectParams memory collectParams = IRecurringCollector.CollectParams({ + agreementId: agreementId, + collectionId: keccak256("test"), + tokens: requested, + dataServiceCut: 0, + receiverDestination: rca.serviceProvider, + maxSlippage: maxSlippage + }); + + bytes memory data = _generateCollectData(collectParams); + + // Expect revert due to excessive slippage (50 > 49) + vm.expectRevert( + abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorExcessiveSlippage.selector, + requested, + maxAllowed, + maxSlippage + ) + ); + vm.prank(rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_OK_WithMaxSlippageDisabled() public { + // Setup: Create agreement with known parameters + IRecurringCollector.RecurringCollectionAgreement memory rca; + rca.deadline = uint64(block.timestamp + 1000); + rca.endsAt = uint64(block.timestamp + 2000); + rca.payer = address(0x123); + rca.dataService = address(0x456); + rca.serviceProvider = address(0x789); + rca.maxInitialTokens = 0; // No initial tokens to keep calculation simple + rca.maxOngoingTokensPerSecond = 1 ether; // 1 token per second + rca.minSecondsPerCollection = 60; // 1 minute + rca.maxSecondsPerCollection = 3600; // 1 hour + rca.nonce = 1; + rca.metadata = ""; + + // Accept the agreement + _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, 1); + IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(rca, 1); + bytes16 agreementId = _accept(signedRCA); + + // Do a first collection to use up initial tokens allowance + skip(rca.minSecondsPerCollection); + IRecurringCollector.CollectParams memory firstCollection = IRecurringCollector.CollectParams({ + agreementId: agreementId, + collectionId: keccak256("first"), + tokens: 1 ether, // Small amount + dataServiceCut: 0, + receiverDestination: rca.serviceProvider, + maxSlippage: type(uint256).max + }); + vm.prank(rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, _generateCollectData(firstCollection)); + + // Wait minimum collection time again for second collection + skip(rca.minSecondsPerCollection); + + // Calculate expected narrowing: max allowed is 60 tokens (60 seconds * 1 token/second) + uint256 maxAllowed = rca.maxOngoingTokensPerSecond * rca.minSecondsPerCollection; // 60 tokens + uint256 requested = maxAllowed + 50 ether; // Request 110 tokens (will be narrowed to 60) + + // Create collect params with slippage disabled (type(uint256).max) + IRecurringCollector.CollectParams memory collectParams = IRecurringCollector.CollectParams({ + agreementId: agreementId, + collectionId: keccak256("test"), + tokens: requested, + dataServiceCut: 0, + receiverDestination: rca.serviceProvider, + maxSlippage: type(uint256).max + }); + + bytes memory data = _generateCollectData(collectParams); + + // Should succeed despite slippage when maxSlippage is disabled + _expectCollectCallAndEmit( + rca, + agreementId, + IGraphPayments.PaymentTypes.IndexingFee, + collectParams, + maxAllowed // Will collect the narrowed amount + ); + + vm.prank(rca.dataService); + uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + assertEq(collected, maxAllowed); + } + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol new file mode 100644 index 000000000..54ebae9a7 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IPaymentsCollector } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; +import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; + +import { Bounder } from "../../../unit/utils/Bounder.t.sol"; +import { PartialControllerMock } from "../../mocks/PartialControllerMock.t.sol"; +import { HorizonStakingMock } from "../../mocks/HorizonStakingMock.t.sol"; +import { PaymentsEscrowMock } from "./PaymentsEscrowMock.t.sol"; +import { RecurringCollectorHelper } from "./RecurringCollectorHelper.t.sol"; + +contract RecurringCollectorSharedTest is Test, Bounder { + struct FuzzyTestCollect { + FuzzyTestAccept fuzzyTestAccept; + uint8 unboundedPaymentType; + IRecurringCollector.CollectParams collectParams; + } + + struct FuzzyTestAccept { + IRecurringCollector.RecurringCollectionAgreement rca; + uint256 unboundedSignerKey; + } + + struct FuzzyTestUpdate { + FuzzyTestAccept fuzzyTestAccept; + IRecurringCollector.RecurringCollectionAgreementUpdate rcau; + } + + RecurringCollector internal _recurringCollector; + PaymentsEscrowMock internal _paymentsEscrow; + HorizonStakingMock internal _horizonStaking; + RecurringCollectorHelper internal _recurringCollectorHelper; + + function setUp() public { + _paymentsEscrow = new PaymentsEscrowMock(); + _horizonStaking = new HorizonStakingMock(); + PartialControllerMock.Entry[] memory entries = new PartialControllerMock.Entry[](2); + entries[0] = PartialControllerMock.Entry({ name: "PaymentsEscrow", addr: address(_paymentsEscrow) }); + entries[1] = PartialControllerMock.Entry({ name: "Staking", addr: address(_horizonStaking) }); + _recurringCollector = new RecurringCollector( + "RecurringCollector", + "1", + address(new PartialControllerMock(entries)), + 1 + ); + _recurringCollectorHelper = new RecurringCollectorHelper(_recurringCollector); + } + + function _sensibleAuthorizeAndAccept( + FuzzyTestAccept calldata _fuzzyTestAccept + ) internal returns (IRecurringCollector.SignedRCA memory, uint256 key, bytes16 agreementId) { + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + _fuzzyTestAccept.rca + ); + key = boundKey(_fuzzyTestAccept.unboundedSignerKey); + IRecurringCollector.SignedRCA memory signedRCA; + (signedRCA, agreementId) = _authorizeAndAccept(rca, key); + return (signedRCA, key, agreementId); + } + + // authorizes signer, signs the RCA, and accepts it + function _authorizeAndAccept( + IRecurringCollector.RecurringCollectionAgreement memory _rca, + uint256 _signerKey + ) internal returns (IRecurringCollector.SignedRCA memory, bytes16 agreementId) { + _recurringCollectorHelper.authorizeSignerWithChecks(_rca.payer, _signerKey); + IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(_rca, _signerKey); + + agreementId = _accept(signedRCA); + return (signedRCA, agreementId); + } + + function _accept(IRecurringCollector.SignedRCA memory _signedRCA) internal returns (bytes16) { + // Set up valid staking provision by default to allow collections to succeed + _setupValidProvision(_signedRCA.rca.serviceProvider, _signedRCA.rca.dataService); + + // Calculate the expected agreement ID for verification + bytes16 expectedAgreementId = _recurringCollector.generateAgreementId( + _signedRCA.rca.payer, + _signedRCA.rca.dataService, + _signedRCA.rca.serviceProvider, + _signedRCA.rca.deadline, + _signedRCA.rca.nonce + ); + + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.AgreementAccepted( + _signedRCA.rca.dataService, + _signedRCA.rca.payer, + _signedRCA.rca.serviceProvider, + expectedAgreementId, + uint64(block.timestamp), + _signedRCA.rca.endsAt, + _signedRCA.rca.maxInitialTokens, + _signedRCA.rca.maxOngoingTokensPerSecond, + _signedRCA.rca.minSecondsPerCollection, + _signedRCA.rca.maxSecondsPerCollection + ); + vm.prank(_signedRCA.rca.dataService); + bytes16 actualAgreementId = _recurringCollector.accept(_signedRCA); + + // Verify the agreement ID matches expectation + assertEq(actualAgreementId, expectedAgreementId); + return actualAgreementId; + } + + function _setupValidProvision(address _serviceProvider, address _dataService) internal { + _horizonStaking.setProvision( + _serviceProvider, + _dataService, + IHorizonStakingTypes.Provision({ + tokens: 1000 ether, + tokensThawing: 0, + sharesThawing: 0, + maxVerifierCut: 100000, // 10% + thawingPeriod: 604800, // 7 days + createdAt: uint64(block.timestamp), + maxVerifierCutPending: 100000, + thawingPeriodPending: 604800, + lastParametersStagedAt: 0, + thawingNonce: 0 + }) + ); + } + + function _cancel( + IRecurringCollector.RecurringCollectionAgreement memory _rca, + bytes16 _agreementId, + IRecurringCollector.CancelAgreementBy _by + ) internal { + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.AgreementCanceled( + _rca.dataService, + _rca.payer, + _rca.serviceProvider, + _agreementId, + uint64(block.timestamp), + _by + ); + vm.prank(_rca.dataService); + _recurringCollector.cancel(_agreementId, _by); + } + + function _expectCollectCallAndEmit( + IRecurringCollector.RecurringCollectionAgreement memory _rca, + bytes16 _agreementId, + IGraphPayments.PaymentTypes __paymentType, + IRecurringCollector.CollectParams memory _fuzzyParams, + uint256 _tokens + ) internal { + vm.expectCall( + address(_paymentsEscrow), + abi.encodeCall( + _paymentsEscrow.collect, + ( + __paymentType, + _rca.payer, + _rca.serviceProvider, + _tokens, + _rca.dataService, + _fuzzyParams.dataServiceCut, + _rca.serviceProvider + ) + ) + ); + vm.expectEmit(address(_recurringCollector)); + emit IPaymentsCollector.PaymentCollected( + __paymentType, + _fuzzyParams.collectionId, + _rca.payer, + _rca.serviceProvider, + _rca.dataService, + _tokens + ); + + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.RCACollected( + _rca.dataService, + _rca.payer, + _rca.serviceProvider, + _agreementId, + _fuzzyParams.collectionId, + _tokens, + _fuzzyParams.dataServiceCut + ); + } + + function _generateValidCollection( + IRecurringCollector.RecurringCollectionAgreement memory _rca, + IRecurringCollector.CollectParams memory _fuzzyParams, + uint256 _unboundedCollectionSkip, + uint256 _unboundedTokens + ) internal view returns (bytes memory, uint256, uint256) { + uint256 collectionSeconds = boundSkip( + _unboundedCollectionSkip, + _rca.minSecondsPerCollection, + _rca.maxSecondsPerCollection + ); + uint256 tokens = bound(_unboundedTokens, 1, _rca.maxOngoingTokensPerSecond * collectionSeconds); + + // Generate the agreement ID deterministically + bytes16 agreementId = _recurringCollector.generateAgreementId( + _rca.payer, + _rca.dataService, + _rca.serviceProvider, + _rca.deadline, + _rca.nonce + ); + + bytes memory data = _generateCollectData( + _generateCollectParams(_rca, agreementId, _fuzzyParams.collectionId, tokens, _fuzzyParams.dataServiceCut) + ); + + return (data, collectionSeconds, tokens); + } + + function _generateCollectParams( + IRecurringCollector.RecurringCollectionAgreement memory _rca, + bytes16 _agreementId, + bytes32 _collectionId, + uint256 _tokens, + uint256 _dataServiceCut + ) internal pure returns (IRecurringCollector.CollectParams memory) { + return + IRecurringCollector.CollectParams({ + agreementId: _agreementId, + collectionId: _collectionId, + tokens: _tokens, + dataServiceCut: _dataServiceCut, + receiverDestination: _rca.serviceProvider, + maxSlippage: type(uint256).max + }); + } + + function _generateCollectData( + IRecurringCollector.CollectParams memory _params + ) internal pure returns (bytes memory) { + return abi.encode(_params); + } + + function _fuzzyCancelAgreementBy(uint8 _seed) internal pure returns (IRecurringCollector.CancelAgreementBy) { + return + IRecurringCollector.CancelAgreementBy( + bound(_seed, 0, uint256(IRecurringCollector.CancelAgreementBy.Payer)) + ); + } + + function _paymentType(uint8 _unboundedPaymentType) internal pure returns (IGraphPayments.PaymentTypes) { + return + IGraphPayments.PaymentTypes( + bound( + _unboundedPaymentType, + uint256(type(IGraphPayments.PaymentTypes).min), + uint256(type(IGraphPayments.PaymentTypes).max) + ) + ); + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol new file mode 100644 index 000000000..70f42af8a --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + + function test_Update_Revert_WhenUpdateElapsed( + IRecurringCollector.RecurringCollectionAgreement memory rca, + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + uint256 unboundedUpdateSkip + ) public { + rca = _recurringCollectorHelper.sensibleRCA(rca); + rcau = _recurringCollectorHelper.sensibleRCAU(rcau); + // Generate deterministic agreement ID + bytes16 agreementId = _recurringCollector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + rcau.agreementId = agreementId; + + boundSkipCeil(unboundedUpdateSkip, type(uint64).max); + rcau.deadline = uint64(bound(rcau.deadline, 0, block.timestamp - 1)); + IRecurringCollector.SignedRCAU memory signedRCAU = IRecurringCollector.SignedRCAU({ + rcau: rcau, + signature: "" + }); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementDeadlineElapsed.selector, + block.timestamp, + rcau.deadline + ); + vm.expectRevert(expectedErr); + vm.prank(rca.dataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_Revert_WhenNeverAccepted( + IRecurringCollector.RecurringCollectionAgreement memory rca, + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau + ) public { + rca = _recurringCollectorHelper.sensibleRCA(rca); + rcau = _recurringCollectorHelper.sensibleRCAU(rcau); + // Generate deterministic agreement ID + bytes16 agreementId = _recurringCollector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + rcau.agreementId = agreementId; + + rcau.deadline = uint64(block.timestamp); + IRecurringCollector.SignedRCAU memory signedRCAU = IRecurringCollector.SignedRCAU({ + rcau: rcau, + signature: "" + }); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + rcau.agreementId, + IRecurringCollector.AgreementState.NotAccepted + ); + vm.expectRevert(expectedErr); + vm.prank(rca.dataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_Revert_WhenDataServiceNotAuthorized( + FuzzyTestUpdate calldata fuzzyTestUpdate, + address notDataService + ) public { + vm.assume(fuzzyTestUpdate.fuzzyTestAccept.rca.dataService != notDataService); + (, uint256 signerKey, bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau.agreementId = agreementId; + + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAUWithCorrectNonce( + rcau, + signerKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorDataServiceNotAuthorized.selector, + signedRCAU.rcau.agreementId, + notDataService + ); + vm.expectRevert(expectedErr); + vm.prank(notDataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_Revert_WhenInvalidSigner( + FuzzyTestUpdate calldata fuzzyTestUpdate, + uint256 unboundedInvalidSignerKey + ) public { + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); + uint256 invalidSignerKey = boundKey(unboundedInvalidSignerKey); + vm.assume(signerKey != invalidSignerKey); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau.agreementId = agreementId; + + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + rcau, + invalidSignerKey + ); + + vm.expectRevert(IRecurringCollector.RecurringCollectorInvalidSigner.selector); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_OK(FuzzyTestUpdate calldata fuzzyTestUpdate) public { + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau.agreementId = agreementId; + // Don't use fuzzed nonce - use correct nonce for first update + rcau.nonce = 1; + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + rcau, + signerKey + ); + + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.AgreementUpdated( + accepted.rca.dataService, + accepted.rca.payer, + accepted.rca.serviceProvider, + rcau.agreementId, + uint64(block.timestamp), + rcau.endsAt, + rcau.maxInitialTokens, + rcau.maxOngoingTokensPerSecond, + rcau.minSecondsPerCollection, + rcau.maxSecondsPerCollection + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU); + + IRecurringCollector.AgreementData memory agreement = _recurringCollector.getAgreement(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_Update_Revert_WhenInvalidNonce_TooLow(FuzzyTestUpdate calldata fuzzyTestUpdate) public { + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau.agreementId = agreementId; + rcau.nonce = 0; // Invalid: should be 1 for first update + + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + rcau, + signerKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector, + rcau.agreementId, + 1, // expected + 0 // provided + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_Revert_WhenInvalidNonce_TooHigh(FuzzyTestUpdate calldata fuzzyTestUpdate) public { + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau.agreementId = agreementId; + rcau.nonce = 5; // Invalid: should be 1 for first update + + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + rcau, + signerKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector, + rcau.agreementId, + 1, // expected + 5 // provided + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_Revert_WhenReplayAttack(FuzzyTestUpdate calldata fuzzyTestUpdate) public { + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau1.agreementId = agreementId; + rcau1.nonce = 1; + + // First update succeeds + IRecurringCollector.SignedRCAU memory signedRCAU1 = _recurringCollectorHelper.generateSignedRCAU( + rcau1, + signerKey + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU1); + + // Second update with different terms and nonce 2 succeeds + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = rcau1; + rcau2.nonce = 2; + rcau2.maxOngoingTokensPerSecond = rcau1.maxOngoingTokensPerSecond * 2; // Different terms + + IRecurringCollector.SignedRCAU memory signedRCAU2 = _recurringCollectorHelper.generateSignedRCAU( + rcau2, + signerKey + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU2); + + // Attempting to replay first update should fail + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector, + rcau1.agreementId, + 3, // expected (current nonce + 1) + 1 // provided (old nonce) + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU1); + } + + function test_Update_OK_NonceIncrementsCorrectly(FuzzyTestUpdate calldata fuzzyTestUpdate) public { + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); + + // Initial nonce should be 0 + IRecurringCollector.AgreementData memory initialAgreement = _recurringCollector.getAgreement(agreementId); + assertEq(initialAgreement.updateNonce, 0); + + // First update with nonce 1 + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau1.agreementId = agreementId; + rcau1.nonce = 1; + + IRecurringCollector.SignedRCAU memory signedRCAU1 = _recurringCollectorHelper.generateSignedRCAU( + rcau1, + signerKey + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU1); + + // Verify nonce incremented to 1 + IRecurringCollector.AgreementData memory updatedAgreement1 = _recurringCollector.getAgreement(agreementId); + assertEq(updatedAgreement1.updateNonce, 1); + + // Second update with nonce 2 + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = rcau1; + rcau2.nonce = 2; + rcau2.maxOngoingTokensPerSecond = rcau1.maxOngoingTokensPerSecond * 2; // Different terms + + IRecurringCollector.SignedRCAU memory signedRCAU2 = _recurringCollectorHelper.generateSignedRCAU( + rcau2, + signerKey + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU2); + + // Verify nonce incremented to 2 + IRecurringCollector.AgreementData memory updatedAgreement2 = _recurringCollector.getAgreement(agreementId); + assertEq(updatedAgreement2.updateNonce, 2); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/utilities/Authorizable.t.sol b/packages/horizon/test/unit/utilities/Authorizable.t.sol index 420cd01ff..66c4bb921 100644 --- a/packages/horizon/test/unit/utilities/Authorizable.t.sol +++ b/packages/horizon/test/unit/utilities/Authorizable.t.sol @@ -14,23 +14,27 @@ contract AuthorizableImp is Authorizable { } contract AuthorizableTest is Test, Bounder { - AuthorizableImp public authorizable; + IAuthorizable public authorizable; AuthorizableHelper authHelper; modifier withFuzzyThaw(uint256 _thawPeriod) { // Max thaw period is 1 year to allow for thawing tests _thawPeriod = bound(_thawPeriod, 1, 60 * 60 * 24 * 365); - setupAuthorizable(new AuthorizableImp(_thawPeriod)); + setupAuthorizable(_thawPeriod); _; } - function setUp() public virtual { - setupAuthorizable(new AuthorizableImp(0)); + function setUp() public { + setupAuthorizable(0); } - function setupAuthorizable(AuthorizableImp _authorizable) internal { - authorizable = _authorizable; - authHelper = new AuthorizableHelper(authorizable); + function setupAuthorizable(uint256 _thawPeriod) internal { + authorizable = newAuthorizable(_thawPeriod); + authHelper = new AuthorizableHelper(authorizable, _thawPeriod); + } + + function newAuthorizable(uint256 _thawPeriod) public virtual returns (IAuthorizable) { + return new AuthorizableImp(_thawPeriod); } function test_AuthorizeSigner(uint256 _unboundedKey, address _authorizer) public { @@ -304,12 +308,12 @@ contract AuthorizableTest is Test, Bounder { authHelper.authorizeAndThawSignerWithChecks(_authorizer, signerKey); - _skip = bound(_skip, 0, authorizable.REVOKE_AUTHORIZATION_THAWING_PERIOD() - 1); + _skip = bound(_skip, 0, authHelper.revokeAuthorizationThawingPeriod() - 1); skip(_skip); bytes memory expectedErr = abi.encodeWithSelector( IAuthorizable.AuthorizableSignerStillThawing.selector, block.timestamp, - block.timestamp - _skip + authorizable.REVOKE_AUTHORIZATION_THAWING_PERIOD() + block.timestamp - _skip + authHelper.revokeAuthorizationThawingPeriod() ); vm.expectRevert(expectedErr); vm.prank(_authorizer); @@ -322,17 +326,19 @@ contract AuthorizableTest is Test, Bounder { } contract AuthorizableHelper is Test { - AuthorizableImp internal authorizable; + IAuthorizable internal authorizable; + uint256 public revokeAuthorizationThawingPeriod; - constructor(AuthorizableImp _authorizable) { + constructor(IAuthorizable _authorizable, uint256 _thawPeriod) { authorizable = _authorizable; + revokeAuthorizationThawingPeriod = _thawPeriod; } function authorizeAndThawSignerWithChecks(address _authorizer, uint256 _signerKey) public { address signer = vm.addr(_signerKey); authorizeSignerWithChecks(_authorizer, _signerKey); - uint256 thawEndTimestamp = block.timestamp + authorizable.REVOKE_AUTHORIZATION_THAWING_PERIOD(); + uint256 thawEndTimestamp = block.timestamp + revokeAuthorizationThawingPeriod; vm.expectEmit(address(authorizable)); emit IAuthorizable.SignerThawing(_authorizer, signer, thawEndTimestamp); vm.prank(_authorizer); @@ -344,7 +350,7 @@ contract AuthorizableHelper is Test { function authorizeAndRevokeSignerWithChecks(address _authorizer, uint256 _signerKey) public { address signer = vm.addr(_signerKey); authorizeAndThawSignerWithChecks(_authorizer, _signerKey); - skip(authorizable.REVOKE_AUTHORIZATION_THAWING_PERIOD() + 1); + skip(revokeAuthorizationThawingPeriod + 1); vm.expectEmit(address(authorizable)); emit IAuthorizable.SignerRevoked(_authorizer, signer); vm.prank(_authorizer); @@ -357,6 +363,7 @@ contract AuthorizableHelper is Test { address signer = vm.addr(_signerKey); assertNotAuthorized(_authorizer, signer); + require(block.timestamp < type(uint256).max, "Test cannot be run at the end of time"); uint256 proofDeadline = block.timestamp + 1; bytes memory proof = generateAuthorizationProof( block.chainid, diff --git a/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol b/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol index 80c7d231d..c3736d3ff 100644 --- a/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol +++ b/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol @@ -22,6 +22,7 @@ contract GraphDirectoryImplementation is GraphDirectory { function getContractFromController(bytes memory contractName) external view returns (address) { return _graphController().getContractProxy(keccak256(contractName)); } + function graphToken() external view returns (IGraphToken) { return _graphToken(); } diff --git a/packages/horizon/test/unit/utils/Bounder.t.sol b/packages/horizon/test/unit/utils/Bounder.t.sol index 82ba2ff15..58e2fa324 100644 --- a/packages/horizon/test/unit/utils/Bounder.t.sol +++ b/packages/horizon/test/unit/utils/Bounder.t.sol @@ -6,18 +6,22 @@ import { Test } from "forge-std/Test.sol"; contract Bounder is Test { uint256 constant SECP256K1_CURVE_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + function boundKeyAndAddr(uint256 _value) internal pure returns (uint256, address) { + uint256 key = bound(_value, 1, SECP256K1_CURVE_ORDER - 1); + return (key, vm.addr(key)); + } + function boundAddrAndKey(uint256 _value) internal pure returns (uint256, address) { - uint256 signerKey = bound(_value, 1, SECP256K1_CURVE_ORDER - 1); - return (signerKey, vm.addr(signerKey)); + return boundKeyAndAddr(_value); } function boundAddr(uint256 _value) internal pure returns (address) { - (, address addr) = boundAddrAndKey(_value); + (, address addr) = boundKeyAndAddr(_value); return addr; } function boundKey(uint256 _value) internal pure returns (uint256) { - (uint256 key, ) = boundAddrAndKey(_value); + (uint256 key, ) = boundKeyAndAddr(_value); return key; } @@ -28,4 +32,21 @@ contract Bounder is Test { function boundTimestampMin(uint256 _value, uint256 _min) internal pure returns (uint256) { return bound(_value, _min, type(uint256).max); } + + function boundSkipFloor(uint256 _value, uint256 _min) internal view returns (uint256) { + return boundSkip(_value, _min, type(uint256).max); + } + + function boundSkipCeil(uint256 _value, uint256 _max) internal view returns (uint256) { + return boundSkip(_value, 0, _max); + } + + function boundSkip(uint256 _value, uint256 _min, uint256 _max) internal view returns (uint256) { + return bound(_value, orTillEndOfTime(_min), orTillEndOfTime(_max)); + } + + function orTillEndOfTime(uint256 _value) internal view returns (uint256) { + uint256 tillEndOfTime = type(uint256).max - block.timestamp; + return _value < tillEndOfTime ? _value : tillEndOfTime; + } } diff --git a/packages/interfaces/contracts/data-service/IDataServiceFees.sol b/packages/interfaces/contracts/data-service/IDataServiceFees.sol index 9cba91d7a..e9bf60bf0 100644 --- a/packages/interfaces/contracts/data-service/IDataServiceFees.sol +++ b/packages/interfaces/contracts/data-service/IDataServiceFees.sol @@ -26,70 +26,6 @@ import { IDataService } from "./IDataService.sol"; * bugs. We may have an active bug bounty program. */ interface IDataServiceFees is IDataService { - /** - * @notice A stake claim, representing provisioned stake that gets locked - * to be released to a service provider. - * @dev StakeClaims are stored in linked lists by service provider, ordered by - * creation timestamp. - * @param tokens The amount of tokens to be locked in the claim - * @param createdAt The timestamp when the claim was created - * @param releasableAt The timestamp when the tokens can be released - * @param nextClaim The next claim in the linked list - */ - struct StakeClaim { - uint256 tokens; - uint256 createdAt; - uint256 releasableAt; - bytes32 nextClaim; - } - - /** - * @notice Emitted when a stake claim is created and stake is locked. - * @param serviceProvider The address of the service provider - * @param claimId The id of the stake claim - * @param tokens The amount of tokens to lock in the claim - * @param unlockTimestamp The timestamp when the tokens can be released - */ - event StakeClaimLocked( - address indexed serviceProvider, - bytes32 indexed claimId, - uint256 tokens, - uint256 unlockTimestamp - ); - - /** - * @notice Emitted when a stake claim is released and stake is unlocked. - * @param serviceProvider The address of the service provider - * @param claimId The id of the stake claim - * @param tokens The amount of tokens released - * @param releasableAt The timestamp when the tokens were released - */ - event StakeClaimReleased( - address indexed serviceProvider, - bytes32 indexed claimId, - uint256 tokens, - uint256 releasableAt - ); - - /** - * @notice Emitted when a series of stake claims are released. - * @param serviceProvider The address of the service provider - * @param claimsCount The number of stake claims being released - * @param tokensReleased The total amount of tokens being released - */ - event StakeClaimsReleased(address indexed serviceProvider, uint256 claimsCount, uint256 tokensReleased); - - /** - * @notice Thrown when attempting to get a stake claim that does not exist. - * @param claimId The id of the stake claim - */ - error DataServiceFeesClaimNotFound(bytes32 claimId); - - /** - * @notice Emitted when trying to lock zero tokens in a stake claim - */ - error DataServiceFeesZeroTokens(); - /** * @notice Releases expired stake claims for the caller. * @dev This function is only meant to be called if the service provider has enough diff --git a/packages/interfaces/contracts/horizon/IRecurringCollector.sol b/packages/interfaces/contracts/horizon/IRecurringCollector.sol new file mode 100644 index 000000000..e8530cc85 --- /dev/null +++ b/packages/interfaces/contracts/horizon/IRecurringCollector.sol @@ -0,0 +1,483 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.22; + +import { IPaymentsCollector } from "./IPaymentsCollector.sol"; +import { IGraphPayments } from "./IGraphPayments.sol"; +import { IAuthorizable } from "./IAuthorizable.sol"; + +/** + * @title Interface for the {RecurringCollector} contract + * @author Edge & Node + * @dev Implements the {IPaymentCollector} interface as defined by the Graph + * Horizon payments protocol. + * @notice Implements a payments collector contract that can be used to collect + * recurrent payments. + */ +interface IRecurringCollector is IAuthorizable, IPaymentsCollector { + /// @notice The state of an agreement + enum AgreementState { + NotAccepted, + Accepted, + CanceledByServiceProvider, + CanceledByPayer + } + + /// @notice The party that can cancel an agreement + enum CancelAgreementBy { + ServiceProvider, + Payer, + ThirdParty + } + + /// @notice Reasons why an agreement is not collectable + enum AgreementNotCollectableReason { + None, + InvalidAgreementState, + ZeroCollectionSeconds, + InvalidTemporalWindow + } + + /** + * @notice A representation of a signed Recurring Collection Agreement (RCA) + * @param rca The Recurring Collection Agreement to be signed + * @param signature The signature of the RCA - 65 bytes: r (32 Bytes) || s (32 Bytes) || v (1 Byte) + */ + struct SignedRCA { + RecurringCollectionAgreement rca; + bytes signature; + } + + /** + * @notice The Recurring Collection Agreement (RCA) + * @param deadline The deadline for accepting the RCA + * @param endsAt The timestamp when the agreement ends + * @param payer The address of the payer the RCA was issued by + * @param dataService The address of the data service the RCA was issued to + * @param serviceProvider The address of the service provider the RCA was issued to + * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection + * on top of the amount allowed for subsequent collections + * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second + * except for the first collection + * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections + * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + * @param nonce A unique nonce for preventing collisions (user-chosen) + * @param metadata Arbitrary metadata to extend functionality if a data service requires it + * + */ + // solhint-disable-next-line gas-struct-packing + struct RecurringCollectionAgreement { + uint64 deadline; + uint64 endsAt; + address payer; + address dataService; + address serviceProvider; + uint256 maxInitialTokens; + uint256 maxOngoingTokensPerSecond; + uint32 minSecondsPerCollection; + uint32 maxSecondsPerCollection; + uint256 nonce; + bytes metadata; + } + + /** + * @notice A representation of a signed Recurring Collection Agreement Update (RCAU) + * @param rcau The Recurring Collection Agreement Update to be signed + * @param signature The signature of the RCAU - 65 bytes: r (32 Bytes) || s (32 Bytes) || v (1 Byte) + */ + struct SignedRCAU { + RecurringCollectionAgreementUpdate rcau; + bytes signature; + } + + /** + * @notice The Recurring Collection Agreement Update (RCAU) + * @param agreementId The agreement ID of the RCAU + * @param deadline The deadline for upgrading the RCA + * @param endsAt The timestamp when the agreement ends + * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection + * on top of the amount allowed for subsequent collections + * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second + * except for the first collection + * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections + * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + * @param nonce The nonce for preventing replay attacks (must be current nonce + 1) + * @param metadata Arbitrary metadata to extend functionality if a data service requires it + */ + // solhint-disable-next-line gas-struct-packing + struct RecurringCollectionAgreementUpdate { + bytes16 agreementId; + uint64 deadline; + uint64 endsAt; + uint256 maxInitialTokens; + uint256 maxOngoingTokensPerSecond; + uint32 minSecondsPerCollection; + uint32 maxSecondsPerCollection; + uint32 nonce; + bytes metadata; + } + + /** + * @notice The data for an agreement + * @dev This struct is used to store the data of an agreement in the contract + * @param dataService The address of the data service + * @param payer The address of the payer + * @param serviceProvider The address of the service provider + * @param acceptedAt The timestamp when the agreement was accepted + * @param lastCollectionAt The timestamp when the agreement was last collected at + * @param endsAt The timestamp when the agreement ends + * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection + * on top of the amount allowed for subsequent collections + * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second + * except for the first collection + * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections + * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + * @param updateNonce The current nonce for updates (prevents replay attacks) + * @param canceledAt The timestamp when the agreement was canceled + * @param state The state of the agreement + */ + struct AgreementData { + address dataService; + address payer; + address serviceProvider; + uint64 acceptedAt; + uint64 lastCollectionAt; + uint64 endsAt; + uint256 maxInitialTokens; + uint256 maxOngoingTokensPerSecond; + uint32 minSecondsPerCollection; + uint32 maxSecondsPerCollection; + uint32 updateNonce; + uint64 canceledAt; + AgreementState state; + } + + /** + * @notice The params for collecting an agreement + * @param agreementId The agreement ID of the RCA + * @param collectionId The collection ID of the RCA + * @param tokens The amount of tokens to collect + * @param dataServiceCut The data service cut in parts per million + * @param receiverDestination The address where the collected fees should be sent + * @param maxSlippage Max acceptable tokens to lose due to rate limiting, or type(uint256).max to ignore + */ + struct CollectParams { + bytes16 agreementId; + bytes32 collectionId; + uint256 tokens; + uint256 dataServiceCut; + address receiverDestination; + uint256 maxSlippage; + } + + /** + * @notice Emitted when an agreement is accepted + * @param dataService The address of the data service + * @param payer The address of the payer + * @param serviceProvider The address of the service provider + * @param agreementId The agreement ID + * @param acceptedAt The timestamp when the agreement was accepted + * @param endsAt The timestamp when the agreement ends + * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection + * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second + * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections + * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + */ + event AgreementAccepted( + address indexed dataService, + address indexed payer, + address indexed serviceProvider, + bytes16 agreementId, + uint64 acceptedAt, + uint64 endsAt, + uint256 maxInitialTokens, + uint256 maxOngoingTokensPerSecond, + uint32 minSecondsPerCollection, + uint32 maxSecondsPerCollection + ); + + /** + * @notice Emitted when an agreement is canceled + * @param dataService The address of the data service + * @param payer The address of the payer + * @param serviceProvider The address of the service provider + * @param agreementId The agreement ID + * @param canceledAt The timestamp when the agreement was canceled + * @param canceledBy The party that canceled the agreement + */ + event AgreementCanceled( + address indexed dataService, + address indexed payer, + address indexed serviceProvider, + bytes16 agreementId, + uint64 canceledAt, + CancelAgreementBy canceledBy + ); + + /** + * @notice Emitted when an agreement is updated + * @param dataService The address of the data service + * @param payer The address of the payer + * @param serviceProvider The address of the service provider + * @param agreementId The agreement ID + * @param updatedAt The timestamp when the agreement was updated + * @param endsAt The timestamp when the agreement ends + * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection + * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second + * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections + * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + */ + event AgreementUpdated( + address indexed dataService, + address indexed payer, + address indexed serviceProvider, + bytes16 agreementId, + uint64 updatedAt, + uint64 endsAt, + uint256 maxInitialTokens, + uint256 maxOngoingTokensPerSecond, + uint32 minSecondsPerCollection, + uint32 maxSecondsPerCollection + ); + + /** + * @notice Emitted when an RCA is collected + * @param dataService The address of the data service + * @param payer The address of the payer + * @param serviceProvider The address of the service provider + * @param agreementId The agreement ID + * @param collectionId The collection ID + * @param tokens The amount of tokens collected + * @param dataServiceCut The tokens cut for the data service + */ + event RCACollected( + address indexed dataService, + address indexed payer, + address indexed serviceProvider, + bytes16 agreementId, + bytes32 collectionId, + uint256 tokens, + uint256 dataServiceCut + ); + + /** + * @notice Thrown when accepting an agreement with a zero ID + */ + error RecurringCollectorAgreementIdZero(); + + /** + * @notice Thrown when interacting with an agreement not owned by the message sender + * @param agreementId The agreement ID + * @param unauthorizedDataService The address of the unauthorized data service + */ + error RecurringCollectorDataServiceNotAuthorized(bytes16 agreementId, address unauthorizedDataService); + /** + * @notice Thrown when the data service is not authorized for the service provider + * @param dataService The address of the unauthorized data service + */ + error RecurringCollectorUnauthorizedDataService(address dataService); + + /** + * @notice Thrown when interacting with an agreement with an elapsed deadline + * @param currentTimestamp The current timestamp + * @param deadline The elapsed deadline timestamp + */ + error RecurringCollectorAgreementDeadlineElapsed(uint256 currentTimestamp, uint64 deadline); + + /** + * @notice Thrown when the signer is invalid + */ + error RecurringCollectorInvalidSigner(); + + /** + * @notice Thrown when the payment type is not IndexingFee + * @param invalidPaymentType The invalid payment type + */ + error RecurringCollectorInvalidPaymentType(IGraphPayments.PaymentTypes invalidPaymentType); + + /** + * @notice Thrown when the caller is not the data service the RCA was issued to + * @param unauthorizedCaller The address of the caller + * @param dataService The address of the data service + */ + error RecurringCollectorUnauthorizedCaller(address unauthorizedCaller, address dataService); + + /** + * @notice Thrown when calling collect() with invalid data + * @param invalidData The invalid data + */ + error RecurringCollectorInvalidCollectData(bytes invalidData); + + /** + * @notice Thrown when interacting with an agreement that has an incorrect state + * @param agreementId The agreement ID + * @param incorrectState The incorrect state + */ + error RecurringCollectorAgreementIncorrectState(bytes16 agreementId, AgreementState incorrectState); + + /** + * @notice Thrown when an agreement is not collectable + * @param agreementId The agreement ID + * @param reason The reason why the agreement is not collectable + */ + error RecurringCollectorAgreementNotCollectable(bytes16 agreementId, AgreementNotCollectableReason reason); + + /** + * @notice Thrown when accepting an agreement with an address that is not set + */ + error RecurringCollectorAgreementAddressNotSet(); + + /** + * @notice Thrown when accepting or upgrading an agreement with an elapsed endsAt + * @param currentTimestamp The current timestamp + * @param endsAt The agreement end timestamp + */ + error RecurringCollectorAgreementElapsedEndsAt(uint256 currentTimestamp, uint64 endsAt); + + /** + * @notice Thrown when accepting or upgrading an agreement with an elapsed endsAt + * @param allowedMinCollectionWindow The allowed minimum collection window + * @param minSecondsPerCollection The minimum seconds per collection + * @param maxSecondsPerCollection The maximum seconds per collection + */ + error RecurringCollectorAgreementInvalidCollectionWindow( + uint32 allowedMinCollectionWindow, + uint32 minSecondsPerCollection, + uint32 maxSecondsPerCollection + ); + + /** + * @notice Thrown when accepting or upgrading an agreement with an invalid duration + * @param requiredMinDuration The required minimum duration + * @param invalidDuration The invalid duration + */ + error RecurringCollectorAgreementInvalidDuration(uint32 requiredMinDuration, uint256 invalidDuration); + + /** + * @notice Thrown when calling collect() with a zero collection seconds + * @param agreementId The agreement ID + * @param currentTimestamp The current timestamp + * @param lastCollectionAt The timestamp when the last collection was done + * + */ + error RecurringCollectorZeroCollectionSeconds( + bytes16 agreementId, + uint256 currentTimestamp, + uint64 lastCollectionAt + ); + + /** + * @notice Thrown when calling collect() too soon + * @param agreementId The agreement ID + * @param secondsSinceLast Seconds since last collection + * @param minSeconds Minimum seconds between collections + */ + error RecurringCollectorCollectionTooSoon(bytes16 agreementId, uint32 secondsSinceLast, uint32 minSeconds); + + /** + * @notice Thrown when calling collect() too late + * @param agreementId The agreement ID + * @param secondsSinceLast Seconds since last collection + * @param maxSeconds Maximum seconds between collections + */ + error RecurringCollectorCollectionTooLate(bytes16 agreementId, uint64 secondsSinceLast, uint32 maxSeconds); + + /** + * @notice Thrown when calling update() with an invalid nonce + * @param agreementId The agreement ID + * @param expected The expected nonce + * @param provided The provided nonce + */ + error RecurringCollectorInvalidUpdateNonce(bytes16 agreementId, uint32 expected, uint32 provided); + + /** + * @notice Thrown when collected tokens are less than requested beyond the allowed slippage + * @param requested The amount of tokens requested to collect + * @param actual The actual amount that would be collected + * @param maxSlippage The maximum allowed slippage + */ + error RecurringCollectorExcessiveSlippage(uint256 requested, uint256 actual, uint256 maxSlippage); + + /** + * @notice Accept an indexing agreement. + * @param signedRCA The signed Recurring Collection Agreement which is to be accepted. + * @return agreementId The deterministically generated agreement ID + */ + function accept(SignedRCA calldata signedRCA) external returns (bytes16 agreementId); + + /** + * @notice Cancel an indexing agreement. + * @param agreementId The agreement's ID. + * @param by The party that is canceling the agreement. + */ + function cancel(bytes16 agreementId, CancelAgreementBy by) external; + + /** + * @notice Update an indexing agreement. + * @param signedRCAU The signed Recurring Collection Agreement Update which is to be applied. + */ + function update(SignedRCAU calldata signedRCAU) external; + + /** + * @notice Computes the hash of a RecurringCollectionAgreement (RCA). + * @param rca The RCA for which to compute the hash. + * @return The hash of the RCA. + */ + function hashRCA(RecurringCollectionAgreement calldata rca) external view returns (bytes32); + + /** + * @notice Computes the hash of a RecurringCollectionAgreementUpdate (RCAU). + * @param rcau The RCAU for which to compute the hash. + * @return The hash of the RCAU. + */ + function hashRCAU(RecurringCollectionAgreementUpdate calldata rcau) external view returns (bytes32); + + /** + * @notice Recovers the signer address of a signed RecurringCollectionAgreement (RCA). + * @param signedRCA The SignedRCA containing the RCA and its signature. + * @return The address of the signer. + */ + function recoverRCASigner(SignedRCA calldata signedRCA) external view returns (address); + + /** + * @notice Recovers the signer address of a signed RecurringCollectionAgreementUpdate (RCAU). + * @param signedRCAU The SignedRCAU containing the RCAU and its signature. + * @return The address of the signer. + */ + function recoverRCAUSigner(SignedRCAU calldata signedRCAU) external view returns (address); + + /** + * @notice Gets an agreement. + * @param agreementId The ID of the agreement to retrieve. + * @return The AgreementData struct containing the agreement's data. + */ + function getAgreement(bytes16 agreementId) external view returns (AgreementData memory); + + /** + * @notice Get collection info for an agreement + * @param agreement The agreement data + * @return isCollectable Whether the agreement is in a valid state that allows collection attempts, + * not that there are necessarily funds available to collect. + * @return collectionSeconds The valid collection duration in seconds (0 if not collectable) + * @return reason The reason why the agreement is not collectable (None if collectable) + */ + function getCollectionInfo( + AgreementData calldata agreement + ) external view returns (bool isCollectable, uint256 collectionSeconds, AgreementNotCollectableReason reason); + + /** + * @notice Generate a deterministic agreement ID from agreement parameters + * @param payer The address of the payer + * @param dataService The address of the data service + * @param serviceProvider The address of the service provider + * @param deadline The deadline for accepting the agreement + * @param nonce A unique nonce for preventing collisions + * @return agreementId The deterministically generated agreement ID + */ + function generateAgreementId( + address payer, + address dataService, + address serviceProvider, + uint64 deadline, + uint256 nonce + ) external pure returns (bytes16 agreementId); +} diff --git a/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol b/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol index f0661c6f4..d805d9f70 100644 --- a/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol +++ b/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.22; import { IAttestation } from "./internal/IAttestation.sol"; +import { IIndexingAgreement } from "./internal/IIndexingAgreement.sol"; import { ISubgraphService } from "./ISubgraphService.sol"; /** @@ -18,7 +19,8 @@ interface IDisputeManager { Null, IndexingDispute, QueryDispute, - LegacyDispute + LegacyDispute, + IndexingFeeDispute } /// @notice Status of a dispute @@ -119,6 +121,32 @@ interface IDisputeManager { uint256 cancellableAt ); + /** + * @notice Emitted when an indexing fee dispute is created for `agreementId` and `indexer` + * by `fisherman`. + * @dev The event emits the amount of `tokens` deposited by the fisherman. + * @param disputeId The dispute id + * @param indexer The indexer address + * @param fisherman The fisherman address + * @param tokens The amount of tokens deposited by the fisherman + * @param payer The address of the payer of the indexing fee + * @param agreementId The agreement id + * @param poi The POI disputed + * @param entities The entities disputed + * @param stakeSnapshot The stake snapshot of the indexer at the time of the dispute + */ + event IndexingFeeDisputeCreated( + bytes32 indexed disputeId, + address indexed indexer, + address indexed fisherman, + uint256 tokens, + address payer, + bytes16 agreementId, + bytes32 poi, + uint256 entities, + uint256 stakeSnapshot + ); + /** * @notice Emitted when an indexing dispute is created for `allocationId` and `indexer` * by `fisherman`. @@ -358,6 +386,18 @@ interface IDisputeManager { */ error DisputeManagerSubgraphServiceNotSet(); + /** + * @notice Thrown when the Indexing Agreement is not disputable + * @param agreementId The indexing agreement id + */ + error DisputeManagerIndexingAgreementNotDisputable(bytes16 agreementId); + + /** + * @notice Thrown when the Indexing Agreement is not disputable + * @param version The indexing agreement version + */ + error DisputeManagerIndexingAgreementInvalidVersion(IIndexingAgreement.IndexingAgreementVersion version); + /** * @notice Initialize this contract. * @param owner The owner of the contract @@ -504,6 +544,29 @@ interface IDisputeManager { uint256 tokensRewards ) external returns (bytes32); + /** + * @notice Create an indexing fee (version 1) dispute for the arbitrator to resolve. + * The disputes are created in reference to a version 1 indexing agreement and specifically + * a POI and entities provided when collecting that agreement. + * This function is called by a fisherman and it will pull `disputeDeposit` GRT tokens. + * + * Requirements: + * - fisherman must have previously approved this contract to pull `disputeDeposit` amount + * of tokens from their balance. + * + * @param agreementId The indexing agreement to dispute + * @param poi The Proof of Indexing (POI) being disputed + * @param entities The number of entities disputed + * @param blockNumber The block number at which the indexing fee was collected + * @return The dispute id + */ + function createIndexingFeeDisputeV1( + bytes16 agreementId, + bytes32 poi, + uint256 entities, + uint256 blockNumber + ) external returns (bytes32); + // -- Arbitrator -- /** diff --git a/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol b/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol index db0bdae3f..cff466423 100644 --- a/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol +++ b/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol @@ -3,8 +3,10 @@ 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"; +import { IIndexingAgreement } from "./internal/IIndexingAgreement.sol"; import { ILegacyAllocation } from "./internal/ILegacyAllocation.sol"; /** @@ -68,12 +70,25 @@ interface ISubgraphService is IDataServiceFees { event CurationCutSet(uint256 curationCut); // solhint-disable-previous-line gas-indexed-events + /** + * @notice Emitted when indexing fees cut is set + * @param indexingFeesCut The indexing fees cut + */ + event IndexingFeesCutSet(uint256 indexingFeesCut); + // solhint-disable-previous-line gas-indexed-events + /** * @notice Thrown when trying to set a curation cut that is not a valid PPM value * @param curationCut The curation cut value */ error SubgraphServiceInvalidCurationCut(uint256 curationCut); + /** + * @notice Thrown when trying to set an indexing fees cut that is not a valid PPM value + * @param indexingFeesCut The indexing fees cut value + */ + error SubgraphServiceInvalidIndexingFeesCut(uint256 indexingFeesCut); + /** * @notice Thrown when an indexer tries to register with an empty URL */ @@ -104,7 +119,7 @@ interface ISubgraphService is IDataServiceFees { error SubgraphServiceInconsistentCollection(uint256 balanceBefore, uint256 balanceAfter); /** - * @notice @notice Thrown when the service provider in the RAV does not match the expected indexer. + * @notice @notice Thrown when the service provider does not match the expected indexer. * @param providedIndexer The address of the provided indexer. * @param expectedIndexer The address of the expected indexer. */ @@ -246,6 +261,13 @@ interface ISubgraphService is IDataServiceFees { */ function setCurationCut(uint256 curationCut) external; + /** + * @notice Sets the data service payment cut for indexing fees + * @dev Emits a {IndexingFeesCutSet} event + * @param indexingFeesCut The indexing fees cut for the payment type + */ + function setIndexingFeesCut(uint256 indexingFeesCut) external; + /** * @notice Sets the payments destination for an indexer to receive payments * @dev Emits a {PaymentsDestinationSet} event @@ -253,6 +275,46 @@ interface ISubgraphService is IDataServiceFees { */ function setPaymentsDestination(address newPaymentsDestination) external; + /** + * @notice Accept an indexing agreement. + * @param allocationId The id of the allocation + * @param signedRCA The signed recurring collector agreement (RCA) that the indexer accepts + * @return agreementId The ID of the accepted indexing agreement + */ + function acceptIndexingAgreement( + address allocationId, + IRecurringCollector.SignedRCA calldata signedRCA + ) external returns (bytes16); + + /** + * @notice Update an indexing agreement. + * @param indexer The address of the indexer + * @param signedRCAU The signed recurring collector agreement update (RCAU) that the indexer accepts + */ + function updateIndexingAgreement(address indexer, IRecurringCollector.SignedRCAU calldata signedRCAU) external; + + /** + * @notice Cancel an indexing agreement by indexer / operator. + * @param indexer The address of the indexer + * @param agreementId The id of the indexing agreement + */ + function cancelIndexingAgreement(address indexer, bytes16 agreementId) external; + + /** + * @notice Cancel an indexing agreement by payer / signer. + * @param agreementId The id of the indexing agreement + */ + function cancelIndexingAgreementByPayer(bytes16 agreementId) external; + + /** + * @notice Get the indexing agreement for a given agreement ID. + * @param agreementId The id of the indexing agreement + * @return The indexing agreement details + */ + function getIndexingAgreement( + bytes16 agreementId + ) external view returns (IIndexingAgreement.AgreementWrapper memory); + /** * @notice Gets the details of an allocation * For legacy allocations use {getLegacyAllocation} diff --git a/packages/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol b/packages/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol new file mode 100644 index 000000000..1f5c5f475 --- /dev/null +++ b/packages/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.22; + +import { IRecurringCollector } from "../../horizon/IRecurringCollector.sol"; + +/** + * @title Interface for the {IndexingAgreement} library contract. + * @author Edge & Node + * @notice Interface for managing indexing agreement data and operations + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +interface IIndexingAgreement { + /// @notice Versions of Indexing Agreement Metadata + enum IndexingAgreementVersion { + V1 + } + + /** + * @notice Indexer Agreement Data + * @param allocationId The allocation ID + * @param version The indexing agreement version + */ + struct State { + address allocationId; + IndexingAgreementVersion version; + } + + /** + * @notice Wrapper for Indexing Agreement and Collector Agreement Data + * @param agreement The indexing agreement state + * @param collectorAgreement The collector agreement data + */ + struct AgreementWrapper { + State agreement; + IRecurringCollector.AgreementData collectorAgreement; + } +} diff --git a/packages/subgraph-service/contracts/DisputeManager.sol b/packages/subgraph-service/contracts/DisputeManager.sol index 6cbd73a22..83cfcdf1f 100644 --- a/packages/subgraph-service/contracts/DisputeManager.sol +++ b/packages/subgraph-service/contracts/DisputeManager.sol @@ -11,6 +11,7 @@ import { IDisputeManager } from "@graphprotocol/interfaces/contracts/subgraph-se import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { IAttestation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAttestation.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; @@ -138,6 +139,20 @@ contract DisputeManager is return _createIndexingDisputeWithAllocation(msg.sender, disputeDeposit, allocationId, poi, blockNumber); } + /// @inheritdoc IDisputeManager + function createIndexingFeeDisputeV1( + bytes16 agreementId, + bytes32 poi, + uint256 entities, + uint256 blockNumber + ) external override returns (bytes32) { + // Get funds from fisherman + _graphToken().pullTokens(msg.sender, disputeDeposit); + + // Create a dispute + return _createIndexingFeeDisputeV1(msg.sender, disputeDeposit, agreementId, poi, entities, blockNumber); + } + /// @inheritdoc IDisputeManager function createQueryDispute(bytes calldata attestationData) external override returns (bytes32) { // Get funds from fisherman @@ -507,6 +522,75 @@ contract DisputeManager is return disputeId; } + /** + * @notice Create indexing fee (version 1) dispute internal function. + * @param _fisherman The fisherman creating the dispute + * @param _deposit Amount of tokens staked as deposit + * @param _agreementId The agreement id being disputed + * @param _poi The POI being disputed + * @param _entities The number of entities disputed + * @param _blockNumber The block number of the disputed POI + * @return The dispute id + */ + function _createIndexingFeeDisputeV1( + address _fisherman, + uint256 _deposit, + bytes16 _agreementId, + bytes32 _poi, + uint256 _entities, + uint256 _blockNumber + ) private returns (bytes32) { + IIndexingAgreement.AgreementWrapper memory wrapper = _getSubgraphService().getIndexingAgreement(_agreementId); + + // Agreement must have been collected on and be a version 1 + require( + wrapper.collectorAgreement.lastCollectionAt > 0, + DisputeManagerIndexingAgreementNotDisputable(_agreementId) + ); + require( + wrapper.agreement.version == IIndexingAgreement.IndexingAgreementVersion.V1, + DisputeManagerIndexingAgreementInvalidVersion(wrapper.agreement.version) + ); + + // Create a disputeId + bytes32 disputeId = keccak256( + abi.encodePacked("IndexingFeeDisputeWithAgreement", _agreementId, _poi, _entities, _blockNumber) + ); + + // Only one dispute at a time + require(!isDisputeCreated(disputeId), DisputeManagerDisputeAlreadyCreated(disputeId)); + + // The indexer must be disputable + uint256 stakeSnapshot = _getStakeSnapshot(wrapper.collectorAgreement.serviceProvider); + require(stakeSnapshot != 0, DisputeManagerZeroTokens()); + + disputes[disputeId] = Dispute( + wrapper.collectorAgreement.serviceProvider, + _fisherman, + _deposit, + 0, // no related dispute, + DisputeType.IndexingFeeDispute, + IDisputeManager.DisputeStatus.Pending, + block.timestamp, + block.timestamp + disputePeriod, + stakeSnapshot + ); + + emit IndexingFeeDisputeCreated( + disputeId, + wrapper.collectorAgreement.serviceProvider, + _fisherman, + _deposit, + wrapper.collectorAgreement.payer, + _agreementId, + _poi, + _entities, + stakeSnapshot + ); + + return disputeId; + } + /** * @notice Accept a dispute * @param _disputeId The id of the dispute diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index 26e73084f..8d591f52e 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -8,7 +8,9 @@ import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/re import { IDataService } from "@graphprotocol/interfaces/contracts/data-service/IDataService.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import { MulticallUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/MulticallUpgradeable.sol"; @@ -23,6 +25,8 @@ import { SubgraphServiceV1Storage } from "./SubgraphServiceStorage.sol"; import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; import { Allocation } from "./libraries/Allocation.sol"; +import { IndexingAgreementDecoder } from "./libraries/IndexingAgreementDecoder.sol"; +import { IndexingAgreement } from "./libraries/IndexingAgreement.sol"; /** * @title SubgraphService contract @@ -48,6 +52,7 @@ contract SubgraphService is using Allocation for mapping(address => IAllocation.State); using Allocation for IAllocation.State; using TokenUtils for IGraphToken; + using IndexingAgreement for IndexingAgreement.StorageManager; /** * @notice Checks that an indexer is registered @@ -65,13 +70,18 @@ contract SubgraphService is * @param disputeManager The address of the DisputeManager contract * @param graphTallyCollector The address of the GraphTallyCollector contract * @param curation The address of the Curation contract + * @param recurringCollector The address of the RecurringCollector contract */ constructor( address graphController, address disputeManager, address graphTallyCollector, - address curation - ) DataService(graphController) Directory(address(this), disputeManager, graphTallyCollector, curation) { + address curation, + address recurringCollector + ) + DataService(graphController) + Directory(address(this), disputeManager, graphTallyCollector, curation, recurringCollector) + { _disableInitializers(); } @@ -225,13 +235,14 @@ contract SubgraphService is _allocations.get(allocationId).indexer == indexer, SubgraphServiceAllocationNotAuthorized(indexer, allocationId) ); + _onCloseAllocation(allocationId, false); _closeAllocation(allocationId, false); emit ServiceStopped(indexer, data); } /** * @notice Collects payment for the service provided by the indexer - * Allows collecting different types of payments such as query fees and indexing rewards. + * Allows collecting different types of payments such as query fees, indexing rewards and indexing fees. * It uses Graph Horizon payments protocol to process payments. * Reverts if the payment type is not supported. * @dev This function is the equivalent of the `collect` function for query fees and the `closeAllocation` function @@ -245,6 +256,12 @@ contract SubgraphService is * * For query fees, see {SubgraphService-_collectQueryFees} for more details. * For indexing rewards, see {AllocationManager-_collectIndexingRewards} for more details. + * For indexing fees, see {SubgraphService-_collectIndexingFees} for more details. + * + * Note that collecting any type of payment will require locking provisioned stake as collateral for a period of time. + * All types of payment share the same pool of provisioned stake however they each have separate accounting: + * - Indexing rewards can make full use of the available stake + * - Query and indexing fees share the pool, combined they can also make full use of the available stake * * @param indexer The address of the indexer * @param paymentType The type of payment to collect as defined in {IGraphPayments} @@ -255,6 +272,9 @@ contract SubgraphService is * - address `allocationId`: The id of the allocation * - bytes32 `poi`: The POI being presented * - bytes `poiMetadata`: The metadata associated with the POI. See {AllocationManager-_collectIndexingRewards} for more details. + * - For indexing fees: + * - bytes16 `agreementId`: The id of the indexing agreement + * - bytes `agreementCollectionMetadata`: The metadata required by the indexing agreement version. */ /// @inheritdoc IDataService function collect( @@ -264,10 +284,10 @@ contract SubgraphService is ) external override + whenNotPaused onlyAuthorizedForProvision(indexer) onlyValidProvision(indexer) onlyRegisteredIndexer(indexer) - whenNotPaused returns (uint256) { uint256 paymentCollected = 0; @@ -276,6 +296,14 @@ contract SubgraphService is paymentCollected = _collectQueryFees(indexer, data); } else if (paymentType == IGraphPayments.PaymentTypes.IndexingRewards) { paymentCollected = _collectIndexingRewards(indexer, data); + } else if (paymentType == IGraphPayments.PaymentTypes.IndexingFee) { + (bytes16 agreementId, bytes memory iaCollectionData) = IndexingAgreementDecoder.decodeCollectData(data); + paymentCollected = _collectIndexingFees( + indexer, + agreementId, + paymentsDestination[indexer], + iaCollectionData + ); } else { revert SubgraphServiceInvalidPaymentType(paymentType); } @@ -301,6 +329,7 @@ contract SubgraphService is IAllocation.State memory allocation = _allocations.get(allocationId); require(allocation.isStale(maxPOIStaleness), SubgraphServiceCannotForceCloseAllocation(allocationId)); require(!allocation.isAltruistic(), SubgraphServiceAllocationIsAltruistic(allocationId)); + _onCloseAllocation(allocationId, true); _closeAllocation(allocationId, true); } @@ -370,6 +399,132 @@ contract SubgraphService is emit CurationCutSet(curationCut); } + /// @inheritdoc ISubgraphService + function setIndexingFeesCut(uint256 indexingFeesCut_) external override onlyOwner { + require(PPMMath.isValidPPM(indexingFeesCut_), SubgraphServiceInvalidIndexingFeesCut(indexingFeesCut_)); + indexingFeesCut = indexingFeesCut_; + emit IndexingFeesCutSet(indexingFeesCut_); + } + + /** + * @inheritdoc ISubgraphService + * @notice Accept an indexing agreement. + * + * See {ISubgraphService.acceptIndexingAgreement}. + * + * Requirements: + * - The agreement's indexer must be registered + * - The caller must be authorized by the agreement's indexer + * - The provision must be valid according to the subgraph service rules + * - Allocation must belong to the indexer and be open + * - Agreement must be for this data service + * - Agreement's subgraph deployment must match the allocation's subgraph deployment + * - Agreement must not have been accepted before + * - Allocation must not have an agreement already + * + * @dev signedRCA.rca.metadata is an encoding of {IndexingAgreement.AcceptIndexingAgreementMetadata} + * + * Emits {IndexingAgreement.IndexingAgreementAccepted} event + * + * @param allocationId The id of the allocation + * @param signedRCA The signed Recurring Collection Agreement + * @return agreementId The ID of the accepted indexing agreement + */ + function acceptIndexingAgreement( + address allocationId, + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCA calldata signedRCA + ) + external + whenNotPaused + onlyAuthorizedForProvision(signedRCA.rca.serviceProvider) + onlyValidProvision(signedRCA.rca.serviceProvider) + onlyRegisteredIndexer(signedRCA.rca.serviceProvider) + returns (bytes16) + { + return IndexingAgreement._getStorageManager().accept(_allocations, allocationId, signedRCA); + } + + /** + * @inheritdoc ISubgraphService + * @notice Update an indexing agreement. + * + * See {IndexingAgreement.update}. + * + * Requirements: + * - The contract must not be paused + * - The indexer must be valid + * + * @param indexer The indexer address + * @param signedRCAU The signed Recurring Collection Agreement Update + */ + function updateIndexingAgreement( + address indexer, + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCAU calldata signedRCAU + ) + external + whenNotPaused + onlyAuthorizedForProvision(indexer) + onlyValidProvision(indexer) + onlyRegisteredIndexer(indexer) + { + IndexingAgreement._getStorageManager().update(indexer, signedRCAU); + } + + /** + * @inheritdoc ISubgraphService + * @notice Cancel an indexing agreement by indexer / operator. + * + * See {IndexingAgreement.cancel}. + * + * @dev Can only be canceled on behalf of a valid indexer. + * + * Requirements: + * - The contract must not be paused + * - The indexer must be valid + * + * @param indexer The indexer address + * @param agreementId The id of the agreement + */ + function cancelIndexingAgreement( + address indexer, + bytes16 agreementId + ) + external + whenNotPaused + onlyAuthorizedForProvision(indexer) + onlyValidProvision(indexer) + onlyRegisteredIndexer(indexer) + { + IndexingAgreement._getStorageManager().cancel(indexer, agreementId); + } + + /** + * @inheritdoc ISubgraphService + * @notice Cancel an indexing agreement by payer / signer. + * + * See {ISubgraphService.cancelIndexingAgreementByPayer}. + * + * Requirements: + * - The caller must be authorized by the payer + * - The agreement must be active + * + * Emits {IndexingAgreementCanceled} event + * + * @param agreementId The id of the agreement + */ + function cancelIndexingAgreementByPayer(bytes16 agreementId) external whenNotPaused { + IndexingAgreement._getStorageManager().cancelByPayer(agreementId); + } + + /// @inheritdoc ISubgraphService + function getIndexingAgreement( + bytes16 agreementId + ) external view returns (IIndexingAgreement.AgreementWrapper memory) { + return IndexingAgreement._getStorageManager().get(agreementId); + } + /// @inheritdoc ISubgraphService function getAllocation(address allocationId) external view override returns (IAllocation.State memory) { return _allocations[allocationId]; @@ -425,6 +580,16 @@ contract SubgraphService is return _isOverAllocated(indexer, _delegationRatio); } + /** + * @notice Internal function to handle closing an allocation + * @dev This function is called when an allocation is closed, either by the indexer or by a third party + * @param _allocationId The id of the allocation being closed + * @param _forceClosed Whether the allocation was force closed + */ + function _onCloseAllocation(address _allocationId, bool _forceClosed) internal { + IndexingAgreement._getStorageManager().onCloseAllocation(_allocationId, _forceClosed); + } + /** * @notice Sets the payments destination for an indexer to receive payments * @dev Emits a {PaymentsDestinationSet} event @@ -585,7 +750,77 @@ contract SubgraphService is _allocations.get(allocationId).indexer == _indexer, SubgraphServiceAllocationNotAuthorized(_indexer, allocationId) ); - return _presentPoi(allocationId, poi_, poiMetadata_, _delegationRatio, paymentsDestination[_indexer]); + + (uint256 paymentCollected, bool allocationForceClosed) = _presentPoi( + allocationId, + poi_, + poiMetadata_, + _delegationRatio, + paymentsDestination[_indexer] + ); + + if (allocationForceClosed) { + _onCloseAllocation(allocationId, true); + } + + return paymentCollected; + } + + /** + * @notice Collect Indexing fees + * Stake equal to the amount being collected times the `stakeToFeesRatio` is locked into a stake claim. + * This claim can be released at a later stage once expired. + * + * It's important to note that before collecting this function will attempt to release any expired stake claims. + * This could lead to an out of gas error if there are too many expired claims. In that case, the indexer will need to + * manually release the claims, see {IDataServiceFees-releaseStake}, before attempting to collect again. + * + * @dev Uses the {RecurringCollector} to collect payment from Graph Horizon payments protocol. + * Fees are distributed to service provider and delegators by {GraphPayments} + * + * Requirements: + * - Indexer must have enough available tokens to lock as economic security for fees + * - Allocation must be open + * + * Emits a {StakeClaimsReleased} event, and a {StakeClaimReleased} event for each claim released. + * Emits a {StakeClaimLocked} event. + * Emits a {IndexingFeesCollectedV1} event. + * + * @param _indexer The address of the indexer + * @param _agreementId The id of the indexing agreement + * @param _paymentsDestination The address where the fees should be sent + * @param _data The indexing agreement collection data + * @return The amount of fees collected + */ + function _collectIndexingFees( + address _indexer, + bytes16 _agreementId, + address _paymentsDestination, + bytes memory _data + ) private returns (uint256) { + (address indexer, uint256 tokensCollected) = IndexingAgreement._getStorageManager().collect( + _allocations, + IndexingAgreement.CollectParams({ + indexer: _indexer, + agreementId: _agreementId, + currentEpoch: _graphEpochManager().currentEpoch(), + receiverDestination: _paymentsDestination, + data: _data, + indexingFeesCut: indexingFeesCut + }) + ); + + _releaseStake(indexer, 0); + if (tokensCollected > 0) { + // lock stake as economic security for fees + _lockStake( + indexer, + tokensCollected * stakeToFeesRatio, + block.timestamp + _disputeManager().getDisputePeriod() + ); + } + + return tokensCollected; } /** diff --git a/packages/subgraph-service/contracts/SubgraphServiceStorage.sol b/packages/subgraph-service/contracts/SubgraphServiceStorage.sol index 15fc33acc..2ecb69293 100644 --- a/packages/subgraph-service/contracts/SubgraphServiceStorage.sol +++ b/packages/subgraph-service/contracts/SubgraphServiceStorage.sol @@ -22,4 +22,7 @@ abstract contract SubgraphServiceV1Storage is ISubgraphService { /// @notice Destination of indexer payments mapping(address indexer => address destination) public override paymentsDestination; + + /// @notice The cut data service takes from indexing fee payments. In PPM. + uint256 public indexingFeesCut; } diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol new file mode 100644 index 000000000..0519b3e3f --- /dev/null +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -0,0 +1,692 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.27; + +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { IEpochManager } from "@graphprotocol/interfaces/contracts/contracts/epochs/IEpochManager.sol"; +import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; +import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; +import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; +import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; +import { RewardsCondition } from "@graphprotocol/interfaces/contracts/contracts/rewards/RewardsCondition.sol"; +import { ProvisionTracker } from "@graphprotocol/horizon/contracts/data-service/libraries/ProvisionTracker.sol"; +import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; + +import { Allocation } from "../libraries/Allocation.sol"; +import { LegacyAllocation } from "../libraries/LegacyAllocation.sol"; + +/** + * @title AllocationHandler contract + * @author Edge & Node + * @notice A helper contract implementing allocation lifecycle management. + * Allows opening, resizing, and closing allocations, as well as collecting indexing rewards by presenting a Proof + * of Indexing (POI). + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +library AllocationHandler { + using ProvisionTracker for mapping(address => uint256); + using Allocation for mapping(address => IAllocation.State); + using Allocation for IAllocation.State; + using LegacyAllocation for mapping(address => ILegacyAllocation.State); + using PPMMath for uint256; + using TokenUtils for IGraphToken; + + /** + * @notice Parameters for the allocation creation + * @param currentEpoch The current epoch at the time of allocation creation + * @param graphStaking The Horizon staking contract to handle token locking + * @param graphRewardsManager The rewards manager to handle rewards distribution + * @param _encodeAllocationProof The EIP712 encoded allocation proof + * @param _indexer The address of the indexer creating the allocation + * @param _delegationRatio The delegation ratio to consider when locking tokens + * @param _allocationId The id of the allocation to be created + * @param _subgraphDeploymentId The id of the subgraph deployment for which the allocation is created + * @param _tokens The amount of tokens to allocate + * @param _allocationProof The EIP712 proof, an EIP712 signed message of (indexer,allocationId) + */ + struct AllocateParams { + uint256 currentEpoch; + IHorizonStaking graphStaking; + IRewardsManager graphRewardsManager; + bytes32 _encodeAllocationProof; + address _indexer; + uint32 _delegationRatio; + address _allocationId; + bytes32 _subgraphDeploymentId; + uint256 _tokens; + bytes _allocationProof; + } + + /** + * @notice Parameters for the POI presentation + * @param maxPOIStaleness The maximum staleness of the POI in epochs + * @param graphEpochManager The epoch manager to get the current epoch + * @param graphStaking The Horizon staking contract to handle token locking + * @param graphRewardsManager The rewards manager to handle rewards distribution + * @param graphToken The Graph token contract to handle token transfers + * @param dataService The data service address (for delegation pool lookups) + * @param _allocationId The id of the allocation for which the POI is presented + * @param _poi The proof of indexing (POI) to be presented + * @param _poiMetadata The metadata associated with the POI + * @param _delegationRatio The delegation ratio to consider when locking tokens + * @param _paymentsDestination The address to which the indexing rewards should be sent + */ + struct PresentParams { + uint256 maxPOIStaleness; + IEpochManager graphEpochManager; + IHorizonStaking graphStaking; + IRewardsManager graphRewardsManager; + IGraphToken graphToken; + address dataService; + address _allocationId; + bytes32 _poi; + bytes _poiMetadata; + uint32 _delegationRatio; + address _paymentsDestination; + } + + /** + * @notice Emitted when an indexer creates an allocation + * @param indexer The address of the indexer + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param tokens The amount of tokens allocated + * @param currentEpoch The current epoch + */ + event AllocationCreated( + address indexed indexer, + address indexed allocationId, + bytes32 indexed subgraphDeploymentId, + uint256 tokens, + uint256 currentEpoch + ); + + /** + * @notice Emitted when an indexer collects indexing rewards for an allocation + * @param indexer The address of the indexer + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param tokensRewards The amount of tokens collected + * @param tokensIndexerRewards The amount of tokens collected for the indexer + * @param tokensDelegationRewards The amount of tokens collected for delegators + * @param poi The POI presented + * @param poiMetadata The metadata associated with the POI + * @param currentEpoch The current epoch + */ + event IndexingRewardsCollected( + address indexed indexer, + address indexed allocationId, + bytes32 indexed subgraphDeploymentId, + uint256 tokensRewards, + uint256 tokensIndexerRewards, + uint256 tokensDelegationRewards, + bytes32 poi, + bytes poiMetadata, + uint256 currentEpoch + ); + + /** + * @notice Emitted when an indexer resizes an allocation + * @param indexer The address of the indexer + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param newTokens The new amount of tokens allocated + * @param oldTokens The old amount of tokens allocated + */ + event AllocationResized( + address indexed indexer, + address indexed allocationId, + bytes32 indexed subgraphDeploymentId, + uint256 newTokens, + uint256 oldTokens + ); + + /** + * @notice Emitted when an indexer closes an allocation + * @param indexer The address of the indexer + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param tokens The amount of tokens allocated + * @param forceClosed Whether the allocation was force closed + */ + event AllocationClosed( + address indexed indexer, + address indexed allocationId, + bytes32 indexed subgraphDeploymentId, + uint256 tokens, + bool forceClosed + ); + + /** + * @notice Emitted when a legacy allocation is migrated into the subgraph service + * @param indexer The address of the indexer + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + */ + event LegacyAllocationMigrated( + address indexed indexer, + address indexed allocationId, + bytes32 indexed subgraphDeploymentId + ); + + /** + * @notice Emitted when the maximum POI staleness is updated + * @param maxPOIStaleness The max POI staleness in seconds + */ + event MaxPOIStalenessSet(uint256 maxPOIStaleness); + // solhint-disable-previous-line gas-indexed-events + + /** + * @notice Emitted when an indexer presents a POI for an allocation + * @param indexer The address of the indexer + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param poi The POI presented + * @param poiMetadata The metadata associated with the POI + * @param condition The rewards condition determined for this POI + */ + event POIPresented( + address indexed indexer, + address indexed allocationId, + bytes32 indexed subgraphDeploymentId, + bytes32 poi, + bytes poiMetadata, + bytes32 condition + ); + + /** + * @notice Thrown when an allocation proof is invalid + * Both `signer` and `allocationId` should match for a valid proof. + * @param signer The address that signed the proof + * @param allocationId The id of the allocation + */ + error AllocationHandlerInvalidAllocationProof(address signer, address allocationId); + + /** + * @notice Thrown when attempting to create an allocation with a zero allocation id + */ + error AllocationHandlerInvalidZeroAllocationId(); + + /** + * @notice Thrown when attempting to collect indexing rewards on a closed allocation + * @param allocationId The id of the allocation + */ + error AllocationHandlerAllocationClosed(address allocationId); + + /** + * @notice Thrown when attempting to resize an allocation with the same size + * @param allocationId The id of the allocation + * @param tokens The amount of tokens + */ + error AllocationHandlerAllocationSameSize(address allocationId, uint256 tokens); + + /** + * @notice Create an allocation + * @dev The `_allocationProof` is a 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationId)` + * + * Requirements: + * - `_allocationId` must not be the zero address + * + * Emits a {AllocationCreated} event + * + * @param _allocations The mapping of allocation ids to allocation states + * @param _legacyAllocations The mapping of legacy allocation ids to legacy allocation states + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param _subgraphAllocatedTokens The mapping of subgraph deployment ids to their allocated tokens + * @param params The parameters for the allocation + */ + function allocate( + mapping(address allocationId => IAllocation.State allocation) storage _allocations, + mapping(address allocationId => ILegacyAllocation.State allocation) storage _legacyAllocations, + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, + AllocateParams calldata params + ) external { + require(params._allocationId != address(0), AllocationHandler.AllocationHandlerInvalidZeroAllocationId()); + + _verifyAllocationProof(params._encodeAllocationProof, params._allocationId, params._allocationProof); + + // Ensure allocation id is not reused + // need to check both subgraph service (on allocations.create()) and legacy allocations + _legacyAllocations.revertIfExists(params.graphStaking, params._allocationId); + + IAllocation.State memory allocation = _allocations.create( + params._indexer, + params._allocationId, + params._subgraphDeploymentId, + params._tokens, + params.graphRewardsManager.onSubgraphAllocationUpdate(params._subgraphDeploymentId), + params.currentEpoch + ); + + // Check that the indexer has enough tokens available + // Note that the delegation ratio ensures overdelegation cannot be used + allocationProvisionTracker.lock(params.graphStaking, params._indexer, params._tokens, params._delegationRatio); + + // Update total allocated tokens for the subgraph deployment + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] = + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] + allocation.tokens; + + emit AllocationHandler.AllocationCreated( + params._indexer, + params._allocationId, + params._subgraphDeploymentId, + allocation.tokens, + params.currentEpoch + ); + } + + /* solhint-disable function-max-lines */ + /** + * @notice Present a POI to collect indexing rewards for an allocation + * Mints indexing rewards using the {RewardsManager} and distributes them to the indexer and delegators. + * + * Requirements for indexing rewards: + * - POI must be non-zero + * - POI must not be stale (older than `maxPOIStaleness`) + * - Allocation must be open for at least one epoch (returns early with 0 if too young) + * + * ## Reward Paths + * + * Rewards follow one of three paths based on allocation and POI state: + * + * **CLAIMED** (normal path): Valid POI, not stale, allocation mature, subgraph not denied + * - Calls `takeRewards()` to mint tokens to this contract + * - Distributes to indexer (stake or payments destination) and delegators + * - Snapshots allocation to prevent double-counting + * + * **RECLAIMED** (redirect path): STALE_POI or ZERO_POI conditions + * - Calls `reclaimRewards()` to mint tokens to configured reclaim address + * - If no reclaim address configured, rewards are dropped (not minted) + * - Snapshots allocation to prevent double-counting + * + * **DEFERRED** (early return): ALLOCATION_TOO_YOUNG or SUBGRAPH_DENIED conditions + * - Returns 0 without calling take or reclaim + * - Does NOT snapshot allocation (preserves rewards for later collection) + * - Allows rewards to be claimed when condition clears + * + * Emits a {POIPresented} event. + * Emits a {IndexingRewardsCollected} event. + * + * @param _allocations The mapping of allocation ids to allocation states + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param _subgraphAllocatedTokens The mapping of subgraph deployment ids to their allocated tokens + * @param params The parameters for the POI presentation + * @return rewardsCollected The amount of tokens collected + * @return allocationForceClosed True if the allocation was automatically closed due to over-allocation, false otherwise + */ + function presentPOI( + mapping(address allocationId => IAllocation.State allocation) storage _allocations, + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, + PresentParams calldata params + ) external returns (uint256 rewardsCollected, bool allocationForceClosed) { + IAllocation.State memory allocation = _allocations.get(params._allocationId); + require(allocation.isOpen(), AllocationHandler.AllocationHandlerAllocationClosed(params._allocationId)); + _allocations.presentPOI(params._allocationId); // Always record POI presentation to prevent staleness + + uint256 currentEpoch = params.graphEpochManager.currentEpoch(); + // Scoped for stack management + { + // Determine rewards condition + bytes32 condition = RewardsCondition.NONE; + if (allocation.isStale(params.maxPOIStaleness)) condition = RewardsCondition.STALE_POI; + else if (params._poi == bytes32(0)) + condition = RewardsCondition.ZERO_POI; + // solhint-disable-next-line gas-strict-inequalities + else if (currentEpoch <= allocation.createdAtEpoch) condition = RewardsCondition.ALLOCATION_TOO_YOUNG; + else if (params.graphRewardsManager.isDenied(allocation.subgraphDeploymentId)) + condition = RewardsCondition.SUBGRAPH_DENIED; + + emit AllocationHandler.POIPresented( + allocation.indexer, + params._allocationId, + allocation.subgraphDeploymentId, + params._poi, + params._poiMetadata, + condition + ); + + // Early return skips the overallocation check intentionally to avoid loss of uncollected rewards + if (condition == RewardsCondition.ALLOCATION_TOO_YOUNG || condition == RewardsCondition.SUBGRAPH_DENIED) { + // Keep reward and reclaim accumulation current even if rewards are not collected + params.graphRewardsManager.onSubgraphAllocationUpdate(allocation.subgraphDeploymentId); + + return (0, false); + } + + bool rewardsReclaimable = condition == RewardsCondition.STALE_POI || condition == RewardsCondition.ZERO_POI; + if (rewardsReclaimable) params.graphRewardsManager.reclaimRewards(condition, params._allocationId); + else rewardsCollected = params.graphRewardsManager.takeRewards(params._allocationId); + } + + // Snapshot rewards to prevent accumulation for next POI, then clear pending + _allocations.snapshotRewards( + params._allocationId, + params.graphRewardsManager.onSubgraphAllocationUpdate(allocation.subgraphDeploymentId) + ); + _allocations.clearPendingRewards(params._allocationId); + + // Scoped for stack management + { + (uint256 tokensIndexerRewards, uint256 tokensDelegationRewards) = _distributeIndexingRewards( + allocation, + rewardsCollected, + params + ); + + emit AllocationHandler.IndexingRewardsCollected( + allocation.indexer, + params._allocationId, + allocation.subgraphDeploymentId, + rewardsCollected, + tokensIndexerRewards, + tokensDelegationRewards, + params._poi, + params._poiMetadata, + currentEpoch + ); + } + + // Check if the indexer is over-allocated and force close the allocation if necessary + if ( + _isOverAllocated( + allocationProvisionTracker, + params.graphStaking, + allocation.indexer, + params._delegationRatio + ) + ) { + allocationForceClosed = true; + _closeAllocation( + _allocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + params.graphRewardsManager, + params._allocationId, + true + ); + } + } + /* solhint-enable function-max-lines */ + + /** + * @notice Close an allocation + * Does not require presenting a POI, use {_collectIndexingRewards} to present a POI and collect rewards + * @dev Note that allocations are long lived. All service payments, including indexing rewards, should be collected periodically + * without the need of closing the allocation. Allocations should only be closed when indexers want to reclaim the allocated + * tokens for other purposes. + * + * Emits a {AllocationClosed} event + * + * @param _allocations The mapping of allocation ids to allocation states + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param _subgraphAllocatedTokens The mapping of subgraph deployment ids to their allocated tokens + * @param graphRewardsManager The rewards manager to handle rewards distribution + * @param _allocationId The id of the allocation to be closed + * @param _forceClosed Whether the allocation was force closed + */ + function closeAllocation( + mapping(address allocationId => IAllocation.State allocation) storage _allocations, + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, + IRewardsManager graphRewardsManager, + address _allocationId, + bool _forceClosed + ) external { + _closeAllocation( + _allocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + graphRewardsManager, + _allocationId, + _forceClosed + ); + } + + /* solhint-disable function-max-lines */ + /** + * @notice Resize an allocation + * @dev Will lock or release tokens in the provision tracker depending on the new allocation size. + * Rewards accrued but not issued before the resize will be accounted for as pending rewards, + * unless the allocation is stale, in which case pending rewards are reclaimed. + * These will be paid out when the indexer presents a POI. + * + * Requirements: + * - `_indexer` must be the owner of the allocation + * - Allocation must be open + * - `_tokens` must be different from the current allocation size + * + * Emits a {AllocationResized} event. + * + * @param _allocations The mapping of allocation ids to allocation states + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param _subgraphAllocatedTokens The mapping of subgraph deployment ids to their allocated tokens + * @param graphStaking The Horizon staking contract to handle token locking + * @param graphRewardsManager The rewards manager to handle rewards distribution + * @param _allocationId The id of the allocation to be resized + * @param _tokens The new amount of tokens to allocate + * @param _delegationRatio The delegation ratio to consider when locking tokens + * @param _maxPOIStaleness The maximum staleness of the POI in seconds + */ + function resizeAllocation( + mapping(address allocationId => IAllocation.State allocation) storage _allocations, + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, + IHorizonStaking graphStaking, + IRewardsManager graphRewardsManager, + address _allocationId, + uint256 _tokens, + uint32 _delegationRatio, + uint256 _maxPOIStaleness + ) external { + IAllocation.State memory allocation = _allocations.get(_allocationId); + require(allocation.isOpen(), AllocationHandler.AllocationHandlerAllocationClosed(_allocationId)); + require( + _tokens != allocation.tokens, + AllocationHandler.AllocationHandlerAllocationSameSize(_allocationId, _tokens) + ); + + // Update provision tracker + uint256 oldTokens = allocation.tokens; + if (_tokens > oldTokens) { + allocationProvisionTracker.lock(graphStaking, allocation.indexer, _tokens - oldTokens, _delegationRatio); + } else { + allocationProvisionTracker.release(allocation.indexer, oldTokens - _tokens); + } + + // Calculate rewards that have been accrued since the last snapshot but not yet issued + uint256 accRewardsPerAllocatedToken = graphRewardsManager.onSubgraphAllocationUpdate( + allocation.subgraphDeploymentId + ); + uint256 accRewardsPerAllocatedTokenPending = !allocation.isAltruistic() + ? accRewardsPerAllocatedToken - allocation.accRewardsPerAllocatedToken + : 0; + + // Update the allocation + _allocations[_allocationId].tokens = _tokens; + _allocations[_allocationId].accRewardsPerAllocatedToken = accRewardsPerAllocatedToken; + _allocations[_allocationId].accRewardsPending += graphRewardsManager.calcRewards( + oldTokens, + accRewardsPerAllocatedTokenPending + ); + + // If allocation is stale, reclaim pending rewards defensively. + // Stale allocations are not performing, so rewards should not accumulate. + if (allocation.isStale(_maxPOIStaleness)) { + graphRewardsManager.reclaimRewards(RewardsCondition.STALE_POI, _allocationId); + _allocations.clearPendingRewards(_allocationId); + } + + // Update total allocated tokens for the subgraph deployment + if (_tokens > oldTokens) { + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] += (_tokens - oldTokens); + } else { + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] -= (oldTokens - _tokens); + } + + emit AllocationHandler.AllocationResized( + allocation.indexer, + _allocationId, + allocation.subgraphDeploymentId, + _tokens, + oldTokens + ); + } + /* solhint-enable function-max-lines */ + + /** + * @notice Checks if an allocation is over-allocated + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param graphStaking The Horizon staking contract to check delegation ratios + * @param _indexer The address of the indexer + * @param _delegationRatio The delegation ratio to consider when locking tokens + * @return True if the allocation is over-allocated, false otherwise + */ + function isOverAllocated( + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + IHorizonStaking graphStaking, + address _indexer, + uint32 _delegationRatio + ) external view returns (bool) { + return _isOverAllocated(allocationProvisionTracker, graphStaking, _indexer, _delegationRatio); + } + + /** + * @notice Close an allocation (internal) + * @dev Reclaims uncollected rewards before closing. + * + * Emits a {AllocationClosed} event + * + * @param _allocations The mapping of allocation ids to allocation states + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param _subgraphAllocatedTokens The mapping of subgraph deployment ids to their allocated tokens + * @param graphRewardsManager The rewards manager to handle rewards distribution + * @param _allocationId The id of the allocation to be closed + * @param _forceClosed Whether the allocation was force closed + */ + function _closeAllocation( + mapping(address allocationId => IAllocation.State allocation) storage _allocations, + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, + IRewardsManager graphRewardsManager, + address _allocationId, + bool _forceClosed + ) private { + IAllocation.State memory allocation = _allocations.get(_allocationId); + + // Reclaim uncollected rewards before closing + uint256 reclaimedRewards = graphRewardsManager.reclaimRewards(RewardsCondition.CLOSE_ALLOCATION, _allocationId); + + // Take rewards snapshot to prevent other allos from counting tokens from this allo + _allocations.snapshotRewards( + _allocationId, + graphRewardsManager.onSubgraphAllocationUpdate(allocation.subgraphDeploymentId) + ); + + // Clear pending rewards only if rewards were reclaimed. This marks them as consumed, + // which could be useful for future logic that searches for unconsumed rewards. + // Known limitation: This capture is incomplete due to other code paths (e.g., _presentPOI) + // that clear pending even when rewards are not consumed. + if (0 < reclaimedRewards) _allocations.clearPendingRewards(_allocationId); + + _allocations.close(_allocationId); + allocationProvisionTracker.release(allocation.indexer, allocation.tokens); + + // Update total allocated tokens for the subgraph deployment + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] = + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] - allocation.tokens; + + emit AllocationHandler.AllocationClosed( + allocation.indexer, + _allocationId, + allocation.subgraphDeploymentId, + allocation.tokens, + _forceClosed + ); + } + + /** + * @notice Distributes indexing rewards to delegators and indexer + * @param _allocation The allocation state + * @param _rewardsCollected Total rewards to distribute + * @param _params The present params containing staking, token, and destination info + * @return tokensIndexerRewards Amount sent to indexer + * @return tokensDelegationRewards Amount sent to delegation pool + */ + function _distributeIndexingRewards( + IAllocation.State memory _allocation, + uint256 _rewardsCollected, + PresentParams memory _params + ) private returns (uint256 tokensIndexerRewards, uint256 tokensDelegationRewards) { + if (_rewardsCollected == 0) return (0, 0); + + // Calculate and distribute delegator share + uint256 delegatorCut = _params.graphStaking.getDelegationFeeCut( + _allocation.indexer, + _params.dataService, + IGraphPayments.PaymentTypes.IndexingRewards + ); + IHorizonStakingTypes.DelegationPool memory pool = _params.graphStaking.getDelegationPool( + _allocation.indexer, + _params.dataService + ); + tokensDelegationRewards = pool.shares > 0 ? _rewardsCollected.mulPPM(delegatorCut) : 0; + if (tokensDelegationRewards > 0) { + _params.graphToken.approve(address(_params.graphStaking), tokensDelegationRewards); + _params.graphStaking.addToDelegationPool(_allocation.indexer, _params.dataService, tokensDelegationRewards); + } + + // Distribute indexer share + tokensIndexerRewards = _rewardsCollected - tokensDelegationRewards; + if (tokensIndexerRewards > 0) { + if (_params._paymentsDestination == address(0)) { + _params.graphToken.approve(address(_params.graphStaking), tokensIndexerRewards); + _params.graphStaking.stakeToProvision(_allocation.indexer, _params.dataService, tokensIndexerRewards); + } else { + _params.graphToken.pushTokens(_params._paymentsDestination, tokensIndexerRewards); + } + } + } + + /** + * @notice Checks if an allocation is over-allocated + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param graphStaking The Horizon staking contract to check delegation ratios + * @param _indexer The address of the indexer + * @param _delegationRatio The delegation ratio to consider when locking tokens + * @return True if the allocation is over-allocated, false otherwise + */ + function _isOverAllocated( + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + IHorizonStaking graphStaking, + address _indexer, + uint32 _delegationRatio + ) private view returns (bool) { + return !allocationProvisionTracker.check(graphStaking, _indexer, _delegationRatio); + } + + /** + * @notice Verifies ownership of an allocation id by verifying an EIP712 allocation proof + * @dev Requirements: + * - Signer must be the allocation id address + * @param _encodeAllocationProof The EIP712 encoded allocation proof + * @param _allocationId The id of the allocation + * @param _proof The EIP712 proof, an EIP712 signed message of (indexer,allocationId) + */ + function _verifyAllocationProof( + bytes32 _encodeAllocationProof, + address _allocationId, + bytes memory _proof + ) private pure { + address signer = ECDSA.recover(_encodeAllocationProof, _proof); + require( + signer == _allocationId, + AllocationHandler.AllocationHandlerInvalidAllocationProof(signer, _allocationId) + ); + } +} diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol new file mode 100644 index 000000000..2f52dc8c0 --- /dev/null +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -0,0 +1,796 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.27; + +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; +import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; + +import { AllocationHandler } from "../libraries/AllocationHandler.sol"; +import { Directory } from "../utilities/Directory.sol"; +import { Allocation } from "./Allocation.sol"; +import { IndexingAgreementDecoder } from "./IndexingAgreementDecoder.sol"; + +/** + * @title IndexingAgreement library + * @author Edge & Node + * @notice Manages indexing agreement lifecycle — acceptance, updates, cancellation, and fee collection. + */ +library IndexingAgreement { + using IndexingAgreement for StorageManager; + using Allocation for IAllocation.State; + using Allocation for mapping(address => IAllocation.State); + + /** + * @notice Accept Indexing Agreement metadata + * @param subgraphDeploymentId The subgraph deployment ID + * @param version The indexing agreement version + * @param terms The indexing agreement terms + */ + struct AcceptIndexingAgreementMetadata { + bytes32 subgraphDeploymentId; + IIndexingAgreement.IndexingAgreementVersion version; + bytes terms; + } + + /** + * @notice Update Indexing Agreement metadata + * @param version The indexing agreement version + * @param terms The indexing agreement terms + */ + struct UpdateIndexingAgreementMetadata { + IIndexingAgreement.IndexingAgreementVersion version; + bytes terms; + } + + /** + * @notice Indexing Agreement Terms (Version 1) + * @param tokensPerSecond The amount of tokens per second + * @param tokensPerEntityPerSecond The amount of tokens per entity per second + */ + struct IndexingAgreementTermsV1 { + uint256 tokensPerSecond; + uint256 tokensPerEntityPerSecond; + } + + /** + * @notice Parameters for collecting indexing fees + * @param indexer The address of the indexer + * @param agreementId The ID of the indexing agreement + * @param currentEpoch The current epoch + * @param receiverDestination The address where the collected fees should be sent + * @param data The encoded data containing the number of entities indexed, proof of indexing, and epoch + * @param indexingFeesCut The indexing fees cut in PPM + */ + struct CollectParams { + address indexer; + bytes16 agreementId; + uint256 currentEpoch; + address receiverDestination; + bytes data; + uint256 indexingFeesCut; + } + + /** + * @notice Nested data for collecting indexing fees V1. + * + * @param entities The number of entities + * @param poi The proof of indexing (POI) + * @param poiBlockNumber The block number of the POI + * @param metadata Additional metadata associated with the collection + * @param maxSlippage Max acceptable tokens to lose due to rate limiting, or type(uint256).max to ignore + */ + struct CollectIndexingFeeDataV1 { + uint256 entities; + bytes32 poi; + uint256 poiBlockNumber; + bytes metadata; + uint256 maxSlippage; + } + + /** + * @notice Storage manager for indexing agreements + * @dev This struct holds the state of indexing agreements and their terms. + * It is used to manage the lifecycle of indexing agreements in the subgraph service. + * @param agreements Mapping of agreement IDs to their states + * @param termsV1 Mapping of agreement IDs to their terms for version 1 agreements + * @param allocationToActiveAgreementId Mapping of allocation IDs to their active agreement IDs + * @custom:storage-location erc7201:graphprotocol.subgraph-service.storage.StorageManager.IndexingAgreement + */ + struct StorageManager { + mapping(bytes16 => IIndexingAgreement.State) agreements; + mapping(bytes16 agreementId => IndexingAgreementTermsV1 data) termsV1; + mapping(address allocationId => bytes16 agreementId) allocationToActiveAgreementId; + } + + /** + * @notice Storage location for the indexing agreement storage manager + * @dev Equals keccak256(abi.encode(uint256(keccak256("graphprotocol.subgraph-service.storage.StorageManager.IndexingAgreement")) - 1)) & ~bytes32(uint256(0xff)) + */ + bytes32 public constant INDEXING_AGREEMENT_STORAGE_MANAGER_LOCATION = + 0xb59b65b7215c7fb95ac34d2ad5aed7c775c8bc77ad936b1b43e17b95efc8e400; + + /** + * @notice Emitted when an indexer collects indexing fees from a V1 agreement + * @param indexer The address of the indexer + * @param payer The address paying for the indexing fees + * @param agreementId The id of the agreement + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param currentEpoch The current epoch + * @param tokensCollected The amount of tokens collected + * @param entities The number of entities indexed + * @param poi The proof of indexing + * @param poiBlockNumber The block number of the proof of indexing + * @param metadata Additional metadata associated with the collection + */ + event IndexingFeesCollectedV1( + address indexed indexer, + address indexed payer, + bytes16 indexed agreementId, + address allocationId, + bytes32 subgraphDeploymentId, + uint256 currentEpoch, + uint256 tokensCollected, + uint256 entities, + bytes32 poi, + uint256 poiBlockNumber, + bytes metadata + ); + + /** + * @notice Emitted when an indexing agreement is canceled + * @param indexer The address of the indexer + * @param payer The address of the payer + * @param agreementId The id of the agreement + * @param canceledOnBehalfOf The address of the entity that canceled the agreement + */ + event IndexingAgreementCanceled( + address indexed indexer, + address indexed payer, + bytes16 indexed agreementId, + address canceledOnBehalfOf + ); + + /** + * @notice Emitted when an indexing agreement is accepted + * @param indexer The address of the indexer + * @param payer The address of the payer + * @param agreementId The id of the agreement + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param version The version of the indexing agreement + * @param versionTerms The version data of the indexing agreement + */ + event IndexingAgreementAccepted( + address indexed indexer, + address indexed payer, + bytes16 indexed agreementId, + address allocationId, + bytes32 subgraphDeploymentId, + IIndexingAgreement.IndexingAgreementVersion version, + bytes versionTerms + ); + + /** + * @notice Emitted when an indexing agreement is updated + * @param indexer The address of the indexer + * @param payer The address of the payer + * @param agreementId The id of the agreement + * @param allocationId The id of the allocation + * @param version The version of the indexing agreement + * @param versionTerms The version data of the indexing agreement + */ + event IndexingAgreementUpdated( + address indexed indexer, + address indexed payer, + bytes16 indexed agreementId, + address allocationId, + IIndexingAgreement.IndexingAgreementVersion version, + bytes versionTerms + ); + + /** + * @notice Thrown when trying to interact with an agreement with an invalid version + * @param version The invalid version + */ + error IndexingAgreementInvalidVersion(IIndexingAgreement.IndexingAgreementVersion version); + + /** + * @notice Thrown when an agreement is not for the subgraph data service + * @param expectedDataService The expected data service address + * @param wrongDataService The wrong data service address + */ + error IndexingAgreementWrongDataService(address expectedDataService, address wrongDataService); + + /** + * @notice Thrown when an agreement and the allocation correspond to different deployment IDs + * @param agreementDeploymentId The agreement's deployment ID + * @param allocationId The allocation ID + * @param allocationDeploymentId The allocation's deployment ID + */ + error IndexingAgreementDeploymentIdMismatch( + bytes32 agreementDeploymentId, + address allocationId, + bytes32 allocationDeploymentId + ); + + /** + * @notice Thrown when the agreement is already accepted + * @param agreementId The agreement ID + */ + error IndexingAgreementAlreadyAccepted(bytes16 agreementId); + + /** + * @notice Thrown when an allocation already has an active agreement + * @param allocationId The allocation ID + */ + error AllocationAlreadyHasIndexingAgreement(address allocationId); + + /** + * @notice Thrown when caller or proxy can not cancel an agreement + * @param owner The address of the owner of the agreement + * @param unauthorized The unauthorized caller + */ + error IndexingAgreementNonCancelableBy(address owner, address unauthorized); + + /** + * @notice Thrown when the agreement is not active + * @param agreementId The agreement ID + */ + error IndexingAgreementNotActive(bytes16 agreementId); + + /** + * @notice Thrown when the agreement is not collectable + * @param agreementId The agreement ID + */ + error IndexingAgreementNotCollectable(bytes16 agreementId); + + /** + * @notice Thrown when trying to interact with an agreement not owned by the indexer + * @param agreementId The agreement ID + * @param unauthorizedIndexer The unauthorized indexer + */ + error IndexingAgreementNotAuthorized(bytes16 agreementId, address unauthorizedIndexer); + + /** + * @notice Thrown when indexing agreement terms are invalid + * @param tokensPerSecond The indexing agreement tokens per second + * @param maxOngoingTokensPerSecond The RCA maximum tokens per second + */ + error IndexingAgreementInvalidTerms(uint256 tokensPerSecond, uint256 maxOngoingTokensPerSecond); + + /* solhint-disable function-max-lines */ + /** + * @notice Accept an indexing agreement. + * + * Requirements: + * - Allocation must belong to the indexer and be open + * - Agreement must be for this data service + * - Agreement's subgraph deployment must match the allocation's subgraph deployment + * - Agreement must not have been accepted before + * - Allocation must not have an agreement already + * + * @dev signedRCA.rca.metadata is an encoding of {IndexingAgreement.AcceptIndexingAgreementMetadata} + * + * Emits {IndexingAgreementAccepted} event + * + * @param self The indexing agreement storage manager + * @param allocations The mapping of allocation IDs to their states + * @param allocationId The id of the allocation + * @param signedRCA The signed Recurring Collection Agreement + * @return The agreement ID assigned to the accepted indexing agreement + */ + function accept( + StorageManager storage self, + mapping(address allocationId => IAllocation.State allocation) storage allocations, + address allocationId, + IRecurringCollector.SignedRCA calldata signedRCA + ) external returns (bytes16) { + IAllocation.State memory allocation = _requireValidAllocation( + allocations, + allocationId, + signedRCA.rca.serviceProvider + ); + + require( + signedRCA.rca.dataService == address(this), + IndexingAgreementWrongDataService(address(this), signedRCA.rca.dataService) + ); + + AcceptIndexingAgreementMetadata memory metadata = IndexingAgreementDecoder.decodeRCAMetadata( + signedRCA.rca.metadata + ); + + bytes16 agreementId = _directory().recurringCollector().generateAgreementId( + signedRCA.rca.payer, + signedRCA.rca.dataService, + signedRCA.rca.serviceProvider, + signedRCA.rca.deadline, + signedRCA.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, signedRCA.rca.maxOngoingTokensPerSecond); + + emit IndexingAgreementAccepted( + signedRCA.rca.serviceProvider, + signedRCA.rca.payer, + agreementId, + allocationId, + metadata.subgraphDeploymentId, + metadata.version, + metadata.terms + ); + + require(_directory().recurringCollector().accept(signedRCA) == agreementId, "internal: agreement ID mismatch"); + return agreementId; + } + /* solhint-enable function-max-lines */ + + /** + * @notice Update an indexing agreement. + * + * Requirements: + * - Agreement must be active + * - The indexer must be the service provider of the agreement + * + * @dev signedRCA.rcau.metadata is an encoding of {IndexingAgreement.UpdateIndexingAgreementMetadata} + * + * Emits {IndexingAgreementUpdated} event + * + * @param self The indexing agreement storage manager + * @param indexer The indexer address + * @param signedRCAU The signed Recurring Collection Agreement Update + */ + function update( + StorageManager storage self, + address indexer, + IRecurringCollector.SignedRCAU calldata signedRCAU + ) external { + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, signedRCAU.rcau.agreementId); + require(_isActive(wrapper), IndexingAgreementNotActive(signedRCAU.rcau.agreementId)); + require( + wrapper.collectorAgreement.serviceProvider == indexer, + IndexingAgreementNotAuthorized(signedRCAU.rcau.agreementId, indexer) + ); + + UpdateIndexingAgreementMetadata memory metadata = IndexingAgreementDecoder.decodeRCAUMetadata( + signedRCAU.rcau.metadata + ); + + require( + wrapper.agreement.version == IIndexingAgreement.IndexingAgreementVersion.V1, + "internal: invalid version" + ); + require( + metadata.version == IIndexingAgreement.IndexingAgreementVersion.V1, + IndexingAgreementInvalidVersion(metadata.version) + ); + _setTermsV1( + self, + signedRCAU.rcau.agreementId, + metadata.terms, + wrapper.collectorAgreement.maxOngoingTokensPerSecond + ); + + emit IndexingAgreementUpdated({ + indexer: wrapper.collectorAgreement.serviceProvider, + payer: wrapper.collectorAgreement.payer, + agreementId: signedRCAU.rcau.agreementId, + allocationId: wrapper.agreement.allocationId, + version: metadata.version, + versionTerms: metadata.terms + }); + + _directory().recurringCollector().update(signedRCAU); + } + + /** + * @notice Cancel an indexing agreement. + * + * @dev This function allows the indexer to cancel an indexing agreement. + * + * Requirements: + * - Agreement must be active + * - The indexer must be the service provider of the agreement + * + * Emits {IndexingAgreementCanceled} event + * + * @param self The indexing agreement storage manager + * @param indexer The indexer address + * @param agreementId The id of the agreement to cancel + */ + function cancel(StorageManager storage self, address indexer, bytes16 agreementId) external { + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, agreementId); + require(_isActive(wrapper), IndexingAgreementNotActive(agreementId)); + require( + wrapper.collectorAgreement.serviceProvider == indexer, + IndexingAgreementNonCancelableBy(wrapper.collectorAgreement.serviceProvider, indexer) + ); + _cancel( + self, + agreementId, + wrapper.agreement, + wrapper.collectorAgreement, + IRecurringCollector.CancelAgreementBy.ServiceProvider + ); + } + + /** + * @notice Cancel an allocation's indexing agreement if it exists. + * + * @dev This function is to be called by the data service when an allocation is closed. + * + * Requirements: + * - The allocation must have an active agreement + * - Agreement must be active + * + * Emits {IndexingAgreementCanceled} event + * + * @param self The indexing agreement storage manager + * @param _allocationId The allocation ID + * @param forceClosed Whether the allocation was force closed + * + */ + function onCloseAllocation(StorageManager storage self, address _allocationId, bool forceClosed) external { + bytes16 agreementId = self.allocationToActiveAgreementId[_allocationId]; + if (agreementId == bytes16(0)) { + return; + } + + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, agreementId); + if (!_isActive(wrapper)) { + return; + } + + _cancel( + self, + agreementId, + wrapper.agreement, + wrapper.collectorAgreement, + forceClosed + ? IRecurringCollector.CancelAgreementBy.ThirdParty + : IRecurringCollector.CancelAgreementBy.ServiceProvider + ); + } + + /** + * @notice Cancel an indexing agreement by the payer. + * + * @dev This function allows the payer to cancel an indexing agreement. + * + * Requirements: + * - Agreement must be active + * - The caller must be authorized to cancel the agreement in the collector on the payer's behalf + * + * Emits {IndexingAgreementCanceled} event + * + * @param self The indexing agreement storage manager + * @param agreementId The id of the agreement to cancel + */ + function cancelByPayer(StorageManager storage self, bytes16 agreementId) external { + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, agreementId); + require(_isActive(wrapper), IndexingAgreementNotActive(agreementId)); + require( + _directory().recurringCollector().isAuthorized(wrapper.collectorAgreement.payer, msg.sender), + IndexingAgreementNonCancelableBy(wrapper.collectorAgreement.payer, msg.sender) + ); + _cancel( + self, + agreementId, + wrapper.agreement, + wrapper.collectorAgreement, + IRecurringCollector.CancelAgreementBy.Payer + ); + } + + /* solhint-disable function-max-lines */ + /** + * @notice Collect Indexing fees + * @dev Uses the {RecurringCollector} to collect payment from Graph Horizon payments protocol. + * Fees are distributed to service provider and delegators by {GraphPayments} + * + * Requirements: + * - Allocation must be open + * - Agreement must be active + * - Agreement must be of version V1 + * - The data must be encoded as per {IndexingAgreementDecoder.decodeCollectIndexingFeeDataV1} + * + * Emits a {IndexingFeesCollectedV1} event. + * + * @param self The indexing agreement storage manager + * @param allocations The mapping of allocation IDs to their states + * @param params The parameters for collecting indexing fees + * @return The address of the service provider that collected the fees + * @return The amount of fees collected + */ + function collect( + StorageManager storage self, + mapping(address allocationId => IAllocation.State allocation) storage allocations, + CollectParams calldata params + ) external returns (address, uint256) { + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, params.agreementId); + IAllocation.State memory allocation = _requireValidAllocation( + allocations, + wrapper.agreement.allocationId, + wrapper.collectorAgreement.serviceProvider + ); + require( + allocation.indexer == params.indexer, + IndexingAgreementNotAuthorized(params.agreementId, params.indexer) + ); + // Get collection info from RecurringCollector (single source of truth for temporal logic) + (bool isCollectable, uint256 collectionSeconds, ) = _directory().recurringCollector().getCollectionInfo( + wrapper.collectorAgreement + ); + require(_isValid(wrapper) && isCollectable, IndexingAgreementNotCollectable(params.agreementId)); + + require( + wrapper.agreement.version == IIndexingAgreement.IndexingAgreementVersion.V1, + IndexingAgreementInvalidVersion(wrapper.agreement.version) + ); + + CollectIndexingFeeDataV1 memory data = IndexingAgreementDecoder.decodeCollectIndexingFeeDataV1(params.data); + + uint256 expectedTokens = (data.entities == 0 && data.poi == bytes32(0)) + ? 0 + : _tokensToCollect(self, params.agreementId, data.entities, collectionSeconds); + + // `tokensCollected` <= `expectedTokens` because the recurring collector will further narrow + // down the tokens allowed, based on the RCA terms. + uint256 tokensCollected = _directory().recurringCollector().collect( + IGraphPayments.PaymentTypes.IndexingFee, + abi.encode( + IRecurringCollector.CollectParams({ + agreementId: params.agreementId, + collectionId: bytes32(uint256(uint160(wrapper.agreement.allocationId))), + tokens: expectedTokens, + dataServiceCut: params.indexingFeesCut, + receiverDestination: params.receiverDestination, + maxSlippage: data.maxSlippage + }) + ) + ); + + emit IndexingFeesCollectedV1( + wrapper.collectorAgreement.serviceProvider, + wrapper.collectorAgreement.payer, + params.agreementId, + wrapper.agreement.allocationId, + allocation.subgraphDeploymentId, + params.currentEpoch, + tokensCollected, + data.entities, + data.poi, + data.poiBlockNumber, + data.metadata + ); + + return (wrapper.collectorAgreement.serviceProvider, tokensCollected); + } + /* solhint-enable function-max-lines */ + + /** + * @notice Get the indexing agreement for a given agreement ID. + * + * @param self The indexing agreement storage manager + * @param agreementId The id of the indexing agreement + * @return The indexing agreement wrapper containing the agreement state and collector agreement data + */ + function get( + StorageManager storage self, + bytes16 agreementId + ) external view returns (IIndexingAgreement.AgreementWrapper memory) { + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, agreementId); + require(wrapper.collectorAgreement.dataService == address(this), IndexingAgreementNotActive(agreementId)); + + return wrapper; + } + + /** + * @notice Get the storage manager for indexing agreements. + * @dev This function retrieves the storage manager for indexing agreements. + * @return m The storage manager for indexing agreements + */ + function _getStorageManager() internal pure returns (StorageManager storage m) { + // solhint-disable-next-line no-inline-assembly + assembly { + m.slot := INDEXING_AGREEMENT_STORAGE_MANAGER_LOCATION + } + } + + /** + * @notice Set the terms for an indexing agreement of version V1. + * @dev This function updates the terms of an indexing agreement in the storage manager. + * @param _manager The indexing agreement storage manager + * @param _agreementId The id of the agreement to update + * @param _data The encoded terms data + * @param maxOngoingTokensPerSecond The RCA maximum tokens per second limit for validation + */ + function _setTermsV1( + StorageManager storage _manager, + bytes16 _agreementId, + bytes memory _data, + uint256 maxOngoingTokensPerSecond + ) private { + IndexingAgreementTermsV1 memory newTerms = IndexingAgreementDecoder.decodeIndexingAgreementTermsV1(_data); + _validateTermsAgainstRCA(newTerms, maxOngoingTokensPerSecond); + _manager.termsV1[_agreementId].tokensPerSecond = newTerms.tokensPerSecond; + _manager.termsV1[_agreementId].tokensPerEntityPerSecond = newTerms.tokensPerEntityPerSecond; + } + + /** + * @notice Cancel an indexing agreement. + * + * @dev This function does the actual agreement cancelation. + * + * Emits {IndexingAgreementCanceled} event + * + * @param _manager The indexing agreement storage manager + * @param _agreementId The id of the agreement to cancel + * @param _agreement The indexing agreement state + * @param _collectorAgreement The collector agreement data + * @param _cancelBy The entity that is canceling the agreement + */ + function _cancel( + StorageManager storage _manager, + bytes16 _agreementId, + IIndexingAgreement.State memory _agreement, + IRecurringCollector.AgreementData memory _collectorAgreement, + IRecurringCollector.CancelAgreementBy _cancelBy + ) private { + // Delete the allocation to active agreement link, so that the allocation + // can be assigned a new indexing agreement in the future. + delete _manager.allocationToActiveAgreementId[_agreement.allocationId]; + + emit IndexingAgreementCanceled( + _collectorAgreement.serviceProvider, + _collectorAgreement.payer, + _agreementId, + _cancelBy == IRecurringCollector.CancelAgreementBy.Payer + ? _collectorAgreement.payer + : _collectorAgreement.serviceProvider + ); + + _directory().recurringCollector().cancel(_agreementId, _cancelBy); + } + + /** + * @notice Requires that the allocation is valid and owned by the indexer. + * + * Requirements: + * - Allocation must belong to the indexer + * - Allocation must be open + * + * @param _allocations The mapping of allocation IDs to their states + * @param _allocationId The id of the allocation + * @param _indexer The address of the indexer + * @return The allocation state + */ + function _requireValidAllocation( + mapping(address => IAllocation.State) storage _allocations, + address _allocationId, + address _indexer + ) private view returns (IAllocation.State memory) { + IAllocation.State memory allocation = _allocations.get(_allocationId); + require( + allocation.indexer == _indexer, + ISubgraphService.SubgraphServiceAllocationNotAuthorized(_indexer, _allocationId) + ); + require(allocation.isOpen(), AllocationHandler.AllocationHandlerAllocationClosed(_allocationId)); + + return allocation; + } + + /** + * @notice Calculate tokens to collect based on pre-validated duration + * @param _manager The storage manager + * @param _agreementId The agreement ID + * @param _entities The number of entities indexed + * @param _collectionSeconds Pre-calculated valid collection duration + * @return The number of tokens to collect + */ + function _tokensToCollect( + StorageManager storage _manager, + bytes16 _agreementId, + uint256 _entities, + uint256 _collectionSeconds + ) private view returns (uint256) { + IndexingAgreementTermsV1 memory termsV1 = _manager.termsV1[_agreementId]; + return _collectionSeconds * (termsV1.tokensPerSecond + termsV1.tokensPerEntityPerSecond * _entities); + } + + /** + * @notice Checks if the agreement is active + * Requirements: + * - The indexing agreement is valid + * - The underlying collector agreement has been accepted + * @param wrapper The agreement wrapper containing the indexing agreement and collector agreement data + * @return True if the agreement is active, false otherwise + **/ + function _isActive(IIndexingAgreement.AgreementWrapper memory wrapper) private view returns (bool) { + return _isValid(wrapper) && wrapper.collectorAgreement.state == IRecurringCollector.AgreementState.Accepted; + } + + /** + * @notice Checks if the agreement is valid + * Requirements: + * - The underlying collector agreement's data service is this contract + * - The indexing agreement has been accepted and has a valid allocation ID + * @param wrapper The agreement wrapper containing the indexing agreement and collector agreement data + * @return True if the agreement is valid, false otherwise + **/ + function _isValid(IIndexingAgreement.AgreementWrapper memory wrapper) private view returns (bool) { + return wrapper.collectorAgreement.dataService == address(this) && wrapper.agreement.allocationId != address(0); + } + + /** + * @notice Gets the Directory + * @return The Directory contract + */ + function _directory() private view returns (Directory) { + return Directory(address(this)); + } + + /** + * @notice Gets the indexing agreement wrapper for a given agreement ID. + * @dev This function retrieves the indexing agreement wrapper containing the agreement state and collector agreement data. + * @param self The indexing agreement storage manager + * @param agreementId The id of the indexing agreement + * @return The indexing agreement wrapper containing the agreement state and collector agreement data + */ + function _get( + StorageManager storage self, + bytes16 agreementId + ) private view returns (IIndexingAgreement.AgreementWrapper memory) { + return + IIndexingAgreement.AgreementWrapper({ + agreement: self.agreements[agreementId], + collectorAgreement: _directory().recurringCollector().getAgreement(agreementId) + }); + } + + /** + * @notice Validates indexing agreement terms against RCA limits + * @param terms The indexing agreement terms to validate + * @param maxOngoingTokensPerSecond The RCA maximum tokens per second limit + */ + function _validateTermsAgainstRCA( + IndexingAgreementTermsV1 memory terms, + uint256 maxOngoingTokensPerSecond + ) private pure { + require( + // solhint-disable-next-line gas-strict-inequalities + terms.tokensPerSecond <= maxOngoingTokensPerSecond, + IndexingAgreementInvalidTerms(terms.tokensPerSecond, maxOngoingTokensPerSecond) + ); + } +} diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol new file mode 100644 index 000000000..a191e7d1f --- /dev/null +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.27; + +import { IndexingAgreementDecoderRaw } from "./IndexingAgreementDecoderRaw.sol"; +import { IndexingAgreement } from "./IndexingAgreement.sol"; + +/** + * @title IndexingAgreementDecoder library + * @author Edge & Node + * @notice Safe decoder for indexing agreement data structures, reverting with typed errors on malformed input. + */ +library IndexingAgreementDecoder { + /** + * @notice Thrown when the data can't be decoded as expected + * @param t The type of data that was expected + * @param data The invalid data + */ + error IndexingAgreementDecoderInvalidData(string t, bytes data); + + /** + * @notice Decodes the data for collecting indexing fees. + * + * @param data The data to decode. + * @return agreementId The agreement ID + * @return nestedData The nested encoded data + */ + function decodeCollectData(bytes memory data) public pure returns (bytes16, bytes memory) { + try IndexingAgreementDecoderRaw.decodeCollectData(data) returns (bytes16 agreementId, bytes memory nestedData) { + return (agreementId, nestedData); + } catch { + revert IndexingAgreementDecoderInvalidData("decodeCollectData", data); + } + } + + /** + * @notice Decodes the RCA metadata. + * + * @param data The data to decode. + * @return The decoded data. See {IndexingAgreement.AcceptIndexingAgreementMetadata} + */ + function decodeRCAMetadata( + bytes memory data + ) public pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { + try IndexingAgreementDecoderRaw.decodeRCAMetadata(data) returns ( + IndexingAgreement.AcceptIndexingAgreementMetadata memory decoded + ) { + return decoded; + } catch { + revert IndexingAgreementDecoderInvalidData("decodeRCAMetadata", data); + } + } + + /** + * @notice Decodes the RCAU metadata. + * + * @param data The data to decode. + * @return The decoded data. See {IndexingAgreement.UpdateIndexingAgreementMetadata} + */ + function decodeRCAUMetadata( + bytes memory data + ) public pure returns (IndexingAgreement.UpdateIndexingAgreementMetadata memory) { + try IndexingAgreementDecoderRaw.decodeRCAUMetadata(data) returns ( + IndexingAgreement.UpdateIndexingAgreementMetadata memory decoded + ) { + return decoded; + } catch { + revert IndexingAgreementDecoderInvalidData("decodeRCAUMetadata", data); + } + } + + /** + * @notice Decodes the collect data for indexing fees V1. + * + * @param data The data to decode. + * @return The decoded data structure. See {IndexingAgreement.CollectIndexingFeeDataV1} + */ + function decodeCollectIndexingFeeDataV1( + bytes memory data + ) public pure returns (IndexingAgreement.CollectIndexingFeeDataV1 memory) { + try IndexingAgreementDecoderRaw.decodeCollectIndexingFeeDataV1(data) returns ( + IndexingAgreement.CollectIndexingFeeDataV1 memory decoded + ) { + return decoded; + } catch { + revert IndexingAgreementDecoderInvalidData("decodeCollectIndexingFeeDataV1", data); + } + } + + /** + * @notice Decodes the data for indexing agreement terms V1. + * + * @param data The data to decode. + * @return The decoded data structure. See {IndexingAgreement.IndexingAgreementTermsV1} + */ + function decodeIndexingAgreementTermsV1( + bytes memory data + ) public pure returns (IndexingAgreement.IndexingAgreementTermsV1 memory) { + try IndexingAgreementDecoderRaw.decodeIndexingAgreementTermsV1(data) returns ( + IndexingAgreement.IndexingAgreementTermsV1 memory decoded + ) { + return decoded; + } catch { + revert IndexingAgreementDecoderInvalidData("decodeIndexingAgreementTermsV1", data); + } + } +} diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol new file mode 100644 index 000000000..91c4071e8 --- /dev/null +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.27; + +import { IndexingAgreement } from "./IndexingAgreement.sol"; + +/** + * @title IndexingAgreementDecoderRaw library + * @author Edge & Node + * @notice Raw ABI decoder for indexing agreement data structures, propagating native revert on malformed input. + */ +library IndexingAgreementDecoderRaw { + /** + * @notice See {IndexingAgreementDecoder.decodeCollectIndexingFeeData} + * @param data The data to decode + * @return agreementId The agreement ID + * @return nestedData The nested encoded data + */ + function decodeCollectData(bytes calldata data) public pure returns (bytes16, bytes memory) { + return abi.decode(data, (bytes16, bytes)); + } + + /** + * @notice See {IndexingAgreementDecoder.decodeRCAMetadata} + * @dev The data should be encoded as {IndexingAgreement.AcceptIndexingAgreementMetadata} + * @param data The data to decode + * @return The decoded data + */ + function decodeRCAMetadata( + bytes calldata data + ) public pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { + return abi.decode(data, (IndexingAgreement.AcceptIndexingAgreementMetadata)); + } + + /** + * @notice See {IndexingAgreementDecoder.decodeRCAUMetadata} + * @dev The data should be encoded as {IndexingAgreement.UpdateIndexingAgreementMetadata} + * @param data The data to decode + * @return The decoded data + */ + function decodeRCAUMetadata( + bytes calldata data + ) public pure returns (IndexingAgreement.UpdateIndexingAgreementMetadata memory) { + return abi.decode(data, (IndexingAgreement.UpdateIndexingAgreementMetadata)); + } + + /** + * @notice See {IndexingAgreementDecoder.decodeCollectIndexingFeeDataV1} + * @dev The data should be encoded as (uint256 entities, bytes32 poi, uint256 epoch) + * @param data The data to decode + * @return The decoded collect indexing fee V1 data + * + */ + function decodeCollectIndexingFeeDataV1( + bytes memory data + ) public pure returns (IndexingAgreement.CollectIndexingFeeDataV1 memory) { + return abi.decode(data, (IndexingAgreement.CollectIndexingFeeDataV1)); + } + + /** + * @notice See {IndexingAgreementDecoder.decodeIndexingAgreementTermsV1} + * @dev The data should be encoded as {IndexingAgreement.IndexingAgreementTermsV1} + * @param data The data to decode + * @return The decoded indexing agreement terms + */ + function decodeIndexingAgreementTermsV1( + bytes memory data + ) public pure returns (IndexingAgreement.IndexingAgreementTermsV1 memory) { + return abi.decode(data, (IndexingAgreement.IndexingAgreementTermsV1)); + } +} diff --git a/packages/subgraph-service/contracts/utilities/AllocationManager.sol b/packages/subgraph-service/contracts/utilities/AllocationManager.sol index cbfe5e663..92179147e 100644 --- a/packages/subgraph-service/contracts/utilities/AllocationManager.sol +++ b/packages/subgraph-service/contracts/utilities/AllocationManager.sol @@ -1,24 +1,21 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.27; -import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; -import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; -import { RewardsCondition } from "@graphprotocol/interfaces/contracts/contracts/rewards/RewardsCondition.sol"; import { GraphDirectory } from "@graphprotocol/horizon/contracts/utilities/GraphDirectory.sol"; import { AllocationManagerV1Storage } from "./AllocationManagerStorage.sol"; import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; -import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { EIP712Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol"; import { Allocation } from "../libraries/Allocation.sol"; import { LegacyAllocation } from "../libraries/LegacyAllocation.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; import { ProvisionTracker } from "@graphprotocol/horizon/contracts/data-service/libraries/ProvisionTracker.sol"; +import { AllocationHandler } from "../libraries/AllocationHandler.sol"; /** * @title AllocationManager contract @@ -101,76 +98,33 @@ abstract contract AllocationManager is bytes memory _allocationProof, uint32 _delegationRatio ) internal { - require(_allocationId != address(0), AllocationManagerInvalidZeroAllocationId()); - - _verifyAllocationProof(_indexer, _allocationId, _allocationProof); - - // Ensure allocation id is not reused - // need to check both subgraph service (on allocations.create()) and legacy allocations - _legacyAllocations.revertIfExists(_graphStaking(), _allocationId); - - uint256 currentEpoch = _graphEpochManager().currentEpoch(); - IAllocation.State memory allocation = _allocations.create( - _indexer, - _allocationId, - _subgraphDeploymentId, - _tokens, - _graphRewardsManager().onSubgraphAllocationUpdate(_subgraphDeploymentId), - currentEpoch + AllocationHandler.allocate( + _allocations, + _legacyAllocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + AllocationHandler.AllocateParams({ + _allocationId: _allocationId, + _allocationProof: _allocationProof, + _encodeAllocationProof: _encodeAllocationProof(_indexer, _allocationId), + _delegationRatio: _delegationRatio, + _indexer: _indexer, + _subgraphDeploymentId: _subgraphDeploymentId, + _tokens: _tokens, + currentEpoch: _graphEpochManager().currentEpoch(), + graphRewardsManager: _graphRewardsManager(), + graphStaking: _graphStaking() + }) ); - - // Check that the indexer has enough tokens available - // Note that the delegation ratio ensures overdelegation cannot be used - allocationProvisionTracker.lock(_graphStaking(), _indexer, _tokens, _delegationRatio); - - // Update total allocated tokens for the subgraph deployment - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] = - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] + allocation.tokens; - - emit AllocationCreated(_indexer, _allocationId, _subgraphDeploymentId, allocation.tokens, currentEpoch); } /** * @notice Present a POI to collect indexing rewards for an allocation * Mints indexing rewards using the {RewardsManager} and distributes them to the indexer and delegators. * - * Requirements for indexing rewards: - * - POI must be non-zero - * - POI must not be stale (older than `maxPOIStaleness`) - * - Allocation must be open for at least one epoch (returns early with 0 if too young) - * - * ## Reward Paths - * - * Rewards follow one of three paths based on allocation and POI state: - * - * **CLAIMED** (normal path): Valid POI, not stale, allocation mature, subgraph not denied - * - Calls `takeRewards()` to mint tokens to this contract - * - Distributes to indexer (stake or payments destination) and delegators - * - Snapshots allocation to prevent double-counting - * - * **RECLAIMED** (redirect path): STALE_POI or ZERO_POI conditions - * - Calls `reclaimRewards()` to mint tokens to configured reclaim address - * - If no reclaim address configured, rewards are dropped (not minted) - * - Snapshots allocation to prevent double-counting - * - * **DEFERRED** (early return): ALLOCATION_TOO_YOUNG or SUBGRAPH_DENIED conditions - * - Returns 0 without calling take or reclaim - * - Does NOT snapshot allocation (preserves rewards for later collection) - * - Allows rewards to be claimed when condition clears - * - * ## Subgraph Denial (Soft Deny) - * - * When a subgraph is denied, this function implements "soft deny": - * - Returns early without claiming or reclaiming - * - Allocation state is preserved (pending rewards not cleared) - * - Pre-denial rewards remain claimable after undeny - * - Ongoing issuance during denial is reclaimed at RewardsManager level (hard deny) - * - * Note: Indexers should present POIs at least every `maxPOIStaleness` to avoid being locked out of rewards. - * A zero POI can be presented if a valid one is unavailable, to prevent staleness and slashing. - * - * Note: Reclaim address changes in RewardsManager apply retroactively to all unclaimed rewards. + * See {AllocationHandler-presentPOI} for detailed reward path documentation. * + * Emits a {POIPresented} event. * Emits a {IndexingRewardsCollected} event. * * @param _allocationId The id of the allocation to collect rewards for @@ -179,6 +133,7 @@ abstract contract AllocationManager is * @param _delegationRatio The delegation ratio to consider when locking tokens * @param _paymentsDestination The address where indexing rewards should be sent * @return rewardsCollected Indexing rewards collected + * @return allocationForceClosed True if the allocation was force closed due to over-allocation */ // solhint-disable-next-line function-max-lines function _presentPoi( @@ -187,75 +142,26 @@ abstract contract AllocationManager is bytes memory _poiMetadata, uint32 _delegationRatio, address _paymentsDestination - ) internal returns (uint256 rewardsCollected) { - IAllocation.State memory allocation = _allocations.get(_allocationId); - require(allocation.isOpen(), AllocationManagerAllocationClosed(_allocationId)); - _allocations.presentPOI(_allocationId); // Always record POI presentation to prevent staleness - - uint256 currentEpoch = _graphEpochManager().currentEpoch(); - // Scoped for stack management - { - // Determine rewards condition - bytes32 condition = RewardsCondition.NONE; - if (allocation.isStale(maxPOIStaleness)) condition = RewardsCondition.STALE_POI; - else if (_poi == bytes32(0)) - condition = RewardsCondition.ZERO_POI; - // solhint-disable-next-line gas-strict-inequalities - else if (currentEpoch <= allocation.createdAtEpoch) condition = RewardsCondition.ALLOCATION_TOO_YOUNG; - else if (_graphRewardsManager().isDenied(allocation.subgraphDeploymentId)) - condition = RewardsCondition.SUBGRAPH_DENIED; - - emit POIPresented( - allocation.indexer, - _allocationId, - allocation.subgraphDeploymentId, - _poi, - _poiMetadata, - condition - ); - - // Early return skips the overallocation check intentionally to avoid loss of uncollected rewards - if (condition == RewardsCondition.ALLOCATION_TOO_YOUNG || condition == RewardsCondition.SUBGRAPH_DENIED) { - // Keep reward and reclaim accumulation current even if rewards are not collected - _graphRewardsManager().onSubgraphAllocationUpdate(allocation.subgraphDeploymentId); - - return 0; - } - - bool rewardsReclaimable = condition == RewardsCondition.STALE_POI || condition == RewardsCondition.ZERO_POI; - if (rewardsReclaimable) _graphRewardsManager().reclaimRewards(condition, _allocationId); - else rewardsCollected = _graphRewardsManager().takeRewards(_allocationId); - } - - // Snapshot rewards to prevent accumulation for next POI, then clear pending - _allocations.snapshotRewards( - _allocationId, - _graphRewardsManager().onSubgraphAllocationUpdate(allocation.subgraphDeploymentId) - ); - _allocations.clearPendingRewards(_allocationId); - - // Scoped for stack management - { - (uint256 tokensIndexerRewards, uint256 tokensDelegationRewards) = _distributeIndexingRewards( - allocation, - rewardsCollected, - _paymentsDestination - ); - - emit IndexingRewardsCollected( - allocation.indexer, - _allocationId, - allocation.subgraphDeploymentId, - rewardsCollected, - tokensIndexerRewards, - tokensDelegationRewards, - _poi, - _poiMetadata, - currentEpoch + ) internal returns (uint256, bool) { + return + AllocationHandler.presentPOI( + _allocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + AllocationHandler.PresentParams({ + maxPOIStaleness: maxPOIStaleness, + graphEpochManager: _graphEpochManager(), + graphStaking: _graphStaking(), + graphRewardsManager: _graphRewardsManager(), + graphToken: _graphToken(), + dataService: address(this), + _allocationId: _allocationId, + _poi: _poi, + _poiMetadata: _poiMetadata, + _delegationRatio: _delegationRatio, + _paymentsDestination: _paymentsDestination + }) ); - } - - if (_isOverAllocated(allocation.indexer, _delegationRatio)) _closeAllocation(_allocationId, true); } /** @@ -277,49 +183,17 @@ abstract contract AllocationManager is * @param _delegationRatio The delegation ratio to consider when locking tokens */ function _resizeAllocation(address _allocationId, uint256 _tokens, uint32 _delegationRatio) internal { - IAllocation.State memory allocation = _allocations.get(_allocationId); - require(allocation.isOpen(), AllocationManagerAllocationClosed(_allocationId)); - require(_tokens != allocation.tokens, AllocationManagerAllocationSameSize(_allocationId, _tokens)); - - // Update provision tracker - uint256 oldTokens = allocation.tokens; - if (_tokens > oldTokens) { - allocationProvisionTracker.lock(_graphStaking(), allocation.indexer, _tokens - oldTokens, _delegationRatio); - } else { - allocationProvisionTracker.release(allocation.indexer, oldTokens - _tokens); - } - - // Calculate rewards that have been accrued since the last snapshot but not yet issued - uint256 accRewardsPerAllocatedToken = _graphRewardsManager().onSubgraphAllocationUpdate( - allocation.subgraphDeploymentId - ); - uint256 accRewardsPerAllocatedTokenPending = !allocation.isAltruistic() - ? accRewardsPerAllocatedToken - allocation.accRewardsPerAllocatedToken - : 0; - - // Update the allocation - _allocations[_allocationId].tokens = _tokens; - _allocations[_allocationId].accRewardsPerAllocatedToken = accRewardsPerAllocatedToken; - _allocations[_allocationId].accRewardsPending += _graphRewardsManager().calcRewards( - oldTokens, - accRewardsPerAllocatedTokenPending + AllocationHandler.resizeAllocation( + _allocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + _graphStaking(), + _graphRewardsManager(), + _allocationId, + _tokens, + _delegationRatio, + maxPOIStaleness ); - - // If allocation is stale, reclaim pending rewards defensively. - // Stale allocations are not performing, so rewards should not accumulate. - if (allocation.isStale(maxPOIStaleness)) { - _graphRewardsManager().reclaimRewards(RewardsCondition.STALE_POI, _allocationId); - _allocations.clearPendingRewards(_allocationId); - } - - // Update total allocated tokens for the subgraph deployment - if (_tokens > oldTokens) { - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] += (_tokens - oldTokens); - } else { - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] -= (oldTokens - _tokens); - } - - emit AllocationResized(allocation.indexer, _allocationId, allocation.subgraphDeploymentId, _tokens, oldTokens); } /** @@ -334,49 +208,18 @@ abstract contract AllocationManager is * - If reclaim address configured: tokens minted to that address * - If no reclaim address: rewards are dropped (not minted anywhere) * - * ## Known Limitation - * - * `clearPendingRewards()` is only called when `0 < reclaimedRewards`. This means: - * - If no reclaim address is configured, `accRewardsPending` may remain non-zero - * * Emits a {AllocationClosed} event * * @param _allocationId The id of the allocation to be closed * @param _forceClosed Whether the allocation was force closed */ function _closeAllocation(address _allocationId, bool _forceClosed) internal { - IAllocation.State memory allocation = _allocations.get(_allocationId); - - // Reclaim uncollected rewards before closing - uint256 reclaimedRewards = _graphRewardsManager().reclaimRewards( - RewardsCondition.CLOSE_ALLOCATION, - _allocationId - ); - - // Take rewards snapshot to prevent other allos from counting tokens from this allo - _allocations.snapshotRewards( - _allocationId, - _graphRewardsManager().onSubgraphAllocationUpdate(allocation.subgraphDeploymentId) - ); - - // Clear pending rewards only if rewards were reclaimed. This marks them as consumed, - // which could be useful for future logic that searches for unconsumed rewards. - // Known limitation: This capture is incomplete due to other code paths (e.g., _presentPOI) - // that clear pending even when rewards are not consumed. - if (0 < reclaimedRewards) _allocations.clearPendingRewards(_allocationId); - - _allocations.close(_allocationId); - allocationProvisionTracker.release(allocation.indexer, allocation.tokens); - - // Update total allocated tokens for the subgraph deployment - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] = - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] - allocation.tokens; - - emit AllocationClosed( - allocation.indexer, + AllocationHandler.closeAllocation( + _allocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + _graphRewardsManager(), _allocationId, - allocation.subgraphDeploymentId, - allocation.tokens, _forceClosed ); } @@ -408,62 +251,7 @@ abstract contract AllocationManager is * @return True if the allocation is over-allocated, false otherwise */ function _isOverAllocated(address _indexer, uint32 _delegationRatio) internal view returns (bool) { - return !allocationProvisionTracker.check(_graphStaking(), _indexer, _delegationRatio); - } - - /** - * @notice Distributes indexing rewards to delegators and indexer - * @param _allocation The allocation state - * @param _rewardsCollected Total rewards to distribute - * @param _paymentsDestination Where to send indexer rewards (0 = stake) - * @return tokensIndexerRewards Amount sent to indexer - * @return tokensDelegationRewards Amount sent to delegation pool - */ - function _distributeIndexingRewards( - IAllocation.State memory _allocation, - uint256 _rewardsCollected, - address _paymentsDestination - ) private returns (uint256 tokensIndexerRewards, uint256 tokensDelegationRewards) { - if (_rewardsCollected == 0) return (0, 0); - - // Calculate and distribute delegator share - uint256 delegatorCut = _graphStaking().getDelegationFeeCut( - _allocation.indexer, - address(this), - IGraphPayments.PaymentTypes.IndexingRewards - ); - IHorizonStakingTypes.DelegationPool memory pool = _graphStaking().getDelegationPool( - _allocation.indexer, - address(this) - ); - tokensDelegationRewards = pool.shares > 0 ? _rewardsCollected.mulPPM(delegatorCut) : 0; - if (tokensDelegationRewards > 0) { - _graphToken().approve(address(_graphStaking()), tokensDelegationRewards); - _graphStaking().addToDelegationPool(_allocation.indexer, address(this), tokensDelegationRewards); - } - - // Distribute indexer share - tokensIndexerRewards = _rewardsCollected - tokensDelegationRewards; - if (tokensIndexerRewards > 0) { - if (_paymentsDestination == address(0)) { - _graphToken().approve(address(_graphStaking()), tokensIndexerRewards); - _graphStaking().stakeToProvision(_allocation.indexer, address(this), tokensIndexerRewards); - } else { - _graphToken().pushTokens(_paymentsDestination, tokensIndexerRewards); - } - } - } - - /** - * @notice Verifies ownership of an allocation id by verifying an EIP712 allocation proof - * @dev Requirements: - * - Signer must be the allocation id address - * @param _indexer The address of the indexer - * @param _allocationId The id of the allocation - * @param _proof The EIP712 proof, an EIP712 signed message of (indexer,allocationId) - */ - function _verifyAllocationProof(address _indexer, address _allocationId, bytes memory _proof) private view { - address signer = ECDSA.recover(_encodeAllocationProof(_indexer, _allocationId), _proof); - require(signer == _allocationId, AllocationManagerInvalidAllocationProof(signer, _allocationId)); + return + AllocationHandler.isOverAllocated(allocationProvisionTracker, _graphStaking(), _indexer, _delegationRatio); } } diff --git a/packages/subgraph-service/contracts/utilities/Directory.sol b/packages/subgraph-service/contracts/utilities/Directory.sol index 0ba82a5b5..6c85af462 100644 --- a/packages/subgraph-service/contracts/utilities/Directory.sol +++ b/packages/subgraph-service/contracts/utilities/Directory.sol @@ -8,6 +8,7 @@ pragma solidity ^0.8.27; import { IDisputeManager } from "@graphprotocol/interfaces/contracts/subgraph-service/IDisputeManager.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { IGraphTallyCollector } from "@graphprotocol/interfaces/contracts/horizon/IGraphTallyCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol"; /** @@ -30,6 +31,10 @@ abstract contract Directory { /// @dev Required to collect payments via Graph Horizon payments protocol IGraphTallyCollector private immutable GRAPH_TALLY_COLLECTOR; + /// @notice The Recurring Collector contract address + /// @dev Required to collect indexing agreement payments via Graph Horizon payments protocol + IRecurringCollector private immutable RECURRING_COLLECTOR; + /// @notice The Curation contract address /// @dev Required for curation fees distribution ICuration private immutable CURATION; @@ -40,12 +45,14 @@ abstract contract Directory { * @param disputeManager The Dispute Manager contract address * @param graphTallyCollector The Graph Tally Collector contract address * @param curation The Curation contract address + * @param recurringCollector The Recurring Collector contract address */ event SubgraphServiceDirectoryInitialized( address subgraphService, address disputeManager, address graphTallyCollector, - address curation + address curation, + address recurringCollector ); /** @@ -72,14 +79,36 @@ abstract contract Directory { * @param disputeManager The Dispute Manager contract address * @param graphTallyCollector The Graph Tally Collector contract address * @param curation The Curation contract address + * @param recurringCollector_ The Recurring Collector contract address */ - constructor(address subgraphService, address disputeManager, address graphTallyCollector, address curation) { + constructor( + address subgraphService, + address disputeManager, + address graphTallyCollector, + address curation, + address recurringCollector_ + ) { SUBGRAPH_SERVICE = ISubgraphService(subgraphService); DISPUTE_MANAGER = IDisputeManager(disputeManager); GRAPH_TALLY_COLLECTOR = IGraphTallyCollector(graphTallyCollector); CURATION = ICuration(curation); + RECURRING_COLLECTOR = IRecurringCollector(recurringCollector_); - emit SubgraphServiceDirectoryInitialized(subgraphService, disputeManager, graphTallyCollector, curation); + emit SubgraphServiceDirectoryInitialized( + subgraphService, + disputeManager, + graphTallyCollector, + curation, + recurringCollector_ + ); + } + + /** + * @notice Returns the Recurring Collector contract address + * @return The Recurring Collector contract + */ + function recurringCollector() external view returns (IRecurringCollector) { + return RECURRING_COLLECTOR; } /** diff --git a/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol b/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol index dcaaf77e5..e64b875ff 100644 --- a/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol +++ b/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol @@ -10,6 +10,7 @@ import { HorizonStakingExtension } from "@graphprotocol/horizon/contracts/stakin import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; import { GraphTallyCollector } from "@graphprotocol/horizon/contracts/payments/collectors/GraphTallyCollector.sol"; +import { RecurringCollector } from "@graphprotocol/horizon/contracts/payments/collectors/RecurringCollector.sol"; import { PaymentsEscrow } from "@graphprotocol/horizon/contracts/payments/PaymentsEscrow.sol"; import { UnsafeUpgrades } from "@openzeppelin/foundry-upgrades/src/Upgrades.sol"; @@ -39,6 +40,7 @@ abstract contract SubgraphBaseTest is Utils, Constants { GraphPayments graphPayments; IPaymentsEscrow escrow; GraphTallyCollector graphTallyCollector; + RecurringCollector recurringCollector; HorizonStaking private stakingBase; HorizonStakingExtension private stakingExtension; @@ -152,12 +154,20 @@ abstract contract SubgraphBaseTest is Utils, Constants { address(controller), REVOKE_SIGNER_THAWING_PERIOD ); + recurringCollector = new RecurringCollector( + "RecurringCollector", + "1", + address(controller), + REVOKE_SIGNER_THAWING_PERIOD + ); + address subgraphServiceImplementation = address( new SubgraphService( address(controller), address(disputeManager), address(graphTallyCollector), - address(curation) + address(curation), + address(recurringCollector) ) ); address subgraphServiceProxy = UnsafeUpgrades.deployTransparentProxy( diff --git a/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol b/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol new file mode 100644 index 000000000..a5fa27dde --- /dev/null +++ b/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; +import { IndexingAgreement } from "../../../contracts/libraries/IndexingAgreement.sol"; +import { Directory } from "../../../contracts/utilities/Directory.sol"; + +contract IndexingAgreementTest is Test { + IndexingAgreement.StorageManager private _storageManager; + address private _mockCollector; + + function setUp() public { + _mockCollector = makeAddr("mockCollector"); + } + + function test_IndexingAgreement_Get(bytes16 agreementId) public { + vm.assume(agreementId != bytes16(0)); + + vm.mockCall( + address(this), + abi.encodeWithSelector(Directory.recurringCollector.selector), + abi.encode(IRecurringCollector(_mockCollector)) + ); + + IRecurringCollector.AgreementData memory collectorAgreement; + vm.mockCall( + _mockCollector, + abi.encodeWithSelector(IRecurringCollector.getAgreement.selector, agreementId), + abi.encode(collectorAgreement) + ); + + vm.expectRevert(abi.encodeWithSelector(IndexingAgreement.IndexingAgreementNotActive.selector, agreementId)); + IndexingAgreement.get(_storageManager, agreementId); + + collectorAgreement.dataService = address(this); + vm.mockCall( + _mockCollector, + abi.encodeWithSelector(IRecurringCollector.getAgreement.selector, agreementId), + abi.encode(collectorAgreement) + ); + + IIndexingAgreement.AgreementWrapper memory wrapper = IndexingAgreement.get(_storageManager, agreementId); + assertEq(wrapper.collectorAgreement.dataService, address(this)); + } + + function test_IndexingAgreement_OnCloseAllocation(bytes16 agreementId, address allocationId, bool stale) public { + vm.assume(agreementId != bytes16(0)); + vm.assume(allocationId != address(0)); + + delete _storageManager; + vm.clearMockedCalls(); + + // No active agreement for allocation ID, returns early, no assertions needed + IndexingAgreement.onCloseAllocation(_storageManager, allocationId, stale); + + // Active agreement for allocation ID, but collector agreement is not set, returns early, no assertions needed + _storageManager.allocationToActiveAgreementId[allocationId] = agreementId; + + IRecurringCollector.AgreementData memory collectorAgreement; + + vm.mockCall( + address(this), + abi.encodeWithSelector(Directory.recurringCollector.selector), + abi.encode(IRecurringCollector(_mockCollector)) + ); + + vm.mockCall( + _mockCollector, + abi.encodeWithSelector(IRecurringCollector.getAgreement.selector, agreementId), + abi.encode(collectorAgreement) + ); + + IndexingAgreement.onCloseAllocation(_storageManager, allocationId, stale); + + // Active agreement for allocation ID, collector agreement is set, should cancel the agreement + collectorAgreement.dataService = address(this); + collectorAgreement.state = IRecurringCollector.AgreementState.Accepted; + + _storageManager.agreements[agreementId] = IIndexingAgreement.State({ + allocationId: allocationId, + version: IIndexingAgreement.IndexingAgreementVersion.V1 + }); + + vm.mockCall( + _mockCollector, + abi.encodeWithSelector(IRecurringCollector.getAgreement.selector, agreementId), + abi.encode(collectorAgreement) + ); + + vm.expectCall(_mockCollector, abi.encodeWithSelector(IRecurringCollector.cancel.selector, agreementId)); + + IndexingAgreement.onCloseAllocation(_storageManager, allocationId, stale); + } + + function test_IndexingAgreement_StorageManagerLocation() public pure { + assertEq( + IndexingAgreement.INDEXING_AGREEMENT_STORAGE_MANAGER_LOCATION, + keccak256( + abi.encode( + uint256(keccak256("graphprotocol.subgraph-service.storage.StorageManager.IndexingAgreement")) - 1 + ) + ) & ~bytes32(uint256(0xff)) + ); + } +} diff --git a/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol b/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol index 093890d3c..95c0371e9 100644 --- a/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol +++ b/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol @@ -36,6 +36,12 @@ abstract contract HorizonStakingSharedTest is SubgraphBaseTest { staking.addToProvision(_indexer, address(subgraphService), _tokens); } + function _removeFromProvision(address _indexer, uint256 _tokens) internal { + staking.thaw(_indexer, address(subgraphService), _tokens); + skip(staking.getProvision(_indexer, address(subgraphService)).thawingPeriod + 1); + staking.deprovision(_indexer, address(subgraphService), 0); + } + function _delegate(address _indexer, address _verifier, uint256 _tokens, uint256 _minSharesOut) internal { staking.delegate(_indexer, _verifier, _tokens, _minSharesOut); } diff --git a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol index bd3091935..7169e5fd8 100644 --- a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol @@ -8,12 +8,12 @@ import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizo import { IGraphTallyCollector } from "@graphprotocol/interfaces/contracts/horizon/IGraphTallyCollector.sol"; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { LinkedList } from "@graphprotocol/horizon/contracts/libraries/LinkedList.sol"; -import { IDataServiceFees } from "@graphprotocol/interfaces/contracts/data-service/IDataServiceFees.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; +import { StakeClaims } from "@graphprotocol/horizon/contracts/data-service/libraries/StakeClaims.sol"; import { Allocation } from "../../../contracts/libraries/Allocation.sol"; import { SubgraphServiceSharedTest } from "../shared/SubgraphServiceShared.t.sol"; @@ -202,7 +202,7 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { uint256 paymentCollected = 0; address allocationId; IndexingRewardsData memory indexingRewardsData; - CollectPaymentData memory collectPaymentDataBefore = _collectPaymentDataBefore(_indexer); + CollectPaymentData memory collectPaymentDataBefore = _collectPaymentData(_indexer); if (_paymentType == IGraphPayments.PaymentTypes.QueryFee) { paymentCollected = _handleQueryFeeCollection(_indexer, _data); @@ -216,7 +216,7 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { // collect rewards subgraphService.collect(_indexer, _paymentType, _data); - CollectPaymentData memory collectPaymentDataAfter = _collectPaymentDataAfter(_indexer); + CollectPaymentData memory collectPaymentDataAfter = _collectPaymentData(_indexer); if (_paymentType == IGraphPayments.PaymentTypes.QueryFee) { _verifyQueryFeeCollection( @@ -237,42 +237,24 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { } } - function _collectPaymentDataBefore(address _indexer) private view returns (CollectPaymentData memory) { + function _collectPaymentData( + address _indexer + ) internal view returns (CollectPaymentData memory collectPaymentData) { address paymentsDestination = subgraphService.paymentsDestination(_indexer); - CollectPaymentData memory collectPaymentDataBefore; - collectPaymentDataBefore.rewardsDestinationBalance = token.balanceOf(paymentsDestination); - collectPaymentDataBefore.indexerProvisionBalance = staking.getProviderTokensAvailable( + collectPaymentData.rewardsDestinationBalance = token.balanceOf(paymentsDestination); + collectPaymentData.indexerProvisionBalance = staking.getProviderTokensAvailable( _indexer, address(subgraphService) ); - collectPaymentDataBefore.delegationPoolBalance = staking.getDelegatedTokensAvailable( + collectPaymentData.delegationPoolBalance = staking.getDelegatedTokensAvailable( _indexer, address(subgraphService) ); - collectPaymentDataBefore.indexerBalance = token.balanceOf(_indexer); - collectPaymentDataBefore.curationBalance = token.balanceOf(address(curation)); - collectPaymentDataBefore.lockedTokens = subgraphService.feesProvisionTracker(_indexer); - collectPaymentDataBefore.indexerStake = staking.getStake(_indexer); - return collectPaymentDataBefore; - } - - function _collectPaymentDataAfter(address _indexer) private view returns (CollectPaymentData memory) { - CollectPaymentData memory collectPaymentDataAfter; - address paymentsDestination = subgraphService.paymentsDestination(_indexer); - collectPaymentDataAfter.rewardsDestinationBalance = token.balanceOf(paymentsDestination); - collectPaymentDataAfter.indexerProvisionBalance = staking.getProviderTokensAvailable( - _indexer, - address(subgraphService) - ); - collectPaymentDataAfter.delegationPoolBalance = staking.getDelegatedTokensAvailable( - _indexer, - address(subgraphService) - ); - collectPaymentDataAfter.indexerBalance = token.balanceOf(_indexer); - collectPaymentDataAfter.curationBalance = token.balanceOf(address(curation)); - collectPaymentDataAfter.lockedTokens = subgraphService.feesProvisionTracker(_indexer); - collectPaymentDataAfter.indexerStake = staking.getStake(_indexer); - return collectPaymentDataAfter; + collectPaymentData.indexerBalance = token.balanceOf(_indexer); + collectPaymentData.curationBalance = token.balanceOf(address(curation)); + collectPaymentData.lockedTokens = subgraphService.feesProvisionTracker(_indexer); + collectPaymentData.indexerStake = staking.getStake(_indexer); + return collectPaymentData; } function _handleQueryFeeCollection( @@ -423,7 +405,7 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { // Check the stake claim ILinkedList.List memory claimsList = _getClaimList(_indexer); bytes32 claimId = _buildStakeClaimId(_indexer, claimsList.nonce - 1); - IDataServiceFees.StakeClaim memory stakeClaim = _getStakeClaim(claimId); + StakeClaims.StakeClaim memory stakeClaim = _getStakeClaim(claimId); uint64 disputePeriod = disputeManager.getDisputePeriod(); assertEq(stakeClaim.tokens, tokensToLock); assertEq(stakeClaim.createdAt, block.timestamp); @@ -540,12 +522,12 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { } function _buildStakeClaimId(address _indexer, uint256 _nonce) private view returns (bytes32) { - return keccak256(abi.encodePacked(address(subgraphService), _indexer, _nonce)); + return StakeClaims.buildStakeClaimId(address(subgraphService), _indexer, _nonce); } - function _getStakeClaim(bytes32 _claimId) private view returns (IDataServiceFees.StakeClaim memory) { + function _getStakeClaim(bytes32 _claimId) private view returns (StakeClaims.StakeClaim memory) { (uint256 tokens, uint256 createdAt, uint256 releasableAt, bytes32 nextClaim) = subgraphService.claims(_claimId); - return IDataServiceFees.StakeClaim(tokens, createdAt, releasableAt, nextClaim); + return StakeClaims.StakeClaim(tokens, createdAt, releasableAt, nextClaim); } // This doesn't matter for testing because the metadata is not decoded onchain but it's expected to be of the form: diff --git a/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol b/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol index 40635570e..7b33537d2 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.27; import { SubgraphServiceTest } from "../SubgraphService.t.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; -import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; +import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; @@ -85,11 +85,7 @@ contract SubgraphServiceAllocationResizeTest is SubgraphServiceTest { uint256 tokens ) public useIndexer useAllocation(tokens) { vm.expectRevert( - abi.encodeWithSelector( - IAllocationManager.AllocationManagerAllocationSameSize.selector, - allocationId, - tokens - ) + abi.encodeWithSelector(AllocationHandler.AllocationHandlerAllocationSameSize.selector, allocationId, tokens) ); subgraphService.resizeAllocation(users.indexer, allocationId, tokens); } @@ -102,7 +98,7 @@ contract SubgraphServiceAllocationResizeTest is SubgraphServiceTest { bytes memory data = abi.encode(allocationId); _stopService(users.indexer, data); vm.expectRevert( - abi.encodeWithSelector(IAllocationManager.AllocationManagerAllocationClosed.selector, allocationId) + abi.encodeWithSelector(AllocationHandler.AllocationHandlerAllocationClosed.selector, allocationId) ); subgraphService.resizeAllocation(users.indexer, allocationId, resizeTokens); } diff --git a/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol b/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol index 0896e9473..5ccc9c2d2 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol @@ -5,7 +5,7 @@ import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/utilities/ProvisionManager.sol"; import { ProvisionTracker } from "@graphprotocol/horizon/contracts/data-service/libraries/ProvisionTracker.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; -import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; +import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { SubgraphServiceTest } from "../SubgraphService.t.sol"; @@ -94,7 +94,7 @@ contract SubgraphServiceAllocationStartTest is SubgraphServiceTest { bytes32 digest = subgraphService.encodeAllocationProof(users.indexer, address(0)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(allocationIdPrivateKey, digest); bytes memory data = abi.encode(subgraphDeployment, tokens, address(0), abi.encodePacked(r, s, v)); - vm.expectRevert(abi.encodeWithSelector(IAllocationManager.AllocationManagerInvalidZeroAllocationId.selector)); + vm.expectRevert(abi.encodeWithSelector(AllocationHandler.AllocationHandlerInvalidZeroAllocationId.selector)); subgraphService.startService(users.indexer, data); } @@ -110,7 +110,7 @@ contract SubgraphServiceAllocationStartTest is SubgraphServiceTest { bytes memory data = abi.encode(subgraphDeployment, tokens, allocationId, abi.encodePacked(r, s, v)); vm.expectRevert( abi.encodeWithSelector( - IAllocationManager.AllocationManagerInvalidAllocationProof.selector, + AllocationHandler.AllocationHandlerInvalidAllocationProof.selector, signer, allocationId ) diff --git a/packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol b/packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol index e77942714..982d7fe83 100644 --- a/packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; -import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; +import { IndexingAgreementDecoder } from "../../../../contracts/libraries/IndexingAgreementDecoder.sol"; import { SubgraphServiceTest } from "../SubgraphService.t.sol"; contract SubgraphServiceCollectTest is SubgraphServiceTest { @@ -14,10 +14,14 @@ contract SubgraphServiceCollectTest is SubgraphServiceTest { function test_SubgraphService_Collect_RevertWhen_InvalidPayment( uint256 tokens ) public useIndexer useAllocation(tokens) { - IGraphPayments.PaymentTypes invalidPaymentType = IGraphPayments.PaymentTypes.IndexingFee; + IGraphPayments.PaymentTypes paymentType = IGraphPayments.PaymentTypes.IndexingFee; vm.expectRevert( - abi.encodeWithSelector(ISubgraphService.SubgraphServiceInvalidPaymentType.selector, invalidPaymentType) + abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeCollectData", + "" + ) ); - subgraphService.collect(users.indexer, invalidPaymentType, ""); + subgraphService.collect(users.indexer, paymentType, ""); } } diff --git a/packages/subgraph-service/test/unit/subgraphService/collect/indexing/indexing.t.sol b/packages/subgraph-service/test/unit/subgraphService/collect/indexing/indexing.t.sol index 94f11e0e5..49c034e52 100644 --- a/packages/subgraph-service/test/unit/subgraphService/collect/indexing/indexing.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/collect/indexing/indexing.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; -import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; +import { AllocationHandler } from "../../../../../contracts/libraries/AllocationHandler.sol"; import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; @@ -270,7 +270,7 @@ contract SubgraphServiceCollectIndexingTest is SubgraphServiceTest { // Attempt to collect on closed allocation should revert vm.expectRevert( - abi.encodeWithSelector(IAllocationManager.AllocationManagerAllocationClosed.selector, allocationId) + abi.encodeWithSelector(AllocationHandler.AllocationHandlerAllocationClosed.selector, allocationId) ); subgraphService.collect(users.indexer, paymentType, data); } diff --git a/packages/subgraph-service/test/unit/subgraphService/governance/maxPOIStaleness.t.sol b/packages/subgraph-service/test/unit/subgraphService/governance/maxPOIStaleness.t.sol index 5968cf623..f0c597e4a 100644 --- a/packages/subgraph-service/test/unit/subgraphService/governance/maxPOIStaleness.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/governance/maxPOIStaleness.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; +import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; import { SubgraphServiceTest } from "../SubgraphService.t.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; @@ -12,7 +12,7 @@ contract SubgraphServiceGovernanceMaxPOIStalenessTest is SubgraphServiceTest { function test_Governance_SetMaxPOIStaleness(uint256 maxPOIStaleness) public useGovernor { vm.expectEmit(address(subgraphService)); - emit IAllocationManager.MaxPOIStalenessSet(maxPOIStaleness); + emit AllocationHandler.MaxPOIStalenessSet(maxPOIStaleness); subgraphService.setMaxPOIStaleness(maxPOIStaleness); assertEq(subgraphService.maxPOIStaleness(), maxPOIStaleness); diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol new file mode 100644 index 000000000..a05c1d61e --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/utilities/ProvisionManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; + +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; +import { IndexingAgreementDecoder } from "../../../../contracts/libraries/IndexingAgreementDecoder.sol"; +import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; +import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenPaused( + address allocationId, + address operator, + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCA calldata signedRCA + ) public withSafeIndexerOrOperator(operator) { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + resetPrank(operator); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenNotAuthorized( + address allocationId, + address operator, + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCA calldata signedRCA + ) public withSafeIndexerOrOperator(operator) { + vm.assume(operator != signedRCA.rca.serviceProvider); + resetPrank(operator); + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerNotAuthorized.selector, + signedRCA.rca.serviceProvider, + operator + ); + vm.expectRevert(expectedErr); + subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidProvision( + address indexer, + uint256 unboundedTokens, + address allocationId, + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCA memory signedRCA + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); + + signedRCA.rca.serviceProvider = indexer; + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerInvalidValue.selector, + "tokens", + tokens, + MINIMUM_PROVISION_TOKENS, + MAXIMUM_PROVISION_TOKENS + ); + vm.expectRevert(expectedErr); + subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenIndexerNotRegistered( + address indexer, + uint256 unboundedTokens, + address allocationId, + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCA memory signedRCA + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); + signedRCA.rca.serviceProvider = indexer; + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, + indexer + ); + vm.expectRevert(expectedErr); + subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenNotDataService( + Seed memory seed, + address incorrectDataService + ) public { + vm.assume(incorrectDataService != address(subgraphService)); + + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + acceptable.rca.dataService = incorrectDataService; + IRecurringCollector.SignedRCA memory unacceptable = _recurringCollectorHelper.generateSignedRCA( + acceptable.rca, + ctx.payer.signerPrivateKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementWrongDataService.selector, + address(subgraphService), + unacceptable.rca.dataService + ); + vm.expectRevert(expectedErr); + vm.prank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptable); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidMetadata(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + acceptable.rca.metadata = bytes("invalid"); + IRecurringCollector.SignedRCA memory unacceptable = _recurringCollectorHelper.generateSignedRCA( + acceptable.rca, + ctx.payer.signerPrivateKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeRCAMetadata", + unacceptable.rca.metadata + ); + vm.expectRevert(expectedErr); + vm.prank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptable); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidAllocation( + Seed memory seed, + address invalidAllocationId + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + + bytes memory expectedErr = abi.encodeWithSelector( + IAllocation.AllocationDoesNotExist.selector, + invalidAllocationId + ); + vm.expectRevert(expectedErr); + vm.prank(indexerState.addr); + subgraphService.acceptIndexingAgreement(invalidAllocationId, acceptable); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAllocationNotAuthorized(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerStateA = _withIndexer(ctx); + IndexerState memory indexerStateB = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptableA = _generateAcceptableSignedRCA(ctx, indexerStateA.addr); + + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceAllocationNotAuthorized.selector, + indexerStateA.addr, + indexerStateB.allocationId + ); + vm.expectRevert(expectedErr); + vm.prank(indexerStateA.addr); + subgraphService.acceptIndexingAgreement(indexerStateB.allocationId, acceptableA); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAllocationClosed(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + + resetPrank(indexerState.addr); + subgraphService.stopService(indexerState.addr, abi.encode(indexerState.allocationId)); + + bytes memory expectedErr = abi.encodeWithSelector( + AllocationHandler.AllocationHandlerAllocationClosed.selector, + indexerState.allocationId + ); + vm.expectRevert(expectedErr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, acceptable); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenDeploymentIdMismatch( + Seed memory seed, + bytes32 wrongSubgraphDeploymentId + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + vm.assume(indexerState.subgraphDeploymentId != wrongSubgraphDeploymentId); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + acceptable.rca.metadata = abi.encode(_newAcceptIndexingAgreementMetadataV1(wrongSubgraphDeploymentId)); + IRecurringCollector.SignedRCA memory unacceptable = _recurringCollectorHelper.generateSignedRCA( + acceptable.rca, + ctx.payer.signerPrivateKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementDeploymentIdMismatch.selector, + wrongSubgraphDeploymentId, + indexerState.allocationId, + indexerState.subgraphDeploymentId + ); + vm.expectRevert(expectedErr); + vm.prank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptable); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAgreementAlreadyAccepted(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (IRecurringCollector.SignedRCA memory accepted, bytes16 agreementId) = _withAcceptedIndexingAgreement( + ctx, + indexerState + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementAlreadyAccepted.selector, + agreementId + ); + vm.expectRevert(expectedErr); + resetPrank(ctx.indexers[0].addr); + subgraphService.acceptIndexingAgreement(ctx.indexers[0].allocationId, accepted); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAgreementAlreadyAllocated( + Seed memory seed, + uint256 alternativeNonce + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + + // First, accept an indexing agreement on the allocation + (IRecurringCollector.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerState); + vm.assume(accepted.rca.nonce != alternativeNonce); + + // Now try to accept a different agreement on the same allocation + // Create a new agreement with different nonce to ensure different agreement ID + IRecurringCollector.RecurringCollectionAgreement + // forge-lint: disable-next-line(mixed-case-variable) + memory newRCA = _generateAcceptableRecurringCollectionAgreement(ctx, indexerState.addr); + newRCA.nonce = alternativeNonce; // Different nonce to ensure different agreement ID + + // Sign the new agreement + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCA memory newSignedRCA = _recurringCollectorHelper.generateSignedRCA( + newRCA, + ctx.payer.signerPrivateKey + ); + + // Expect the error when trying to accept a second agreement on the same allocation + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.AllocationAlreadyHasIndexingAgreement.selector, + indexerState.allocationId + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, newSignedRCA); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidTermsData(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.RecurringCollectionAgreement memory notAcceptableRCA = acceptable.rca; + bytes memory invalidTermsData = bytes("invalid terms data"); + notAcceptableRCA.metadata = abi.encode( + _newAcceptIndexingAgreementMetadataV1Terms(indexerState.subgraphDeploymentId, invalidTermsData) + ); + IRecurringCollector.SignedRCA memory notAcceptable = _recurringCollectorHelper.generateSignedRCA( + notAcceptableRCA, + ctx.payer.signerPrivateKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeIndexingAgreementTermsV1", + invalidTermsData + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, notAcceptable); + } + + function test_SubgraphService_AcceptIndexingAgreement(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + IndexingAgreement.AcceptIndexingAgreementMetadata memory metadata = abi.decode( + acceptable.rca.metadata, + (IndexingAgreement.AcceptIndexingAgreementMetadata) + ); + // Generate deterministic agreement ID for event expectation + bytes16 expectedAgreementId = recurringCollector.generateAgreementId( + acceptable.rca.payer, + acceptable.rca.dataService, + acceptable.rca.serviceProvider, + acceptable.rca.deadline, + acceptable.rca.nonce + ); + + vm.expectEmit(address(subgraphService)); + emit IndexingAgreement.IndexingAgreementAccepted( + acceptable.rca.serviceProvider, + acceptable.rca.payer, + expectedAgreementId, + indexerState.allocationId, + metadata.subgraphDeploymentId, + metadata.version, + metadata.terms + ); + + resetPrank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, acceptable); + } + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol new file mode 100644 index 000000000..4a8f020c7 --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; + +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementBaseTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_GetIndexingAgreement( + Seed memory seed, + address operator, + bytes16 fuzzyAgreementId + ) public { + vm.assume(_isSafeSubgraphServiceCaller(operator)); + + resetPrank(address(operator)); + + // Get unkown indexing agreement + vm.expectRevert( + abi.encodeWithSelector(IndexingAgreement.IndexingAgreementNotActive.selector, fuzzyAgreementId) + ); + subgraphService.getIndexingAgreement(fuzzyAgreementId); + + // Accept an indexing agreement + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (IRecurringCollector.SignedRCA memory accepted, bytes16 agreementId) = _withAcceptedIndexingAgreement( + ctx, + indexerState + ); + IIndexingAgreement.AgreementWrapper memory agreement = subgraphService.getIndexingAgreement(agreementId); + _assertEqualAgreement(accepted.rca, agreement); + } + + function test_SubgraphService_Revert_WhenUnsafeAddress_WhenProxyAdmin(address indexer, bytes16 agreementId) public { + address operator = _transparentUpgradeableProxyAdmin(); + assertFalse(_isSafeSubgraphServiceCaller(operator)); + + vm.expectRevert(TransparentUpgradeableProxy.ProxyDeniedAdminAccess.selector); + resetPrank(address(operator)); + subgraphService.cancelIndexingAgreement(indexer, agreementId); + } + + function test_SubgraphService_Revert_WhenUnsafeAddress_WhenGraphProxyAdmin(uint256 unboundedTokens) public { + address indexer = GRAPH_PROXY_ADMIN_ADDRESS; + assertFalse(_isSafeSubgraphServiceCaller(indexer)); + + uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); + mint(indexer, tokens); + resetPrank(indexer); + vm.expectRevert("Cannot fallback to proxy target"); + staking.provision(indexer, address(subgraphService), tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol new file mode 100644 index 000000000..4ca5b56fc --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/utilities/ProvisionManager.sol"; + +import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_CancelIndexingAgreementByPayer_Revert_WhenPaused( + address rando, + bytes16 agreementId + ) public withSafeIndexerOrOperator(rando) { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + resetPrank(rando); + subgraphService.cancelIndexingAgreementByPayer(agreementId); + } + + function test_SubgraphService_CancelIndexingAgreementByPayer_Revert_WhenNotAuthorized( + Seed memory seed, + address rando + ) public withSafeIndexerOrOperator(rando) { + Context storage ctx = _newCtx(seed); + (IRecurringCollector.SignedRCA memory accepted, bytes16 agreementId) = _withAcceptedIndexingAgreement( + ctx, + _withIndexer(ctx) + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNonCancelableBy.selector, + accepted.rca.payer, + rando + ); + vm.expectRevert(expectedErr); + resetPrank(rando); + subgraphService.cancelIndexingAgreementByPayer(agreementId); + } + + function test_SubgraphService_CancelIndexingAgreementByPayer_Revert_WhenNotAccepted( + Seed memory seed, + bytes16 agreementId + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + + resetPrank(indexerState.addr); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotActive.selector, + agreementId + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreementByPayer(agreementId); + } + + function test_SubgraphService_CancelIndexingAgreementByPayer_Revert_WhenCanceled( + Seed memory seed, + bool cancelSource + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( + ctx, + indexerState + ); + IRecurringCollector.CancelAgreementBy by = cancelSource + ? IRecurringCollector.CancelAgreementBy.ServiceProvider + : IRecurringCollector.CancelAgreementBy.Payer; + _cancelAgreement(ctx, acceptedAgreementId, indexerState.addr, accepted.rca.payer, by); + + resetPrank(indexerState.addr); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotActive.selector, + acceptedAgreementId + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreementByPayer(acceptedAgreementId); + } + + function test_SubgraphService_CancelIndexingAgreementByPayer(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( + ctx, + _withIndexer(ctx) + ); + + _cancelAgreement( + ctx, + acceptedAgreementId, + accepted.rca.serviceProvider, + accepted.rca.payer, + IRecurringCollector.CancelAgreementBy.Payer + ); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenPaused( + address operator, + address indexer, + bytes16 agreementId + ) public withSafeIndexerOrOperator(operator) { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + resetPrank(operator); + subgraphService.cancelIndexingAgreement(indexer, agreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenNotAuthorized( + address operator, + address indexer, + bytes16 agreementId + ) public withSafeIndexerOrOperator(operator) { + vm.assume(operator != indexer); + resetPrank(operator); + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerNotAuthorized.selector, + indexer, + operator + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreement(indexer, agreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenInvalidProvision( + address indexer, + bytes16 agreementId, + uint256 unboundedTokens + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); + + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerInvalidValue.selector, + "tokens", + tokens, + MINIMUM_PROVISION_TOKENS, + MAXIMUM_PROVISION_TOKENS + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreement(indexer, agreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenIndexerNotRegistered( + address indexer, + bytes16 agreementId, + uint256 unboundedTokens + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, + indexer + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreement(indexer, agreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenNotAccepted( + Seed memory seed, + bytes16 agreementId + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + + resetPrank(indexerState.addr); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotActive.selector, + agreementId + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreement(indexerState.addr, agreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenCanceled( + Seed memory seed, + bool cancelSource + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( + ctx, + indexerState + ); + IRecurringCollector.CancelAgreementBy by = cancelSource + ? IRecurringCollector.CancelAgreementBy.ServiceProvider + : IRecurringCollector.CancelAgreementBy.Payer; + _cancelAgreement(ctx, acceptedAgreementId, accepted.rca.serviceProvider, accepted.rca.payer, by); + + resetPrank(indexerState.addr); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotActive.selector, + acceptedAgreementId + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreement(indexerState.addr, acceptedAgreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_OK(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( + ctx, + _withIndexer(ctx) + ); + + _cancelAgreement( + ctx, + acceptedAgreementId, + accepted.rca.serviceProvider, + accepted.rca.payer, + IRecurringCollector.CancelAgreementBy.ServiceProvider + ); + } + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol new file mode 100644 index 000000000..447f54f3d --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IPaymentsCollector } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsCollector.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/utilities/ProvisionManager.sol"; + +import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; +import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; +import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; +import { IndexingAgreementDecoder } from "../../../../contracts/libraries/IndexingAgreementDecoder.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_CollectIndexingFees_OK( + Seed memory seed, + uint256 entities, + bytes32 poi, + uint256 unboundedTokensCollected + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( + ctx, + indexerState + ); + + assertEq(subgraphService.feesProvisionTracker(indexerState.addr), 0, "Should be 0 before collect"); + + resetPrank(indexerState.addr); + subgraphService.setPaymentsDestination(indexerState.addr); + + bytes memory data = abi.encode( + IRecurringCollector.CollectParams({ + agreementId: acceptedAgreementId, + collectionId: bytes32(uint256(uint160(indexerState.allocationId))), + tokens: 0, + dataServiceCut: 0, + receiverDestination: indexerState.addr, + maxSlippage: type(uint256).max + }) + ); + uint256 tokensCollected = bound(unboundedTokensCollected, 1, indexerState.tokens / STAKE_TO_FEES_RATIO); + + vm.mockCall( + address(recurringCollector), + abi.encodeWithSelector(IPaymentsCollector.collect.selector, IGraphPayments.PaymentTypes.IndexingFee, data), + abi.encode(tokensCollected) + ); + _expectCollectCallAndEmit(data, indexerState, accepted, acceptedAgreementId, tokensCollected, entities, poi); + + skip(1); // To make agreement collectable + + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(acceptedAgreementId, entities, poi, epochManager.currentEpochBlock(), bytes("")) + ); + + assertEq( + subgraphService.feesProvisionTracker(indexerState.addr), + tokensCollected * STAKE_TO_FEES_RATIO, + "Should be exactly locked tokens" + ); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenPaused( + address indexer, + bytes16 agreementId, + uint256 entities, + bytes32 poi + ) public withSafeIndexerOrOperator(indexer) { + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + resetPrank(indexer); + subgraphService.collect( + indexer, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenNotAuthorized( + address operator, + address indexer, + bytes16 agreementId, + uint256 entities, + bytes32 poi + ) public withSafeIndexerOrOperator(operator) { + vm.assume(operator != indexer); + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + resetPrank(operator); + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerNotAuthorized.selector, + indexer, + operator + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexer, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenInvalidProvision( + uint256 unboundedTokens, + address indexer, + bytes16 agreementId, + uint256 entities, + bytes32 poi + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); + + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerInvalidValue.selector, + "tokens", + tokens, + MINIMUM_PROVISION_TOKENS, + MAXIMUM_PROVISION_TOKENS + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexer, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenIndexerNotRegistered( + uint256 unboundedTokens, + address indexer, + bytes16 agreementId, + uint256 entities, + bytes32 poi + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, + indexer + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexer, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenInvalidData(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + + bytes memory invalidData = bytes("invalid data"); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeCollectData", + invalidData + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.collect(indexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, invalidData); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenInvalidAgreement( + Seed memory seed, + bytes16 agreementId, + uint256 entities, + bytes32 poi + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + + bytes memory expectedErr = abi.encodeWithSelector(IAllocation.AllocationDoesNotExist.selector, address(0)); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Reverts_WhenInvalidNestedData(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); + + resetPrank(indexerState.addr); + + bytes memory invalidNestedData = bytes("invalid nested data"); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeCollectIndexingFeeDataV1", + invalidNestedData + ); + vm.expectRevert(expectedErr); + + skip(1); // To make agreement collectable + + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectData(acceptedAgreementId, invalidNestedData) + ); + } + + function test_SubgraphService_CollectIndexingFees_Reverts_WhenIndexingAgreementNotAuthorized( + Seed memory seed, + uint256 entities, + bytes32 poi + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IndexerState memory otherIndexerState = _withIndexer(ctx); + (, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); + + vm.assume(otherIndexerState.addr != indexerState.addr); + + resetPrank(otherIndexerState.addr); + + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotAuthorized.selector, + acceptedAgreementId, + otherIndexerState.addr + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + otherIndexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(acceptedAgreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Reverts_WhenStopService( + Seed memory seed, + uint256 entities, + bytes32 poi + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); + + resetPrank(indexerState.addr); + subgraphService.stopService(indexerState.addr, abi.encode(indexerState.allocationId)); + + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + + bytes memory expectedErr = abi.encodeWithSelector( + AllocationHandler.AllocationHandlerAllocationClosed.selector, + indexerState.allocationId + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(acceptedAgreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Reverts_WhenCloseStaleAllocation( + Seed memory seed, + uint256 entities, + bytes32 poi + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); + + skip(MAX_POI_STALENESS + 1); + resetPrank(indexerState.addr); + subgraphService.closeStaleAllocation(indexerState.allocationId); + + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + + bytes memory expectedErr = abi.encodeWithSelector( + AllocationHandler.AllocationHandlerAllocationClosed.selector, + indexerState.allocationId + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(acceptedAgreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + /* solhint-enable graph/func-name-mixedcase */ + + function _expectCollectCallAndEmit( + bytes memory _data, + IndexerState memory _indexerState, + IRecurringCollector.SignedRCA memory _accepted, + bytes16 _acceptedAgreementId, + uint256 _tokensCollected, + uint256 _entities, + bytes32 _poi + ) private { + vm.expectCall( + address(recurringCollector), + abi.encodeCall(IPaymentsCollector.collect, (IGraphPayments.PaymentTypes.IndexingFee, _data)) + ); + vm.expectEmit(address(subgraphService)); + emit IndexingAgreement.IndexingFeesCollectedV1( + _indexerState.addr, + _accepted.rca.payer, + _acceptedAgreementId, + _indexerState.allocationId, + _indexerState.subgraphDeploymentId, + epochManager.currentEpoch(), + _tokensCollected, + _entities, + _poi, + epochManager.currentEpochBlock(), + bytes("") + ); + } +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol new file mode 100644 index 000000000..2eb409f03 --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; +import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; + +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndexingAgreementSharedTest { + using PPMMath for uint256; + + struct TestState { + uint256 escrowBalance; + uint256 indexerBalance; + uint256 indexerTokensLocked; + } + + struct ExpectedTokens { + uint256 expectedTotalTokensCollected; + uint256 expectedTokensLocked; + uint256 expectedProtocolTokensBurnt; + uint256 expectedIndexerTokensCollected; + } + + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_CollectIndexingFee_Integration( + Seed memory seed, + uint256 fuzzyTokensCollected + ) public { + // Setup + ExpectedTokens memory expectedTokens = _newExpectedTokens(fuzzyTokensCollected); + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + _addTokensToProvision(indexerState, expectedTokens.expectedTokensLocked); + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + ctx.ctxInternal.seed.rca + ); + bytes16 acceptedAgreementId = _sharedSetup(ctx, rca, indexerState, expectedTokens); + + TestState memory beforeCollect = _getState(rca.payer, indexerState.addr); + + // Collect + resetPrank(indexerState.addr); + uint256 tokensCollected = subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1( + acceptedAgreementId, + 1, + keccak256(abi.encodePacked("poi")), + epochManager.currentEpochBlock(), + bytes("") + ) + ); + + TestState memory afterCollect = _getState(rca.payer, indexerState.addr); + _sharedAssert(beforeCollect, afterCollect, expectedTokens, tokensCollected); + } + + function test_SubgraphService_CollectIndexingFee_WhenCanceledByPayer_Integration( + Seed memory seed, + uint256 fuzzyTokensCollected + ) public { + // Setup + ExpectedTokens memory expectedTokens = _newExpectedTokens(fuzzyTokensCollected); + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + ctx.ctxInternal.seed.rca + ); + bytes16 acceptedAgreementId = _sharedSetup(ctx, rca, indexerState, expectedTokens); + + // Cancel the indexing agreement by the payer + resetPrank(ctx.payer.signer); + subgraphService.cancelIndexingAgreementByPayer(acceptedAgreementId); + + TestState memory beforeCollect = _getState(rca.payer, indexerState.addr); + + // Collect + resetPrank(indexerState.addr); + uint256 tokensCollected = subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1( + acceptedAgreementId, + 1, + keccak256(abi.encodePacked("poi")), + epochManager.currentEpochBlock(), + bytes("") + ) + ); + + TestState memory afterCollect = _getState(rca.payer, indexerState.addr); + _sharedAssert(beforeCollect, afterCollect, expectedTokens, tokensCollected); + } + + function test_SubgraphService_CollectIndexingRewards_CancelsAgreementWhenOverAllocated_Integration( + Seed memory seed + ) public { + // Setup context and indexer with active agreement + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (, bytes16 agreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); + + // Ensure enough gap so that reward distribution (1% of tokens) doesn't undo the over-allocation + vm.assume(indexerState.tokens > MINIMUM_PROVISION_TOKENS * 2); + + // Reduce indexer's provision to force over-allocation after collecting rewards + uint256 extraTokens = indexerState.tokens - MINIMUM_PROVISION_TOKENS; + _removeTokensFromProvision(indexerState, extraTokens); + + // Verify indexer will be over-allocated after presenting POI + assertTrue(subgraphService.isOverAllocated(indexerState.addr)); + + // Advance past allocation creation epoch so POI is not considered "too young" + vm.roll(block.number + EPOCH_LENGTH); + + // Collect indexing rewards - this should trigger allocation closure and agreement cancellation + bytes memory collectData = abi.encode(indexerState.allocationId, keccak256("poi"), bytes("metadata")); + resetPrank(indexerState.addr); + subgraphService.collect(indexerState.addr, IGraphPayments.PaymentTypes.IndexingRewards, collectData); + + // Verify the indexing agreement was properly cancelled + IIndexingAgreement.AgreementWrapper memory agreement = subgraphService.getIndexingAgreement(agreementId); + assertEq( + uint8(agreement.collectorAgreement.state), + uint8(IRecurringCollector.AgreementState.CanceledByServiceProvider) + ); + } + + /* solhint-enable graph/func-name-mixedcase */ + + function _sharedSetup( + Context storage _ctx, + IRecurringCollector.RecurringCollectionAgreement memory _rca, + IndexerState memory _indexerState, + ExpectedTokens memory _expectedTokens + ) internal returns (bytes16) { + _addTokensToProvision(_indexerState, _expectedTokens.expectedTokensLocked); + + IndexingAgreement.IndexingAgreementTermsV1 memory terms = IndexingAgreement.IndexingAgreementTermsV1({ + tokensPerSecond: 1, + tokensPerEntityPerSecond: 0 // no payment for entities + }); + _rca.deadline = uint64(block.timestamp); // accept now + _rca.endsAt = type(uint64).max; // no expiration + _rca.maxInitialTokens = 0; // no initial payment + _rca.maxOngoingTokensPerSecond = type(uint32).max; // unlimited tokens per second + _rca.minSecondsPerCollection = 1; // 1 second between collections + _rca.maxSecondsPerCollection = type(uint32).max; // no maximum time between collections + _rca.serviceProvider = _indexerState.addr; // service provider is the indexer + _rca.dataService = address(subgraphService); // data service is the subgraph service + _rca.metadata = _encodeAcceptIndexingAgreementMetadataV1(_indexerState.subgraphDeploymentId, terms); + + _setupPayerWithEscrow( + _rca.payer, + _ctx.payer.signerPrivateKey, + _indexerState.addr, + _expectedTokens.expectedTotalTokensCollected + ); + + resetPrank(_indexerState.addr); + // Set the payments destination to the indexer address + subgraphService.setPaymentsDestination(_indexerState.addr); + + // Accept the Indexing Agreement + bytes16 agreementId = subgraphService.acceptIndexingAgreement( + _indexerState.allocationId, + _recurringCollectorHelper.generateSignedRCA(_rca, _ctx.payer.signerPrivateKey) + ); + + // Skip ahead to collection point + skip(_expectedTokens.expectedTotalTokensCollected / terms.tokensPerSecond); + + return agreementId; + } + + function _newExpectedTokens(uint256 _fuzzyTokensCollected) internal view returns (ExpectedTokens memory) { + uint256 expectedTotalTokensCollected = bound(_fuzzyTokensCollected, 1000, 1_000_000); + uint256 expectedTokensLocked = STAKE_TO_FEES_RATIO * expectedTotalTokensCollected; + uint256 expectedProtocolTokensBurnt = expectedTotalTokensCollected.mulPPMRoundUp( + graphPayments.PROTOCOL_PAYMENT_CUT() + ); + uint256 expectedIndexerTokensCollected = expectedTotalTokensCollected - expectedProtocolTokensBurnt; + return + ExpectedTokens({ + expectedTotalTokensCollected: expectedTotalTokensCollected, + expectedTokensLocked: expectedTokensLocked, + expectedProtocolTokensBurnt: expectedProtocolTokensBurnt, + expectedIndexerTokensCollected: expectedIndexerTokensCollected + }); + } + + function _sharedAssert( + TestState memory _beforeCollect, + TestState memory _afterCollect, + ExpectedTokens memory _expectedTokens, + uint256 _tokensCollected + ) internal pure { + uint256 indexerTokensCollected = _afterCollect.indexerBalance - _beforeCollect.indexerBalance; + assertEq(_expectedTokens.expectedTotalTokensCollected, _tokensCollected, "Total tokens collected should match"); + assertEq( + _expectedTokens.expectedProtocolTokensBurnt, + _tokensCollected - indexerTokensCollected, + "Protocol tokens burnt should match" + ); + assertEq( + _expectedTokens.expectedIndexerTokensCollected, + indexerTokensCollected, + "Indexer tokens collected should match" + ); + assertEq( + _afterCollect.escrowBalance, + _beforeCollect.escrowBalance - _expectedTokens.expectedTotalTokensCollected, + "_Escrow balance should be reduced by the amount collected" + ); + + assertEq( + _afterCollect.indexerTokensLocked, + _beforeCollect.indexerTokensLocked + _expectedTokens.expectedTokensLocked, + "_Locked tokens should match" + ); + } + + function _addTokensToProvision(IndexerState memory _indexerState, uint256 _tokens) private { + deal({ token: address(token), to: _indexerState.addr, give: _tokens }); + vm.startPrank(_indexerState.addr); + _addToProvision(_indexerState.addr, _tokens); + vm.stopPrank(); + } + + function _removeTokensFromProvision(IndexerState memory _indexerState, uint256 _tokens) private { + deal({ token: address(token), to: _indexerState.addr, give: _tokens }); + vm.startPrank(_indexerState.addr); + _removeFromProvision(_indexerState.addr, _tokens); + vm.stopPrank(); + } + + function _setupPayerWithEscrow( + address _payer, + uint256 _signerPrivateKey, + address _indexer, + uint256 _escrowTokens + ) private { + _recurringCollectorHelper.authorizeSignerWithChecks(_payer, _signerPrivateKey); + + deal({ token: address(token), to: _payer, give: _escrowTokens }); + vm.startPrank(_payer); + _escrow(_escrowTokens, _indexer); + vm.stopPrank(); + } + + function _escrow(uint256 _tokens, address _indexer) private { + token.approve(address(escrow), _tokens); + escrow.deposit(address(recurringCollector), _indexer, _tokens); + } + + function _getState(address _payer, address _indexer) private view returns (TestState memory) { + CollectPaymentData memory collect = _collectPaymentData(_indexer); + + return + TestState({ + escrowBalance: escrow.getBalance(_payer, address(recurringCollector), _indexer), + indexerBalance: collect.indexerBalance, + indexerTokensLocked: collect.lockedTokens + }); + } +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol new file mode 100644 index 000000000..e2ff20260 --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; + +import { Bounder } from "@graphprotocol/horizon/test/unit/utils/Bounder.t.sol"; +import { RecurringCollectorHelper } from "@graphprotocol/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol"; +import { SubgraphServiceTest } from "../SubgraphService.t.sol"; + +contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Bounder { + struct Context { + PayerState payer; + IndexerState[] indexers; + mapping(address allocationId => address indexer) allocations; + ContextInternal ctxInternal; + } + + struct IndexerState { + address addr; + address allocationId; + bytes32 subgraphDeploymentId; + uint256 tokens; + } + + struct PayerState { + address signer; + uint256 signerPrivateKey; + } + + struct ContextInternal { + IndexerSeed[] indexers; + Seed seed; + bool initialized; + } + + struct Seed { + IndexerSeed indexer0; + IndexerSeed indexer1; + IRecurringCollector.RecurringCollectionAgreement rca; + IRecurringCollector.RecurringCollectionAgreementUpdate rcau; + IndexingAgreement.IndexingAgreementTermsV1 termsV1; + PayerSeed payer; + } + + struct IndexerSeed { + address addr; + string label; + uint256 unboundedProvisionTokens; + uint256 unboundedAllocationPrivateKey; + bytes32 subgraphDeploymentId; + } + + struct PayerSeed { + uint256 unboundedSignerPrivateKey; + } + + Context internal _context; + + bytes32 internal constant TRANSPARENT_UPGRADEABLE_PROXY_ADMIN_ADDRESS_SLOT = + 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + address internal constant GRAPH_PROXY_ADMIN_ADDRESS = 0x15c603B7eaA8eE1a272a69C4af3462F926de777F; + + RecurringCollectorHelper internal _recurringCollectorHelper; + + modifier withSafeIndexerOrOperator(address operator) { + vm.assume(_isSafeSubgraphServiceCaller(operator)); + _; + } + + function setUp() public override { + super.setUp(); + + _recurringCollectorHelper = new RecurringCollectorHelper(recurringCollector); + } + + /* + * HELPERS + */ + + function _subgraphServiceSafePrank(address _addr) internal returns (address) { + address originalPrankAddress = msg.sender; + vm.assume(_isSafeSubgraphServiceCaller(_addr)); + resetPrank(_addr); + + return originalPrankAddress; + } + + function _stopOrResetPrank(address _originalSender) internal { + if (_originalSender == 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38) { + vm.stopPrank(); + } else { + resetPrank(_originalSender); + } + } + + function _cancelAgreement( + Context storage _ctx, + bytes16 _agreementId, + address _indexer, + address _payer, + IRecurringCollector.CancelAgreementBy _by + ) internal { + bool byIndexer = _by == IRecurringCollector.CancelAgreementBy.ServiceProvider; + vm.expectEmit(address(subgraphService)); + emit IndexingAgreement.IndexingAgreementCanceled(_indexer, _payer, _agreementId, byIndexer ? _indexer : _payer); + + if (byIndexer) { + _subgraphServiceSafePrank(_indexer); + subgraphService.cancelIndexingAgreement(_indexer, _agreementId); + } else { + _subgraphServiceSafePrank(_ctx.payer.signer); + subgraphService.cancelIndexingAgreementByPayer(_agreementId); + } + } + + function _withIndexer(Context storage _ctx) internal returns (IndexerState memory) { + require(_ctx.ctxInternal.indexers.length > 0, "No indexer seeds available"); + + IndexerSeed memory indexerSeed = _ctx.ctxInternal.indexers[_ctx.ctxInternal.indexers.length - 1]; + _ctx.ctxInternal.indexers.pop(); + + indexerSeed.label = string.concat("_withIndexer-", Strings.toString(_ctx.ctxInternal.indexers.length)); + + return _setupIndexer(_ctx, indexerSeed); + } + + function _setupIndexer(Context storage _ctx, IndexerSeed memory _seed) internal returns (IndexerState memory) { + vm.assume(_getIndexer(_ctx, _seed.addr).addr == address(0)); + + (uint256 allocationKey, address allocationId) = boundKeyAndAddr(_seed.unboundedAllocationPrivateKey); + vm.assume(_ctx.allocations[allocationId] == address(0)); + _ctx.allocations[allocationId] = _seed.addr; + + uint256 tokens = bound(_seed.unboundedProvisionTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); + + IndexerState memory indexer = IndexerState({ + addr: _seed.addr, + allocationId: allocationId, + subgraphDeploymentId: _seed.subgraphDeploymentId, + tokens: tokens + }); + vm.label(indexer.addr, string.concat("_setupIndexer-", _seed.label)); + + // Mint tokens to the indexer + mint(_seed.addr, tokens); + + // Create the indexer + address originalPrank = _subgraphServiceSafePrank(indexer.addr); + _createProvision(indexer.addr, indexer.tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); + _register(indexer.addr, abi.encode("url", "geoHash", address(0))); + bytes memory data = _createSubgraphAllocationData( + indexer.addr, + indexer.subgraphDeploymentId, + allocationKey, + indexer.tokens + ); + _startService(indexer.addr, data); + + _ctx.indexers.push(indexer); + + _stopOrResetPrank(originalPrank); + + return indexer; + } + + function _withAcceptedIndexingAgreement( + Context storage _ctx, + IndexerState memory _indexerState + ) internal returns (IRecurringCollector.SignedRCA memory, bytes16 agreementId) { + IRecurringCollector.RecurringCollectionAgreement memory rca = _ctx.ctxInternal.seed.rca; + + IndexingAgreement.AcceptIndexingAgreementMetadata memory metadata = _newAcceptIndexingAgreementMetadataV1( + _indexerState.subgraphDeploymentId + ); + rca.serviceProvider = _indexerState.addr; + rca.dataService = address(subgraphService); + rca.metadata = abi.encode(metadata); + + rca = _recurringCollectorHelper.sensibleRCA(rca); + + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA( + rca, + _ctx.payer.signerPrivateKey + ); + _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, _ctx.payer.signerPrivateKey); + + // Generate deterministic agreement ID for event expectation + agreementId = recurringCollector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + + vm.expectEmit(address(subgraphService)); + emit IndexingAgreement.IndexingAgreementAccepted( + rca.serviceProvider, + rca.payer, + agreementId, + _indexerState.allocationId, + metadata.subgraphDeploymentId, + metadata.version, + metadata.terms + ); + _subgraphServiceSafePrank(_indexerState.addr); + bytes16 actualAgreementId = subgraphService.acceptIndexingAgreement(_indexerState.allocationId, signedRCA); + + // Verify the agreement ID matches expectation + assertEq(actualAgreementId, agreementId); + return (signedRCA, agreementId); + } + + function _newCtx(Seed memory _seed) internal returns (Context storage) { + require(_context.ctxInternal.initialized == false, "Context already initialized"); + Context storage ctx = _context; + + // Initialize + ctx.ctxInternal.initialized = true; + + // Setup seeds + ctx.ctxInternal.seed = _seed; + ctx.ctxInternal.indexers.push(_seed.indexer0); + ctx.ctxInternal.indexers.push(_seed.indexer1); + + // Setup payer + ctx.payer.signerPrivateKey = boundKey(ctx.ctxInternal.seed.payer.unboundedSignerPrivateKey); + ctx.payer.signer = vm.addr(ctx.payer.signerPrivateKey); + + return ctx; + } + + // forge-lint: disable-next-item(mixed-case-function) + function _generateAcceptableSignedRCA( + Context storage _ctx, + address _indexerAddress + ) internal returns (IRecurringCollector.SignedRCA memory) { + IRecurringCollector.RecurringCollectionAgreement memory rca = _generateAcceptableRecurringCollectionAgreement( + _ctx, + _indexerAddress + ); + _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, _ctx.payer.signerPrivateKey); + + return _recurringCollectorHelper.generateSignedRCA(rca, _ctx.payer.signerPrivateKey); + } + + function _generateAcceptableRecurringCollectionAgreement( + Context storage _ctx, + address _indexerAddress + ) internal view returns (IRecurringCollector.RecurringCollectionAgreement memory) { + IndexerState memory indexer = _requireIndexer(_ctx, _indexerAddress); + IndexingAgreement.AcceptIndexingAgreementMetadata memory metadata = _newAcceptIndexingAgreementMetadataV1( + indexer.subgraphDeploymentId + ); + IRecurringCollector.RecurringCollectionAgreement memory rca = _ctx.ctxInternal.seed.rca; + rca.serviceProvider = indexer.addr; + rca.dataService = address(subgraphService); + rca.metadata = abi.encode(metadata); + + return _recurringCollectorHelper.sensibleRCA(rca); + } + + // forge-lint: disable-next-item(mixed-case-function) + function _generateAcceptableSignedRCAU( + Context storage _ctx, + IRecurringCollector.RecurringCollectionAgreement memory _rca + ) internal view returns (IRecurringCollector.SignedRCAU memory) { + IRecurringCollector.RecurringCollectionAgreementUpdate + memory rcau = _generateAcceptableRecurringCollectionAgreementUpdate(_ctx, _rca); + // Set correct nonce for first update (should be 1) + rcau.nonce = 1; + return _recurringCollectorHelper.generateSignedRCAU(rcau, _ctx.payer.signerPrivateKey); + } + + function _generateAcceptableRecurringCollectionAgreementUpdate( + Context storage _ctx, + IRecurringCollector.RecurringCollectionAgreement memory _rca + ) internal view returns (IRecurringCollector.RecurringCollectionAgreementUpdate memory) { + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _ctx.ctxInternal.seed.rcau; + // Generate deterministic agreement ID for the update + rcau.agreementId = recurringCollector.generateAgreementId( + _rca.payer, + _rca.dataService, + _rca.serviceProvider, + _rca.deadline, + _rca.nonce + ); + rcau.metadata = _encodeUpdateIndexingAgreementMetadataV1( + _newUpdateIndexingAgreementMetadataV1( + bound(_ctx.ctxInternal.seed.termsV1.tokensPerSecond, 0, _rca.maxOngoingTokensPerSecond), + _ctx.ctxInternal.seed.termsV1.tokensPerEntityPerSecond + ) + ); + return _recurringCollectorHelper.sensibleRCAU(rcau); + } + + function _requireIndexer(Context storage _ctx, address _indexer) internal view returns (IndexerState memory) { + IndexerState memory indexerState = _getIndexer(_ctx, _indexer); + require(indexerState.addr != address(0), "Indexer not found in context"); + + return indexerState; + } + + function _getIndexer(Context storage _ctx, address _indexer) internal view returns (IndexerState memory zero) { + for (uint256 i = 0; i < _ctx.indexers.length; i++) { + if (_ctx.indexers[i].addr == _indexer) { + return _ctx.indexers[i]; + } + } + + return zero; + } + + function _isSafeSubgraphServiceCaller(address _candidate) internal view returns (bool) { + return + _candidate != address(0) && + _candidate != address(_transparentUpgradeableProxyAdmin()) && + _candidate != address(proxyAdmin); + } + + function _transparentUpgradeableProxyAdmin() internal view returns (address) { + return + address( + uint160(uint256(vm.load(address(subgraphService), TRANSPARENT_UPGRADEABLE_PROXY_ADMIN_ADDRESS_SLOT))) + ); + } + + function _newAcceptIndexingAgreementMetadataV1( + bytes32 _subgraphDeploymentId + ) internal pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { + return + _newAcceptIndexingAgreementMetadataV1Terms( + _subgraphDeploymentId, + abi.encode( + IndexingAgreement.IndexingAgreementTermsV1({ tokensPerSecond: 0, tokensPerEntityPerSecond: 0 }) + ) + ); + } + + function _newAcceptIndexingAgreementMetadataV1Terms( + bytes32 _subgraphDeploymentId, + bytes memory _terms + ) internal pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { + return + IndexingAgreement.AcceptIndexingAgreementMetadata({ + subgraphDeploymentId: _subgraphDeploymentId, + version: IIndexingAgreement.IndexingAgreementVersion.V1, + terms: _terms + }); + } + + function _newUpdateIndexingAgreementMetadataV1( + uint256 _tokensPerSecond, + uint256 _tokensPerEntityPerSecond + ) internal pure returns (IndexingAgreement.UpdateIndexingAgreementMetadata memory) { + return + IndexingAgreement.UpdateIndexingAgreementMetadata({ + version: IIndexingAgreement.IndexingAgreementVersion.V1, + terms: abi.encode( + IndexingAgreement.IndexingAgreementTermsV1({ + tokensPerSecond: _tokensPerSecond, + tokensPerEntityPerSecond: _tokensPerEntityPerSecond + }) + ) + }); + } + + function _encodeCollectDataV1( + bytes16 _agreementId, + uint256 _entities, + bytes32 _poi, + uint256 _poiBlock, + bytes memory _metadata + ) internal pure returns (bytes memory) { + return _encodeCollectData(_agreementId, _encodeV1Data(_entities, _poi, _poiBlock, _metadata)); + } + + function _encodeCollectData(bytes16 _agreementId, bytes memory _nestedData) internal pure returns (bytes memory) { + return abi.encode(_agreementId, _nestedData); + } + + function _encodeV1Data( + uint256 _entities, + bytes32 _poi, + uint256 _poiBlock, + bytes memory _metadata + ) internal pure returns (bytes memory) { + return + abi.encode( + IndexingAgreement.CollectIndexingFeeDataV1({ + entities: _entities, + poi: _poi, + poiBlockNumber: _poiBlock, + metadata: _metadata, + maxSlippage: type(uint256).max + }) + ); + } + + function _encodeAcceptIndexingAgreementMetadataV1( + bytes32 _subgraphDeploymentId, + IndexingAgreement.IndexingAgreementTermsV1 memory _terms + ) internal pure returns (bytes memory) { + return + abi.encode( + IndexingAgreement.AcceptIndexingAgreementMetadata({ + subgraphDeploymentId: _subgraphDeploymentId, + version: IIndexingAgreement.IndexingAgreementVersion.V1, + terms: abi.encode(_terms) + }) + ); + } + + function _encodeUpdateIndexingAgreementMetadataV1( + IndexingAgreement.UpdateIndexingAgreementMetadata memory _t + ) internal pure returns (bytes memory) { + return abi.encode(_t); + } + + function _assertEqualAgreement( + IRecurringCollector.RecurringCollectionAgreement memory _expected, + IIndexingAgreement.AgreementWrapper memory _actual + ) internal pure { + assertEq(_expected.dataService, _actual.collectorAgreement.dataService); + assertEq(_expected.payer, _actual.collectorAgreement.payer); + assertEq(_expected.serviceProvider, _actual.collectorAgreement.serviceProvider); + assertEq(_expected.endsAt, _actual.collectorAgreement.endsAt); + assertEq(_expected.maxInitialTokens, _actual.collectorAgreement.maxInitialTokens); + assertEq(_expected.maxOngoingTokensPerSecond, _actual.collectorAgreement.maxOngoingTokensPerSecond); + assertEq(_expected.minSecondsPerCollection, _actual.collectorAgreement.minSecondsPerCollection); + assertEq(_expected.maxSecondsPerCollection, _actual.collectorAgreement.maxSecondsPerCollection); + } +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol new file mode 100644 index 000000000..d968ba178 --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/utilities/ProvisionManager.sol"; + +import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; +import { IndexingAgreementDecoder } from "../../../../contracts/libraries/IndexingAgreementDecoder.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_UpdateIndexingAgreementIndexingAgreement_Revert_WhenPaused( + address operator, + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCAU calldata signedRCAU + ) public withSafeIndexerOrOperator(operator) { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + resetPrank(operator); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + subgraphService.updateIndexingAgreement(operator, signedRCAU); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenNotAuthorized( + address indexer, + address notAuthorized, + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCAU calldata signedRCAU + ) public withSafeIndexerOrOperator(notAuthorized) { + vm.assume(notAuthorized != indexer); + resetPrank(notAuthorized); + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerNotAuthorized.selector, + indexer, + notAuthorized + ); + vm.expectRevert(expectedErr); + subgraphService.updateIndexingAgreement(indexer, signedRCAU); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenInvalidProvision( + address indexer, + uint256 unboundedTokens, + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCAU memory signedRCAU + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); + + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerInvalidValue.selector, + "tokens", + tokens, + MINIMUM_PROVISION_TOKENS, + MAXIMUM_PROVISION_TOKENS + ); + vm.expectRevert(expectedErr); + subgraphService.updateIndexingAgreement(indexer, signedRCAU); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenIndexerNotRegistered( + address indexer, + uint256 unboundedTokens, + // forge-lint: disable-next-line(mixed-case-variable) + IRecurringCollector.SignedRCAU memory signedRCAU + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); + + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, + indexer + ); + vm.expectRevert(expectedErr); + subgraphService.updateIndexingAgreement(indexer, signedRCAU); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenNotAccepted(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCAU memory acceptableUpdate = _generateAcceptableSignedRCAU( + ctx, + _generateAcceptableRecurringCollectionAgreement(ctx, indexerState.addr) + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotActive.selector, + acceptableUpdate.rcau.agreementId + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.updateIndexingAgreement(indexerState.addr, acceptableUpdate); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenNotAuthorizedForAgreement( + Seed memory seed + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerStateA = _withIndexer(ctx); + IndexerState memory indexerStateB = _withIndexer(ctx); + (IRecurringCollector.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerStateA); + IRecurringCollector.SignedRCAU memory acceptableUpdate = _generateAcceptableSignedRCAU(ctx, accepted.rca); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotAuthorized.selector, + acceptableUpdate.rcau.agreementId, + indexerStateB.addr + ); + vm.expectRevert(expectedErr); + resetPrank(indexerStateB.addr); + subgraphService.updateIndexingAgreement(indexerStateB.addr, acceptableUpdate); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenInvalidMetadata(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (IRecurringCollector.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerState); + IRecurringCollector.RecurringCollectionAgreementUpdate + memory acceptableUpdate = _generateAcceptableRecurringCollectionAgreementUpdate(ctx, accepted.rca); + acceptableUpdate.metadata = bytes("invalid"); + // Set correct nonce for first update (should be 1) + acceptableUpdate.nonce = 1; + IRecurringCollector.SignedRCAU memory unacceptableUpdate = _recurringCollectorHelper.generateSignedRCAU( + acceptableUpdate, + ctx.payer.signerPrivateKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeRCAUMetadata", + unacceptableUpdate.rcau.metadata + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.updateIndexingAgreement(indexerState.addr, unacceptableUpdate); + } + + function test_SubgraphService_UpdateIndexingAgreement_OK(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (IRecurringCollector.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerState); + IRecurringCollector.SignedRCAU memory acceptableUpdate = _generateAcceptableSignedRCAU(ctx, accepted.rca); + + IndexingAgreement.UpdateIndexingAgreementMetadata memory metadata = abi.decode( + acceptableUpdate.rcau.metadata, + (IndexingAgreement.UpdateIndexingAgreementMetadata) + ); + + vm.expectEmit(address(subgraphService)); + emit IndexingAgreement.IndexingAgreementUpdated( + accepted.rca.serviceProvider, + accepted.rca.payer, + acceptableUpdate.rcau.agreementId, + indexerState.allocationId, + metadata.version, + metadata.terms + ); + + resetPrank(indexerState.addr); + subgraphService.updateIndexingAgreement(indexerState.addr, acceptableUpdate); + } + /* solhint-enable graph/func-name-mixedcase */ +} From 5471d32484f6b4c4ca12b3a08924b92bc09971a4 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:25:04 +0000 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20post-Horizon=20cleanup=20?= =?UTF-8?q?=E2=80=94=20remove=20transition-period=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove HorizonStakingExtension, ExponentialRebates, LibFixedMath, MathUtils, and all transition-period guard logic. Simplify HorizonStaking to direct implementation without delegatecall split. Clean up RewardsManager multi-issuer model to single subgraphService. Remove legacy allocation/dispute code from SubgraphService. Includes OZ audit fixes: - OZ L-01: re-validate thawingPeriod when accepting provision parameters - OZ L-02: return correct result for getThawedTokens for delegations - OZ N-01: remove more deprecated code - OZ N-02: outdated documentation Adds force withdraw for legacy stake and delegations. Replaces MathUtils.min with OpenZeppelin Math.min throughout. Original commits on rem-horizon-cleanup-merge (f65877e88..5b9463f26) Plus housekeeping commits 8f5d93449 and ae9f59513 --- .../tests/unit/disputes/poi.test.ts | 8 +- .../tests/unit/disputes/query.test.ts | 8 +- .../tests/unit/l2/l2Curation.test.ts | 27 +- .../tests/unit/l2/l2GNS.test.ts | 106 +-- .../tests/unit/l2/l2Staking.test.ts | 8 +- .../unit/rewards/rewards-calculations.test.ts | 3 + .../unit/rewards/rewards-distribution.test.ts | 3 + .../rewards-eligibility-oracle.test.ts | 3 + .../unit/rewards/rewards-reclaim.test.ts | 36 + .../rewards-signal-allocation-update.test.ts | 3 + .../tests/unit/rewards/rewards.test.ts | 4 + .../tests/unit/staking/allocation.test.ts | 4 + .../tests/unit/staking/delegation.test.ts | 8 +- .../contracts/l2/curation/L2Curation.sol | 7 +- .../contracts/rewards/RewardsManager.sol | 54 +- .../data-service/libraries/StakeClaims.sol | 1 + .../contracts/libraries/LibFixedMath.sol | 299 -------- .../horizon/contracts/libraries/MathUtils.sol | 56 -- .../collectors/RecurringCollector.sol | 2 + .../contracts/staking/HorizonStaking.sol | 231 ++---- .../contracts/staking/HorizonStakingBase.sol | 54 +- .../staking/HorizonStakingExtension.sol | 485 ------------ .../staking/HorizonStakingStorage.sol | 5 +- .../staking/libraries/ExponentialRebates.sol | 71 -- .../contracts/utilities/GraphDirectory.sol | 25 +- .../ignition/modules/core/HorizonStaking.ts | 33 +- packages/horizon/package.json | 2 +- packages/horizon/scripts/integration | 6 - packages/horizon/tasks/test/integration.ts | 11 +- .../tasks/transitions/thawing-period.ts | 22 - .../test/deployment/HorizonStaking.test.ts | 12 +- .../delegator.test.ts | 143 ---- .../multicall.test.ts | 114 --- .../during-transition-period/operator.test.ts | 99 --- .../permissionless.test.ts | 66 -- .../service-provider.test.ts | 521 ------------- .../during-transition-period/slasher.test.ts | 88 --- packages/horizon/test/unit/GraphBase.t.sol | 13 +- .../DataServicePausableUpgradeable.t.sol | 121 ++- .../DataServiceImpPausableUpgradeable.sol | 4 + .../HorizonStakingShared.t.sol | 711 +----------------- .../unit/staking/allocation/allocation.t.sol | 31 - .../test/unit/staking/allocation/close.t.sol | 113 --- .../unit/staking/allocation/collect.t.sol | 81 -- .../delegation/forceWithdrawDelegated.t.sol | 114 +++ .../unit/staking/delegation/withdraw.t.sol | 52 ++ .../unit/staking/governance/governance.t.sol | 13 - .../unit/staking/provision/parameters.t.sol | 32 + .../unit/staking/provision/provision.t.sol | 16 - .../serviceProvider/serviceProvider.t.sol | 31 - .../test/unit/staking/slash/legacySlash.t.sol | 251 ------- .../unit/staking/stake/forceWithdraw.t.sol | 125 +++ .../test/unit/staking/stake/unstake.t.sol | 73 -- .../test/unit/staking/stake/withdraw.t.sol | 15 - .../test/unit/utilities/GraphDirectory.t.sol | 4 +- .../GraphDirectoryImplementation.sol | 5 - packages/horizon/test/unit/utils/Users.sol | 1 - .../contracts/rewards/IRewardsManager.sol | 2 +- .../contracts/horizon/IHorizonStaking.sol | 7 +- .../horizon/internal/IHorizonStakingBase.sol | 16 +- .../internal/IHorizonStakingExtension.sol | 215 ------ .../horizon/internal/IHorizonStakingMain.sol | 114 ++- .../subgraph-service/IDisputeManager.sol | 54 +- .../subgraph-service/ISubgraphService.sol | 10 - .../internal/IAllocationManager.sol | 12 - .../internal/ILegacyAllocation.sol | 8 +- packages/issuance/package.json | 4 +- .../contracts/DisputeManager.sol | 46 +- .../contracts/SubgraphService.sol | 10 - .../contracts/libraries/AllocationHandler.sol | 2 +- .../contracts/libraries/IndexingAgreement.sol | 6 +- .../libraries/IndexingAgreementDecoderRaw.sol | 2 +- .../contracts/libraries/LegacyAllocation.sol | 60 +- .../contracts/utilities/AllocationManager.sol | 15 - packages/subgraph-service/package.json | 2 +- packages/subgraph-service/scripts/integration | 7 - .../tasks/test/integration.ts | 11 +- .../dispute-manager.test.ts | 157 ---- .../governance.test.ts | 76 -- .../during-transition-period/indexer.test.ts | 100 --- .../legacy-dispute-manager.test.ts | 256 ------- .../test/unit/SubgraphBaseTest.t.sol | 5 +- .../unit/disputeManager/DisputeManager.t.sol | 82 +- .../unit/disputeManager/disputes/legacy.t.sol | 51 -- .../libraries/LegacyAllocationLibrary.t.sol | 32 - .../unit/mocks/LegacyAllocationHarness.sol | 20 - .../unit/shared/HorizonStakingShared.t.sol | 63 -- .../subgraphService/SubgraphService.t.sol | 12 +- .../subgraphService/allocation/start.t.sol | 4 +- .../subgraphService/governance/legacy.t.sol | 29 - .../indexing-agreement/accept.t.sol | 1 - .../indexing-agreement/shared.t.sol | 2 - .../src/deployments/horizon/actions.ts | 21 - 93 files changed, 788 insertions(+), 5160 deletions(-) delete mode 100644 packages/horizon/contracts/libraries/LibFixedMath.sol delete mode 100644 packages/horizon/contracts/libraries/MathUtils.sol delete mode 100644 packages/horizon/contracts/staking/HorizonStakingExtension.sol delete mode 100644 packages/horizon/contracts/staking/libraries/ExponentialRebates.sol delete mode 100644 packages/horizon/tasks/transitions/thawing-period.ts delete mode 100644 packages/horizon/test/integration/during-transition-period/delegator.test.ts delete mode 100644 packages/horizon/test/integration/during-transition-period/multicall.test.ts delete mode 100644 packages/horizon/test/integration/during-transition-period/operator.test.ts delete mode 100644 packages/horizon/test/integration/during-transition-period/permissionless.test.ts delete mode 100644 packages/horizon/test/integration/during-transition-period/service-provider.test.ts delete mode 100644 packages/horizon/test/integration/during-transition-period/slasher.test.ts delete mode 100644 packages/horizon/test/unit/staking/allocation/allocation.t.sol delete mode 100644 packages/horizon/test/unit/staking/allocation/close.t.sol delete mode 100644 packages/horizon/test/unit/staking/allocation/collect.t.sol create mode 100644 packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol delete mode 100644 packages/horizon/test/unit/staking/slash/legacySlash.t.sol create mode 100644 packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol delete mode 100644 packages/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol delete mode 100644 packages/subgraph-service/test/integration/during-transition-period/dispute-manager.test.ts delete mode 100644 packages/subgraph-service/test/integration/during-transition-period/governance.test.ts delete mode 100644 packages/subgraph-service/test/integration/during-transition-period/indexer.test.ts delete mode 100644 packages/subgraph-service/test/integration/during-transition-period/legacy-dispute-manager.test.ts delete mode 100644 packages/subgraph-service/test/unit/disputeManager/disputes/legacy.t.sol delete mode 100644 packages/subgraph-service/test/unit/libraries/LegacyAllocationLibrary.t.sol delete mode 100644 packages/subgraph-service/test/unit/mocks/LegacyAllocationHarness.sol delete mode 100644 packages/subgraph-service/test/unit/subgraphService/governance/legacy.t.sol diff --git a/packages/contracts-test/tests/unit/disputes/poi.test.ts b/packages/contracts-test/tests/unit/disputes/poi.test.ts index b465f5986..b391dd0d4 100644 --- a/packages/contracts-test/tests/unit/disputes/poi.test.ts +++ b/packages/contracts-test/tests/unit/disputes/poi.test.ts @@ -1,4 +1,4 @@ -import { DisputeManager } from '@graphprotocol/contracts' +import { DisputeManager, IRewardsManager } from '@graphprotocol/contracts' import { EpochManager } from '@graphprotocol/contracts' import { GraphToken } from '@graphprotocol/contracts' import { IStaking } from '@graphprotocol/contracts' @@ -30,6 +30,7 @@ describe('DisputeManager:POI', () => { let epochManager: EpochManager let grt: GraphToken let staking: IStaking + let rewardsManager: IRewardsManager // Derive some channel keys for each indexer used to sign attestations const indexerChannelKey = deriveChannelKey() @@ -92,10 +93,15 @@ describe('DisputeManager:POI', () => { epochManager = contracts.EpochManager as EpochManager grt = contracts.GraphToken as GraphToken staking = contracts.Staking as IStaking + rewardsManager = contracts.RewardsManager as IRewardsManager // Give some funds to the fisherman await grt.connect(governor).mint(fisherman.address, fishermanTokens) await grt.connect(fisherman).approve(disputeManager.address, fishermanTokens) + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts-test/tests/unit/disputes/query.test.ts b/packages/contracts-test/tests/unit/disputes/query.test.ts index 73238b4e0..e411bd028 100644 --- a/packages/contracts-test/tests/unit/disputes/query.test.ts +++ b/packages/contracts-test/tests/unit/disputes/query.test.ts @@ -1,5 +1,5 @@ import { createAttestation, Receipt } from '@graphprotocol/common-ts' -import { DisputeManager } from '@graphprotocol/contracts' +import { DisputeManager, IRewardsManager } from '@graphprotocol/contracts' import { EpochManager } from '@graphprotocol/contracts' import { GraphToken } from '@graphprotocol/contracts' import { IStaking } from '@graphprotocol/contracts' @@ -35,6 +35,7 @@ describe('DisputeManager:Query', () => { let epochManager: EpochManager let grt: GraphToken let staking: IStaking + let rewardsManager: IRewardsManager // Derive some channel keys for each indexer used to sign attestations const indexer1ChannelKey = deriveChannelKey() @@ -121,6 +122,7 @@ describe('DisputeManager:Query', () => { epochManager = contracts.EpochManager as EpochManager grt = contracts.GraphToken as GraphToken staking = contracts.Staking as IStaking + rewardsManager = contracts.RewardsManager as IRewardsManager // Give some funds to the fisherman for (const dst of [fisherman, fisherman2]) { @@ -139,6 +141,10 @@ describe('DisputeManager:Query', () => { indexerAddress: indexer.address, receipt, } + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts-test/tests/unit/l2/l2Curation.test.ts b/packages/contracts-test/tests/unit/l2/l2Curation.test.ts index 6ee8a5cd3..a680ec28c 100644 --- a/packages/contracts-test/tests/unit/l2/l2Curation.test.ts +++ b/packages/contracts-test/tests/unit/l2/l2Curation.test.ts @@ -154,7 +154,7 @@ describe('L2Curation', () => { let me: SignerWithAddress let governor: SignerWithAddress let curator: SignerWithAddress - let stakingMock: SignerWithAddress + let subgraphServiceMock: SignerWithAddress let gnsImpersonator: Signer let fixture: NetworkFixture @@ -310,8 +310,8 @@ describe('L2Curation', () => { const beforeTotalBalance = await grt.balanceOf(curation.address) // Source of tokens must be the staking for this to work - await grt.connect(stakingMock).transfer(curation.address, tokensToCollect) - const tx = curation.connect(stakingMock).collect(subgraphDeploymentID, tokensToCollect) + await grt.connect(subgraphServiceMock).transfer(curation.address, tokensToCollect) + const tx = curation.connect(subgraphServiceMock).collect(subgraphDeploymentID, tokensToCollect) await expect(tx).emit(curation, 'Collected').withArgs(subgraphDeploymentID, tokensToCollect) // After state @@ -325,7 +325,7 @@ describe('L2Curation', () => { before(async function () { // Use stakingMock so we can call collect - ;[me, curator, stakingMock] = await graph.getTestAccounts() + ;[me, curator, subgraphServiceMock] = await graph.getTestAccounts() ;({ governor } = await graph.getNamedAccounts()) fixture = new NetworkFixture(graph.provider) contracts = await fixture.load(governor, true) @@ -343,8 +343,11 @@ describe('L2Curation', () => { await grt.connect(gnsImpersonator).approve(curation.address, curatorTokens) // Give some funds to the staking contract and approve the curation contract - await grt.connect(governor).mint(stakingMock.address, tokensToCollect) - await grt.connect(stakingMock).approve(curation.address, tokensToCollect) + await grt.connect(governor).mint(subgraphServiceMock.address, tokensToCollect) + await grt.connect(subgraphServiceMock).approve(curation.address, tokensToCollect) + + // Set the subgraph service + await curation.connect(governor).setSubgraphService(subgraphServiceMock.address) }) beforeEach(async function () { @@ -514,10 +517,10 @@ describe('L2Curation', () => { context('> not curated', function () { it('reject collect tokens distributed to the curation pool', async function () { // Source of tokens must be the staking for this to work - await controller.connect(governor).setContractProxy(utils.id('Staking'), stakingMock.address) + await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking - const tx = curation.connect(stakingMock).collect(subgraphDeploymentID, tokensToCollect) + const tx = curation.connect(subgraphServiceMock).collect(subgraphDeploymentID, tokensToCollect) await expect(tx).revertedWith('Subgraph deployment must be curated to collect fees') }) }) @@ -529,11 +532,11 @@ describe('L2Curation', () => { it('reject collect tokens distributed from invalid address', async function () { const tx = curation.connect(me).collect(subgraphDeploymentID, tokensToCollect) - await expect(tx).revertedWith('Caller must be the subgraph service or staking contract') + await expect(tx).revertedWith('Caller must be the subgraph service') }) it('should collect tokens distributed to the curation pool', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), stakingMock.address) + await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking await shouldCollect(toGRT('1')) @@ -544,7 +547,7 @@ describe('L2Curation', () => { }) it('should collect tokens and then unsignal all', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), stakingMock.address) + await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking // Collect increase the pool reserves @@ -556,7 +559,7 @@ describe('L2Curation', () => { }) it('should collect tokens and then unsignal multiple times', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), stakingMock.address) + await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking // Collect increase the pool reserves diff --git a/packages/contracts-test/tests/unit/l2/l2GNS.test.ts b/packages/contracts-test/tests/unit/l2/l2GNS.test.ts index 5b8f1d028..0fd691939 100644 --- a/packages/contracts-test/tests/unit/l2/l2GNS.test.ts +++ b/packages/contracts-test/tests/unit/l2/l2GNS.test.ts @@ -2,12 +2,10 @@ import { L2GNS } from '@graphprotocol/contracts' import { L2GraphTokenGateway } from '@graphprotocol/contracts' import { L2Curation } from '@graphprotocol/contracts' import { GraphToken } from '@graphprotocol/contracts' -import { IL2Staking } from '@graphprotocol/contracts' import { L1GNS, L1GraphTokenGateway } from '@graphprotocol/contracts' import { buildSubgraph, buildSubgraphId, - deriveChannelKey, GraphNetworkContracts, helpers, PublishSubgraph, @@ -44,7 +42,6 @@ interface L1SubgraphParams { describe('L2GNS', () => { const graph = hre.graph() let me: SignerWithAddress - let attacker: SignerWithAddress let other: SignerWithAddress let governor: SignerWithAddress let fixture: NetworkFixture @@ -58,7 +55,6 @@ describe('L2GNS', () => { let gns: L2GNS let curation: L2Curation let grt: GraphToken - let staking: IL2Staking let newSubgraph0: PublishSubgraph let newSubgraph1: PublishSubgraph @@ -109,7 +105,7 @@ describe('L2GNS', () => { before(async function () { newSubgraph0 = buildSubgraph() - ;[me, attacker, other] = await graph.getTestAccounts() + ;[me, other] = await graph.getTestAccounts() ;({ governor } = await graph.getNamedAccounts()) fixture = new NetworkFixture(graph.provider) @@ -118,7 +114,6 @@ describe('L2GNS', () => { fixtureContracts = await fixture.load(governor, true) l2GraphTokenGateway = fixtureContracts.L2GraphTokenGateway as L2GraphTokenGateway gns = fixtureContracts.L2GNS as L2GNS - staking = fixtureContracts.L2Staking as unknown as IL2Staking curation = fixtureContracts.L2Curation as L2Curation grt = fixtureContracts.GraphToken as GraphToken @@ -354,61 +349,6 @@ describe('L2GNS', () => { .emit(gns, 'SignalMinted') .withArgs(l2SubgraphId, me.address, expectedNSignal, expectedSignal, curatedTokens) }) - it('protects the owner against a rounding attack', async function () { - const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - const collectTokens = curatedTokens.mul(20) - - await staking.connect(governor).setCurationPercentage(100000) - - // Set up an indexer account with some stake - await grt.connect(governor).mint(attacker.address, toGRT('1000000')) - // Curate 1 wei GRT by minting 1 GRT and burning most of it - await grt.connect(attacker).approve(curation.address, toBN(1)) - await curation.connect(attacker).mint(newSubgraph0.subgraphDeploymentID, toBN(1), 0) - - // Check this actually gave us 1 wei signal - expect(await curation.getCurationPoolTokens(newSubgraph0.subgraphDeploymentID)).eq(1) - await grt.connect(attacker).approve(staking.address, toGRT('1000000')) - await staking.connect(attacker).stake(toGRT('100000')) - const channelKey = deriveChannelKey() - // Allocate to the same deployment ID - await staking - .connect(attacker) - .allocateFrom( - attacker.address, - newSubgraph0.subgraphDeploymentID, - toGRT('100000'), - channelKey.address, - randomHexBytes(32), - await channelKey.generateProof(attacker.address), - ) - // Spoof some query fees, 10% of which will go to the Curation pool - await staking.connect(attacker).collect(collectTokens, channelKey.address) - // The curation pool now has 1 wei shares and a lot of tokens, so the rounding attack is prepared - // But L2GNS will protect the owner by sending the tokens - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - await gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatedTokens, callhookData) - - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - const tx = gns - .connect(me) - .finishSubgraphTransferFromL1( - l2SubgraphId, - newSubgraph0.subgraphDeploymentID, - subgraphMetadata, - versionMetadata, - ) - await expect(tx) - .emit(gns, 'SubgraphPublished') - .withArgs(l2SubgraphId, newSubgraph0.subgraphDeploymentID, DEFAULT_RESERVE_RATIO) - await expect(tx).emit(gns, 'SubgraphMetadataUpdated').withArgs(l2SubgraphId, subgraphMetadata) - await expect(tx).emit(gns, 'CuratorBalanceReturnedToBeneficiary') - await expect(tx).emit(gns, 'SubgraphUpgraded').withArgs(l2SubgraphId, 0, 0, newSubgraph0.subgraphDeploymentID) - await expect(tx) - .emit(gns, 'SubgraphVersionUpdated') - .withArgs(l2SubgraphId, newSubgraph0.subgraphDeploymentID, versionMetadata) - await expect(tx).emit(gns, 'SubgraphL2TransferFinalized').withArgs(l2SubgraphId) - }) it('cannot be called by someone other than the subgraph owner', async function () { const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) @@ -654,50 +594,6 @@ describe('L2GNS', () => { expect(gnsBalanceAfter).eq(gnsBalanceBefore) }) - it('protects the curator against a rounding attack', async function () { - // Transfer a subgraph from L1 with only 1 wei GRT of curated signal - const { l1SubgraphId, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - const curatedTokens = toBN('1') - await transferMockSubgraphFromL1(l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata) - // Prepare the rounding attack by setting up an indexer and collecting a lot of query fees - const curatorTokens = toGRT('10000') - const collectTokens = curatorTokens.mul(20) - await staking.connect(governor).setCurationPercentage(100000) - // Set up an indexer account with some stake - await grt.connect(governor).mint(attacker.address, toGRT('1000000')) - - await grt.connect(attacker).approve(staking.address, toGRT('1000000')) - await staking.connect(attacker).stake(toGRT('100000')) - const channelKey = deriveChannelKey() - // Allocate to the same deployment ID - await staking - .connect(attacker) - .allocateFrom( - attacker.address, - newSubgraph0.subgraphDeploymentID, - toGRT('100000'), - channelKey.address, - randomHexBytes(32), - await channelKey.generateProof(attacker.address), - ) - // Spoof some query fees, 10% of which will go to the Curation pool - await staking.connect(attacker).collect(collectTokens, channelKey.address) - - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(1), l1SubgraphId, me.address]) - const curatorTokensBefore = await grt.balanceOf(me.address) - const gnsBalanceBefore = await grt.balanceOf(gns.address) - const tx = gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatorTokens, callhookData) - await expect(tx) - .emit(gns, 'CuratorBalanceReturnedToBeneficiary') - .withArgs(l1SubgraphId, me.address, curatorTokens) - const curatorTokensAfter = await grt.balanceOf(me.address) - expect(curatorTokensAfter).eq(curatorTokensBefore.add(curatorTokens)) - const gnsBalanceAfter = await grt.balanceOf(gns.address) - // gatewayFinalizeTransfer will mint the tokens that are sent to the curator, - // so the GNS balance should be the same - expect(gnsBalanceAfter).eq(gnsBalanceBefore) - }) - it('if a subgraph was deprecated after transfer, it returns the tokens to the beneficiary', async function () { const l1GNSMockL2Alias = await helpers.getL2SignerFromL1(l1GNSMock.address) // Eth for gas: diff --git a/packages/contracts-test/tests/unit/l2/l2Staking.test.ts b/packages/contracts-test/tests/unit/l2/l2Staking.test.ts index 39dc75e7a..cf22eaba0 100644 --- a/packages/contracts-test/tests/unit/l2/l2Staking.test.ts +++ b/packages/contracts-test/tests/unit/l2/l2Staking.test.ts @@ -1,4 +1,4 @@ -import { IL2Staking } from '@graphprotocol/contracts' +import { IL2Staking, IRewardsManager } from '@graphprotocol/contracts' import { L2GraphTokenGateway } from '@graphprotocol/contracts' import { GraphToken } from '@graphprotocol/contracts' import { EpochManager, L1GNS, L1GraphTokenGateway, L1Staking } from '@graphprotocol/contracts' @@ -35,6 +35,7 @@ describe('L2Staking', () => { let l2GraphTokenGateway: L2GraphTokenGateway let staking: IL2Staking let grt: GraphToken + let rewardsManager: IRewardsManager const tokens10k = toGRT('10000') const tokens100k = toGRT('100000') @@ -88,6 +89,7 @@ describe('L2Staking', () => { l1StakingMock = l1MockContracts.L1Staking as L1Staking l1GNSMock = l1MockContracts.L1GNS as L1GNS l1GRTGatewayMock = l1MockContracts.L1GraphTokenGateway as L1GraphTokenGateway + rewardsManager = fixtureContracts.RewardsManager as IRewardsManager // Deploy L2 arbitrum bridge await fixture.loadL2ArbitrumBridge(governor) @@ -99,6 +101,10 @@ describe('L2Staking', () => { await grt.connect(me).approve(staking.address, tokens1m) await grt.connect(governor).mint(other.address, tokens1m) await grt.connect(other).approve(staking.address, tokens1m) + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts index e07717805..168166745 100644 --- a/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts +++ b/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts @@ -146,6 +146,9 @@ describe('Rewards - Calculations', () => { await grt.connect(wallet).approve(staking.address, toGRT('1000000')) await grt.connect(wallet).approve(curation.address, toGRT('1000000')) } + + // Set the staking contract as the subgraph service so it can call takeRewards + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts index d4a55c1b9..e34ace2fd 100644 --- a/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts +++ b/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts @@ -85,6 +85,9 @@ describe('Rewards - Distribution', () => { await grt.connect(wallet).approve(staking.address, toGRT('1000000')) await grt.connect(wallet).approve(curation.address, toGRT('1000000')) } + + // Set the staking contract as the subgraph service so it can call takeRewards + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts index ee60c3dd2..f26d5dded 100644 --- a/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts +++ b/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts @@ -97,6 +97,9 @@ describe('Rewards - Eligibility Oracle', () => { await grt.connect(wallet).approve(staking.address, toGRT('1000000')) await grt.connect(wallet).approve(curation.address, toGRT('1000000')) } + + // Set the staking contract as the subgraph service so it can call takeRewards + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts index ece4b213d..ff8d8cb55 100644 --- a/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts +++ b/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts @@ -109,6 +109,9 @@ describe('Rewards - Reclaim Addresses', () => { await grt.connect(wallet).approve(staking.address, toGRT('1000000')) await grt.connect(wallet).approve(curation.address, toGRT('1000000')) } + + // Set the staking contract as the subgraph service so it can call takeRewards + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { @@ -729,6 +732,39 @@ describe('Rewards - Reclaim Addresses', () => { await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') }) + it('should return 0 when reason is NONE', async function () { + // Setup allocation in real staking contract + await setupIndexerAllocation() + + // Also set allocation data in mock so RewardsManager can query it + const tokensAllocated = toGRT('12500') + await mockSubgraphService.setAllocation( + allocationID1, + true, + indexer1.address, + subgraphDeploymentID1, + tokensAllocated, + 0, + 0, + ) + await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) + + // Jump to next epoch to accrue rewards + await helpers.mineEpoch(epochManager) + + // Call reclaimRewards with NONE (HashZero) - should return 0 + const result = await mockSubgraphService.callStatic.callReclaimRewards( + rewardsManager.address, + HashZero, + allocationID1, + ) + expect(result).eq(0) + + // Verify no RewardsReclaimed event emitted + const tx = await mockSubgraphService.callReclaimRewards(rewardsManager.address, HashZero, allocationID1) + await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') + }) + it('should reject when called by unauthorized address', async function () { // Try to call reclaimRewards directly from indexer1 (not the subgraph service) const abiCoder = hre.ethers.utils.defaultAbiCoder diff --git a/packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts index accf1ea60..62097acbb 100644 --- a/packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts +++ b/packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts @@ -58,6 +58,9 @@ describe('Rewards: Signal and Allocation Update Accounting', () => { curation = contracts.Curation as Curation staking = contracts.Staking as IStaking rewardsManager = contracts.RewardsManager as RewardsManager + + // Set the staking contract as the subgraph service so it can call takeRewards + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts-test/tests/unit/rewards/rewards.test.ts b/packages/contracts-test/tests/unit/rewards/rewards.test.ts index 09e5e39a1..3d4139a34 100644 --- a/packages/contracts-test/tests/unit/rewards/rewards.test.ts +++ b/packages/contracts-test/tests/unit/rewards/rewards.test.ts @@ -159,6 +159,10 @@ describe('Rewards', () => { await grt.connect(wallet).approve(staking.address, toGRT('1000000')) await grt.connect(wallet).approve(curation.address, toGRT('1000000')) } + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts-test/tests/unit/staking/allocation.test.ts b/packages/contracts-test/tests/unit/staking/allocation.test.ts index dd28aa73d..76de77a35 100644 --- a/packages/contracts-test/tests/unit/staking/allocation.test.ts +++ b/packages/contracts-test/tests/unit/staking/allocation.test.ts @@ -379,6 +379,10 @@ describe('Staking:Allocation', () => { // Give some funds to the delegator and approve staking contract to use funds on delegator behalf await grt.connect(governor).mint(delegator.address, tokensToDelegate) await grt.connect(delegator).approve(staking.address, tokensToDelegate) + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts-test/tests/unit/staking/delegation.test.ts b/packages/contracts-test/tests/unit/staking/delegation.test.ts index 71f911006..3542e817e 100644 --- a/packages/contracts-test/tests/unit/staking/delegation.test.ts +++ b/packages/contracts-test/tests/unit/staking/delegation.test.ts @@ -1,4 +1,4 @@ -import { EpochManager } from '@graphprotocol/contracts' +import { EpochManager, IRewardsManager } from '@graphprotocol/contracts' import { GraphToken } from '@graphprotocol/contracts' import { IStaking } from '@graphprotocol/contracts' import { deriveChannelKey, GraphNetworkContracts, helpers, randomHexBytes, toBN, toGRT } from '@graphprotocol/sdk' @@ -29,6 +29,7 @@ describe('Staking::Delegation', () => { let epochManager: EpochManager let grt: GraphToken let staking: IStaking + let rewardsManager: IRewardsManager // Test values const poi = randomHexBytes() @@ -159,6 +160,7 @@ describe('Staking::Delegation', () => { epochManager = contracts.EpochManager as EpochManager grt = contracts.GraphToken as GraphToken staking = contracts.Staking as IStaking + rewardsManager = contracts.RewardsManager as IRewardsManager // Distribute test funds for (const wallet of [delegator, delegator2]) { @@ -173,6 +175,10 @@ describe('Staking::Delegation', () => { } await grt.connect(governor).mint(assetHolder.address, tokensToCollect) await grt.connect(assetHolder).approve(staking.address, tokensToCollect) + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts/contracts/l2/curation/L2Curation.sol b/packages/contracts/contracts/l2/curation/L2Curation.sol index 56e83c13a..fd26bd2ac 100644 --- a/packages/contracts/contracts/l2/curation/L2Curation.sol +++ b/packages/contracts/contracts/l2/curation/L2Curation.sol @@ -171,11 +171,8 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation { * @param _tokens Amount of Graph Tokens to add to reserves */ function collect(bytes32 _subgraphDeploymentID, uint256 _tokens) external override { - // Only SubgraphService and Staking contract are authorized as callers - require( - msg.sender == subgraphService || msg.sender == address(staking()), - "Caller must be the subgraph service or staking contract" - ); + // Only SubgraphService is authorized as caller + require(msg.sender == subgraphService, "Caller must be the subgraph service"); // Must be curated to accept tokens require(isCurated(_subgraphDeploymentID), "Subgraph deployment must be curated to collect fees"); diff --git a/packages/contracts/contracts/rewards/RewardsManager.sol b/packages/contracts/contracts/rewards/RewardsManager.sol index ffae7877b..7323bede0 100644 --- a/packages/contracts/contracts/rewards/RewardsManager.sol +++ b/packages/contracts/contracts/rewards/RewardsManager.sol @@ -22,12 +22,22 @@ import { RewardsCondition } from "@graphprotocol/interfaces/contracts/contracts/ /** * @title Rewards Manager Contract * @author Edge & Node - * @notice Manages indexing rewards distribution using a two-level accumulation model: - * signal → subgraph → allocation. See docs/RewardAccountingSafety.md for details. + * @notice Manages rewards distribution for indexers and delegators in the Graph Protocol + * @dev Tracks how inflationary GRT rewards should be handed out. Relies on the Curation contract + * and the Staking contract. Signaled GRT in Curation determine what percentage of the tokens go + * towards each subgraph. Then each Subgraph can have multiple Indexers Staked on it. Thus, the + * total rewards for the Subgraph are split up for each Indexer based on much they have Staked on + * that Subgraph. * - * @dev Issuance source: `issuanceAllocator` if set, otherwise `issuancePerBlock` storage. - * Getter functions (getAccRewardsPerSignal, getRewards, etc.) may overestimate until - * takeRewards is called due to pending state updates. + * Note: + * The contract provides getter functions to query the state of accrued rewards: + * - getAccRewardsPerSignal + * - getAccRewardsForSubgraph + * - getAccRewardsPerAllocatedToken + * - getRewards + * These functions may overestimate the actual rewards due to changes in the total supply + * until the actual takeRewards function is called. + * custom:security-contact Please email security+contracts@ thegraph.com (remove space) if you find any bugs. We might have an active bug bounty program. */ contract RewardsManager is GraphUpgradeable, @@ -446,19 +456,13 @@ contract RewardsManager is /** * @notice Get total allocated tokens for a subgraph across all issuers * @param _subgraphDeploymentID Subgraph deployment - * @return Total tokens allocated to this subgraph - */ - function _getSubgraphAllocatedTokens(bytes32 _subgraphDeploymentID) private view returns (uint256) { - uint256 subgraphAllocatedTokens = 0; - address[2] memory rewardsIssuers = [address(staking()), address(subgraphService)]; - for (uint256 i = 0; i < rewardsIssuers.length; ++i) { - if (rewardsIssuers[i] != address(0)) { - subgraphAllocatedTokens += IRewardsIssuer(rewardsIssuers[i]).getSubgraphAllocatedTokens( - _subgraphDeploymentID - ); - } - } - return subgraphAllocatedTokens; + * @return subgraphAllocatedTokens Total tokens allocated to this subgraph + */ + function _getSubgraphAllocatedTokens( + bytes32 _subgraphDeploymentID + ) private view returns (uint256 subgraphAllocatedTokens) { + if (address(subgraphService) != address(0)) + subgraphAllocatedTokens += subgraphService.getSubgraphAllocatedTokens(_subgraphDeploymentID); } // -- Updates -- @@ -578,7 +582,7 @@ contract RewardsManager is /** * @inheritdoc IRewardsManager - * @dev Hook called from the Staking contract on allocate() and close() + * @dev Hook called from the IRewardsIssuer contract on allocate() and close() * * ## Claimability Behavior * @@ -626,10 +630,7 @@ contract RewardsManager is * takeRewards(). */ function getRewards(address _rewardsIssuer, address _allocationID) external view override returns (uint256) { - require( - _rewardsIssuer == address(staking()) || _rewardsIssuer == address(subgraphService), - "Not a rewards issuer" - ); + require(_rewardsIssuer == address(subgraphService), "Not a rewards issuer"); ( bool isActive, @@ -783,7 +784,7 @@ contract RewardsManager is /** * @inheritdoc IRewardsManager * @dev This function can only be called by an authorized rewards issuer which are - * the staking contract (for legacy allocations), and the subgraph service (for new allocations). + * - the subgraph service (for allocations). * Mints 0 tokens if the allocation is not active. * @dev First successful reclaim wins - short-circuits on reclaim: * - If subgraph denied with reclaim address → reclaim to SUBGRAPH_DENIED address (eligibility NOT checked) @@ -793,10 +794,7 @@ contract RewardsManager is */ function takeRewards(address _allocationID) external override returns (uint256) { address rewardsIssuer = msg.sender; - require( - rewardsIssuer == address(staking()) || rewardsIssuer == address(subgraphService), - "Caller must be a rewards issuer" - ); + require(rewardsIssuer == address(subgraphService), "Caller must be a rewards issuer"); (uint256 rewards, address indexer, bytes32 subgraphDeploymentID) = _calcAllocationRewards( rewardsIssuer, diff --git a/packages/horizon/contracts/data-service/libraries/StakeClaims.sol b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol index 00ad95348..7590b709c 100644 --- a/packages/horizon/contracts/data-service/libraries/StakeClaims.sol +++ b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol @@ -216,6 +216,7 @@ library StakeClaims { address _serviceProvider, uint256 _nonce ) internal pure returns (bytes32) { + // forge-lint: disable-next-line(asm-keccak256) return keccak256(abi.encodePacked(_dataService, _serviceProvider, _nonce)); } } diff --git a/packages/horizon/contracts/libraries/LibFixedMath.sol b/packages/horizon/contracts/libraries/LibFixedMath.sol deleted file mode 100644 index 4b31d1ef3..000000000 --- a/packages/horizon/contracts/libraries/LibFixedMath.sol +++ /dev/null @@ -1,299 +0,0 @@ -/* - - Copyright 2017 Bprotocol Foundation, 2019 ZeroEx Intl. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -*/ - -// SPDX-License-Identifier: Apache-2.0 - -pragma solidity ^0.8.27; - -// TODO: Re-enable and fix issues when publishing a new version -// solhint-disable function-max-lines, gas-strict-inequalities -// forge-lint: disable-start(unsafe-typecast) - -/** - * @title LibFixedMath - * @author Edge & Node - * @notice This library provides fixed-point arithmetic operations. - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -library LibFixedMath { - // 1 - int256 private constant FIXED_1 = int256(0x0000000000000000000000000000000080000000000000000000000000000000); - // 2**255 - int256 private constant MIN_FIXED_VAL = type(int256).min; - // 0 - int256 private constant EXP_MAX_VAL = 0; - // -63.875 - int256 private constant EXP_MIN_VAL = -int256(0x0000000000000000000000000000001ff0000000000000000000000000000000); - - /** - * @notice Get one as a fixed-point number - * @return f The fixed-point representation of one - */ - function one() internal pure returns (int256 f) { - f = FIXED_1; - } - - /** - * @notice Returns the subtraction of two fixed point numbers, reverting on overflow - * @param a The first fixed point number - * @param b The second fixed point number to subtract - * @return c The result of a - b - */ - function sub(int256 a, int256 b) internal pure returns (int256 c) { - if (b == MIN_FIXED_VAL) { - revert("out-of-bounds"); - } - c = _add(a, -b); - } - - /** - * @notice Returns the multiplication of two fixed point numbers, reverting on overflow - * @param a The first fixed point number - * @param b The second fixed point number - * @return c The result of a * b - */ - function mul(int256 a, int256 b) internal pure returns (int256 c) { - c = _mul(a, b) / FIXED_1; - } - - /** - * @notice Performs (a * n) / d, without scaling for precision - * @param a The first fixed point number - * @param n The numerator - * @param d The denominator - * @return c The result of (a * n) / d - */ - function mulDiv(int256 a, int256 n, int256 d) internal pure returns (int256 c) { - c = _div(_mul(a, n), d); - } - - /** - * @notice Returns the unsigned integer result of multiplying a fixed-point number with an integer - * @dev Negative results are clamped to zero. Reverts if the multiplication overflows. - * @param f Fixed-point number - * @param u Unsigned integer - * @return Unsigned integer result, clamped to zero if negative - */ - function uintMul(int256 f, uint256 u) internal pure returns (uint256) { - if (int256(u) < int256(0)) { - revert("out-of-bounds"); - } - int256 c = _mul(f, int256(u)); - if (c <= 0) { - return 0; - } - return uint256(uint256(c) >> 127); - } - - /** - * @notice Convert signed `n` / `d` to a fixed-point number - * @param n Numerator - * @param d Denominator - * @return f Fixed-point representation of n/d - */ - function toFixed(int256 n, int256 d) internal pure returns (int256 f) { - f = _div(_mul(n, FIXED_1), d); - } - - /** - * @notice Convert a fixed-point number to an integer - * @param f Fixed-point number - * @return n Integer representation - */ - function toInteger(int256 f) internal pure returns (int256 n) { - return f / FIXED_1; - } - - /** - * @notice Compute the natural exponent for a fixed-point number EXP_MIN_VAL <= `x` <= 1 - * @param x Fixed-point number to compute exponent for - * @return r The natural exponent of x - */ - function exp(int256 x) internal pure returns (int256 r) { - if (x < EXP_MIN_VAL) { - // Saturate to zero below EXP_MIN_VAL. - return 0; - } - if (x == 0) { - return FIXED_1; - } - if (x > EXP_MAX_VAL) { - revert("out-of-bounds"); - } - - // Rewrite the input as a product of natural exponents and a - // single residual q, where q is a number of small magnitude. - // For example: e^-34.419 = e^(-32 - 2 - 0.25 - 0.125 - 0.044) - // = e^-32 * e^-2 * e^-0.25 * e^-0.125 * e^-0.044 - // -> q = -0.044 - - // Multiply with the taylor series for e^q - int256 y; - int256 z; - // q = x % 0.125 (the residual) - z = y = x % 0x0000000000000000000000000000000010000000000000000000000000000000; - z = (z * y) / FIXED_1; - r += z * 0x10e1b3be415a0000; // add y^02 * (20! / 02!) - z = (z * y) / FIXED_1; - r += z * 0x05a0913f6b1e0000; // add y^03 * (20! / 03!) - z = (z * y) / FIXED_1; - r += z * 0x0168244fdac78000; // add y^04 * (20! / 04!) - z = (z * y) / FIXED_1; - r += z * 0x004807432bc18000; // add y^05 * (20! / 05!) - z = (z * y) / FIXED_1; - r += z * 0x000c0135dca04000; // add y^06 * (20! / 06!) - z = (z * y) / FIXED_1; - r += z * 0x0001b707b1cdc000; // add y^07 * (20! / 07!) - z = (z * y) / FIXED_1; - r += z * 0x000036e0f639b800; // add y^08 * (20! / 08!) - z = (z * y) / FIXED_1; - r += z * 0x00000618fee9f800; // add y^09 * (20! / 09!) - z = (z * y) / FIXED_1; - r += z * 0x0000009c197dcc00; // add y^10 * (20! / 10!) - z = (z * y) / FIXED_1; - r += z * 0x0000000e30dce400; // add y^11 * (20! / 11!) - z = (z * y) / FIXED_1; - r += z * 0x000000012ebd1300; // add y^12 * (20! / 12!) - z = (z * y) / FIXED_1; - r += z * 0x0000000017499f00; // add y^13 * (20! / 13!) - z = (z * y) / FIXED_1; - r += z * 0x0000000001a9d480; // add y^14 * (20! / 14!) - z = (z * y) / FIXED_1; - r += z * 0x00000000001c6380; // add y^15 * (20! / 15!) - z = (z * y) / FIXED_1; - r += z * 0x000000000001c638; // add y^16 * (20! / 16!) - z = (z * y) / FIXED_1; - r += z * 0x0000000000001ab8; // add y^17 * (20! / 17!) - z = (z * y) / FIXED_1; - r += z * 0x000000000000017c; // add y^18 * (20! / 18!) - z = (z * y) / FIXED_1; - r += z * 0x0000000000000014; // add y^19 * (20! / 19!) - z = (z * y) / FIXED_1; - r += z * 0x0000000000000001; // add y^20 * (20! / 20!) - r = r / 0x21c3677c82b40000 + y + FIXED_1; // divide by 20! and then add y^1 / 1! + y^0 / 0! - - // Multiply with the non-residual terms. - x = -x; - // e ^ -32 - if ((x & int256(0x0000000000000000000000000000001000000000000000000000000000000000)) != 0) { - r = - (r * int256(0x00000000000000000000000000000000000000f1aaddd7742e56d32fb9f99744)) / - int256(0x0000000000000000000000000043cbaf42a000812488fc5c220ad7b97bf6e99e); // * e ^ -32 - } - // e ^ -16 - if ((x & int256(0x0000000000000000000000000000000800000000000000000000000000000000)) != 0) { - r = - (r * int256(0x00000000000000000000000000000000000afe10820813d65dfe6a33c07f738f)) / - int256(0x000000000000000000000000000005d27a9f51c31b7c2f8038212a0574779991); // * e ^ -16 - } - // e ^ -8 - if ((x & int256(0x0000000000000000000000000000000400000000000000000000000000000000)) != 0) { - r = - (r * int256(0x0000000000000000000000000000000002582ab704279e8efd15e0265855c47a)) / - int256(0x0000000000000000000000000000001b4c902e273a58678d6d3bfdb93db96d02); // * e ^ -8 - } - // e ^ -4 - if ((x & int256(0x0000000000000000000000000000000200000000000000000000000000000000)) != 0) { - r = - (r * int256(0x000000000000000000000000000000001152aaa3bf81cb9fdb76eae12d029571)) / - int256(0x00000000000000000000000000000003b1cc971a9bb5b9867477440d6d157750); // * e ^ -4 - } - // e ^ -2 - if ((x & int256(0x0000000000000000000000000000000100000000000000000000000000000000)) != 0) { - r = - (r * int256(0x000000000000000000000000000000002f16ac6c59de6f8d5d6f63c1482a7c86)) / - int256(0x000000000000000000000000000000015bf0a8b1457695355fb8ac404e7a79e3); // * e ^ -2 - } - // e ^ -1 - if ((x & int256(0x0000000000000000000000000000000080000000000000000000000000000000)) != 0) { - r = - (r * int256(0x000000000000000000000000000000004da2cbf1be5827f9eb3ad1aa9866ebb3)) / - int256(0x00000000000000000000000000000000d3094c70f034de4b96ff7d5b6f99fcd8); // * e ^ -1 - } - // e ^ -0.5 - if ((x & int256(0x0000000000000000000000000000000040000000000000000000000000000000)) != 0) { - r = - (r * int256(0x0000000000000000000000000000000063afbe7ab2082ba1a0ae5e4eb1b479dc)) / - int256(0x00000000000000000000000000000000a45af1e1f40c333b3de1db4dd55f29a7); // * e ^ -0.5 - } - // e ^ -0.25 - if ((x & int256(0x0000000000000000000000000000000020000000000000000000000000000000)) != 0) { - r = - (r * int256(0x0000000000000000000000000000000070f5a893b608861e1f58934f97aea57d)) / - int256(0x00000000000000000000000000000000910b022db7ae67ce76b441c27035c6a1); // * e ^ -0.25 - } - // e ^ -0.125 - if ((x & int256(0x0000000000000000000000000000000010000000000000000000000000000000)) != 0) { - r = - (r * int256(0x00000000000000000000000000000000783eafef1c0a8f3978c7f81824d62ebf)) / - int256(0x0000000000000000000000000000000088415abbe9a76bead8d00cf112e4d4a8); // * e ^ -0.125 - } - } - - /** - * @notice Returns the multiplication of two numbers, reverting on overflow - * @param a First number - * @param b Second number - * @return c The result of a * b - */ - function _mul(int256 a, int256 b) private pure returns (int256 c) { - if (a == 0 || b == 0) { - return 0; - } - unchecked { - c = a * b; - if (c / a != b || c / b != a) { - revert("overflow"); - } - } - } - - /** - * @notice Returns the division of two numbers, reverting on division by zero - * @param a Dividend - * @param b Divisor - * @return c The result of a / b - */ - function _div(int256 a, int256 b) private pure returns (int256 c) { - if (b == 0) { - revert("overflow"); - } - if (a == MIN_FIXED_VAL && b == -1) { - revert("overflow"); - } - unchecked { - c = a / b; - } - } - - /** - * @notice Adds two numbers, reverting on overflow - * @param a First number - * @param b Second number - * @return c The result of a + b - */ - function _add(int256 a, int256 b) private pure returns (int256 c) { - unchecked { - c = a + b; - if ((a < 0 && b < 0 && c > a) || (a > 0 && b > 0 && c < a)) { - revert("overflow"); - } - } - } -} diff --git a/packages/horizon/contracts/libraries/MathUtils.sol b/packages/horizon/contracts/libraries/MathUtils.sol deleted file mode 100644 index a1822df61..000000000 --- a/packages/horizon/contracts/libraries/MathUtils.sol +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later - -// TODO: Re-enable and fix issues when publishing a new version -// solhint-disable gas-strict-inequalities - -pragma solidity ^0.8.27; - -/** - * @title MathUtils Library - * @author Edge & Node - * @notice A collection of functions to perform math operations - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -library MathUtils { - /** - * @notice Calculates the weighted average of two values pondering each of these - * values based on configured weights - * @dev The contribution of each value N is - * weightN/(weightA + weightB). The calculation rounds up to ensure the result - * is always equal or greater than the smallest of the two values. - * @param valueA The amount for value A - * @param weightA The weight to use for value A - * @param valueB The amount for value B - * @param weightB The weight to use for value B - * @return The weighted average result - */ - function weightedAverageRoundingUp( - uint256 valueA, - uint256 weightA, - uint256 valueB, - uint256 weightB - ) internal pure returns (uint256) { - return ((valueA * weightA) + (valueB * weightB) + (weightA + weightB - 1)) / (weightA + weightB); - } - - /** - * @notice Returns the minimum of two numbers - * @param x The first number - * @param y The second number - * @return The minimum of the two numbers - */ - function min(uint256 x, uint256 y) internal pure returns (uint256) { - return x <= y ? x : y; - } - - /** - * @notice Returns the difference between two numbers or zero if negative - * @param x The first number - * @param y The second number - * @return The difference between the two numbers or zero if negative - */ - function diffOrZero(uint256 x, uint256 y) internal pure returns (uint256) { - return (x > y) ? x - y : 0; - } -} diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index b0b10b642..5a4b7876d 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -452,6 +452,8 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC _collectionSeconds >= _agreement.minSecondsPerCollection, RecurringCollectorCollectionTooSoon( _agreementId, + // casting to uint32 is safe because _collectionSeconds < minSecondsPerCollection (uint32) + // forge-lint: disable-next-line(unsafe-typecast) uint32(_collectionSeconds), _agreement.minSecondsPerCollection ) diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index f77761483..588e06ecd 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -9,12 +9,11 @@ pragma solidity ^0.8.27; import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; -import { MathUtils } from "../libraries/MathUtils.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { PPMMath } from "../libraries/PPMMath.sol"; import { LinkedList } from "../libraries/LinkedList.sol"; @@ -28,9 +27,6 @@ import { HorizonStakingBase } from "./HorizonStakingBase.sol"; * @dev Implements the {IHorizonStakingMain} interface. * @dev This is the main Staking contract in The Graph protocol after the Horizon upgrade. * It is designed to be deployed as an upgrade to the L2Staking contract from the legacy contracts package. - * @dev It uses a {HorizonStakingExtension} contract to implement the full {IHorizonStaking} interface through delegatecalls. - * This is due to the contract size limit on Arbitrum (24kB). The extension contract implements functionality to support - * the legacy staking functions. It can be eventually removed without affecting the main staking contract. * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ @@ -42,9 +38,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { /// @dev Maximum number of simultaneous stake thaw requests (per provision) or undelegations (per delegation) uint256 private constant MAX_THAW_REQUESTS = 1_000; - /// @dev Address of the staking extension contract - address private immutable STAKING_EXTENSION_ADDRESS; - /// @dev Minimum amount of delegation. uint256 private constant MIN_DELEGATION = 1e18; @@ -79,50 +72,12 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { /** * @notice The staking contract is upgradeable however we still use the constructor to set a few immutable variables * @param controller The address of the Graph controller contract - * @param stakingExtensionAddress The address of the staking extension contract * @param subgraphDataServiceAddress The address of the subgraph data service */ constructor( address controller, - address stakingExtensionAddress, address subgraphDataServiceAddress - ) HorizonStakingBase(controller, subgraphDataServiceAddress) { - STAKING_EXTENSION_ADDRESS = stakingExtensionAddress; - } - - /** - * @notice Delegates the current call to the StakingExtension implementation. - * @dev This function does not return to its internal call site, it will return directly to the - * external caller. - */ - fallback() external { - // solhint-disable-previous-line payable-fallback, no-complex-fallback - address extensionImpl = STAKING_EXTENSION_ADDRESS; - // solhint-disable-next-line no-inline-assembly - assembly { - // (a) get free memory pointer - let ptr := mload(0x40) - - // (1) copy incoming call data - calldatacopy(ptr, 0, calldatasize()) - - // (2) forward call to logic contract - let result := delegatecall(gas(), extensionImpl, ptr, calldatasize(), 0, 0) - let size := returndatasize() - - // (3) retrieve return data - returndatacopy(ptr, 0, size) - - // (4) forward return data back to caller - switch result - case 0 { - revert(ptr, size) - } - default { - return(ptr, size) - } - } - } + ) HorizonStakingBase(controller, subgraphDataServiceAddress) {} /* * STAKING @@ -158,6 +113,11 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { _withdraw(msg.sender); } + /// @inheritdoc IHorizonStakingMain + function forceWithdraw(address serviceProvider) external override notPaused { + _withdraw(serviceProvider); + } + /* * PROVISIONS */ @@ -258,6 +218,11 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { require(prov.createdAt != 0, HorizonStakingInvalidProvision(serviceProvider, verifier)); if ((prov.maxVerifierCutPending != prov.maxVerifierCut) || (prov.thawingPeriodPending != prov.thawingPeriod)) { + // Re-validate thawing period in case governor reduced _maxThawingPeriod after staging + require( + prov.thawingPeriodPending <= _maxThawingPeriod, + HorizonStakingInvalidThawingPeriod(prov.thawingPeriodPending, _maxThawingPeriod) + ); prov.maxVerifierCut = prov.maxVerifierCutPending; prov.thawingPeriod = prov.thawingPeriodPending; emit ProvisionParametersSet(serviceProvider, verifier, prov.maxVerifierCut, prov.thawingPeriod); @@ -369,33 +334,15 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { address serviceProvider, address // deprecated - kept for backwards compatibility ) external override notPaused returns (uint256) { - // Get the delegation pool of the indexer - address delegator = msg.sender; - DelegationPoolInternal storage pool = _legacyDelegationPools[serviceProvider]; - DelegationInternal storage delegation = pool.delegators[delegator]; - - // Validation - uint256 tokensToWithdraw = 0; - uint256 currentEpoch = _graphEpochManager().currentEpoch(); - if ( - delegation.__DEPRECATED_tokensLockedUntil > 0 && currentEpoch >= delegation.__DEPRECATED_tokensLockedUntil - ) { - tokensToWithdraw = delegation.__DEPRECATED_tokensLocked; - } - require(tokensToWithdraw > 0, HorizonStakingNothingToWithdraw()); - - // Reset lock - delegation.__DEPRECATED_tokensLocked = 0; - delegation.__DEPRECATED_tokensLockedUntil = 0; - - emit StakeDelegatedWithdrawn(serviceProvider, delegator, tokensToWithdraw); - - // -- Interactions -- - - // Return tokens to the delegator - _graphToken().pushTokens(delegator, tokensToWithdraw); + return _withdrawDelegatedLegacy(serviceProvider, msg.sender); + } - return tokensToWithdraw; + /// @inheritdoc IHorizonStakingMain + function forceWithdrawDelegated( + address serviceProvider, + address delegator + ) external override notPaused returns (uint256) { + return _withdrawDelegatedLegacy(serviceProvider, delegator); } /* @@ -409,33 +356,18 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { uint256 tokensVerifier, address verifierDestination ) external override notPaused { - // TRANSITION PERIOD: remove after the transition period - // Check if sender is authorized to slash on the deprecated list - if (__DEPRECATED_slashers[msg.sender]) { - // Forward call to staking extension - // solhint-disable-next-line avoid-low-level-calls - (bool success, ) = STAKING_EXTENSION_ADDRESS.delegatecall( - abi.encodeCall( - IHorizonStakingExtension.legacySlash, - (serviceProvider, tokens, tokensVerifier, verifierDestination) - ) - ); - require(success, HorizonStakingLegacySlashFailed()); - return; - } - address verifier = msg.sender; Provision storage prov = _provisions[serviceProvider][verifier]; DelegationPoolInternal storage pool = _getDelegationPool(serviceProvider, verifier); uint256 tokensProvisionTotal = prov.tokens + pool.tokens; require(tokensProvisionTotal != 0, HorizonStakingNoTokensToSlash()); - uint256 tokensToSlash = MathUtils.min(tokens, tokensProvisionTotal); + uint256 tokensToSlash = Math.min(tokens, tokensProvisionTotal); // Slash service provider first // - A portion goes to verifier as reward // - A portion gets burned - uint256 providerTokensSlashed = MathUtils.min(prov.tokens, tokensToSlash); + uint256 providerTokensSlashed = Math.min(prov.tokens, tokensToSlash); if (providerTokensSlashed > 0) { // Pay verifier reward - must be within the maxVerifierCut percentage uint256 maxVerifierTokens = providerTokensSlashed.mulPPM(prov.maxVerifierCut); @@ -540,12 +472,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { emit DelegationSlashingEnabled(); } - /// @inheritdoc IHorizonStakingMain - function clearThawingPeriod() external override onlyGovernor { - __DEPRECATED_thawingPeriod = 0; - emit ThawingPeriodCleared(); - } - /// @inheritdoc IHorizonStakingMain function setMaxThawingPeriod(uint64 maxThawingPeriod) external override onlyGovernor { _maxThawingPeriod = maxThawingPeriod; @@ -571,17 +497,19 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { } /* - * GETTERS + * PRIVATE FUNCTIONS */ - /// @inheritdoc IHorizonStakingMain - function getStakingExtension() external view override returns (address) { - return STAKING_EXTENSION_ADDRESS; - } - - /* - * PRIVATE FUNCTIONS + /** + * @notice Deposit tokens into the service provider stake. + * Emits a {HorizonStakeDeposited} event. + * @param _serviceProvider The address of the service provider. + * @param _tokens The amount of tokens to deposit. */ + function _stake(address _serviceProvider, uint256 _tokens) internal { + _serviceProviders[_serviceProvider].tokensStaked = _serviceProviders[_serviceProvider].tokensStaked + _tokens; + emit HorizonStakeDeposited(_serviceProvider, _tokens); + } /** * @notice Deposit tokens on the service provider stake, on behalf of the service provider. @@ -601,12 +529,7 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { /** * @notice Move idle stake back to the owner's account. - * Stake is removed from the protocol: - * - During the transition period it's locked for a period of time before it can be withdrawn - * by calling {withdraw}. - * - After the transition period it's immediately withdrawn. - * Note that after the transition period if there are tokens still locked they will have to be - * withdrawn by calling {withdraw}. + * Stake is immediately removed from the protocol. * @param _tokens Amount of tokens to unstake */ function _unstake(uint256 _tokens) private { @@ -616,45 +539,19 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { require(_tokens <= tokensIdle, HorizonStakingInsufficientIdleStake(_tokens, tokensIdle)); ServiceProviderInternal storage sp = _serviceProviders[serviceProvider]; - uint256 stakedTokens = sp.tokensStaked; - - // This is also only during the transition period: we need - // to ensure tokens stay locked after closing legacy allocations. - // After sufficient time (56 days?) we should remove the closeAllocation function - // and set the thawing period to 0. - uint256 lockingPeriod = __DEPRECATED_thawingPeriod; - if (lockingPeriod == 0) { - sp.tokensStaked = stakedTokens - _tokens; - _graphToken().pushTokens(serviceProvider, _tokens); - emit HorizonStakeWithdrawn(serviceProvider, _tokens); - } else { - // Before locking more tokens, withdraw any unlocked ones if possible - if (sp.__DEPRECATED_tokensLocked != 0 && block.number >= sp.__DEPRECATED_tokensLockedUntil) { - _withdraw(serviceProvider); - } - // TRANSITION PERIOD: remove after the transition period - // Take into account period averaging for multiple unstake requests - if (sp.__DEPRECATED_tokensLocked > 0) { - lockingPeriod = MathUtils.weightedAverageRoundingUp( - MathUtils.diffOrZero(sp.__DEPRECATED_tokensLockedUntil, block.number), // Remaining thawing period - sp.__DEPRECATED_tokensLocked, // Weighted by remaining unstaked tokens - lockingPeriod, // Thawing period - _tokens // Weighted by new tokens to unstake - ); - } + sp.tokensStaked -= _tokens; - // Update balances - sp.__DEPRECATED_tokensLocked = sp.__DEPRECATED_tokensLocked + _tokens; - sp.__DEPRECATED_tokensLockedUntil = block.number + lockingPeriod; - emit HorizonStakeLocked(serviceProvider, sp.__DEPRECATED_tokensLocked, sp.__DEPRECATED_tokensLockedUntil); - } + _graphToken().pushTokens(serviceProvider, _tokens); + emit HorizonStakeWithdrawn(serviceProvider, _tokens); } /** * @notice Withdraw service provider tokens once the thawing period (initiated by {unstake}) has passed. * All thawed tokens are withdrawn. - * @dev TRANSITION PERIOD: This is only needed during the transition period while we still have - * a global lock. After that, unstake() will automatically withdraw. + * This function is for backwards compatibility with the legacy staking contract. + * It only allows withdrawing tokens unstaked before horizon upgrade. + * @dev This function can't be removed in case there are still pre-horizon unstakes. + * Note that it's assumed unstakes have already passed their thawing period. * @param _serviceProvider Address of service provider to withdraw funds from */ function _withdraw(address _serviceProvider) private { @@ -662,10 +559,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { ServiceProviderInternal storage sp = _serviceProviders[_serviceProvider]; uint256 tokensToWithdraw = sp.__DEPRECATED_tokensLocked; require(tokensToWithdraw != 0, HorizonStakingInvalidZeroTokens()); - require( - block.number >= sp.__DEPRECATED_tokensLockedUntil, - HorizonStakingStillThawing(sp.__DEPRECATED_tokensLockedUntil) - ); // Reset locked tokens sp.__DEPRECATED_tokensLocked = 0; @@ -685,8 +578,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { * service, where the data service is the verifier. * This function can be called by the service provider or by an operator authorized by the provider * for this specific verifier. - * @dev TRANSITION PERIOD: During the transition period, only the subgraph data service can be used as a verifier. This - * prevents an escape hatch for legacy allocation stake. * @param _serviceProvider The service provider address * @param _tokens The amount of tokens that will be locked and slashable * @param _verifier The verifier address for which the tokens are provisioned (who will be able to slash the tokens) @@ -701,11 +592,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { uint64 _thawingPeriod ) private { require(_tokens > 0, HorizonStakingInvalidZeroTokens()); - // TRANSITION PERIOD: Remove this after the transition period - it prevents an early escape hatch for legacy allocations - require( - _verifier == SUBGRAPH_DATA_SERVICE_ADDRESS || __DEPRECATED_thawingPeriod == 0, - HorizonStakingInvalidVerifier(_verifier) - ); require(PPMMath.isValidPPM(_maxVerifierCut), HorizonStakingInvalidMaxVerifierCut(_maxVerifierCut)); require( _thawingPeriod <= _maxThawingPeriod, @@ -958,8 +844,8 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { * @dev The parameter `nThawRequests` can be set to a non zero value to fulfill a specific number of thaw * requests in the event that fulfilling all of them results in a gas limit error. Otherwise, the function * will attempt to fulfill all thaw requests until the first one that is not yet expired is found. - * @dev If the delegation pool was completely slashed before withdrawing, calling this function will fulfill - * the thaw requests with an amount equal to zero. + * @dev If the delegation pool was completely slashed before withdrawing, calling this function will revert + * until the pool state is repaired with {IHorizonStakingMain-addToDelegationPool}. * @param _serviceProvider The service provider address * @param _verifier The verifier address * @param _newServiceProvider The new service provider address @@ -1231,6 +1117,39 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { emit OperatorSet(msg.sender, _verifier, _operator, _allowed); } + /** + * @notice Withdraw legacy undelegated tokens for a delegator. + * @dev This function handles pre-Horizon undelegations where tokens are locked + * in the legacy delegation pool. + * @param _serviceProvider The service provider address + * @param _delegator The delegator address + * @return The amount of tokens withdrawn + */ + function _withdrawDelegatedLegacy(address _serviceProvider, address _delegator) private returns (uint256) { + DelegationPoolInternal storage pool = _legacyDelegationPools[_serviceProvider]; + DelegationInternal storage delegation = pool.delegators[_delegator]; + + // Validation + uint256 tokensToWithdraw = 0; + if (delegation.__DEPRECATED_tokensLockedUntil > 0) { + tokensToWithdraw = delegation.__DEPRECATED_tokensLocked; + } + require(tokensToWithdraw > 0, HorizonStakingNothingToWithdraw()); + + // Reset lock + delegation.__DEPRECATED_tokensLocked = 0; + delegation.__DEPRECATED_tokensLockedUntil = 0; + + emit StakeDelegatedWithdrawn(_serviceProvider, _delegator, tokensToWithdraw); + + // -- Interactions -- + + // Return tokens to the delegator + _graphToken().pushTokens(_delegator, tokensToWithdraw); + + return tokensToWithdraw; + } + /** * @notice Check if an operator is authorized for the caller on a specific verifier / data service. * @dev Note that this function handles the special case where the verifier is the subgraph data service, diff --git a/packages/horizon/contracts/staking/HorizonStakingBase.sol b/packages/horizon/contracts/staking/HorizonStakingBase.sol index f8ae1fa18..199e894d3 100644 --- a/packages/horizon/contracts/staking/HorizonStakingBase.sol +++ b/packages/horizon/contracts/staking/HorizonStakingBase.sol @@ -10,7 +10,7 @@ import { IHorizonStakingBase } from "@graphprotocol/interfaces/contracts/horizon import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; -import { MathUtils } from "../libraries/MathUtils.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { LinkedList } from "../libraries/LinkedList.sol"; import { Multicall } from "@openzeppelin/contracts/utils/Multicall.sol"; @@ -23,9 +23,7 @@ import { HorizonStakingV1Storage } from "./HorizonStakingStorage.sol"; * @author Edge & Node * @notice This contract is the base staking contract implementing storage getters for both internal * and external use. - * @dev Implementation of the {IHorizonStakingBase} interface. - * @dev It's meant to be inherited by the {HorizonStaking} and {HorizonStakingExtension} - * contracts so some internal functions are also included here. + * @dev Implementation of the {IHorizonStakingBase} interface, meant to be inherited by {HorizonStaking}. * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ @@ -54,6 +52,11 @@ abstract contract HorizonStakingBase is SUBGRAPH_DATA_SERVICE_ADDRESS = subgraphDataServiceAddress; } + /// @inheritdoc IHorizonStakingBase + function getSubgraphService() external view override returns (address) { + return SUBGRAPH_DATA_SERVICE_ADDRESS; + } + /// @inheritdoc IHorizonStakingBase /// @dev Removes deprecated fields from the return value. function getServiceProvider(address serviceProvider) external view override returns (ServiceProvider memory) { @@ -127,7 +130,7 @@ abstract contract HorizonStakingBase is uint256 tokensAvailableDelegated = _getDelegatedTokensAvailable(serviceProvider, verifier); uint256 tokensDelegatedMax = tokensAvailableProvider * (uint256(delegationRatio)); - uint256 tokensDelegatedCapacity = MathUtils.min(tokensAvailableDelegated, tokensDelegatedMax); + uint256 tokensDelegatedCapacity = Math.min(tokensAvailableDelegated, tokensDelegatedMax); return tokensAvailableProvider + tokensDelegatedCapacity; } @@ -179,14 +182,26 @@ abstract contract HorizonStakingBase is } uint256 thawedTokens = 0; - Provision storage prov = _provisions[serviceProvider][verifier]; - uint256 tokensThawing = prov.tokensThawing; - uint256 sharesThawing = prov.sharesThawing; + uint256 tokensThawing; + uint256 sharesThawing; + uint256 thawingNonce; + + if (requestType == ThawRequestType.Provision) { + Provision storage prov = _provisions[serviceProvider][verifier]; + tokensThawing = prov.tokensThawing; + sharesThawing = prov.sharesThawing; + thawingNonce = prov.thawingNonce; + } else { + DelegationPoolInternal storage pool = _getDelegationPool(serviceProvider, verifier); + tokensThawing = pool.tokensThawing; + sharesThawing = pool.sharesThawing; + thawingNonce = pool.thawingNonce; + } bytes32 thawRequestId = thawRequestList.head; while (thawRequestId != bytes32(0)) { ThawRequest storage thawRequest = _getThawRequest(requestType, thawRequestId); - if (thawRequest.thawingNonce == prov.thawingNonce) { + if (thawRequest.thawingNonce == thawingNonce) { if (thawRequest.thawingUntil <= block.timestamp) { // sharesThawing cannot be zero if there is a valid thaw request so the next division is safe uint256 tokens = (thawRequest.shares * tokensThawing) / sharesThawing; @@ -218,31 +233,18 @@ abstract contract HorizonStakingBase is return _delegationSlashingEnabled; } - /** - * @notice Deposit tokens into the service provider stake. - * @dev TRANSITION PERIOD: After transition period move to IHorizonStakingMain. Temporarily it - * needs to be here since it's used by both {HorizonStaking} and {HorizonStakingExtension}. - * - * Emits a {HorizonStakeDeposited} event. - * @param _serviceProvider The address of the service provider. - * @param _tokens The amount of tokens to deposit. - */ - function _stake(address _serviceProvider, uint256 _tokens) internal { - _serviceProviders[_serviceProvider].tokensStaked = _serviceProviders[_serviceProvider].tokensStaked + _tokens; - emit HorizonStakeDeposited(_serviceProvider, _tokens); - } - /** * @notice Gets the service provider's idle stake which is the stake that is not being * used for any provision. Note that this only includes service provider's self stake. - * @dev Note that the calculation considers tokens that were locked in the legacy staking contract. - * @dev TRANSITION PERIOD: update the calculation after the transition period. + * @dev Note that the calculation: + * - assumes tokens that were allocated to a subgraph deployment pre-horizon were all unallocated. + * - considers tokens that were locked in the legacy staking contract and never withdrawn. + * * @param _serviceProvider The address of the service provider. * @return The amount of tokens that are idle. */ function _getIdleStake(address _serviceProvider) internal view returns (uint256) { uint256 tokensUsed = _serviceProviders[_serviceProvider].tokensProvisioned + - _serviceProviders[_serviceProvider].__DEPRECATED_tokensAllocated + _serviceProviders[_serviceProvider].__DEPRECATED_tokensLocked; uint256 tokensStaked = _serviceProviders[_serviceProvider].tokensStaked; return tokensStaked > tokensUsed ? tokensStaked - tokensUsed : 0; diff --git a/packages/horizon/contracts/staking/HorizonStakingExtension.sol b/packages/horizon/contracts/staking/HorizonStakingExtension.sol deleted file mode 100644 index 7046c0473..000000000 --- a/packages/horizon/contracts/staking/HorizonStakingExtension.sol +++ /dev/null @@ -1,485 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later - -pragma solidity ^0.8.27; - -// TODO: Re-enable and fix issues when publishing a new version -// solhint-disable function-max-lines, gas-strict-inequalities -// forge-lint: disable-start(mixed-case-variable, mixed-case-function, unwrapped-modifier-logic) - -import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol"; -import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; -import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol"; - -import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; -import { MathUtils } from "../libraries/MathUtils.sol"; -import { ExponentialRebates } from "./libraries/ExponentialRebates.sol"; -import { PPMMath } from "../libraries/PPMMath.sol"; - -import { HorizonStakingBase } from "./HorizonStakingBase.sol"; - -/** - * @title Horizon Staking extension contract - * @author Edge & Node - * @notice The {HorizonStakingExtension} contract implements the legacy functionality required to support the transition - * to the Horizon Staking contract. It allows indexers to close allocations and collect pending query fees, but it - * does not allow for the creation of new allocations. This should allow indexers to migrate to a subgraph data service - * without losing rewards or having service interruptions. - * @dev TRANSITION PERIOD: Once the transition period passes this contract can be removed (note that an upgrade to the - * RewardsManager will also be required). It's expected the transition period to last for at least a full allocation cycle - * (28 epochs). - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -contract HorizonStakingExtension is HorizonStakingBase, IHorizonStakingExtension { - using TokenUtils for IGraphToken; - using PPMMath for uint256; - - /** - * @dev Check if the caller is the slasher. - */ - modifier onlySlasher() { - require(__DEPRECATED_slashers[msg.sender], "!slasher"); - _; - } - - /** - * @notice The staking contract is upgradeable however we still use the constructor to set a few immutable variables - * @param controller The address of the Graph controller contract - * @param subgraphDataServiceAddress The address of the subgraph data service - */ - constructor( - address controller, - address subgraphDataServiceAddress - ) HorizonStakingBase(controller, subgraphDataServiceAddress) {} - - /// @inheritdoc IHorizonStakingExtension - function closeAllocation(address allocationID, bytes32 poi) external override notPaused { - _closeAllocation(allocationID, poi); - } - - /// @inheritdoc IHorizonStakingExtension - function collect(uint256 tokens, address allocationID) external override notPaused { - // Allocation identifier validation - require(allocationID != address(0), "!alloc"); - - // Allocation must exist - AllocationState allocState = _getAllocationState(allocationID); - require(allocState != AllocationState.Null, "!collect"); - - // If the query fees are zero, we don't want to revert - // but we also don't need to do anything, so just return - if (tokens == 0) { - return; - } - - Allocation storage alloc = __DEPRECATED_allocations[allocationID]; - bytes32 subgraphDeploymentID = alloc.subgraphDeploymentID; - - uint256 queryFees = tokens; // Tokens collected from the channel - uint256 protocolTax = 0; // Tokens burnt as protocol tax - uint256 curationFees = 0; // Tokens distributed to curators as curation fees - uint256 queryRebates = 0; // Tokens to distribute to indexer - uint256 delegationRewards = 0; // Tokens to distribute to delegators - - { - // -- Pull tokens from the sender -- - _graphToken().pullTokens(msg.sender, queryFees); - - // -- Collect protocol tax -- - protocolTax = _collectTax(queryFees, __DEPRECATED_protocolPercentage); - queryFees = queryFees - protocolTax; - - // -- Collect curation fees -- - // Only if the subgraph deployment is curated - curationFees = _collectCurationFees(subgraphDeploymentID, queryFees, __DEPRECATED_curationPercentage); - queryFees = queryFees - curationFees; - - // -- Process rebate reward -- - // Using accumulated fees and subtracting previously distributed rebates - // allows for multiple vouchers to be collected while following the rebate formula - alloc.collectedFees = alloc.collectedFees + queryFees; - - // No rebates if indexer has no stake or if lambda is zero - uint256 newRebates = (alloc.tokens == 0 || __DEPRECATED_lambdaNumerator == 0) - ? 0 - : ExponentialRebates.exponentialRebates( - alloc.collectedFees, - alloc.tokens, - __DEPRECATED_alphaNumerator, - __DEPRECATED_alphaDenominator, - __DEPRECATED_lambdaNumerator, - __DEPRECATED_lambdaDenominator - ); - - // -- Ensure rebates to distribute are within bounds -- - // Indexers can become under or over rebated if rebate parameters (alpha, lambda) - // change between successive collect calls for the same allocation - - // Ensure rebates to distribute are not negative (indexer is over-rebated) - queryRebates = MathUtils.diffOrZero(newRebates, alloc.distributedRebates); - - // Ensure rebates to distribute are not greater than available (indexer is under-rebated) - queryRebates = MathUtils.min(queryRebates, queryFees); - - // -- Burn rebates remanent -- - _graphToken().burnTokens(queryFees - queryRebates); - - // -- Distribute rebates -- - if (queryRebates > 0) { - alloc.distributedRebates = alloc.distributedRebates + queryRebates; - - // -- Collect delegation rewards into the delegation pool -- - delegationRewards = _collectDelegationQueryRewards(alloc.indexer, queryRebates); - queryRebates = queryRebates - delegationRewards; - - // -- Transfer or restake rebates -- - _sendRewards(queryRebates, alloc.indexer, __DEPRECATED_rewardsDestination[alloc.indexer] == address(0)); - } - } - - emit RebateCollected( - msg.sender, - alloc.indexer, - subgraphDeploymentID, - allocationID, - _graphEpochManager().currentEpoch(), - tokens, - protocolTax, - curationFees, - queryFees, - queryRebates, - delegationRewards - ); - } - - /// @inheritdoc IHorizonStakingExtension - function legacySlash( - address indexer, - uint256 tokens, - uint256 reward, - address beneficiary - ) external override onlySlasher notPaused { - ServiceProviderInternal storage indexerStake = _serviceProviders[indexer]; - - // Only able to slash a non-zero number of tokens - require(tokens > 0, "!tokens"); - - // Rewards comes from tokens slashed balance - require(tokens >= reward, "rewards>slash"); - - // Cannot slash stake of an indexer without any or enough stake - require(indexerStake.tokensStaked > 0, "!stake"); - require(tokens <= indexerStake.tokensStaked, "slash>stake"); - - // Validate beneficiary of slashed tokens - require(beneficiary != address(0), "!beneficiary"); - - // Slashing tokens that are already provisioned would break provision accounting, we need to limit - // the slash amount. This can be compensated for, by slashing with the main slash function if needed. - uint256 slashableStake = indexerStake.tokensStaked - indexerStake.tokensProvisioned; - if (slashableStake == 0) { - emit StakeSlashed(indexer, 0, 0, beneficiary); - return; - } - if (tokens > slashableStake) { - reward = (reward * slashableStake) / tokens; - tokens = slashableStake; - } - - // Slashing more tokens than freely available (over allocation condition) - // Unlock locked tokens to avoid the indexer to withdraw them - uint256 tokensUsed = indexerStake.__DEPRECATED_tokensAllocated + indexerStake.__DEPRECATED_tokensLocked; - uint256 tokensAvailable = tokensUsed > indexerStake.tokensStaked ? 0 : indexerStake.tokensStaked - tokensUsed; - if (tokens > tokensAvailable && indexerStake.__DEPRECATED_tokensLocked > 0) { - uint256 tokensOverAllocated = tokens - tokensAvailable; - uint256 tokensToUnlock = MathUtils.min(tokensOverAllocated, indexerStake.__DEPRECATED_tokensLocked); - indexerStake.__DEPRECATED_tokensLocked = indexerStake.__DEPRECATED_tokensLocked - tokensToUnlock; - if (indexerStake.__DEPRECATED_tokensLocked == 0) { - indexerStake.__DEPRECATED_tokensLockedUntil = 0; - } - } - - // Remove tokens to slash from the stake - indexerStake.tokensStaked = indexerStake.tokensStaked - tokens; - - // -- Interactions -- - - // Set apart the reward for the beneficiary and burn remaining slashed stake - _graphToken().burnTokens(tokens - reward); - - // Give the beneficiary a reward for slashing - _graphToken().pushTokens(beneficiary, reward); - - emit StakeSlashed(indexer, tokens, reward, beneficiary); - } - - /// @inheritdoc IHorizonStakingExtension - function isAllocation(address allocationID) external view override returns (bool) { - return _getAllocationState(allocationID) != AllocationState.Null; - } - - /// @inheritdoc IHorizonStakingExtension - function getAllocation(address allocationID) external view override returns (Allocation memory) { - return __DEPRECATED_allocations[allocationID]; - } - - /// @inheritdoc IRewardsIssuer - function getAllocationData( - address allocationID - ) external view override returns (bool, address, bytes32, uint256, uint256, uint256) { - Allocation memory allo = __DEPRECATED_allocations[allocationID]; - bool isActive = _getAllocationState(allocationID) == AllocationState.Active; - return (isActive, allo.indexer, allo.subgraphDeploymentID, allo.tokens, allo.accRewardsPerAllocatedToken, 0); - } - - /// @inheritdoc IHorizonStakingExtension - function getAllocationState(address allocationID) external view override returns (AllocationState) { - return _getAllocationState(allocationID); - } - - /// @inheritdoc IRewardsIssuer - function getSubgraphAllocatedTokens(bytes32 subgraphDeploymentID) external view override returns (uint256) { - return __DEPRECATED_subgraphAllocations[subgraphDeploymentID]; - } - - /// @inheritdoc IHorizonStakingExtension - function getIndexerStakedTokens(address indexer) external view override returns (uint256) { - return _serviceProviders[indexer].tokensStaked; - } - - /// @inheritdoc IHorizonStakingExtension - function getSubgraphService() external view override returns (address) { - return SUBGRAPH_DATA_SERVICE_ADDRESS; - } - - /// @inheritdoc IHorizonStakingExtension - function hasStake(address indexer) external view override returns (bool) { - return _serviceProviders[indexer].tokensStaked > 0; - } - - /// @inheritdoc IHorizonStakingExtension - function __DEPRECATED_getThawingPeriod() external view returns (uint64) { - return __DEPRECATED_thawingPeriod; - } - - /// @inheritdoc IHorizonStakingExtension - function isOperator(address operator, address serviceProvider) public view override returns (bool) { - return _legacyOperatorAuth[serviceProvider][operator]; - } - - /** - * @notice Collect tax to burn for an amount of tokens - * @param _tokens Total tokens received used to calculate the amount of tax to collect - * @param _percentage Percentage of tokens to burn as tax - * @return Amount of tax charged - */ - function _collectTax(uint256 _tokens, uint256 _percentage) private returns (uint256) { - uint256 tax = _tokens.mulPPMRoundUp(_percentage); - _graphToken().burnTokens(tax); // Burn tax if any - return tax; - } - - /** - * @notice Triggers an update of rewards due to a change in allocations - * @param _subgraphDeploymentID Subgraph deployment updated - */ - function _updateRewards(bytes32 _subgraphDeploymentID) private { - _graphRewardsManager().onSubgraphAllocationUpdate(_subgraphDeploymentID); - } - - /** - * @notice Assign rewards for the closed allocation to indexer and delegators - * @param _allocationID Allocation - * @param _indexer Address of the indexer that did the allocation - */ - function _distributeRewards(address _allocationID, address _indexer) private { - // Automatically triggers update of rewards snapshot as allocation will change - // after this call. Take rewards mint tokens for the Staking contract to distribute - // between indexer and delegators - uint256 totalRewards = _graphRewardsManager().takeRewards(_allocationID); - if (totalRewards == 0) { - return; - } - - // Calculate delegation rewards and add them to the delegation pool - uint256 delegationRewards = _collectDelegationIndexingRewards(_indexer, totalRewards); - uint256 indexerRewards = totalRewards - delegationRewards; - - // Send the indexer rewards - _sendRewards(indexerRewards, _indexer, __DEPRECATED_rewardsDestination[_indexer] == address(0)); - } - - /** - * @notice Send rewards to the appropriate destination - * @param _tokens Number of rewards tokens - * @param _beneficiary Address of the beneficiary of rewards - * @param _restake Whether to restake or not - */ - function _sendRewards(uint256 _tokens, address _beneficiary, bool _restake) private { - if (_tokens == 0) return; - - if (_restake) { - // Restake to place fees into the indexer stake - _stake(_beneficiary, _tokens); - } else { - // Transfer funds to the beneficiary's designated rewards destination if set - address destination = __DEPRECATED_rewardsDestination[_beneficiary]; - _graphToken().pushTokens(destination == address(0) ? _beneficiary : destination, _tokens); - } - } - - /** - * @notice Close an allocation and free the staked tokens - * @param _allocationID The allocation identifier - * @param _poi Proof of indexing submitted for the allocated period - */ - function _closeAllocation(address _allocationID, bytes32 _poi) private { - // Allocation must exist and be active - AllocationState allocState = _getAllocationState(_allocationID); - require(allocState == AllocationState.Active, "!active"); - - // Get allocation - Allocation memory alloc = __DEPRECATED_allocations[_allocationID]; - - // Validate that an allocation cannot be closed before one epoch - alloc.closedAtEpoch = _graphEpochManager().currentEpoch(); - uint256 epochs = MathUtils.diffOrZero(alloc.closedAtEpoch, alloc.createdAtEpoch); - - // Indexer or operator can close an allocation - // Anyone is allowed to close ONLY under two concurrent conditions - // - After maxAllocationEpochs passed - // - When the allocation is for non-zero amount of tokens - bool isIndexerOrOperator = msg.sender == alloc.indexer || isOperator(msg.sender, alloc.indexer); - if (epochs <= __DEPRECATED_maxAllocationEpochs || alloc.tokens == 0) { - require(isIndexerOrOperator, "!auth"); - } - - // -- Rewards Distribution -- - - // Process non-zero-allocation rewards tracking - if (alloc.tokens > 0) { - // Distribute rewards if proof of indexing was presented by the indexer or operator - if (isIndexerOrOperator && _poi != 0 && epochs > 0) { - _distributeRewards(_allocationID, alloc.indexer); - } else { - _updateRewards(alloc.subgraphDeploymentID); - } - - // Free allocated tokens from use - _serviceProviders[alloc.indexer].__DEPRECATED_tokensAllocated = - _serviceProviders[alloc.indexer].__DEPRECATED_tokensAllocated - alloc.tokens; - - // Track total allocations per subgraph - // Used for rewards calculations - __DEPRECATED_subgraphAllocations[alloc.subgraphDeploymentID] = - __DEPRECATED_subgraphAllocations[alloc.subgraphDeploymentID] - alloc.tokens; - } - - // Close the allocation - // Note that this breaks CEI pattern. We update after the rewards distribution logic as it expects the allocation - // to still be active. There shouldn't be reentrancy risk here as all internal calls are to trusted contracts. - __DEPRECATED_allocations[_allocationID].closedAtEpoch = alloc.closedAtEpoch; - - emit AllocationClosed( - alloc.indexer, - alloc.subgraphDeploymentID, - alloc.closedAtEpoch, - alloc.tokens, - _allocationID, - msg.sender, - _poi, - !isIndexerOrOperator - ); - } - - /** - * @notice Collect the delegation rewards for query fees - * @dev This function will assign the collected fees to the delegation pool - * @param _indexer Indexer to which the tokens to distribute are related - * @param _tokens Total tokens received used to calculate the amount of fees to collect - * @return Amount of delegation rewards - */ - function _collectDelegationQueryRewards(address _indexer, uint256 _tokens) private returns (uint256) { - uint256 delegationRewards = 0; - DelegationPoolInternal storage pool = _legacyDelegationPools[_indexer]; - if (pool.tokens > 0 && uint256(pool.__DEPRECATED_queryFeeCut).isValidPPM()) { - uint256 indexerCut = uint256(pool.__DEPRECATED_queryFeeCut).mulPPM(_tokens); - delegationRewards = _tokens - indexerCut; - pool.tokens = pool.tokens + delegationRewards; - } - return delegationRewards; - } - - /** - * @notice Collect the delegation rewards for indexing - * @dev This function will assign the collected fees to the delegation pool - * @param _indexer Indexer to which the tokens to distribute are related - * @param _tokens Total tokens received used to calculate the amount of fees to collect - * @return Amount of delegation rewards - */ - function _collectDelegationIndexingRewards(address _indexer, uint256 _tokens) private returns (uint256) { - uint256 delegationRewards = 0; - DelegationPoolInternal storage pool = _legacyDelegationPools[_indexer]; - if (pool.tokens > 0 && uint256(pool.__DEPRECATED_indexingRewardCut).isValidPPM()) { - uint256 indexerCut = uint256(pool.__DEPRECATED_indexingRewardCut).mulPPM(_tokens); - delegationRewards = _tokens - indexerCut; - pool.tokens = pool.tokens + delegationRewards; - } - return delegationRewards; - } - - /** - * @notice Collect the curation fees for a subgraph deployment from an amount of tokens - * @dev This function transfer curation fees to the Curation contract by calling Curation.collect - * @param _subgraphDeploymentID Subgraph deployment to which the curation fees are related - * @param _tokens Total tokens received used to calculate the amount of fees to collect - * @param _curationCut Percentage of tokens to collect as fees - * @return Amount of curation fees - */ - function _collectCurationFees( - bytes32 _subgraphDeploymentID, - uint256 _tokens, - uint256 _curationCut - ) private returns (uint256) { - if (_tokens == 0) { - return 0; - } - - ICuration curation = _graphCuration(); - bool isCurationEnabled = _curationCut > 0 && address(curation) != address(0); - - if (isCurationEnabled && curation.isCurated(_subgraphDeploymentID)) { - uint256 curationFees = _tokens.mulPPMRoundUp(_curationCut); - if (curationFees > 0) { - // Transfer and call collect() - // This function transfer tokens to a trusted protocol contracts - // Then we call collect() to do the transfer Bookkeeping - _graphRewardsManager().onSubgraphSignalUpdate(_subgraphDeploymentID); - _graphToken().pushTokens(address(curation), curationFees); - curation.collect(_subgraphDeploymentID, curationFees); - } - return curationFees; - } - return 0; - } - - /** - * @notice Return the current state of an allocation - * @param _allocationID Allocation identifier - * @return AllocationState enum with the state of the allocation - */ - function _getAllocationState(address _allocationID) private view returns (AllocationState) { - Allocation storage alloc = __DEPRECATED_allocations[_allocationID]; - - if (alloc.indexer == address(0)) { - return AllocationState.Null; - } - - if (alloc.createdAtEpoch != 0 && alloc.closedAtEpoch == 0) { - return AllocationState.Active; - } - - return AllocationState.Closed; - } -} diff --git a/packages/horizon/contracts/staking/HorizonStakingStorage.sol b/packages/horizon/contracts/staking/HorizonStakingStorage.sol index 92c769a42..21b8f58d4 100644 --- a/packages/horizon/contracts/staking/HorizonStakingStorage.sol +++ b/packages/horizon/contracts/staking/HorizonStakingStorage.sol @@ -5,7 +5,6 @@ pragma solidity ^0.8.27; // TODO: Re-enable and fix issues when publishing a new version // forge-lint: disable-start(mixed-case-variable) -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; @@ -65,7 +64,7 @@ abstract contract HorizonStakingV1Storage { /// @dev Allocation details. /// Deprecated, now applied on the subgraph data service - mapping(address allocationId => IHorizonStakingExtension.Allocation allocation) internal __DEPRECATED_allocations; + mapping(address allocationId => bytes32 __DEPRECATED_allocation) internal __DEPRECATED_allocations; /// @dev Subgraph allocations, tracks the tokens allocated to a subgraph deployment /// Deprecated, now applied on the SubgraphService @@ -92,7 +91,7 @@ abstract contract HorizonStakingV1Storage { uint32 internal __DEPRECATED_delegationParametersCooldown; /// @dev Time in epochs a delegator needs to wait to withdraw delegated stake - /// Deprecated, now only enforced during a transition period + /// Deprecated, enforced by each data service as needed. uint32 internal __DEPRECATED_delegationUnbondingPeriod; /// @dev Percentage of tokens to tax a delegation deposit diff --git a/packages/horizon/contracts/staking/libraries/ExponentialRebates.sol b/packages/horizon/contracts/staking/libraries/ExponentialRebates.sol deleted file mode 100644 index e06706139..000000000 --- a/packages/horizon/contracts/staking/libraries/ExponentialRebates.sol +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later - -pragma solidity ^0.8.27; - -// TODO: Re-enable and fix issues when publishing a new version -// forge-lint: disable-start(unsafe-typecast) - -import { LibFixedMath } from "../../libraries/LibFixedMath.sol"; - -/** - * @title ExponentialRebates library - * @author Edge & Node - * @notice A library to compute query fee rebates using an exponential formula - * @dev This is only used for backwards compatibility in HorizonStaking, and should - * be removed after the transition period. - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -library ExponentialRebates { - /// @dev Maximum value of the exponent for which to compute the exponential before clamping to zero. - uint32 private constant MAX_EXPONENT = 15; - - /** - * @notice The exponential formula used to compute fee-based rewards for staking pools in a given epoch - * @dev This function does not perform bounds checking on the inputs, but the following conditions - * need to be true: - * 0 <= alphaNumerator / alphaDenominator <= 1 - * 0 < lambdaNumerator / lambdaDenominator - * The exponential rebates function has the form: - * `(1 - alpha * exp ^ (-lambda * stake / fees)) * fees` - * @param fees Fees generated by indexer in the staking pool - * @param stake Stake attributed to the indexer in the staking pool - * @param alphaNumerator Numerator of `alpha` in the rebates function - * @param alphaDenominator Denominator of `alpha` in the rebates function - * @param lambdaNumerator Numerator of `lambda` in the rebates function - * @param lambdaDenominator Denominator of `lambda` in the rebates function - * @return rewards Rewards owed to the staking pool - */ - function exponentialRebates( - uint256 fees, - uint256 stake, - uint32 alphaNumerator, - uint32 alphaDenominator, - uint32 lambdaNumerator, - uint32 lambdaDenominator - ) external pure returns (uint256) { - // If alpha is zero indexer gets 100% fees rebate - int256 alpha = LibFixedMath.toFixed(int32(alphaNumerator), int32(alphaDenominator)); - if (alpha == 0) { - return fees; - } - - // No rebates if no fees... - if (fees == 0) { - return 0; - } - - // Award all fees as rebate if the exponent is too large - int256 lambda = LibFixedMath.toFixed(int32(lambdaNumerator), int32(lambdaDenominator)); - int256 exponent = LibFixedMath.mulDiv(lambda, int256(stake), int256(fees)); - if (LibFixedMath.toInteger(exponent) > int256(uint256(MAX_EXPONENT))) { - return fees; - } - - // Compute `1 - alpha * exp ^(-exponent)` - int256 factor = LibFixedMath.sub(LibFixedMath.one(), LibFixedMath.mul(alpha, LibFixedMath.exp(-exponent))); - - // Weight the fees by the factor - return LibFixedMath.uintMul(factor, fees); - } -} diff --git a/packages/horizon/contracts/utilities/GraphDirectory.sol b/packages/horizon/contracts/utilities/GraphDirectory.sol index 2dd8cdec5..1eb7aba61 100644 --- a/packages/horizon/contracts/utilities/GraphDirectory.sol +++ b/packages/horizon/contracts/utilities/GraphDirectory.sol @@ -13,8 +13,6 @@ import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/r import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol"; import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; -import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol"; - /** * @title GraphDirectory contract * @author Edge & Node @@ -55,13 +53,6 @@ abstract contract GraphDirectory { /// @notice The Graph Proxy Admin contract address IGraphProxyAdmin private immutable GRAPH_PROXY_ADMIN; - // -- Legacy Graph contracts -- - // These are required for backwards compatibility on HorizonStakingExtension - // TRANSITION PERIOD: remove these once HorizonStakingExtension is removed - - /// @notice The Curation contract address - ICuration private immutable GRAPH_CURATION; - /** * @notice Emitted when the GraphDirectory is initialized * @param graphToken The Graph Token contract address @@ -73,7 +64,6 @@ abstract contract GraphDirectory { * @param graphRewardsManager The Rewards Manager contract address * @param graphTokenGateway The Token Gateway contract address * @param graphProxyAdmin The Graph Proxy Admin contract address - * @param graphCuration The Curation contract address */ event GraphDirectoryInitialized( address indexed graphToken, @@ -84,8 +74,7 @@ abstract contract GraphDirectory { address graphEpochManager, address graphRewardsManager, address graphTokenGateway, - address graphProxyAdmin, - address graphCuration + address graphProxyAdmin ); /** @@ -116,7 +105,6 @@ abstract contract GraphDirectory { GRAPH_REWARDS_MANAGER = IRewardsManager(_getContractFromController("RewardsManager")); GRAPH_TOKEN_GATEWAY = ITokenGateway(_getContractFromController("GraphTokenGateway")); GRAPH_PROXY_ADMIN = IGraphProxyAdmin(_getContractFromController("GraphProxyAdmin")); - GRAPH_CURATION = ICuration(_getContractFromController("Curation")); emit GraphDirectoryInitialized( address(GRAPH_TOKEN), @@ -127,8 +115,7 @@ abstract contract GraphDirectory { address(GRAPH_EPOCH_MANAGER), address(GRAPH_REWARDS_MANAGER), address(GRAPH_TOKEN_GATEWAY), - address(GRAPH_PROXY_ADMIN), - address(GRAPH_CURATION) + address(GRAPH_PROXY_ADMIN) ); } @@ -204,14 +191,6 @@ abstract contract GraphDirectory { return GRAPH_PROXY_ADMIN; } - /** - * @notice Get the Curation contract - * @return The Curation contract - */ - function _graphCuration() internal view returns (ICuration) { - return GRAPH_CURATION; - } - /** * @notice Get a contract address from the controller * @dev Requirements: diff --git a/packages/horizon/ignition/modules/core/HorizonStaking.ts b/packages/horizon/ignition/modules/core/HorizonStaking.ts index c4044b0af..a7bec9076 100644 --- a/packages/horizon/ignition/modules/core/HorizonStaking.ts +++ b/packages/horizon/ignition/modules/core/HorizonStaking.ts @@ -3,8 +3,6 @@ import GraphProxyAdminArtifact from '@graphprotocol/contracts/artifacts/contract import { buildModule } from '@nomicfoundation/hardhat-ignition/modules' import HorizonStakingArtifact from '../../../build/contracts/contracts/staking/HorizonStaking.sol/HorizonStaking.json' -import HorizonStakingExtensionArtifact from '../../../build/contracts/contracts/staking/HorizonStakingExtension.sol/HorizonStakingExtension.json' -import ExponentialRebatesArtifact from '../../../build/contracts/contracts/staking/libraries/ExponentialRebates.sol/ExponentialRebates.json' import GraphPeripheryModule, { MigratePeripheryModule } from '../periphery/periphery' import { upgradeGraphProxy } from '../proxy/GraphProxy' import { deployImplementation } from '../proxy/implementation' @@ -17,25 +15,11 @@ export default buildModule('HorizonStaking', (m) => { const subgraphServiceAddress = m.getParameter('subgraphServiceAddress') const maxThawingPeriod = m.getParameter('maxThawingPeriod') - // Deploy HorizonStakingExtension - requires periphery and proxies to be registered in the controller - const ExponentialRebates = m.library('ExponentialRebates', ExponentialRebatesArtifact) - const HorizonStakingExtension = m.contract( - 'HorizonStakingExtension', - HorizonStakingExtensionArtifact, - [Controller, subgraphServiceAddress], - { - libraries: { - ExponentialRebates: ExponentialRebates, - }, - after: [GraphPeripheryModule, HorizonProxiesModule], - }, - ) - // Deploy HorizonStaking implementation const HorizonStakingImplementation = deployImplementation(m, { name: 'HorizonStaking', artifact: HorizonStakingArtifact, - constructorArgs: [Controller, HorizonStakingExtension, subgraphServiceAddress], + constructorArgs: [Controller, subgraphServiceAddress], }) // Upgrade proxy to implementation contract @@ -61,24 +45,11 @@ export const MigrateHorizonStakingDeployerModule = buildModule('HorizonStakingDe const HorizonStakingProxy = m.contractAt('HorizonStakingProxy', GraphProxyArtifact, horizonStakingAddress) - // Deploy HorizonStakingExtension - requires periphery and proxies to be registered in the controller - const ExponentialRebates = m.library('ExponentialRebates', ExponentialRebatesArtifact) - const HorizonStakingExtension = m.contract( - 'HorizonStakingExtension', - HorizonStakingExtensionArtifact, - [Controller, subgraphServiceAddress], - { - libraries: { - ExponentialRebates: ExponentialRebates, - }, - }, - ) - // Deploy HorizonStaking implementation const HorizonStakingImplementation = deployImplementation(m, { name: 'HorizonStaking', artifact: HorizonStakingArtifact, - constructorArgs: [Controller, HorizonStakingExtension, subgraphServiceAddress], + constructorArgs: [Controller, subgraphServiceAddress], }) return { HorizonStakingProxy, HorizonStakingImplementation } diff --git a/packages/horizon/package.json b/packages/horizon/package.json index 1e712eb99..09eb7eaaf 100644 --- a/packages/horizon/package.json +++ b/packages/horizon/package.json @@ -23,7 +23,7 @@ "lint": "pnpm lint:ts; pnpm lint:sol; pnpm lint:forge; pnpm lint:md; pnpm lint:json", "lint:ts": "eslint --fix --cache '**/*.{js,ts,cjs,mjs,jsx,tsx}'; prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'", "lint:sol": "solhint --fix --noPrompt --noPoster 'contracts/**/*.sol'; prettier -w --cache --log-level warn '**/*.sol'", - "lint:forge": "forge lint", + "lint:forge": "forge lint contracts/", "lint:md": "markdownlint --fix --ignore-path ../../.gitignore '**/*.md'; prettier -w --cache --log-level warn '**/*.md'", "lint:json": "prettier -w --cache --log-level warn '**/*.json'", "clean": "rm -rf build dist cache cache_forge typechain-types", diff --git a/packages/horizon/scripts/integration b/packages/horizon/scripts/integration index baf48cf5e..c92a85ee8 100755 --- a/packages/horizon/scripts/integration +++ b/packages/horizon/scripts/integration @@ -100,12 +100,6 @@ npx hardhat deploy:migrate --network localhost --horizon-config integration --st # Step 4 - Governor npx hardhat deploy:migrate --network localhost --horizon-config integration --step 4 --patch-config --account-index 1 --hide-banner --standalone -# Run integration tests - During transition period -npx hardhat test:integration --phase during-transition-period --network localhost - -# Clear thawing period -npx hardhat transition:clear-thawing --network localhost - # Run integration tests - After transition period npx hardhat test:integration --phase after-transition-period --network localhost diff --git a/packages/horizon/tasks/test/integration.ts b/packages/horizon/tasks/test/integration.ts index 95b2ea230..bba9fa1c2 100644 --- a/packages/horizon/tasks/test/integration.ts +++ b/packages/horizon/tasks/test/integration.ts @@ -4,13 +4,9 @@ import { TASK_TEST } from 'hardhat/builtin-tasks/task-names' import { task } from 'hardhat/config' task('test:integration', 'Runs all integration tests') - .addParam( - 'phase', - 'Test phase to run: "during-transition-period", "after-transition-period", "after-delegation-slashing-enabled"', - ) + .addParam('phase', 'Test phase to run: "after-transition-period", "after-delegation-slashing-enabled"') .setAction(async (taskArgs, hre) => { // Get test files for each phase - const duringTransitionPeriodFiles = await glob('test/integration/during-transition-period/**/*.{js,ts}') const afterTransitionPeriodFiles = await glob('test/integration/after-transition-period/**/*.{js,ts}') const afterDelegationSlashingEnabledFiles = await glob( 'test/integration/after-delegation-slashing-enabled/**/*.{js,ts}', @@ -20,9 +16,6 @@ task('test:integration', 'Runs all integration tests') printBanner(taskArgs.phase, 'INTEGRATION TESTS: ') switch (taskArgs.phase) { - case 'during-transition-period': - await hre.run(TASK_TEST, { testFiles: duringTransitionPeriodFiles }) - break case 'after-transition-period': await hre.run(TASK_TEST, { testFiles: afterTransitionPeriodFiles }) break @@ -31,7 +24,7 @@ task('test:integration', 'Runs all integration tests') break default: throw new Error( - 'Invalid phase. Must be "during-transition-period", "after-transition-period", "after-delegation-slashing-enabled", or "all"', + 'Invalid phase. Must be "after-transition-period", "after-delegation-slashing-enabled", or "all"', ) } }) diff --git a/packages/horizon/tasks/transitions/thawing-period.ts b/packages/horizon/tasks/transitions/thawing-period.ts deleted file mode 100644 index e21e2bad2..000000000 --- a/packages/horizon/tasks/transitions/thawing-period.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { requireLocalNetwork } from '@graphprotocol/toolshed/hardhat' -import { printBanner } from '@graphprotocol/toolshed/utils' -import { task, types } from 'hardhat/config' - -task('transition:clear-thawing', 'Clears the thawing period in HorizonStaking') - .addOptionalParam('governorIndex', 'Derivation path index for the governor account', 1, types.int) - .addFlag('skipNetworkCheck', 'Skip the network check (use with caution)') - .setAction(async (taskArgs, hre) => { - printBanner('CLEARING THAWING PERIOD') - - if (!taskArgs.skipNetworkCheck) { - requireLocalNetwork(hre) - } - - const graph = hre.graph() - const governor = await graph.accounts.getGovernor(taskArgs.governorIndex) - const horizonStaking = graph.horizon.contracts.HorizonStaking - - console.log('Clearing thawing period...') - await horizonStaking.connect(governor).clearThawingPeriod() - console.log('Thawing period cleared') - }) diff --git a/packages/horizon/test/deployment/HorizonStaking.test.ts b/packages/horizon/test/deployment/HorizonStaking.test.ts index fed2af75f..f60d92b52 100644 --- a/packages/horizon/test/deployment/HorizonStaking.test.ts +++ b/packages/horizon/test/deployment/HorizonStaking.test.ts @@ -1,5 +1,5 @@ import { loadConfig } from '@graphprotocol/toolshed/hardhat' -import { assert, expect } from 'chai' +import { expect } from 'chai' import hre from 'hardhat' import { graphProxyTests } from './lib/GraphProxy.test' @@ -27,16 +27,6 @@ describe('HorizonStaking', function () { expect(delegationSlashingEnabled).to.equal(false) }) - testIf(4)('should set a non zero thawing period', async function () { - if (process.env.IGNITION_DEPLOYMENT_TYPE === 'protocol') { - assert.fail('Deployment type "protocol": no historical state available') - } - const thawingPeriod = await HorizonStaking.__DEPRECATED_getThawingPeriod() - expect(thawingPeriod).to.not.equal(0) - }) - - it.skip('should set the right staking extension address') - testIf(4)('should set the right subgraph data service address', async function () { const subgraphDataServiceAddress = await HorizonStaking.getSubgraphService() expect(subgraphDataServiceAddress).to.equal(config.$global.subgraphServiceAddress) diff --git a/packages/horizon/test/integration/during-transition-period/delegator.test.ts b/packages/horizon/test/integration/during-transition-period/delegator.test.ts deleted file mode 100644 index 352599f18..000000000 --- a/packages/horizon/test/integration/during-transition-period/delegator.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { ZERO_ADDRESS } from '@graphprotocol/toolshed' -import { delegators } from '@graphprotocol/toolshed/fixtures' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Delegator', () => { - let snapshotId: string - - const thawingPeriod = 2419200n // 28 days - - // Subgraph service address is not set for integration tests - const subgraphServiceAddress = '0x0000000000000000000000000000000000000000' - - const graph = hre.graph() - const horizonStaking = graph.horizon.contracts.HorizonStaking - const graphToken = graph.horizon.contracts.L2GraphToken - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Existing Protocol Users', () => { - describe('User undelegated before horizon was deployed', () => { - let indexer: HardhatEthersSigner - let delegator: HardhatEthersSigner - let tokens: bigint - - before(async () => { - const delegatorFixture = delegators[2] - const delegationFixture = delegatorFixture.delegations[0] - - // Verify delegator is undelegated - expect(delegatorFixture.undelegate).to.be.true - - // Get signers - indexer = await ethers.getSigner(delegationFixture.indexerAddress) - delegator = await ethers.getSigner(delegatorFixture.address) - - // Get tokens - tokens = delegationFixture.tokens - }) - - it('should be able to withdraw their tokens after the thawing period', async () => { - // Get the thawing period - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Mine remaining blocks to complete thawing period - for (let i = 0; i < Number(thawingPeriod) + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get delegator balance before withdrawing - const balanceBefore = await graphToken.balanceOf(delegator.address) - - // Withdraw tokens - await horizonStaking.connect(delegator)['withdrawDelegated(address,address)'](indexer.address, ZERO_ADDRESS) - - // Get delegator balance after withdrawing - const balanceAfter = await graphToken.balanceOf(delegator.address) - - // Expected balance after is the balance before plus the tokens minus the 0.5% delegation tax - const expectedBalanceAfter = balanceBefore + tokens - (tokens * 5000n) / 1000000n - - // Verify tokens are withdrawn - expect(balanceAfter).to.equal(expectedBalanceAfter) - }) - - it('should revert if the thawing period has not passed', async () => { - // Withdraw tokens - await expect( - horizonStaking.connect(delegator)['withdrawDelegated(address,address)'](indexer.address, ZERO_ADDRESS), - ).to.be.revertedWithCustomError(horizonStaking, 'HorizonStakingNothingToWithdraw') - }) - }) - - describe('Transition period is over', () => { - let governor: HardhatEthersSigner - let indexer: HardhatEthersSigner - let delegator: HardhatEthersSigner - let tokens: bigint - - before(async () => { - const delegatorFixture = delegators[0] - const delegationFixture = delegatorFixture.delegations[0] - - // Get signers - governor = await graph.accounts.getGovernor() - indexer = await ethers.getSigner(delegationFixture.indexerAddress) - delegator = await ethers.getSigner(delegatorFixture.address) - - // Get tokens - tokens = delegationFixture.tokens - }) - - it('should be able to undelegate during transition period and withdraw after transition period', async () => { - // Get delegator's delegation - const delegation = await horizonStaking.getDelegation( - indexer.address, - subgraphServiceAddress, - delegator.address, - ) - - // Undelegate tokens - await horizonStaking - .connect(delegator) - ['undelegate(address,address,uint256)'](indexer.address, subgraphServiceAddress, delegation.shares) - - // Wait for thawing period - await ethers.provider.send('evm_increaseTime', [Number(thawingPeriod) + 1]) - await ethers.provider.send('evm_mine', []) - - // Clear thawing period - await horizonStaking.connect(governor).clearThawingPeriod() - - // Get delegator balance before withdrawing - const balanceBefore = await graphToken.balanceOf(delegator.address) - - // Withdraw tokens - await horizonStaking - .connect(delegator) - ['withdrawDelegated(address,address,uint256)'](indexer.address, ZERO_ADDRESS, BigInt(1)) - - // Get delegator balance after withdrawing - const balanceAfter = await graphToken.balanceOf(delegator.address) - - // Expected balance after is the balance before plus the tokens minus the 0.5% delegation tax - // because the delegation was before the horizon upgrade, after the upgrade there is no tax - const expectedBalanceAfter = balanceBefore + tokens - (tokens * 5000n) / 1000000n - - // Verify tokens are withdrawn - expect(balanceAfter).to.equal(expectedBalanceAfter) - }) - }) - }) -}) diff --git a/packages/horizon/test/integration/during-transition-period/multicall.test.ts b/packages/horizon/test/integration/during-transition-period/multicall.test.ts deleted file mode 100644 index 948cd8f5f..000000000 --- a/packages/horizon/test/integration/during-transition-period/multicall.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { ONE_MILLION, PaymentTypes } from '@graphprotocol/toolshed' -import { setGRTBalance } from '@graphprotocol/toolshed/hardhat' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Service Provider', () => { - let snapshotId: string - - const maxVerifierCut = 50_000n - const thawingPeriod = 2419200n - - const graph = hre.graph() - const horizonStaking = graph.horizon.contracts.HorizonStaking - const graphToken = graph.horizon.contracts.L2GraphToken - - const subgraphServiceAddress = '0x0000000000000000000000000000000000000000' - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('New Protocol Users', () => { - let serviceProvider: HardhatEthersSigner - - before(async () => { - ;[, , serviceProvider] = await graph.accounts.getTestAccounts() - await setGRTBalance(graph.provider, graphToken.target, serviceProvider.address, ONE_MILLION) - }) - - it('should allow multicalling stake+provision calls', async () => { - const tokensToStake = ethers.parseEther('1000') - const tokensToProvision = ethers.parseEther('100') - - // check state before - const beforeProvision = await horizonStaking.getProvision(serviceProvider.address, subgraphServiceAddress) - expect(beforeProvision.tokens).to.equal(0) - expect(beforeProvision.maxVerifierCut).to.equal(0) - expect(beforeProvision.thawingPeriod).to.equal(0) - expect(beforeProvision.createdAt).to.equal(0) - - // multicall - await graphToken.connect(serviceProvider).approve(horizonStaking.target, tokensToStake) - const stakeCalldata = horizonStaking.interface.encodeFunctionData('stake', [tokensToStake]) - const provisionCalldata = horizonStaking.interface.encodeFunctionData('provision', [ - serviceProvider.address, - subgraphServiceAddress, - tokensToProvision, - maxVerifierCut, - thawingPeriod, - ]) - await horizonStaking.connect(serviceProvider).multicall([stakeCalldata, provisionCalldata]) - - // check state after - const block = await graph.provider.getBlock('latest') - const afterProvision = await horizonStaking.getProvision(serviceProvider.address, subgraphServiceAddress) - expect(afterProvision.tokens).to.equal(tokensToProvision) - expect(afterProvision.maxVerifierCut).to.equal(maxVerifierCut) - expect(afterProvision.thawingPeriod).to.equal(thawingPeriod) - expect(afterProvision.createdAt).to.equal(block?.timestamp) - }) - - it('should allow multicalling delegation parameter set calls', async () => { - // check state before - const beforeIndexingRewards = await horizonStaking.getDelegationFeeCut( - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.IndexingRewards, - ) - const beforeQueryFee = await horizonStaking.getDelegationFeeCut( - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.QueryFee, - ) - expect(beforeIndexingRewards).to.equal(0) - expect(beforeQueryFee).to.equal(0) - - // multicall - const indexingRewardsCalldata = horizonStaking.interface.encodeFunctionData('setDelegationFeeCut', [ - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.IndexingRewards, - 10_000n, - ]) - const queryFeeCalldata = horizonStaking.interface.encodeFunctionData('setDelegationFeeCut', [ - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.QueryFee, - 12_345n, - ]) - await horizonStaking.connect(serviceProvider).multicall([indexingRewardsCalldata, queryFeeCalldata]) - - // check state after - const afterIndexingRewards = await horizonStaking.getDelegationFeeCut( - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.IndexingRewards, - ) - const afterQueryFee = await horizonStaking.getDelegationFeeCut( - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.QueryFee, - ) - expect(afterIndexingRewards).to.equal(10_000n) - expect(afterQueryFee).to.equal(12_345n) - }) - }) -}) diff --git a/packages/horizon/test/integration/during-transition-period/operator.test.ts b/packages/horizon/test/integration/during-transition-period/operator.test.ts deleted file mode 100644 index ab5b26ebf..000000000 --- a/packages/horizon/test/integration/during-transition-period/operator.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { generatePOI } from '@graphprotocol/toolshed' -import { indexers } from '@graphprotocol/toolshed/fixtures' -import { getEventData } from '@graphprotocol/toolshed/hardhat' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Operator', () => { - let snapshotId: string - - // Subgraph service address is not set for integration tests - const subgraphServiceAddress = '0x0000000000000000000000000000000000000000' - - const graph = hre.graph() - const horizonStaking = graph.horizon.contracts.HorizonStaking - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Existing Protocol Users', () => { - let indexer: HardhatEthersSigner - let operator: HardhatEthersSigner - let allocationID: string - let allocationTokens: bigint - let delegationIndexingCut: number - - before(async () => { - const indexerFixture = indexers[0] - const allocationFixture = indexerFixture.allocations[0] - - // Get signers - indexer = await ethers.getSigner(indexerFixture.address) - ;[operator] = await graph.accounts.getTestAccounts() - - // Get allocation details - allocationID = allocationFixture.allocationID - allocationTokens = allocationFixture.tokens - delegationIndexingCut = indexerFixture.indexingRewardCut - - // Set the operator - await horizonStaking.connect(indexer).setOperator(subgraphServiceAddress, operator.address, true) - }) - - it('should allow the operator to close an open legacy allocation and collect rewards', async () => { - // Use a non-zero POI - const poi = generatePOI('poi') - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Get delegation pool before closing allocation - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Mine blocks to simulate time passing - const halfThawingPeriod = Number(thawingPeriod) / 2 - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get idle stake before closing allocation - const idleStakeBefore = await horizonStaking.getIdleStake(indexer.address) - - // Close allocation - const tx = await horizonStaking.connect(operator).closeAllocation(allocationID, poi) - const eventData = await getEventData( - tx, - 'event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount)', - ) - const rewards = eventData[2] - - // Verify rewards are not zero - expect(rewards).to.not.equal(0, 'Rewards were not transferred to service provider') - - // Verify rewards minus delegation cut are restaked - const idleStakeAfter = await horizonStaking.getIdleStake(indexer.address) - const idleStakeRewardsTokens = (rewards * BigInt(delegationIndexingCut)) / 1000000n - expect(idleStakeAfter).to.equal( - idleStakeBefore + allocationTokens + idleStakeRewardsTokens, - 'Rewards were not restaked', - ) - - // Verify delegators cut is added to delegation pool - const delegationPool = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPool.tokens - const delegationRewardsTokens = rewards - idleStakeRewardsTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationRewardsTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - }) -}) diff --git a/packages/horizon/test/integration/during-transition-period/permissionless.test.ts b/packages/horizon/test/integration/during-transition-period/permissionless.test.ts deleted file mode 100644 index a7d13e302..000000000 --- a/packages/horizon/test/integration/during-transition-period/permissionless.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { generatePOI } from '@graphprotocol/toolshed' -import { indexers } from '@graphprotocol/toolshed/fixtures' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Permissionless', () => { - let snapshotId: string - - const graph = hre.graph() - const horizonStaking = graph.horizon.contracts.HorizonStaking - const epochManager = graph.horizon.contracts.EpochManager - const subgraphServiceAddress = '0x0000000000000000000000000000000000000000' - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('After max allocation epochs', () => { - let indexer: HardhatEthersSigner - let anySigner: HardhatEthersSigner - let allocationID: string - let allocationTokens: bigint - - before(async () => { - // Get signers - indexer = await ethers.getSigner(indexers[0].address) - ;[anySigner] = await graph.accounts.getTestAccounts() - - // ensure anySigner is not operator for the indexer - await horizonStaking.connect(indexer).setOperator(subgraphServiceAddress, anySigner.address, false) - - // Get allocation details - allocationID = indexers[0].allocations[0].allocationID - allocationTokens = indexers[0].allocations[0].tokens - }) - - it('should allow any user to close an allocation after 28 epochs', async () => { - // Get indexer's idle stake before closing allocation - const idleStakeBefore = await horizonStaking.getIdleStake(indexer.address) - - // Mine blocks to simulate 28 epochs passing - const startingEpoch = await epochManager.currentEpoch() - while ((await epochManager.currentEpoch()) - startingEpoch < 28) { - await ethers.provider.send('evm_mine', []) - } - - // Close allocation - const poi = generatePOI('poi') - await horizonStaking.connect(anySigner).closeAllocation(allocationID, poi) - - // Get indexer's idle stake after closing allocation - const idleStakeAfter = await horizonStaking.getIdleStake(indexer.address) - - // Verify allocation tokens were added to indexer's idle stake but no rewards were collected - expect(idleStakeAfter).to.be.equal(idleStakeBefore + allocationTokens) - }) - }) -}) diff --git a/packages/horizon/test/integration/during-transition-period/service-provider.test.ts b/packages/horizon/test/integration/during-transition-period/service-provider.test.ts deleted file mode 100644 index 0be3c6112..000000000 --- a/packages/horizon/test/integration/during-transition-period/service-provider.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { generatePOI, ONE_MILLION } from '@graphprotocol/toolshed' -import { indexers } from '@graphprotocol/toolshed/fixtures' -import { getEventData, setGRTBalance } from '@graphprotocol/toolshed/hardhat' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Service Provider', () => { - let snapshotId: string - - const graph = hre.graph() - const { stake, collect } = graph.horizon.actions - const horizonStaking = graph.horizon.contracts.HorizonStaking - const graphToken = graph.horizon.contracts.L2GraphToken - - // Subgraph service address is not set for integration tests - const subgraphServiceAddress = '0x0000000000000000000000000000000000000000' - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('New Protocol Users', () => { - let serviceProvider: HardhatEthersSigner - let tokensToStake = ethers.parseEther('1000') - - before(async () => { - ;[, , serviceProvider] = await graph.accounts.getTestAccounts() - await setGRTBalance(graph.provider, graphToken.target, serviceProvider.address, ONE_MILLION) - - // Stake tokens to service provider - await stake(serviceProvider, [tokensToStake]) - }) - - it('should allow service provider to unstake and withdraw after thawing period', async () => { - const tokensToUnstake = ethers.parseEther('100') - const balanceBefore = await graphToken.balanceOf(serviceProvider.address) - - // First unstake request - await horizonStaking.connect(serviceProvider).unstake(tokensToUnstake) - - // During transition period, tokens are locked by thawing period - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Mine remaining blocks to complete thawing period - for (let i = 0; i < Number(thawingPeriod) + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Now we can withdraw - await horizonStaking.connect(serviceProvider).withdraw() - const balanceAfter = await graphToken.balanceOf(serviceProvider.address) - - expect(balanceAfter).to.equal( - balanceBefore + tokensToUnstake, - 'Tokens were not transferred back to service provider', - ) - }) - - it('should handle multiple unstake requests correctly', async () => { - // Make multiple unstake requests - const request1 = ethers.parseEther('50') - const request2 = ethers.parseEther('75') - - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // First unstake request - await horizonStaking.connect(serviceProvider).unstake(request1) - - // Mine half of thawing period blocks - const halfThawingPeriod = Number(thawingPeriod) / 2 - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Second unstake request - await horizonStaking.connect(serviceProvider).unstake(request2) - - // Mine remaining blocks to complete first unstake thawing period - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Check that withdraw reverts since thawing period is not complete - await expect(horizonStaking.connect(serviceProvider).withdraw()).to.be.revertedWithCustomError( - horizonStaking, - 'HorizonStakingStillThawing', - ) - - // Mine remaining blocks to complete thawing period - for (let i = 0; i < halfThawingPeriod + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get balance before withdrawing - const balanceBefore = await graphToken.balanceOf(serviceProvider.address) - - // Withdraw all thawed tokens - await horizonStaking.connect(serviceProvider).withdraw() - - // Verify all tokens are withdrawn and transferred back to service provider - const balanceAfter = await graphToken.balanceOf(serviceProvider.address) - expect(balanceAfter).to.equal( - balanceBefore + request1 + request2, - 'Tokens were not transferred back to service provider', - ) - }) - - describe('Transition period is over', () => { - let governor: HardhatEthersSigner - let tokensToUnstake: bigint - - before(async () => { - // Get governor - governor = await graph.accounts.getGovernor() - - // Set tokens - tokensToStake = ethers.parseEther('100000') - tokensToUnstake = ethers.parseEther('10000') - }) - - it('should be able to withdraw tokens that were unstaked during transition period', async () => { - // Stake tokens - await stake(serviceProvider, [tokensToStake]) - - // Unstake tokens - await horizonStaking.connect(serviceProvider).unstake(tokensToUnstake) - - // Get balance before withdrawing - const balanceBefore = await graphToken.balanceOf(serviceProvider.address) - - // Get thawing period - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Clear thawing period - await horizonStaking.connect(governor).clearThawingPeriod() - - // Mine blocks to complete thawing period - for (let i = 0; i < Number(thawingPeriod) + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Withdraw tokens - await horizonStaking.connect(serviceProvider).withdraw() - - // Get balance after withdrawing - const balanceAfter = await graphToken.balanceOf(serviceProvider.address) - expect(balanceAfter).to.equal( - balanceBefore + tokensToUnstake, - 'Tokens were not transferred back to service provider', - ) - }) - - it('should be able to unstake tokens without a thawing period', async () => { - // Stake tokens - await stake(serviceProvider, [tokensToStake]) - - // Clear thawing period - await horizonStaking.connect(governor).clearThawingPeriod() - - // Get balance before withdrawing - const balanceBefore = await graphToken.balanceOf(serviceProvider.address) - - // Unstake tokens - await horizonStaking.connect(serviceProvider).unstake(tokensToUnstake) - - // Get balance after withdrawing - const balanceAfter = await graphToken.balanceOf(serviceProvider.address) - expect(balanceAfter).to.equal( - balanceBefore + tokensToUnstake, - 'Tokens were not transferred back to service provider', - ) - }) - }) - }) - - describe('Existing Protocol Users', () => { - let indexer: HardhatEthersSigner - let tokensUnstaked: bigint - - before(async () => { - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - tokensUnstaked = indexerFixture.tokensToUnstake || 0n - - await setGRTBalance(graph.provider, graphToken.target, indexer.address, ONE_MILLION) - }) - - it('should allow service provider to withdraw their locked tokens after thawing period passes', async () => { - // Get balance before withdrawing - const balanceBefore = await graphToken.balanceOf(indexer.address) - - // Get thawing period - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Mine blocks to complete thawing period - for (let i = 0; i < Number(thawingPeriod) + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Withdraw tokens - await horizonStaking.connect(indexer).withdraw() - - // Verify tokens are transferred back to service provider - const balanceAfter = await graphToken.balanceOf(indexer.address) - expect(balanceAfter).to.equal( - balanceBefore + tokensUnstaked, - 'Tokens were not transferred back to service provider', - ) - }) - - describe('Legacy allocations', () => { - describe('Restaking', () => { - let delegationIndexingCut: number - let delegationQueryFeeCut: number - let allocationID: string - let allocationTokens: bigint - let gateway: HardhatEthersSigner - - beforeEach(async () => { - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - delegationIndexingCut = indexerFixture.indexingRewardCut - delegationQueryFeeCut = indexerFixture.queryFeeCut - allocationID = indexerFixture.allocations[0].allocationID - allocationTokens = indexerFixture.allocations[0].tokens - gateway = await graph.accounts.getGateway() - await setGRTBalance(graph.provider, graphToken.target, gateway.address, ONE_MILLION) - }) - - it('should be able to close an open legacy allocation and collect rewards', async () => { - // Use a non-zero POI - const poi = generatePOI('poi') - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Get delegation pool before closing allocation - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Mine blocks to simulate time passing - const halfThawingPeriod = Number(thawingPeriod) / 2 - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get idle stake before closing allocation - const idleStakeBefore = await horizonStaking.getIdleStake(indexer.address) - - // Close allocation - const tx = await horizonStaking.connect(indexer).closeAllocation(allocationID, poi) - const eventData = await getEventData( - tx, - 'event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount)', - ) - const rewards = eventData[2] - - // Verify rewards are not zero - expect(rewards).to.not.equal(0, 'Rewards were not transferred to service provider') - - // Verify rewards minus delegation cut are restaked - const idleStakeAfter = await horizonStaking.getIdleStake(indexer.address) - const idleStakeRewardsTokens = (rewards * BigInt(delegationIndexingCut)) / 1000000n - expect(idleStakeAfter).to.equal( - idleStakeBefore + allocationTokens + idleStakeRewardsTokens, - 'Rewards were not restaked', - ) - - // Verify delegators cut is added to delegation pool - const delegationPool = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPool.tokens - const delegationRewardsTokens = rewards - idleStakeRewardsTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationRewardsTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - - it('should be able to collect query fees', async () => { - const tokensToCollect = ethers.parseEther('1000') - - // Get idle stake before collecting - const idleStakeBefore = await horizonStaking.getIdleStake(indexer.address) - - // Get delegation pool before collecting - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Collect query fees - await collect(gateway, [tokensToCollect, allocationID]) - - // Get idle stake after collecting - const idleStakeAfter = await horizonStaking.getIdleStake(indexer.address) - - // Subtract protocol tax (1%) and curation fees (10% after the protocol tax deduction) - const protocolTax = (tokensToCollect * 1n) / 100n - const curationFees = (tokensToCollect * 99n) / 1000n - const remainingTokens = tokensToCollect - protocolTax - curationFees - - // Verify tokens minus delegators cut are restaked - const indexerCutTokens = (remainingTokens * BigInt(delegationQueryFeeCut)) / 1000000n - expect(idleStakeAfter).to.equal(idleStakeBefore + indexerCutTokens, 'Indexer cut was not restaked') - - // Verify delegators cut is added to delegation pool - const delegationPool = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPool.tokens - const delegationCutTokens = remainingTokens - indexerCutTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationCutTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - - it('should be able to close an allocation and collect query fees for the closed allocation', async () => { - // Use a non-zero POI - const poi = generatePOI('poi') - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Mine blocks to simulate time passing - const halfThawingPeriod = Number(thawingPeriod) / 2 - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Close allocation - await horizonStaking.connect(indexer).closeAllocation(allocationID, poi) - - // Tokens to collect - const tokensToCollect = ethers.parseEther('1000') - - // Get idle stake before collecting - const idleStakeBefore = await horizonStaking.getIdleStake(indexer.address) - - // Get delegation pool before collecting - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Collect query fees - await collect(gateway, [tokensToCollect, allocationID]) - - // Get idle stake after collecting - const idleStakeAfter = await horizonStaking.getIdleStake(indexer.address) - - // Subtract protocol tax (1%) and curation fees (10% after the protocol tax deduction) - const protocolTax = (tokensToCollect * 1n) / 100n - const curationFees = (tokensToCollect * 99n) / 1000n - const remainingTokens = tokensToCollect - protocolTax - curationFees - - // Verify tokens minus delegators cut are restaked - const indexerCutTokens = (remainingTokens * BigInt(delegationQueryFeeCut)) / 1000000n - expect(idleStakeAfter).to.equal(idleStakeBefore + indexerCutTokens, 'Indexer cut was not restaked') - - // Verify delegators cut is added to delegation pool - const delegationPool = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPool.tokens - const delegationCutTokens = remainingTokens - indexerCutTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationCutTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - }) - - describe('With rewardsDestination set', () => { - let delegationIndexingCut: number - let delegationQueryFeeCut: number - let rewardsDestination: string - let allocationID: string - let gateway: HardhatEthersSigner - - beforeEach(async () => { - const indexerFixture = indexers[1] - indexer = await ethers.getSigner(indexerFixture.address) - delegationIndexingCut = indexerFixture.indexingRewardCut - delegationQueryFeeCut = indexerFixture.queryFeeCut - rewardsDestination = indexerFixture.rewardsDestination! - allocationID = indexerFixture.allocations[0].allocationID - gateway = await graph.accounts.getGateway() - await setGRTBalance(graph.provider, graphToken.target, gateway.address, ONE_MILLION) - }) - - it('should be able to close an open allocation and collect rewards', async () => { - // Use a non-zero POI - const poi = generatePOI('poi') - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Get delegation tokens before - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Mine blocks to simulate time passing - const halfThawingPeriod = Number(thawingPeriod) / 2 - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get rewards destination balance before closing allocation - const balanceBefore = await graphToken.balanceOf(rewardsDestination) - - // Close allocation - const tx = await horizonStaking.connect(indexer).closeAllocation(allocationID, poi) - const eventData = await getEventData( - tx, - 'event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount)', - ) - const rewards = eventData[2] - - // Verify rewards are not zero - expect(rewards).to.not.equal(0, 'Rewards were not transferred to rewards destination') - - // Verify indexer rewards cut is transferred to rewards destination - const balanceAfter = await graphToken.balanceOf(rewardsDestination) - const indexerCutTokens = (rewards * BigInt(delegationIndexingCut)) / 1000000n - expect(balanceAfter).to.equal( - balanceBefore + indexerCutTokens, - 'Indexer cut was not transferred to rewards destination', - ) - - // Verify delegators cut is added to delegation pool - const delegationPoolAfter = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPoolAfter.tokens - const delegationCutTokens = rewards - indexerCutTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationCutTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - - it('should be able to collect query fees', async () => { - const tokensToCollect = ethers.parseEther('1000') - - // Get rewards destination balance before collecting - const balanceBefore = await graphToken.balanceOf(rewardsDestination) - - // Get delegation tokens before - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Collect query fees - await collect(gateway, [tokensToCollect, allocationID]) - - // Get rewards destination balance after collecting - const balanceAfter = await graphToken.balanceOf(rewardsDestination) - - // Subtract protocol tax (1%) and curation fees (10% after the protocol tax deduction) - const protocolTax = (tokensToCollect * 1n) / 100n - const curationFees = (tokensToCollect * 99n) / 1000n - const remainingTokens = tokensToCollect - protocolTax - curationFees - - // Verify indexer cut is transferred to rewards destination - const indexerCutTokens = (remainingTokens * BigInt(delegationQueryFeeCut)) / 1000000n - expect(balanceAfter).to.equal( - balanceBefore + indexerCutTokens, - 'Indexer cut was not transferred to rewards destination', - ) - - // Verify delegators cut is added to delegation pool - const delegationPoolAfter = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPoolAfter.tokens - const delegationCutTokens = remainingTokens - indexerCutTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationCutTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - }) - }) - - describe('Transition period is over', () => { - let governor: HardhatEthersSigner - let tokensToUnstake: bigint - - before(async () => { - // Get governor - governor = await graph.accounts.getGovernor() - - // Get indexer - const indexerFixture = indexers[2] - indexer = await ethers.getSigner(indexerFixture.address) - - // Set tokens - tokensToUnstake = ethers.parseEther('10000') - }) - - it('should be able to withdraw tokens that were unstaked during transition period', async () => { - // Unstake tokens during transition period - await horizonStaking.connect(indexer).unstake(tokensToUnstake) - - // Get thawing period - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Clear thawing period - await horizonStaking.connect(governor).clearThawingPeriod() - - // Mine blocks to complete thawing period - for (let i = 0; i < Number(thawingPeriod) + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get balance before withdrawing - const balanceBefore = await graphToken.balanceOf(indexer.address) - - // Withdraw tokens - await horizonStaking.connect(indexer).withdraw() - - // Get balance after withdrawing - const balanceAfter = await graphToken.balanceOf(indexer.address) - expect(balanceAfter).to.equal( - balanceBefore + tokensToUnstake, - 'Tokens were not transferred back to service provider', - ) - }) - }) - }) -}) diff --git a/packages/horizon/test/integration/during-transition-period/slasher.test.ts b/packages/horizon/test/integration/during-transition-period/slasher.test.ts deleted file mode 100644 index 47ced0883..000000000 --- a/packages/horizon/test/integration/during-transition-period/slasher.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { indexers } from '@graphprotocol/toolshed/fixtures' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Slasher', () => { - let snapshotId: string - - let indexer: string - let slasher: HardhatEthersSigner - let tokensToSlash: bigint - - const graph = hre.graph() - const horizonStaking = graph.horizon.contracts.HorizonStaking - const graphToken = graph.horizon.contracts.L2GraphToken - - before(async () => { - slasher = await graph.accounts.getArbitrator() - }) - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Available tokens', () => { - before(() => { - const indexerFixture = indexers[0] - indexer = indexerFixture.address - tokensToSlash = ethers.parseEther('10000') - }) - - it('should be able to slash indexer stake', async () => { - // Before slash state - const idleStakeBeforeSlash = await horizonStaking.getIdleStake(indexer) - const tokensVerifier = tokensToSlash / 2n - const slasherBeforeBalance = await graphToken.balanceOf(slasher.address) - - // Slash tokens - await horizonStaking.connect(slasher).slash(indexer, tokensToSlash, tokensVerifier, slasher.address) - - // Indexer's stake should have decreased - const idleStakeAfterSlash = await horizonStaking.getIdleStake(indexer) - expect(idleStakeAfterSlash).to.equal(idleStakeBeforeSlash - tokensToSlash, 'Indexer stake should have decreased') - - // Slasher should have received the tokens - const slasherAfterBalance = await graphToken.balanceOf(slasher.address) - expect(slasherAfterBalance).to.equal( - slasherBeforeBalance + tokensVerifier, - 'Slasher should have received the tokens', - ) - }) - }) - - describe('Locked tokens', () => { - before(() => { - const indexerFixture = indexers[1] - indexer = indexerFixture.address - tokensToSlash = indexerFixture.stake - }) - - it('should be able to slash locked tokens', async () => { - // Before slash state - const tokensVerifier = tokensToSlash / 2n - const slasherBeforeBalance = await graphToken.balanceOf(slasher.address) - - // Slash tokens - await horizonStaking.connect(slasher).slash(indexer, tokensToSlash, tokensVerifier, slasher.address) - - // Indexer's entire stake should have been slashed - const indexerStakeAfterSlash = await horizonStaking.getServiceProvider(indexer) - expect(indexerStakeAfterSlash.tokensStaked).to.equal(0n, 'Indexer stake should have been slashed') - - // Slasher should have received the tokens - const slasherAfterBalance = await graphToken.balanceOf(slasher.address) - expect(slasherAfterBalance).to.equal( - slasherBeforeBalance + tokensVerifier, - 'Slasher should have received the tokens', - ) - }) - }) -}) diff --git a/packages/horizon/test/unit/GraphBase.t.sol b/packages/horizon/test/unit/GraphBase.t.sol index 4aa5b66f1..14ffb2ccb 100644 --- a/packages/horizon/test/unit/GraphBase.t.sol +++ b/packages/horizon/test/unit/GraphBase.t.sol @@ -12,7 +12,6 @@ import { GraphPayments } from "contracts/payments/GraphPayments.sol"; import { GraphTallyCollector } from "contracts/payments/collectors/GraphTallyCollector.sol"; import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; import { HorizonStaking } from "contracts/staking/HorizonStaking.sol"; -import { HorizonStakingExtension } from "contracts/staking/HorizonStakingExtension.sol"; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { MockGRTToken } from "../../contracts/mocks/MockGRTToken.sol"; import { EpochManagerMock } from "contracts/mocks/EpochManagerMock.sol"; @@ -41,7 +40,6 @@ abstract contract GraphBaseTest is IHorizonStakingTypes, Utils, Constants { GraphTallyCollector graphTallyCollector; HorizonStaking private stakingBase; - HorizonStakingExtension private stakingExtension; address subgraphDataServiceLegacyAddress = makeAddr("subgraphDataServiceLegacyAddress"); address subgraphDataServiceAddress = makeAddr("subgraphDataServiceAddress"); @@ -69,8 +67,7 @@ abstract contract GraphBaseTest is IHorizonStakingTypes, Utils, Constants { operator: createUser("operator"), gateway: createUser("gateway"), verifier: createUser("verifier"), - delegator: createUser("delegator"), - legacySlasher: createUser("legacySlasher") + delegator: createUser("delegator") }); // Deploy protocol contracts @@ -84,7 +81,6 @@ abstract contract GraphBaseTest is IHorizonStakingTypes, Utils, Constants { vm.label({ account: address(payments), newLabel: "GraphPayments" }); vm.label({ account: address(escrow), newLabel: "PaymentsEscrow" }); vm.label({ account: address(staking), newLabel: "HorizonStaking" }); - vm.label({ account: address(stakingExtension), newLabel: "HorizonStakingExtension" }); vm.label({ account: address(graphTallyCollector), newLabel: "GraphTallyCollector" }); // Ensure caller is back to the original msg.sender @@ -192,12 +188,7 @@ abstract contract GraphBaseTest is IHorizonStakingTypes, Utils, Constants { escrow = PaymentsEscrow(escrowProxyAddress); } - stakingExtension = new HorizonStakingExtension(address(controller), subgraphDataServiceLegacyAddress); - stakingBase = new HorizonStaking( - address(controller), - address(stakingExtension), - subgraphDataServiceLegacyAddress - ); + stakingBase = new HorizonStaking(address(controller), subgraphDataServiceLegacyAddress); graphTallyCollector = new GraphTallyCollector( "GraphTallyCollector", diff --git a/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol b/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol index f85569151..520676ec0 100644 --- a/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol +++ b/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol @@ -3,17 +3,20 @@ pragma solidity ^0.8.27; import { GraphBaseTest } from "../../GraphBase.t.sol"; import { DataServiceImpPausableUpgradeable } from "../implementations/DataServiceImpPausableUpgradeable.sol"; +import { IDataServicePausable } from "@graphprotocol/interfaces/contracts/data-service/IDataServicePausable.sol"; import { UnsafeUpgrades } from "@openzeppelin/foundry-upgrades/src/Upgrades.sol"; import { PPMMath } from "./../../../../contracts/libraries/PPMMath.sol"; contract DataServicePausableUpgradeableTest is GraphBaseTest { - function test_WhenTheContractIsDeployed() external { - ( - DataServiceImpPausableUpgradeable dataService, - DataServiceImpPausableUpgradeable implementation - ) = _deployDataService(); + DataServiceImpPausableUpgradeable private dataService; + function setUp() public override { + super.setUp(); + (dataService, ) = _deployDataService(); + } + + function test_WhenTheContractIsDeployed() external view { // via proxy - ensure that the proxy was initialized correctly // these calls validate proxy storage was correctly initialized uint32 delegationRatio = dataService.getDelegationRatio(); @@ -30,13 +33,113 @@ contract DataServicePausableUpgradeableTest is GraphBaseTest { (uint64 minThawingPeriod, uint64 maxThawingPeriod) = dataService.getThawingPeriodRange(); assertEq(minThawingPeriod, type(uint64).min); assertEq(maxThawingPeriod, type(uint64).max); + } + + // -- setPauseGuardian -- + + function test_SetPauseGuardian() external { + address guardian = makeAddr("guardian"); + + vm.expectEmit(address(dataService)); + emit IDataServicePausable.PauseGuardianSet(guardian, true); + dataService.setPauseGuardian(guardian, true); + + assertTrue(dataService.pauseGuardians(guardian)); + } + + function test_SetPauseGuardian_Remove() external { + address guardian = makeAddr("guardian"); + dataService.setPauseGuardian(guardian, true); + + vm.expectEmit(address(dataService)); + emit IDataServicePausable.PauseGuardianSet(guardian, false); + dataService.setPauseGuardian(guardian, false); + + assertFalse(dataService.pauseGuardians(guardian)); + } + + function test_RevertWhen_SetPauseGuardian_NoChange_AlreadyFalse() external { + address guardian = makeAddr("guardian"); + + // guardian defaults to false, setting to false should revert + vm.expectRevert( + abi.encodeWithSelector( + IDataServicePausable.DataServicePausablePauseGuardianNoChange.selector, + guardian, + false + ) + ); + dataService.setPauseGuardian(guardian, false); + } + + function test_RevertWhen_SetPauseGuardian_NoChange_AlreadyTrue() external { + address guardian = makeAddr("guardian"); + dataService.setPauseGuardian(guardian, true); + + // guardian is already true, setting to true should revert + vm.expectRevert( + abi.encodeWithSelector( + IDataServicePausable.DataServicePausablePauseGuardianNoChange.selector, + guardian, + true + ) + ); + dataService.setPauseGuardian(guardian, true); + } + + // -- pause -- + + function test_Pause() external { + address guardian = makeAddr("guardian"); + dataService.setPauseGuardian(guardian, true); + + vm.prank(guardian); + dataService.pause(); + + assertTrue(dataService.paused()); + } + + function test_RevertWhen_Pause_NotGuardian() external { + address notGuardian = makeAddr("notGuardian"); - // this ensures that implementation immutables were correctly initialized - // and they can be read via the proxy - assertEq(implementation.controller(), address(controller)); - assertEq(dataService.controller(), address(controller)); + vm.expectRevert( + abi.encodeWithSelector(IDataServicePausable.DataServicePausableNotPauseGuardian.selector, notGuardian) + ); + vm.prank(notGuardian); + dataService.pause(); } + // -- unpause -- + + function test_Unpause() external { + address guardian = makeAddr("guardian"); + dataService.setPauseGuardian(guardian, true); + + vm.startPrank(guardian); + dataService.pause(); + dataService.unpause(); + vm.stopPrank(); + + assertFalse(dataService.paused()); + } + + function test_RevertWhen_Unpause_NotGuardian() external { + address guardian = makeAddr("guardian"); + dataService.setPauseGuardian(guardian, true); + + vm.prank(guardian); + dataService.pause(); + + address notGuardian = makeAddr("notGuardian"); + vm.expectRevert( + abi.encodeWithSelector(IDataServicePausable.DataServicePausableNotPauseGuardian.selector, notGuardian) + ); + vm.prank(notGuardian); + dataService.unpause(); + } + + // -- helpers -- + function _deployDataService() internal returns (DataServiceImpPausableUpgradeable, DataServiceImpPausableUpgradeable) diff --git a/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol b/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol index 32fb97b22..2eccd5899 100644 --- a/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol +++ b/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol @@ -31,6 +31,10 @@ contract DataServiceImpPausableUpgradeable is DataServicePausableUpgradeable { function slash(address serviceProvider, bytes calldata data) external {} + function setPauseGuardian(address _pauseGuardian, bool _allowed) external { + _setPauseGuardian(_pauseGuardian, _allowed); + } + function controller() external view returns (address) { return address(_graphController()); } diff --git a/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol b/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol index 5861b9f27..1c15ce738 100644 --- a/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol +++ b/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol @@ -3,16 +3,13 @@ pragma solidity ^0.8.27; import { GraphBaseTest } from "../../GraphBase.t.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; -import { IHorizonStakingBase } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol"; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; import { LinkedList } from "../../../../contracts/libraries/LinkedList.sol"; -import { MathUtils } from "../../../../contracts/libraries/MathUtils.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { PPMMath } from "../../../../contracts/libraries/PPMMath.sol"; -import { ExponentialRebates } from "../../../../contracts/staking/libraries/ExponentialRebates.sol"; abstract contract HorizonStakingSharedTest is GraphBaseTest { using LinkedList for ILinkedList.List; @@ -21,13 +18,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { event Transfer(address indexed from, address indexed to, uint tokens); address internal _allocationId = makeAddr("allocationId"); - bytes32 internal constant _SUBGRAPH_DEPLOYMENT_ID = keccak256("subgraphDeploymentID"); - uint256 internal constant MAX_ALLOCATION_EPOCHS = 28; - - uint32 internal alphaNumerator = 100; - uint32 internal alphaDenominator = 100; - uint32 internal lambdaNumerator = 60; - uint32 internal lambdaDenominator = 100; /* * MODIFIERS @@ -78,17 +68,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { _createProvision(users.indexer, dataService, tokens, maxVerifierCut, thawingPeriod); } - modifier useAllocation(uint256 tokens) { - vm.assume(tokens <= MAX_STAKING_TOKENS); - _createAllocation(users.indexer, _allocationId, _SUBGRAPH_DEPLOYMENT_ID, tokens); - _; - } - - modifier useRebateParameters() { - _setStorageRebateParameters(alphaNumerator, alphaDenominator, lambdaNumerator, lambdaDenominator); - _; - } - /* * HELPERS: these are shortcuts to perform common actions that often involve multiple contract calls */ @@ -103,34 +82,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { _provision(serviceProvider, verifier, tokens, maxVerifierCut, thawingPeriod); } - // This allows setting up contract state with legacy allocations - function _createAllocation( - address serviceProvider, - address allocationId, - bytes32 subgraphDeploymentId, - uint256 tokens - ) internal { - _setStorageMaxAllocationEpochs(MAX_ALLOCATION_EPOCHS); - - IHorizonStakingExtension.Allocation memory _allocation = IHorizonStakingExtension.Allocation({ - indexer: serviceProvider, - subgraphDeploymentID: subgraphDeploymentId, - tokens: tokens, - createdAtEpoch: block.timestamp, - closedAtEpoch: 0, - collectedFees: 0, - __DEPRECATED_effectiveAllocation: 0, - accRewardsPerAllocatedToken: 0, - distributedRebates: 0 - }); - _setStorageAllocation(_allocation, allocationId, tokens); - - // delegation pool initialized - _setStorageDelegationPool(serviceProvider, 0, uint32(PPMMath.MAX_PPM), uint32(PPMMath.MAX_PPM)); - - require(token.transfer(address(staking), tokens), "Transfer failed"); - } - /* * ACTIONS: these are individual contract calls wrapped in assertion blocks to ensure they work as expected */ @@ -150,7 +101,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { // stakeTo token.approve(address(staking), tokens); vm.expectEmit(); - emit IHorizonStakingBase.HorizonStakeDeposited(serviceProvider, tokens); + emit IHorizonStakingMain.HorizonStakeDeposited(serviceProvider, tokens); staking.stakeTo(serviceProvider, tokens); // after @@ -183,7 +134,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { // stakeTo token.approve(address(staking), tokens); vm.expectEmit(); - emit IHorizonStakingBase.HorizonStakeDeposited(serviceProvider, tokens); + emit IHorizonStakingMain.HorizonStakeDeposited(serviceProvider, tokens); vm.expectEmit(); emit IHorizonStakingMain.ProvisionIncreased(serviceProvider, verifier, tokens); staking.stakeToProvision(serviceProvider, verifier, tokens); @@ -230,48 +181,15 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { function _unstake(uint256 _tokens) internal { (, address msgSender, ) = vm.readCallers(); - uint256 deprecatedThawingPeriod = staking.__DEPRECATED_getThawingPeriod(); - // before uint256 beforeSenderBalance = token.balanceOf(msgSender); uint256 beforeStakingBalance = token.balanceOf(address(staking)); ServiceProviderInternal memory beforeServiceProvider = _getStorageServiceProviderInternal(msgSender); - bool withdrawCalled = beforeServiceProvider.__DEPRECATED_tokensLocked != 0 && - block.number >= beforeServiceProvider.__DEPRECATED_tokensLockedUntil; - - if (deprecatedThawingPeriod != 0 && beforeServiceProvider.__DEPRECATED_tokensLocked > 0) { - deprecatedThawingPeriod = MathUtils.weightedAverageRoundingUp( - MathUtils.diffOrZero( - withdrawCalled ? 0 : beforeServiceProvider.__DEPRECATED_tokensLockedUntil, - block.number - ), - withdrawCalled ? 0 : beforeServiceProvider.__DEPRECATED_tokensLocked, - deprecatedThawingPeriod, - _tokens - ); - } - // unstake - if (deprecatedThawingPeriod == 0) { - vm.expectEmit(address(staking)); - emit IHorizonStakingMain.HorizonStakeWithdrawn(msgSender, _tokens); - } else { - if (withdrawCalled) { - vm.expectEmit(address(staking)); - emit IHorizonStakingMain.HorizonStakeWithdrawn( - msgSender, - beforeServiceProvider.__DEPRECATED_tokensLocked - ); - } + vm.expectEmit(address(staking)); + emit IHorizonStakingMain.HorizonStakeWithdrawn(msgSender, _tokens); - vm.expectEmit(address(staking)); - emit IHorizonStakingMain.HorizonStakeLocked( - msgSender, - withdrawCalled ? _tokens : beforeServiceProvider.__DEPRECATED_tokensLocked + _tokens, - block.number + deprecatedThawingPeriod - ); - } staking.unstake(_tokens); // after @@ -280,41 +198,16 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { ServiceProviderInternal memory afterServiceProvider = _getStorageServiceProviderInternal(msgSender); // assert - if (deprecatedThawingPeriod == 0) { - assertEq(afterSenderBalance, _tokens + beforeSenderBalance); - assertEq(afterStakingBalance, beforeStakingBalance - _tokens); - assertEq(afterServiceProvider.tokensStaked, beforeServiceProvider.tokensStaked - _tokens); - assertEq(afterServiceProvider.tokensProvisioned, beforeServiceProvider.tokensProvisioned); - assertEq( - afterServiceProvider.__DEPRECATED_tokensAllocated, - beforeServiceProvider.__DEPRECATED_tokensAllocated - ); - assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, beforeServiceProvider.__DEPRECATED_tokensLocked); - assertEq( - afterServiceProvider.__DEPRECATED_tokensLockedUntil, - beforeServiceProvider.__DEPRECATED_tokensLockedUntil - ); - } else { - assertEq( - afterServiceProvider.tokensStaked, - withdrawCalled - ? beforeServiceProvider.tokensStaked - beforeServiceProvider.__DEPRECATED_tokensLocked - : beforeServiceProvider.tokensStaked - ); - assertEq( - afterServiceProvider.__DEPRECATED_tokensLocked, - _tokens + (withdrawCalled ? 0 : beforeServiceProvider.__DEPRECATED_tokensLocked) - ); - assertEq(afterServiceProvider.__DEPRECATED_tokensLockedUntil, block.number + deprecatedThawingPeriod); - assertEq(afterServiceProvider.tokensProvisioned, beforeServiceProvider.tokensProvisioned); - assertEq( - afterServiceProvider.__DEPRECATED_tokensAllocated, - beforeServiceProvider.__DEPRECATED_tokensAllocated - ); - uint256 tokensTransferred = (withdrawCalled ? beforeServiceProvider.__DEPRECATED_tokensLocked : 0); - assertEq(afterSenderBalance, beforeSenderBalance + tokensTransferred); - assertEq(afterStakingBalance, beforeStakingBalance - tokensTransferred); - } + assertEq(afterSenderBalance, _tokens + beforeSenderBalance); + assertEq(afterStakingBalance, beforeStakingBalance - _tokens); + assertEq(afterServiceProvider.tokensStaked, beforeServiceProvider.tokensStaked - _tokens); + assertEq(afterServiceProvider.tokensProvisioned, beforeServiceProvider.tokensProvisioned); + assertEq(afterServiceProvider.__DEPRECATED_tokensAllocated, beforeServiceProvider.__DEPRECATED_tokensAllocated); + assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, beforeServiceProvider.__DEPRECATED_tokensLocked); + assertEq( + afterServiceProvider.__DEPRECATED_tokensLockedUntil, + beforeServiceProvider.__DEPRECATED_tokensLockedUntil + ); } function _withdraw() internal { @@ -1453,19 +1346,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterEnabled, true); } - function _clearThawingPeriod() internal { - // clearThawingPeriod - vm.expectEmit(address(staking)); - emit IHorizonStakingMain.ThawingPeriodCleared(); - staking.clearThawingPeriod(); - - // after - uint64 afterThawingPeriod = staking.__DEPRECATED_getThawingPeriod(); - - // assert - assertEq(afterThawingPeriod, 0); - } - function _setMaxThawingPeriod(uint64 maxThawingPeriod) internal { // setMaxThawingPeriod vm.expectEmit(address(staking)); @@ -1509,8 +1389,8 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { // Calculate expected tokens after slashing CalcValuesSlash memory calcValues; - calcValues.tokensToSlash = MathUtils.min(tokens, before.provision.tokens + before.pool.tokens); - calcValues.providerTokensSlashed = MathUtils.min(before.provision.tokens, calcValues.tokensToSlash); + calcValues.tokensToSlash = Math.min(tokens, before.provision.tokens + before.pool.tokens); + calcValues.providerTokensSlashed = Math.min(before.provision.tokens, calcValues.tokensToSlash); calcValues.delegationTokensSlashed = calcValues.tokensToSlash - calcValues.providerTokensSlashed; if (calcValues.tokensToSlash > 0) { @@ -1612,314 +1492,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { } } - // use struct to avoid 'stack too deep' error - struct CalcValuesCloseAllocation { - uint256 rewards; - uint256 delegatorRewards; - uint256 indexerRewards; - } - struct BeforeValuesCloseAllocation { - IHorizonStakingExtension.Allocation allocation; - DelegationPoolInternalTest pool; - ServiceProviderInternal serviceProvider; - uint256 subgraphAllocations; - uint256 stakingBalance; - uint256 indexerBalance; - uint256 beneficiaryBalance; - } - - // Current rewards manager is mocked and assumed to mint fixed rewards - function _closeAllocation(address allocationId, bytes32 poi) internal { - (, address msgSender, ) = vm.readCallers(); - - // before - BeforeValuesCloseAllocation memory beforeValues; - beforeValues.allocation = staking.getAllocation(allocationId); - beforeValues.pool = _getStorageDelegationPoolInternal( - beforeValues.allocation.indexer, - subgraphDataServiceLegacyAddress, - true - ); - beforeValues.serviceProvider = _getStorageServiceProviderInternal(beforeValues.allocation.indexer); - beforeValues.subgraphAllocations = _getStorageSubgraphAllocations(beforeValues.allocation.subgraphDeploymentID); - beforeValues.stakingBalance = token.balanceOf(address(staking)); - beforeValues.indexerBalance = token.balanceOf(beforeValues.allocation.indexer); - beforeValues.beneficiaryBalance = token.balanceOf( - _getStorageRewardsDestination(beforeValues.allocation.indexer) - ); - - bool isAuth = staking.isAuthorized( - beforeValues.allocation.indexer, - subgraphDataServiceLegacyAddress, - msgSender - ); - address rewardsDestination = _getStorageRewardsDestination(beforeValues.allocation.indexer); - - CalcValuesCloseAllocation memory calcValues = CalcValuesCloseAllocation({ - rewards: ALLOCATIONS_REWARD_CUT, - delegatorRewards: ALLOCATIONS_REWARD_CUT - - uint256(beforeValues.pool.__DEPRECATED_indexingRewardCut).mulPPM(ALLOCATIONS_REWARD_CUT), - indexerRewards: 0 - }); - calcValues.indexerRewards = - ALLOCATIONS_REWARD_CUT - (beforeValues.pool.tokens > 0 ? calcValues.delegatorRewards : 0); - - // closeAllocation - vm.expectEmit(address(staking)); - emit IHorizonStakingExtension.AllocationClosed( - beforeValues.allocation.indexer, - beforeValues.allocation.subgraphDeploymentID, - epochManager.currentEpoch(), - beforeValues.allocation.tokens, - allocationId, - msgSender, - poi, - !isAuth - ); - staking.closeAllocation(allocationId, poi); - - // after - IHorizonStakingExtension.Allocation memory afterAllocation = staking.getAllocation(allocationId); - DelegationPoolInternalTest memory afterPool = _getStorageDelegationPoolInternal( - beforeValues.allocation.indexer, - subgraphDataServiceLegacyAddress, - true - ); - ServiceProviderInternal memory afterServiceProvider = _getStorageServiceProviderInternal( - beforeValues.allocation.indexer - ); - uint256 afterSubgraphAllocations = _getStorageSubgraphAllocations(beforeValues.allocation.subgraphDeploymentID); - uint256 afterStakingBalance = token.balanceOf(address(staking)); - uint256 afterIndexerBalance = token.balanceOf(beforeValues.allocation.indexer); - uint256 afterBeneficiaryBalance = token.balanceOf(rewardsDestination); - - if (beforeValues.allocation.tokens > 0) { - if (isAuth && poi != 0) { - if (rewardsDestination != address(0)) { - assertEq( - beforeValues.stakingBalance + calcValues.rewards - calcValues.indexerRewards, - afterStakingBalance - ); - assertEq(beforeValues.indexerBalance, afterIndexerBalance); - assertEq(beforeValues.beneficiaryBalance + calcValues.indexerRewards, afterBeneficiaryBalance); - } else { - assertEq(beforeValues.stakingBalance + calcValues.rewards, afterStakingBalance); - assertEq(beforeValues.indexerBalance, afterIndexerBalance); - assertEq(beforeValues.beneficiaryBalance, afterBeneficiaryBalance); - } - } else { - assertEq(beforeValues.stakingBalance, afterStakingBalance); - assertEq(beforeValues.indexerBalance, afterIndexerBalance); - assertEq(beforeValues.beneficiaryBalance, afterBeneficiaryBalance); - } - } else { - assertEq(beforeValues.stakingBalance, afterStakingBalance); - assertEq(beforeValues.indexerBalance, afterIndexerBalance); - assertEq(beforeValues.beneficiaryBalance, afterBeneficiaryBalance); - } - - assertEq(afterAllocation.indexer, beforeValues.allocation.indexer); - assertEq(afterAllocation.subgraphDeploymentID, beforeValues.allocation.subgraphDeploymentID); - assertEq(afterAllocation.tokens, beforeValues.allocation.tokens); - assertEq(afterAllocation.createdAtEpoch, beforeValues.allocation.createdAtEpoch); - assertEq(afterAllocation.closedAtEpoch, epochManager.currentEpoch()); - assertEq(afterAllocation.collectedFees, beforeValues.allocation.collectedFees); - assertEq( - afterAllocation.__DEPRECATED_effectiveAllocation, - beforeValues.allocation.__DEPRECATED_effectiveAllocation - ); - assertEq(afterAllocation.accRewardsPerAllocatedToken, beforeValues.allocation.accRewardsPerAllocatedToken); - assertEq(afterAllocation.distributedRebates, beforeValues.allocation.distributedRebates); - - if (beforeValues.allocation.tokens > 0 && isAuth && poi != 0 && rewardsDestination == address(0)) { - assertEq( - afterServiceProvider.tokensStaked, - beforeValues.serviceProvider.tokensStaked + calcValues.indexerRewards - ); - } else { - assertEq(afterServiceProvider.tokensStaked, beforeValues.serviceProvider.tokensStaked); - } - assertEq(afterServiceProvider.tokensProvisioned, beforeValues.serviceProvider.tokensProvisioned); - assertEq( - afterServiceProvider.__DEPRECATED_tokensAllocated + beforeValues.allocation.tokens, - beforeValues.serviceProvider.__DEPRECATED_tokensAllocated - ); - assertEq( - afterServiceProvider.__DEPRECATED_tokensLocked, - beforeValues.serviceProvider.__DEPRECATED_tokensLocked - ); - assertEq( - afterServiceProvider.__DEPRECATED_tokensLockedUntil, - beforeValues.serviceProvider.__DEPRECATED_tokensLockedUntil - ); - - assertEq(afterSubgraphAllocations + beforeValues.allocation.tokens, beforeValues.subgraphAllocations); - - if (beforeValues.allocation.tokens > 0 && isAuth && poi != 0 && beforeValues.pool.tokens > 0) { - assertEq(afterPool.tokens, beforeValues.pool.tokens + calcValues.delegatorRewards); - } else { - assertEq(afterPool.tokens, beforeValues.pool.tokens); - } - } - - // use struct to avoid 'stack too deep' error - struct BeforeValuesCollect { - IHorizonStakingExtension.Allocation allocation; - DelegationPoolInternalTest pool; - ServiceProviderInternal serviceProvider; - uint256 stakingBalance; - uint256 senderBalance; - uint256 curationBalance; - uint256 beneficiaryBalance; - } - struct CalcValuesCollect { - uint256 protocolTaxTokens; - uint256 queryFees; - uint256 curationCutTokens; - uint256 newRebates; - uint256 payment; - uint256 delegationFeeCut; - } - struct AfterValuesCollect { - IHorizonStakingExtension.Allocation allocation; - DelegationPoolInternalTest pool; - ServiceProviderInternal serviceProvider; - uint256 stakingBalance; - uint256 senderBalance; - uint256 curationBalance; - uint256 beneficiaryBalance; - } - - function _collect(uint256 tokens, address allocationId) internal { - (, address msgSender, ) = vm.readCallers(); - - // before - BeforeValuesCollect memory beforeValues; - beforeValues.allocation = staking.getAllocation(allocationId); - beforeValues.pool = _getStorageDelegationPoolInternal( - beforeValues.allocation.indexer, - subgraphDataServiceLegacyAddress, - true - ); - beforeValues.serviceProvider = _getStorageServiceProviderInternal(beforeValues.allocation.indexer); - - (uint32 curationPercentage, uint32 protocolPercentage) = _getStorageProtocolTaxAndCuration(); - address rewardsDestination = _getStorageRewardsDestination(beforeValues.allocation.indexer); - - beforeValues.stakingBalance = token.balanceOf(address(staking)); - beforeValues.senderBalance = token.balanceOf(msgSender); - beforeValues.curationBalance = token.balanceOf(address(curation)); - beforeValues.beneficiaryBalance = token.balanceOf(rewardsDestination); - - // calc some stuff - CalcValuesCollect memory calcValues; - calcValues.protocolTaxTokens = tokens.mulPPMRoundUp(protocolPercentage); - calcValues.queryFees = tokens - calcValues.protocolTaxTokens; - calcValues.curationCutTokens = 0; - if (curation.isCurated(beforeValues.allocation.subgraphDeploymentID)) { - calcValues.curationCutTokens = calcValues.queryFees.mulPPMRoundUp(curationPercentage); - calcValues.queryFees -= calcValues.curationCutTokens; - } - calcValues.newRebates = ExponentialRebates.exponentialRebates( - calcValues.queryFees + beforeValues.allocation.collectedFees, - beforeValues.allocation.tokens, - alphaNumerator, - alphaDenominator, - lambdaNumerator, - lambdaDenominator - ); - calcValues.payment = calcValues.newRebates > calcValues.queryFees - ? calcValues.queryFees - : calcValues.newRebates; - calcValues.delegationFeeCut = 0; - if (beforeValues.pool.tokens > 0) { - calcValues.delegationFeeCut = - calcValues.payment - calcValues.payment.mulPPM(beforeValues.pool.__DEPRECATED_queryFeeCut); - calcValues.payment -= calcValues.delegationFeeCut; - } - - // staking.collect() - if (tokens > 0) { - vm.expectEmit(address(staking)); - emit IHorizonStakingExtension.RebateCollected( - msgSender, - beforeValues.allocation.indexer, - beforeValues.allocation.subgraphDeploymentID, - allocationId, - epochManager.currentEpoch(), - tokens, - calcValues.protocolTaxTokens, - calcValues.curationCutTokens, - calcValues.queryFees, - calcValues.payment, - calcValues.delegationFeeCut - ); - } - staking.collect(tokens, allocationId); - - // after - AfterValuesCollect memory afterValues; - afterValues.allocation = staking.getAllocation(allocationId); - afterValues.pool = _getStorageDelegationPoolInternal( - beforeValues.allocation.indexer, - subgraphDataServiceLegacyAddress, - true - ); - afterValues.serviceProvider = _getStorageServiceProviderInternal(beforeValues.allocation.indexer); - afterValues.stakingBalance = token.balanceOf(address(staking)); - afterValues.senderBalance = token.balanceOf(msgSender); - afterValues.curationBalance = token.balanceOf(address(curation)); - afterValues.beneficiaryBalance = token.balanceOf(rewardsDestination); - - // assert - assertEq(afterValues.senderBalance + tokens, beforeValues.senderBalance); - assertEq(afterValues.curationBalance, beforeValues.curationBalance + calcValues.curationCutTokens); - if (rewardsDestination != address(0)) { - assertEq(afterValues.beneficiaryBalance, beforeValues.beneficiaryBalance + calcValues.payment); - assertEq(afterValues.stakingBalance, beforeValues.stakingBalance + calcValues.delegationFeeCut); - } else { - assertEq(afterValues.beneficiaryBalance, beforeValues.beneficiaryBalance); - assertEq( - afterValues.stakingBalance, - beforeValues.stakingBalance + calcValues.delegationFeeCut + calcValues.payment - ); - } - - assertEq( - afterValues.allocation.collectedFees, - beforeValues.allocation.collectedFees + tokens - calcValues.protocolTaxTokens - calcValues.curationCutTokens - ); - assertEq(afterValues.allocation.indexer, beforeValues.allocation.indexer); - assertEq(afterValues.allocation.subgraphDeploymentID, beforeValues.allocation.subgraphDeploymentID); - assertEq(afterValues.allocation.tokens, beforeValues.allocation.tokens); - assertEq(afterValues.allocation.createdAtEpoch, beforeValues.allocation.createdAtEpoch); - assertEq(afterValues.allocation.closedAtEpoch, beforeValues.allocation.closedAtEpoch); - assertEq( - afterValues.allocation.accRewardsPerAllocatedToken, - beforeValues.allocation.accRewardsPerAllocatedToken - ); - assertEq( - afterValues.allocation.distributedRebates, - beforeValues.allocation.distributedRebates + calcValues.newRebates - ); - - assertEq(afterValues.pool.tokens, beforeValues.pool.tokens + calcValues.delegationFeeCut); - assertEq(afterValues.pool.shares, beforeValues.pool.shares); - assertEq(afterValues.pool.tokensThawing, beforeValues.pool.tokensThawing); - assertEq(afterValues.pool.sharesThawing, beforeValues.pool.sharesThawing); - assertEq(afterValues.pool.thawingNonce, beforeValues.pool.thawingNonce); - - assertEq(afterValues.serviceProvider.tokensProvisioned, beforeValues.serviceProvider.tokensProvisioned); - if (rewardsDestination != address(0)) { - assertEq(afterValues.serviceProvider.tokensStaked, beforeValues.serviceProvider.tokensStaked); - } else { - assertEq( - afterValues.serviceProvider.tokensStaked, - beforeValues.serviceProvider.tokensStaked + calcValues.payment - ); - } - } - /* * STORAGE HELPERS */ @@ -1964,22 +1536,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { return vm.load(address(staking), bytes32(slot)) == bytes32(uint256(1)); } - function _setStorageDeprecatedThawingPeriod(uint32 _thawingPeriod) internal { - uint256 slot = 13; - - // Read the current value of the slot - uint256 currentSlotValue = uint256(vm.load(address(staking), bytes32(slot))); - - // Create a mask to clear the bits for __DEPRECATED_thawingPeriod (bits 0-31) - uint256 mask = ~(uint256(0xFFFFFFFF)); // Mask to clear the first 32 bits - - // Clear the bits for __DEPRECATED_thawingPeriod and set the new value - uint256 newSlotValue = (currentSlotValue & mask) | uint256(_thawingPeriod); - - // Store the updated value back into the slot - vm.store(address(staking), bytes32(slot), bytes32(newSlotValue)); - } - function _setStorageServiceProvider( address _indexer, uint256 _tokensStaked, @@ -2091,62 +1647,9 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { return delegation; } - function _setStorageAllocation( - IHorizonStakingExtension.Allocation memory allocation, - address allocationId, - uint256 tokens - ) internal { - // __DEPRECATED_allocations - uint256 allocationsSlot = 15; - bytes32 allocationBaseSlot = keccak256(abi.encode(allocationId, allocationsSlot)); - vm.store(address(staking), allocationBaseSlot, bytes32(uint256(uint160(allocation.indexer)))); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 1), allocation.subgraphDeploymentID); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 2), bytes32(tokens)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 3), bytes32(allocation.createdAtEpoch)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 4), bytes32(allocation.closedAtEpoch)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 5), bytes32(allocation.collectedFees)); - vm.store( - address(staking), - bytes32(uint256(allocationBaseSlot) + 6), - bytes32(allocation.__DEPRECATED_effectiveAllocation) - ); - vm.store( - address(staking), - bytes32(uint256(allocationBaseSlot) + 7), - bytes32(allocation.accRewardsPerAllocatedToken) - ); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 8), bytes32(allocation.distributedRebates)); - - // _serviceProviders - uint256 serviceProviderSlot = 14; - bytes32 serviceProviderBaseSlot = keccak256(abi.encode(allocation.indexer, serviceProviderSlot)); - uint256 currentTokensStaked = uint256(vm.load(address(staking), serviceProviderBaseSlot)); - uint256 currentTokensProvisioned = uint256( - vm.load(address(staking), bytes32(uint256(serviceProviderBaseSlot) + 1)) - ); - vm.store( - address(staking), - bytes32(uint256(serviceProviderBaseSlot) + 0), - bytes32(currentTokensStaked + tokens) - ); - vm.store( - address(staking), - bytes32(uint256(serviceProviderBaseSlot) + 1), - bytes32(currentTokensProvisioned + tokens) - ); - - // __DEPRECATED_subgraphAllocations - uint256 subgraphsAllocationsSlot = 16; - bytes32 subgraphAllocationsBaseSlot = keccak256( - abi.encode(allocation.subgraphDeploymentID, subgraphsAllocationsSlot) - ); - uint256 currentAllocatedTokens = uint256(vm.load(address(staking), subgraphAllocationsBaseSlot)); - vm.store(address(staking), subgraphAllocationsBaseSlot, bytes32(currentAllocatedTokens + tokens)); - } - - function _getStorageSubgraphAllocations(bytes32 subgraphDeploymentId) internal view returns (uint256) { + function _getStorageSubgraphAllocations(bytes32 subgraphDeploymentID) internal view returns (uint256) { uint256 subgraphsAllocationsSlot = 16; - bytes32 subgraphAllocationsBaseSlot = keccak256(abi.encode(subgraphDeploymentId, subgraphsAllocationsSlot)); + bytes32 subgraphAllocationsBaseSlot = keccak256(abi.encode(subgraphDeploymentID, subgraphsAllocationsSlot)); return uint256(vm.load(address(staking), subgraphAllocationsBaseSlot)); } @@ -2162,40 +1665,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { return address(uint160(uint256(vm.load(address(staking), rewardsDestinationSlotBaseSlot)))); } - function _setStorageMaxAllocationEpochs(uint256 maxAllocationEpochs) internal { - uint256 slot = 13; - - // Read the current value of the storage slot - uint256 currentSlotValue = uint256(vm.load(address(staking), bytes32(slot))); - - // Mask to clear the specific bits for __DEPRECATED_maxAllocationEpochs (bits 128-159) - uint256 mask = ~(uint256(0xFFFFFFFF) << 128); - - // Clear the bits and set the new maxAllocationEpochs value - uint256 newSlotValue = (currentSlotValue & mask) | (uint256(maxAllocationEpochs) << 128); - - // Store the updated value back into the slot - vm.store(address(staking), bytes32(slot), bytes32(newSlotValue)); - - uint256 readMaxAllocationEpochs = _getStorageMaxAllocationEpochs(); - assertEq(readMaxAllocationEpochs, maxAllocationEpochs); - } - - function _getStorageMaxAllocationEpochs() internal view returns (uint256) { - uint256 slot = 13; - - // Read the current value of the storage slot - uint256 currentSlotValue = uint256(vm.load(address(staking), bytes32(slot))); - - // Mask to isolate bits 128-159 - uint256 mask = uint256(0xFFFFFFFF) << 128; - - // Extract the maxAllocationEpochs by masking and shifting - uint256 maxAllocationEpochs = (currentSlotValue & mask) >> 128; - - return maxAllocationEpochs; - } - function _setStorageDelegationPool( address serviceProvider, uint256 tokens, @@ -2211,148 +1680,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { vm.store(address(staking), tokensSlot, bytes32(tokens)); } - function _setStorageRebateParameters( - uint32 alphaNumerator_, - uint32 alphaDenominator_, - uint32 lambdaNumerator_, - uint32 lambdaDenominator_ - ) internal { - // Store alpha numerator and denominator in slot 13 - uint256 alphaSlot = 13; - - uint256 newAlphaSlotValue; - { - uint256 alphaNumeratorOffset = 160; // Offset for __DEPRECATED_alphaNumerator (20th byte) - uint256 alphaDenominatorOffset = 192; // Offset for __DEPRECATED_alphaDenominator (24th byte) - - // Read current value of the slot - uint256 currentAlphaSlotValue = uint256(vm.load(address(staking), bytes32(alphaSlot))); - - // Create a mask to clear the bits for alphaNumerator and alphaDenominator - uint256 alphaMask = ~(uint256(0xFFFFFFFF) << alphaNumeratorOffset) & - ~(uint256(0xFFFFFFFF) << alphaDenominatorOffset); - - // Clear and set new values - newAlphaSlotValue = - (currentAlphaSlotValue & alphaMask) | - (uint256(alphaNumerator_) << alphaNumeratorOffset) | - (uint256(alphaDenominator_) << alphaDenominatorOffset); - } - - // Store the updated value back into the slot - vm.store(address(staking), bytes32(alphaSlot), bytes32(newAlphaSlotValue)); - - // Store lambda numerator and denominator in slot 25 - uint256 lambdaSlot = 25; - - uint256 newLambdaSlotValue; - { - uint256 lambdaNumeratorOffset = 160; // Offset for lambdaNumerator (20th byte) - uint256 lambdaDenominatorOffset = 192; // Offset for lambdaDenominator (24th byte) - - // Read current value of the slot - uint256 currentLambdaSlotValue = uint256(vm.load(address(staking), bytes32(lambdaSlot))); - - // Create a mask to clear the bits for lambdaNumerator and lambdaDenominator - uint256 lambdaMask = ~(uint256(0xFFFFFFFF) << lambdaNumeratorOffset) & - ~(uint256(0xFFFFFFFF) << lambdaDenominatorOffset); - - // Clear and set new values - newLambdaSlotValue = - (currentLambdaSlotValue & lambdaMask) | - (uint256(lambdaNumerator_) << lambdaNumeratorOffset) | - (uint256(lambdaDenominator_) << lambdaDenominatorOffset); - } - - // Store the updated value back into the slot - vm.store(address(staking), bytes32(lambdaSlot), bytes32(newLambdaSlotValue)); - - // Verify the storage - ( - uint32 readAlphaNumerator, - uint32 readAlphaDenominator, - uint32 readLambdaNumerator, - uint32 readLambdaDenominator - ) = _getStorageRebateParameters(); - assertEq(readAlphaNumerator, alphaNumerator_); - assertEq(readAlphaDenominator, alphaDenominator_); - assertEq(readLambdaNumerator, lambdaNumerator_); - assertEq(readLambdaDenominator, lambdaDenominator_); - } - - function _getStorageRebateParameters() internal view returns (uint32, uint32, uint32, uint32) { - // Read alpha numerator and denominator - uint256 alphaSlot = 13; - uint256 alphaValues = uint256(vm.load(address(staking), bytes32(alphaSlot))); - // forge-lint: disable-next-line(unsafe-typecast) - uint32 alphaNumerator_ = uint32(alphaValues >> 160); - // forge-lint: disable-next-line(unsafe-typecast) - uint32 alphaDenominator_ = uint32(alphaValues >> 192); - - // Read lambda numerator and denominator - uint256 lambdaSlot = 25; - uint256 lambdaValues = uint256(vm.load(address(staking), bytes32(lambdaSlot))); - // forge-lint: disable-next-line(unsafe-typecast) - uint32 lambdaNumerator_ = uint32(lambdaValues >> 160); - // forge-lint: disable-next-line(unsafe-typecast) - uint32 lambdaDenominator_ = uint32(lambdaValues >> 192); - - return (alphaNumerator_, alphaDenominator_, lambdaNumerator_, lambdaDenominator_); - } - - // function _setStorageProtocolTaxAndCuration(uint32 curationPercentage, uint32 taxPercentage) private { - // bytes32 slot = bytes32(uint256(13)); - // uint256 curationOffset = 4; - // uint256 protocolTaxOffset = 8; - // bytes32 originalValue = vm.load(address(staking), slot); - - // bytes32 newProtocolTaxValue = bytes32( - // ((uint256(originalValue) & - // ~((0xFFFFFFFF << (8 * curationOffset)) | (0xFFFFFFFF << (8 * protocolTaxOffset)))) | - // (uint256(curationPercentage) << (8 * curationOffset))) | - // (uint256(taxPercentage) << (8 * protocolTaxOffset)) - // ); - // vm.store(address(staking), slot, newProtocolTaxValue); - - // (uint32 readCurationPercentage, uint32 readTaxPercentage) = _getStorageProtocolTaxAndCuration(); - // assertEq(readCurationPercentage, curationPercentage); - // } - - function _setStorageProtocolTaxAndCuration(uint32 curationPercentage, uint32 taxPercentage) internal { - bytes32 slot = bytes32(uint256(13)); - - // Offsets for the percentages - uint256 curationOffset = 32; // __DEPRECATED_curationPercentage (2nd uint32, bits 32-63) - uint256 protocolTaxOffset = 64; // __DEPRECATED_protocolPercentage (3rd uint32, bits 64-95) - - // Read the current slot value - uint256 originalValue = uint256(vm.load(address(staking), slot)); - - // Create masks to clear the specific bits for the two percentages - uint256 mask = ~(uint256(0xFFFFFFFF) << curationOffset) & ~(uint256(0xFFFFFFFF) << protocolTaxOffset); // Mask for curationPercentage // Mask for protocolTax - - // Clear the existing bits and set the new values - uint256 newSlotValue = (originalValue & mask) | - (uint256(curationPercentage) << curationOffset) | - (uint256(taxPercentage) << protocolTaxOffset); - - // Store the updated slot value - vm.store(address(staking), slot, bytes32(newSlotValue)); - - // Verify the values were set correctly - (uint32 readCurationPercentage, uint32 readTaxPercentage) = _getStorageProtocolTaxAndCuration(); - assertEq(readCurationPercentage, curationPercentage); - assertEq(readTaxPercentage, taxPercentage); - } - - function _getStorageProtocolTaxAndCuration() internal view returns (uint32, uint32) { - bytes32 slot = bytes32(uint256(13)); - bytes32 value = vm.load(address(staking), slot); - uint32 curationPercentage = uint32(uint256(value) >> 32); - uint32 taxPercentage = uint32(uint256(value) >> 64); - return (curationPercentage, taxPercentage); - } - /* * MISC: private functions to help with testing */ diff --git a/packages/horizon/test/unit/staking/allocation/allocation.t.sol b/packages/horizon/test/unit/staking/allocation/allocation.t.sol deleted file mode 100644 index e4b0e22c3..000000000 --- a/packages/horizon/test/unit/staking/allocation/allocation.t.sol +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import { HorizonStakingTest } from "../HorizonStaking.t.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; - -contract HorizonStakingAllocationTest is HorizonStakingTest { - /* - * TESTS - */ - - function testAllocation_GetAllocationState_Active(uint256 tokens) public useIndexer useAllocation(tokens) { - IHorizonStakingExtension.AllocationState state = staking.getAllocationState(_allocationId); - assertEq(uint16(state), uint16(IHorizonStakingExtension.AllocationState.Active)); - } - - function testAllocation_GetAllocationState_Null() public view { - IHorizonStakingExtension.AllocationState state = staking.getAllocationState(_allocationId); - assertEq(uint16(state), uint16(IHorizonStakingExtension.AllocationState.Null)); - } - - function testAllocation_IsAllocation(uint256 tokens) public useIndexer useAllocation(tokens) { - bool isAllocation = staking.isAllocation(_allocationId); - assertTrue(isAllocation); - } - - function testAllocation_IsNotAllocation() public view { - bool isAllocation = staking.isAllocation(_allocationId); - assertFalse(isAllocation); - } -} diff --git a/packages/horizon/test/unit/staking/allocation/close.t.sol b/packages/horizon/test/unit/staking/allocation/close.t.sol deleted file mode 100644 index e5d222b59..000000000 --- a/packages/horizon/test/unit/staking/allocation/close.t.sol +++ /dev/null @@ -1,113 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import { HorizonStakingTest } from "../HorizonStaking.t.sol"; -import { PPMMath } from "../../../../contracts/libraries/PPMMath.sol"; - -contract HorizonStakingCloseAllocationTest is HorizonStakingTest { - using PPMMath for uint256; - - bytes32 internal constant _POI = keccak256("poi"); - - /* - * MODIFIERS - */ - - modifier useLegacyOperator() { - resetPrank(users.indexer); - _setOperator(subgraphDataServiceLegacyAddress, users.operator, true); - vm.startPrank(users.operator); - _; - vm.stopPrank(); - } - - /* - * TESTS - */ - - function testCloseAllocation(uint256 tokens) public useIndexer useAllocation(1 ether) { - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - // Skip 15 epochs - vm.roll(15); - - _closeAllocation(_allocationId, _POI); - } - - function testCloseAllocation_Operator(uint256 tokens) public useLegacyOperator useAllocation(1 ether) { - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - // Skip 15 epochs - vm.roll(15); - - _closeAllocation(_allocationId, _POI); - } - - function testCloseAllocation_WithBeneficiaryAddress(uint256 tokens) public useIndexer useAllocation(1 ether) { - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - address beneficiary = makeAddr("beneficiary"); - _setStorageRewardsDestination(users.indexer, beneficiary); - - // Skip 15 epochs - vm.roll(15); - - _closeAllocation(_allocationId, _POI); - } - - function testCloseAllocation_RevertWhen_NotActive() public { - vm.expectRevert("!active"); - staking.closeAllocation(_allocationId, _POI); - } - - function testCloseAllocation_RevertWhen_NotIndexer() public useIndexer useAllocation(1 ether) { - resetPrank(users.delegator); - vm.expectRevert("!auth"); - staking.closeAllocation(_allocationId, _POI); - } - - function testCloseAllocation_AfterMaxEpochs_AnyoneCanClose( - uint256 tokens - ) public useIndexer useAllocation(1 ether) { - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - // Skip to over the max allocation epochs - vm.roll((MAX_ALLOCATION_EPOCHS + 1) * EPOCH_LENGTH + 1); - - resetPrank(users.delegator); - _closeAllocation(_allocationId, 0x0); - } - - function testCloseAllocation_RevertWhen_ZeroTokensNotAuthorized() public useIndexer useAllocation(1 ether) { - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, 100 ether, 0, 0); - - resetPrank(users.delegator); - vm.expectRevert("!auth"); - staking.closeAllocation(_allocationId, 0x0); - } - - function testCloseAllocation_WithDelegation( - uint256 tokens, - uint256 delegationTokens, - uint32 indexingRewardCut - ) public useIndexer useAllocation(1 ether) { - tokens = bound(tokens, 2, MAX_STAKING_TOKENS); - delegationTokens = bound(delegationTokens, 0, MAX_STAKING_TOKENS); - vm.assume(indexingRewardCut <= MAX_PPM); - - uint256 legacyAllocationTokens = tokens / 2; - uint256 provisionTokens = tokens - legacyAllocationTokens; - - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, provisionTokens, 0, 0); - _setStorageDelegationPool(users.indexer, delegationTokens, indexingRewardCut, 0); - - // Skip 15 epochs - vm.roll(15); - - _closeAllocation(_allocationId, _POI); - } -} diff --git a/packages/horizon/test/unit/staking/allocation/collect.t.sol b/packages/horizon/test/unit/staking/allocation/collect.t.sol deleted file mode 100644 index 20fde8e91..000000000 --- a/packages/horizon/test/unit/staking/allocation/collect.t.sol +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import { console } from "forge-std/console.sol"; - -import { HorizonStakingTest } from "../HorizonStaking.t.sol"; -import { ExponentialRebates } from "../../../../contracts/staking/libraries/ExponentialRebates.sol"; -import { PPMMath } from "../../../../contracts/libraries/PPMMath.sol"; - -contract HorizonStakingCollectAllocationTest is HorizonStakingTest { - using PPMMath for uint256; - - /* - * TESTS - */ - - function testCollectAllocation_RevertWhen_InvalidAllocationId( - uint256 tokens - ) public useIndexer useAllocation(1 ether) { - vm.expectRevert("!alloc"); - staking.collect(tokens, address(0)); - } - - function testCollectAllocation_RevertWhen_Null(uint256 tokens) public { - vm.expectRevert("!collect"); - staking.collect(tokens, _allocationId); - } - - function testCollect_Tokens( - uint256 allocationTokens, - uint256 collectTokens, - uint256 curationTokens, - uint32 curationPercentage, - uint32 protocolTaxPercentage, - uint256 delegationTokens, - uint32 queryFeeCut - ) public useIndexer useRebateParameters useAllocation(allocationTokens) { - collectTokens = bound(collectTokens, 0, MAX_STAKING_TOKENS); - curationTokens = bound(curationTokens, 0, MAX_STAKING_TOKENS); - delegationTokens = bound(delegationTokens, 0, MAX_STAKING_TOKENS); - vm.assume(curationPercentage <= MAX_PPM); - vm.assume(protocolTaxPercentage <= MAX_PPM); - vm.assume(queryFeeCut <= MAX_PPM); - - resetPrank(users.indexer); - _setStorageProtocolTaxAndCuration(curationPercentage, protocolTaxPercentage); - console.log("queryFeeCut", queryFeeCut); - _setStorageDelegationPool(users.indexer, delegationTokens, 0, queryFeeCut); - curation.signal(_SUBGRAPH_DEPLOYMENT_ID, curationTokens); - - resetPrank(users.gateway); - approve(address(staking), collectTokens); - _collect(collectTokens, _allocationId); - } - - function testCollect_WithBeneficiaryAddress( - uint256 allocationTokens, - uint256 collectTokens - ) public useIndexer useRebateParameters useAllocation(allocationTokens) { - collectTokens = bound(collectTokens, 0, MAX_STAKING_TOKENS); - - address beneficiary = makeAddr("beneficiary"); - _setStorageRewardsDestination(users.indexer, beneficiary); - - resetPrank(users.gateway); - approve(address(staking), collectTokens); - _collect(collectTokens, _allocationId); - - uint256 newRebates = ExponentialRebates.exponentialRebates( - collectTokens, - allocationTokens, - alphaNumerator, - alphaDenominator, - lambdaNumerator, - lambdaDenominator - ); - uint256 payment = newRebates > collectTokens ? collectTokens : newRebates; - - assertEq(token.balanceOf(beneficiary), payment); - } -} diff --git a/packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol b/packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol new file mode 100644 index 000000000..5331fd9ea --- /dev/null +++ b/packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; + +import { HorizonStakingTest } from "../HorizonStaking.t.sol"; + +contract HorizonStakingForceWithdrawDelegatedTest is HorizonStakingTest { + /* + * MODIFIERS + */ + + modifier useDelegator() { + resetPrank(users.delegator); + _; + } + + /* + * HELPERS + */ + + function _setLegacyDelegation( + address _indexer, + address _delegator, + uint256 _shares, + uint256 __DEPRECATED_tokensLocked, + uint256 __DEPRECATED_tokensLockedUntil + ) public { + // Calculate the base storage slot for the serviceProvider in the mapping + bytes32 baseSlot = keccak256(abi.encode(_indexer, uint256(20))); + + // Calculate the slot for the delegator's DelegationInternal struct + bytes32 delegatorSlot = keccak256(abi.encode(_delegator, bytes32(uint256(baseSlot) + 4))); + + // Use vm.store to set each field of the struct + vm.store(address(staking), bytes32(uint256(delegatorSlot)), bytes32(_shares)); + vm.store(address(staking), bytes32(uint256(delegatorSlot) + 1), bytes32(__DEPRECATED_tokensLocked)); + vm.store(address(staking), bytes32(uint256(delegatorSlot) + 2), bytes32(__DEPRECATED_tokensLockedUntil)); + } + + /* + * ACTIONS + */ + + function _forceWithdrawDelegated(address _indexer, address _delegator) internal { + IHorizonStakingTypes.DelegationPool memory pool = staking.getDelegationPool( + _indexer, + subgraphDataServiceLegacyAddress + ); + uint256 beforeStakingBalance = token.balanceOf(address(staking)); + uint256 beforeDelegatorBalance = token.balanceOf(_delegator); + + vm.expectEmit(address(staking)); + emit IHorizonStakingMain.StakeDelegatedWithdrawn(_indexer, _delegator, pool.tokens); + staking.forceWithdrawDelegated(_indexer, _delegator); + + uint256 afterStakingBalance = token.balanceOf(address(staking)); + uint256 afterDelegatorBalance = token.balanceOf(_delegator); + + assertEq(afterStakingBalance, beforeStakingBalance - pool.tokens); + assertEq(afterDelegatorBalance - pool.tokens, beforeDelegatorBalance); + + DelegationInternal memory delegation = _getStorageDelegation( + _indexer, + subgraphDataServiceLegacyAddress, + _delegator, + true + ); + assertEq(delegation.shares, 0); + assertEq(delegation.__DEPRECATED_tokensLocked, 0); + assertEq(delegation.__DEPRECATED_tokensLockedUntil, 0); + } + + /* + * TESTS + */ + + function testForceWithdrawDelegated_Tokens(uint256 tokensLocked) public useDelegator { + vm.assume(tokensLocked > 0); + + _setStorageDelegationPool(users.indexer, tokensLocked, 0, 0); + _setLegacyDelegation(users.indexer, users.delegator, 0, tokensLocked, 1); + require(token.transfer(address(staking), tokensLocked), "transfer failed"); + + // switch to a third party (not the delegator) + resetPrank(users.operator); + + _forceWithdrawDelegated(users.indexer, users.delegator); + } + + function testForceWithdrawDelegated_CalledByDelegator(uint256 tokensLocked) public useDelegator { + vm.assume(tokensLocked > 0); + + _setStorageDelegationPool(users.indexer, tokensLocked, 0, 0); + _setLegacyDelegation(users.indexer, users.delegator, 0, tokensLocked, 1); + require(token.transfer(address(staking), tokensLocked), "transfer failed"); + + // delegator can also call forceWithdrawDelegated on themselves + _forceWithdrawDelegated(users.indexer, users.delegator); + } + + function testForceWithdrawDelegated_RevertWhen_NoTokens() public useDelegator { + _setStorageDelegationPool(users.indexer, 0, 0, 0); + _setLegacyDelegation(users.indexer, users.delegator, 0, 0, 0); + + // switch to a third party + resetPrank(users.operator); + + bytes memory expectedError = abi.encodeWithSignature("HorizonStakingNothingToWithdraw()"); + vm.expectRevert(expectedError); + staking.forceWithdrawDelegated(users.indexer, users.delegator); + } +} diff --git a/packages/horizon/test/unit/staking/delegation/withdraw.t.sol b/packages/horizon/test/unit/staking/delegation/withdraw.t.sol index e50c2ff66..bdc811c56 100644 --- a/packages/horizon/test/unit/staking/delegation/withdraw.t.sol +++ b/packages/horizon/test/unit/staking/delegation/withdraw.t.sol @@ -160,4 +160,56 @@ contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { resetPrank(users.delegator); _withdrawDelegated(users.indexer, subgraphDataServiceAddress, 0); } + + function testWithdrawDelegation_GetThawedTokens( + uint256 delegationAmount, + uint256 withdrawShares + ) + public + useIndexer + useProvision(10_000_000 ether, 0, MAX_THAWING_PERIOD) + useDelegation(delegationAmount) + useUndelegate(withdrawShares) + { + ILinkedList.List memory thawingRequests = staking.getThawRequestList( + IHorizonStakingTypes.ThawRequestType.Delegation, + users.indexer, + subgraphDataServiceAddress, + users.delegator + ); + ThawRequest memory thawRequest = staking.getThawRequest( + IHorizonStakingTypes.ThawRequestType.Delegation, + thawingRequests.tail + ); + + // Before thawing period passes, thawed tokens should be 0 + uint256 thawedTokensBefore = staking.getThawedTokens( + IHorizonStakingTypes.ThawRequestType.Delegation, + users.indexer, + subgraphDataServiceAddress, + users.delegator + ); + assertEq(thawedTokensBefore, 0); + + // Skip past thawing period + skip(thawRequest.thawingUntil + 1); + + // After thawing period, thawed tokens should match expected amount + uint256 thawedTokensAfter = staking.getThawedTokens( + IHorizonStakingTypes.ThawRequestType.Delegation, + users.indexer, + subgraphDataServiceAddress, + users.delegator + ); + + // Thawed tokens should be greater than 0 and should match what we can withdraw + assertGt(thawedTokensAfter, 0); + + // Withdraw and verify the amount matches + uint256 balanceBefore = token.balanceOf(users.delegator); + _withdrawDelegated(users.indexer, subgraphDataServiceAddress, 0); + uint256 balanceAfter = token.balanceOf(users.delegator); + + assertEq(balanceAfter - balanceBefore, thawedTokensAfter); + } } diff --git a/packages/horizon/test/unit/staking/governance/governance.t.sol b/packages/horizon/test/unit/staking/governance/governance.t.sol index 068dbee6b..7d6c90461 100644 --- a/packages/horizon/test/unit/staking/governance/governance.t.sol +++ b/packages/horizon/test/unit/staking/governance/governance.t.sol @@ -37,19 +37,6 @@ contract HorizonStakingGovernanceTest is HorizonStakingTest { staking.setDelegationSlashingEnabled(); } - function testGovernance_ClearThawingPeriod(uint32 thawingPeriod) public useGovernor { - // simulate previous thawing period - _setStorageDeprecatedThawingPeriod(thawingPeriod); - - _clearThawingPeriod(); - } - - function testGovernance_ClearThawingPeriod_NotGovernor() public useIndexer { - bytes memory expectedError = abi.encodeWithSignature("ManagedOnlyGovernor()"); - vm.expectRevert(expectedError); - staking.clearThawingPeriod(); - } - function testGovernance__SetMaxThawingPeriod(uint64 maxThawingPeriod) public useGovernor { _setMaxThawingPeriod(maxThawingPeriod); } diff --git a/packages/horizon/test/unit/staking/provision/parameters.t.sol b/packages/horizon/test/unit/staking/provision/parameters.t.sol index 9a723e1c3..0b3ed7203 100644 --- a/packages/horizon/test/unit/staking/provision/parameters.t.sol +++ b/packages/horizon/test/unit/staking/provision/parameters.t.sol @@ -175,4 +175,36 @@ contract HorizonStakingProvisionParametersTest is HorizonStakingTest { ); staking.acceptProvisionParameters(users.indexer); } + + function test_ProvisionParametersAccept_RevertWhen_MaxThawingPeriodReduced( + uint256 amount, + uint32 maxVerifierCut, + uint64 thawingPeriod + ) public useIndexer useValidParameters(maxVerifierCut, thawingPeriod) { + vm.assume(amount > 0); + vm.assume(amount <= MAX_STAKING_TOKENS); + vm.assume(thawingPeriod > 0); + + // Create provision with initial parameters (thawingPeriod = 0) + _createProvision(users.indexer, subgraphDataServiceAddress, amount, 0, 0); + + // Stage new parameters with valid thawing period + _setProvisionParameters(users.indexer, subgraphDataServiceAddress, maxVerifierCut, thawingPeriod); + + // Governor reduces max thawing period to below the staged value + uint64 newMaxThawingPeriod = thawingPeriod - 1; + resetPrank(users.governor); + _setMaxThawingPeriod(newMaxThawingPeriod); + + // Verifier tries to accept the parameters - should revert + resetPrank(subgraphDataServiceAddress); + vm.expectRevert( + abi.encodeWithSelector( + IHorizonStakingMain.HorizonStakingInvalidThawingPeriod.selector, + thawingPeriod, + newMaxThawingPeriod + ) + ); + staking.acceptProvisionParameters(users.indexer); + } } diff --git a/packages/horizon/test/unit/staking/provision/provision.t.sol b/packages/horizon/test/unit/staking/provision/provision.t.sol index 7862dd60c..53b29a0f2 100644 --- a/packages/horizon/test/unit/staking/provision/provision.t.sol +++ b/packages/horizon/test/unit/staking/provision/provision.t.sol @@ -94,22 +94,6 @@ contract HorizonStakingProvisionTest is HorizonStakingTest { staking.provision(users.indexer, subgraphDataServiceAddress, amount, maxVerifierCut, thawingPeriod); } - function testProvision_RevertWhen_VerifierIsNotSubgraphDataServiceDuringTransitionPeriod( - uint256 amount - ) public useIndexer useStake(amount) { - // simulate the transition period - _setStorageDeprecatedThawingPeriod(THAWING_PERIOD_IN_BLOCKS); - - // oddly we use subgraphDataServiceLegacyAddress as the subgraph service address - // so subgraphDataServiceAddress is not the subgraph service ¯\_(ツ)_/¯ - bytes memory expectedError = abi.encodeWithSignature( - "HorizonStakingInvalidVerifier(address)", - subgraphDataServiceAddress - ); - vm.expectRevert(expectedError); - staking.provision(users.indexer, subgraphDataServiceAddress, amount, 0, 0); - } - function testProvision_AddTokensToProvision( uint256 amount, uint32 maxVerifierCut, diff --git a/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol b/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol index 84008c01f..99ad0f25a 100644 --- a/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol +++ b/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol @@ -99,37 +99,6 @@ contract HorizonStakingServiceProviderTest is HorizonStakingTest { assertEq(providerTokensAvailable, amount); } - function testServiceProvider_HasStake( - uint256 amount - ) public useIndexer useProvision(amount, MAX_PPM, MAX_THAWING_PERIOD) { - assertTrue(staking.hasStake(users.indexer)); - - _thaw(users.indexer, subgraphDataServiceAddress, amount); - skip(MAX_THAWING_PERIOD + 1); - _deprovision(users.indexer, subgraphDataServiceAddress, 0); - staking.unstake(amount); - - assertFalse(staking.hasStake(users.indexer)); - } - - function testServiceProvider_GetIndexerStakedTokens( - uint256 amount - ) public useIndexer useProvision(amount, MAX_PPM, MAX_THAWING_PERIOD) { - assertEq(staking.getIndexerStakedTokens(users.indexer), amount); - - _thaw(users.indexer, subgraphDataServiceAddress, amount); - // Does not discount thawing tokens - assertEq(staking.getIndexerStakedTokens(users.indexer), amount); - - skip(MAX_THAWING_PERIOD + 1); - _deprovision(users.indexer, subgraphDataServiceAddress, 0); - // Does not discount thawing tokens - assertEq(staking.getIndexerStakedTokens(users.indexer), amount); - - staking.unstake(amount); - assertEq(staking.getIndexerStakedTokens(users.indexer), 0); - } - function testServiceProvider_RevertIf_InvalidDelegationFeeCut( uint256 cut, uint8 paymentTypeInput diff --git a/packages/horizon/test/unit/staking/slash/legacySlash.t.sol b/packages/horizon/test/unit/staking/slash/legacySlash.t.sol deleted file mode 100644 index 0e1724ecb..000000000 --- a/packages/horizon/test/unit/staking/slash/legacySlash.t.sol +++ /dev/null @@ -1,251 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; - -import { HorizonStakingTest } from "../HorizonStaking.t.sol"; - -contract HorizonStakingLegacySlashTest is HorizonStakingTest { - /* - * MODIFIERS - */ - - modifier useLegacySlasher(address slasher) { - bytes32 storageKey = keccak256(abi.encode(slasher, 18)); - vm.store(address(staking), storageKey, bytes32(uint256(1))); - _; - } - - /* - * HELPERS - */ - - function _setIndexer( - address _indexer, - uint256 _tokensStaked, - uint256 _tokensAllocated, - uint256 _tokensLocked, - uint256 _tokensLockedUntil - ) public { - bytes32 baseSlot = keccak256(abi.encode(_indexer, 14)); - - vm.store(address(staking), bytes32(uint256(baseSlot)), bytes32(_tokensStaked)); - vm.store(address(staking), bytes32(uint256(baseSlot) + 1), bytes32(_tokensAllocated)); - vm.store(address(staking), bytes32(uint256(baseSlot) + 2), bytes32(_tokensLocked)); - vm.store(address(staking), bytes32(uint256(baseSlot) + 3), bytes32(_tokensLockedUntil)); - } - - /* - * ACTIONS - */ - - function _legacySlash(address _indexer, uint256 _tokens, uint256 _rewards, address _beneficiary) internal { - // before - uint256 beforeStakingBalance = token.balanceOf(address(staking)); - uint256 beforeRewardsDestinationBalance = token.balanceOf(_beneficiary); - ServiceProviderInternal memory beforeIndexer = _getStorageServiceProviderInternal(_indexer); - - // calculate slashable stake - uint256 slashableStake = beforeIndexer.tokensStaked - beforeIndexer.tokensProvisioned; - uint256 actualTokens = _tokens; - uint256 actualRewards = _rewards; - if (slashableStake == 0) { - actualTokens = 0; - actualRewards = 0; - } else if (_tokens > slashableStake) { - actualRewards = (_rewards * slashableStake) / _tokens; - actualTokens = slashableStake; - } - - // slash - vm.expectEmit(address(staking)); - emit IHorizonStakingExtension.StakeSlashed(_indexer, actualTokens, actualRewards, _beneficiary); - staking.slash(_indexer, _tokens, _rewards, _beneficiary); - - // after - uint256 afterStakingBalance = token.balanceOf(address(staking)); - uint256 afterRewardsDestinationBalance = token.balanceOf(_beneficiary); - ServiceProviderInternal memory afterIndexer = _getStorageServiceProviderInternal(_indexer); - - assertEq(beforeStakingBalance - actualTokens, afterStakingBalance); - assertEq(beforeRewardsDestinationBalance, afterRewardsDestinationBalance - actualRewards); - assertEq(afterIndexer.tokensStaked, beforeIndexer.tokensStaked - actualTokens); - } - - /* - * TESTS - */ - function testSlash_Legacy( - uint256 tokensStaked, - uint256 tokensProvisioned, - uint256 slashTokens, - uint256 reward - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokensStaked > 0); - vm.assume(tokensStaked <= MAX_STAKING_TOKENS); - vm.assume(tokensProvisioned > 0); - vm.assume(tokensProvisioned <= tokensStaked); - slashTokens = bound(slashTokens, 1, tokensStaked); - reward = bound(reward, 0, slashTokens); - - _stake(tokensStaked); - _provision(users.indexer, subgraphDataServiceLegacyAddress, tokensProvisioned, 0, 0); - - resetPrank(users.legacySlasher); - _legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_UsingLockedTokens( - uint256 tokens, - uint256 slashTokens, - uint256 reward - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokens > 1); - slashTokens = bound(slashTokens, 1, tokens); - reward = bound(reward, 0, slashTokens); - - _setIndexer(users.indexer, tokens, 0, tokens, block.timestamp + 1); - // Send tokens manually to staking - require(token.transfer(address(staking), tokens), "Transfer failed"); - - resetPrank(users.legacySlasher); - _legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_UsingAllocatedTokens( - uint256 tokens, - uint256 slashTokens, - uint256 reward - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokens > 1); - slashTokens = bound(slashTokens, 1, tokens); - reward = bound(reward, 0, slashTokens); - - _setIndexer(users.indexer, tokens, 0, tokens, 0); - // Send tokens manually to staking - require(token.transfer(address(staking), tokens), "Transfer failed"); - - resetPrank(users.legacySlasher); - staking.legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_RevertWhen_CallerNotSlasher( - uint256 tokens, - uint256 slashTokens, - uint256 reward - ) public useIndexer { - vm.assume(tokens > 0); - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - vm.expectRevert("!slasher"); - staking.legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_RevertWhen_RewardsOverSlashTokens( - uint256 tokens, - uint256 slashTokens, - uint256 reward - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokens > 0); - vm.assume(slashTokens > 0); - vm.assume(reward > slashTokens); - - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - resetPrank(users.legacySlasher); - vm.expectRevert("rewards>slash"); - staking.legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_RevertWhen_NoStake( - uint256 slashTokens, - uint256 reward - ) public useLegacySlasher(users.legacySlasher) { - vm.assume(slashTokens > 0); - reward = bound(reward, 0, slashTokens); - - resetPrank(users.legacySlasher); - vm.expectRevert("!stake"); - staking.legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_RevertWhen_ZeroTokens( - uint256 tokens - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokens > 0); - - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - resetPrank(users.legacySlasher); - vm.expectRevert("!tokens"); - staking.legacySlash(users.indexer, 0, 0, makeAddr("fisherman")); - } - - function testSlash_Legacy_RevertWhen_NoBeneficiary( - uint256 tokens, - uint256 slashTokens, - uint256 reward - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokens > 0); - slashTokens = bound(slashTokens, 1, tokens); - reward = bound(reward, 0, slashTokens); - - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - resetPrank(users.legacySlasher); - vm.expectRevert("!beneficiary"); - staking.legacySlash(users.indexer, slashTokens, reward, address(0)); - } - - function test_LegacySlash_WhenTokensAllocatedGreaterThanStake() - public - useIndexer - useLegacySlasher(users.legacySlasher) - { - // Setup indexer with: - // - tokensStaked = 1000 GRT - // - tokensAllocated = 800 GRT - // - tokensLocked = 300 GRT - // This means tokensUsed (1100 GRT) > tokensStaked (1000 GRT) - _setIndexer( - users.indexer, - 1000 ether, // tokensStaked - 800 ether, // tokensAllocated - 300 ether, // tokensLocked - 0 // tokensLockedUntil - ); - - // Send tokens manually to staking - require(token.transfer(address(staking), 1100 ether), "Transfer failed"); - - resetPrank(users.legacySlasher); - _legacySlash(users.indexer, 1000 ether, 500 ether, makeAddr("fisherman")); - } - - function test_LegacySlash_WhenDelegateCallFails() public useIndexer useLegacySlasher(users.legacySlasher) { - // Setup indexer with: - // - tokensStaked = 1000 GRT - // - tokensAllocated = 800 GRT - // - tokensLocked = 300 GRT - - _setIndexer( - users.indexer, - 1000 ether, // tokensStaked - 800 ether, // tokensAllocated - 300 ether, // tokensLocked - 0 // tokensLockedUntil - ); - - // Send tokens manually to staking - require(token.transfer(address(staking), 1100 ether), "Transfer failed"); - - // Change staking extension code to an invalid opcode so the delegatecall reverts - address stakingExtension = staking.getStakingExtension(); - vm.etch(stakingExtension, hex"fe"); - - resetPrank(users.legacySlasher); - bytes memory expectedError = abi.encodeWithSignature("HorizonStakingLegacySlashFailed()"); - vm.expectRevert(expectedError); - staking.slash(users.indexer, 1000 ether, 500 ether, makeAddr("fisherman")); - } -} diff --git a/packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol b/packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol new file mode 100644 index 000000000..843d7e087 --- /dev/null +++ b/packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; + +import { HorizonStakingTest } from "../HorizonStaking.t.sol"; + +contract HorizonStakingForceWithdrawTest is HorizonStakingTest { + /* + * HELPERS + */ + + function _forceWithdraw(address _serviceProvider) internal { + (, address msgSender, ) = vm.readCallers(); + + // before + ServiceProviderInternal memory beforeServiceProvider = _getStorageServiceProviderInternal(_serviceProvider); + uint256 beforeServiceProviderBalance = token.balanceOf(_serviceProvider); + uint256 beforeCallerBalance = token.balanceOf(msgSender); + uint256 beforeStakingBalance = token.balanceOf(address(staking)); + + // forceWithdraw + vm.expectEmit(address(staking)); + emit IHorizonStakingMain.HorizonStakeWithdrawn( + _serviceProvider, + beforeServiceProvider.__DEPRECATED_tokensLocked + ); + staking.forceWithdraw(_serviceProvider); + + // after + ServiceProviderInternal memory afterServiceProvider = _getStorageServiceProviderInternal(_serviceProvider); + uint256 afterServiceProviderBalance = token.balanceOf(_serviceProvider); + uint256 afterCallerBalance = token.balanceOf(msgSender); + uint256 afterStakingBalance = token.balanceOf(address(staking)); + + // assert - tokens go to service provider, not caller + assertEq( + afterServiceProviderBalance - beforeServiceProviderBalance, + beforeServiceProvider.__DEPRECATED_tokensLocked + ); + assertEq(afterCallerBalance, beforeCallerBalance); // caller balance unchanged + assertEq(beforeStakingBalance - afterStakingBalance, beforeServiceProvider.__DEPRECATED_tokensLocked); + + // assert - service provider state updated + assertEq( + afterServiceProvider.tokensStaked, + beforeServiceProvider.tokensStaked - beforeServiceProvider.__DEPRECATED_tokensLocked + ); + assertEq(afterServiceProvider.tokensProvisioned, beforeServiceProvider.tokensProvisioned); + assertEq(afterServiceProvider.__DEPRECATED_tokensAllocated, beforeServiceProvider.__DEPRECATED_tokensAllocated); + assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, 0); + assertEq(afterServiceProvider.__DEPRECATED_tokensLockedUntil, 0); + } + + /* + * TESTS + */ + + function testForceWithdraw_Tokens(uint256 tokens, uint256 tokensLocked) public useIndexer { + tokens = bound(tokens, 1, MAX_STAKING_TOKENS); + tokensLocked = bound(tokensLocked, 1, tokens); + + // simulate locked tokens ready to withdraw + require(token.transfer(address(staking), tokens), "transfer failed"); + _setStorageServiceProvider(users.indexer, tokens, 0, tokensLocked, block.number, 0); + + _createProvision(users.indexer, subgraphDataServiceAddress, tokens, 0, MAX_THAWING_PERIOD); + + // switch to a different user (not the service provider) + resetPrank(users.delegator); + + _forceWithdraw(users.indexer); + } + + function testForceWithdraw_CalledByServiceProvider(uint256 tokens, uint256 tokensLocked) public useIndexer { + tokens = bound(tokens, 1, MAX_STAKING_TOKENS); + tokensLocked = bound(tokensLocked, 1, tokens); + + // simulate locked tokens ready to withdraw + require(token.transfer(address(staking), tokens), "transfer failed"); + _setStorageServiceProvider(users.indexer, tokens, 0, tokensLocked, block.number, 0); + + _createProvision(users.indexer, subgraphDataServiceAddress, tokens, 0, MAX_THAWING_PERIOD); + + // before + ServiceProviderInternal memory beforeServiceProvider = _getStorageServiceProviderInternal(users.indexer); + uint256 beforeServiceProviderBalance = token.balanceOf(users.indexer); + uint256 beforeStakingBalance = token.balanceOf(address(staking)); + + // service provider can also call forceWithdraw on themselves + vm.expectEmit(address(staking)); + emit IHorizonStakingMain.HorizonStakeWithdrawn(users.indexer, beforeServiceProvider.__DEPRECATED_tokensLocked); + staking.forceWithdraw(users.indexer); + + // after + ServiceProviderInternal memory afterServiceProvider = _getStorageServiceProviderInternal(users.indexer); + uint256 afterServiceProviderBalance = token.balanceOf(users.indexer); + uint256 afterStakingBalance = token.balanceOf(address(staking)); + + // assert + assertEq( + afterServiceProviderBalance - beforeServiceProviderBalance, + beforeServiceProvider.__DEPRECATED_tokensLocked + ); + assertEq(beforeStakingBalance - afterStakingBalance, beforeServiceProvider.__DEPRECATED_tokensLocked); + assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, 0); + assertEq(afterServiceProvider.__DEPRECATED_tokensLockedUntil, 0); + } + + function testForceWithdraw_RevertWhen_ZeroTokens(uint256 tokens) public useIndexer { + tokens = bound(tokens, 1, MAX_STAKING_TOKENS); + + // simulate zero locked tokens + require(token.transfer(address(staking), tokens), "transfer failed"); + _setStorageServiceProvider(users.indexer, tokens, 0, 0, 0, 0); + + _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, MAX_THAWING_PERIOD); + + // switch to a different user + resetPrank(users.delegator); + + vm.expectRevert(abi.encodeWithSelector(IHorizonStakingMain.HorizonStakingInvalidZeroTokens.selector)); + staking.forceWithdraw(users.indexer); + } +} diff --git a/packages/horizon/test/unit/staking/stake/unstake.t.sol b/packages/horizon/test/unit/staking/stake/unstake.t.sol index 98d508e2a..5cf89bf8f 100644 --- a/packages/horizon/test/unit/staking/stake/unstake.t.sol +++ b/packages/horizon/test/unit/staking/stake/unstake.t.sol @@ -24,79 +24,6 @@ contract HorizonStakingUnstakeTest is HorizonStakingTest { _unstake(tokensToUnstake); } - function testUnstake_LockingPeriodGreaterThanZero_NoThawing( - uint256 tokens, - uint256 tokensToUnstake, - uint32 maxVerifierCut, - uint64 thawingPeriod - ) public useIndexer useProvision(tokens, maxVerifierCut, thawingPeriod) { - tokensToUnstake = bound(tokensToUnstake, 1, tokens); - - // simulate transition period - _setStorageDeprecatedThawingPeriod(THAWING_PERIOD_IN_BLOCKS); - - // thaw, wait and deprovision - _thaw(users.indexer, subgraphDataServiceAddress, tokens); - skip(thawingPeriod + 1); - _deprovision(users.indexer, subgraphDataServiceAddress, 0); - - // unstake - _unstake(tokensToUnstake); - } - - function testUnstake_LockingPeriodGreaterThanZero_TokensDoneThawing( - uint256 tokens, - uint256 tokensToUnstake, - uint256 tokensLocked - ) public useIndexer { - // bounds - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - tokensToUnstake = bound(tokensToUnstake, 1, tokens); - tokensLocked = bound(tokensLocked, 1, MAX_STAKING_TOKENS); - - // simulate locked tokens with past locking period - _setStorageDeprecatedThawingPeriod(THAWING_PERIOD_IN_BLOCKS); - require(token.transfer(address(staking), tokensLocked), "Transfer failed"); - _setStorageServiceProvider(users.indexer, tokensLocked, 0, tokensLocked, block.number, 0); - - // create provision, thaw and deprovision - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, MAX_THAWING_PERIOD); - _thaw(users.indexer, subgraphDataServiceLegacyAddress, tokens); - skip(MAX_THAWING_PERIOD + 1); - _deprovision(users.indexer, subgraphDataServiceLegacyAddress, 0); - - // unstake - _unstake(tokensToUnstake); - } - - function testUnstake_LockingPeriodGreaterThanZero_TokensStillThawing( - uint256 tokens, - uint256 tokensToUnstake, - uint256 tokensThawing, - uint32 tokensThawingUntilBlock - ) public useIndexer { - // bounds - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - tokensToUnstake = bound(tokensToUnstake, 1, tokens); - tokensThawing = bound(tokensThawing, 1, MAX_STAKING_TOKENS); - vm.assume(tokensThawingUntilBlock > block.number); - vm.assume(tokensThawingUntilBlock < block.number + THAWING_PERIOD_IN_BLOCKS); - - // simulate locked tokens still thawing - _setStorageDeprecatedThawingPeriod(THAWING_PERIOD_IN_BLOCKS); - require(token.transfer(address(staking), tokensThawing), "Transfer failed"); - _setStorageServiceProvider(users.indexer, tokensThawing, 0, tokensThawing, tokensThawingUntilBlock, 0); - - // create provision, thaw and deprovision - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, MAX_THAWING_PERIOD); - _thaw(users.indexer, subgraphDataServiceLegacyAddress, tokens); - skip(MAX_THAWING_PERIOD + 1); - _deprovision(users.indexer, subgraphDataServiceLegacyAddress, 0); - - // unstake - _unstake(tokensToUnstake); - } - function testUnstake_RevertWhen_ZeroTokens( uint256 amount, uint32 maxVerifierCut, diff --git a/packages/horizon/test/unit/staking/stake/withdraw.t.sol b/packages/horizon/test/unit/staking/stake/withdraw.t.sol index 4cd6666b9..6afeb85cc 100644 --- a/packages/horizon/test/unit/staking/stake/withdraw.t.sol +++ b/packages/horizon/test/unit/staking/stake/withdraw.t.sol @@ -35,19 +35,4 @@ contract HorizonStakingWithdrawTest is HorizonStakingTest { vm.expectRevert(abi.encodeWithSelector(IHorizonStakingMain.HorizonStakingInvalidZeroTokens.selector)); staking.withdraw(); } - - function testWithdraw_RevertWhen_StillThawing(uint256 tokens, uint256 tokensLocked) public useIndexer { - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - tokensLocked = bound(tokensLocked, 1, tokens); - - // simulate locked tokens still thawing - uint256 thawUntil = block.timestamp + 1; - require(token.transfer(address(staking), tokens), "Transfer failed"); - _setStorageServiceProvider(users.indexer, tokens, 0, tokensLocked, thawUntil, 0); - - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, MAX_THAWING_PERIOD); - - vm.expectRevert(abi.encodeWithSelector(IHorizonStakingMain.HorizonStakingStillThawing.selector, thawUntil)); - staking.withdraw(); - } } diff --git a/packages/horizon/test/unit/utilities/GraphDirectory.t.sol b/packages/horizon/test/unit/utilities/GraphDirectory.t.sol index a0b22f6bb..5606eedc6 100644 --- a/packages/horizon/test/unit/utilities/GraphDirectory.t.sol +++ b/packages/horizon/test/unit/utilities/GraphDirectory.t.sol @@ -17,8 +17,7 @@ contract GraphDirectoryTest is GraphBaseTest { _getContractFromController("EpochManager"), _getContractFromController("RewardsManager"), _getContractFromController("GraphTokenGateway"), - _getContractFromController("GraphProxyAdmin"), - _getContractFromController("Curation") + _getContractFromController("GraphProxyAdmin") ); _deployImplementation(address(controller)); } @@ -47,7 +46,6 @@ contract GraphDirectoryTest is GraphBaseTest { assertEq(_getContractFromController("RewardsManager"), address(directory.graphRewardsManager())); assertEq(_getContractFromController("GraphTokenGateway"), address(directory.graphTokenGateway())); assertEq(_getContractFromController("GraphProxyAdmin"), address(directory.graphProxyAdmin())); - assertEq(_getContractFromController("Curation"), address(directory.graphCuration())); } function test_RevertWhen_AnInvalidContractGetterIsCalled() external { diff --git a/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol b/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol index c3736d3ff..b3c6198df 100644 --- a/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol +++ b/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol @@ -12,7 +12,6 @@ import { IEpochManager } from "@graphprotocol/interfaces/contracts/contracts/epo import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol"; import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; -import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol"; import { GraphDirectory } from "./../../../contracts/utilities/GraphDirectory.sol"; @@ -58,8 +57,4 @@ contract GraphDirectoryImplementation is GraphDirectory { function graphProxyAdmin() external view returns (IGraphProxyAdmin) { return _graphProxyAdmin(); } - - function graphCuration() external view returns (ICuration) { - return _graphCuration(); - } } diff --git a/packages/horizon/test/unit/utils/Users.sol b/packages/horizon/test/unit/utils/Users.sol index 56f67396f..bd6177cf0 100644 --- a/packages/horizon/test/unit/utils/Users.sol +++ b/packages/horizon/test/unit/utils/Users.sol @@ -9,5 +9,4 @@ struct Users { address gateway; address verifier; address delegator; - address legacySlasher; } diff --git a/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol b/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol index aa7d32eba..05d609101 100644 --- a/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol +++ b/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol @@ -278,7 +278,7 @@ interface IRewardsManager { /** * @notice Pull rewards from the contract for a particular allocation - * @dev This function can only be called by the Staking contract. + * @dev This function can only be called by the Subgraph Service contract. * This function will mint the necessary tokens to reward based on the inflation calculation. * @param allocationID Allocation * @return Assigned rewards amount diff --git a/packages/interfaces/contracts/horizon/IHorizonStaking.sol b/packages/interfaces/contracts/horizon/IHorizonStaking.sol index 4e680a1e5..9b16ad368 100644 --- a/packages/interfaces/contracts/horizon/IHorizonStaking.sol +++ b/packages/interfaces/contracts/horizon/IHorizonStaking.sol @@ -5,15 +5,14 @@ pragma solidity ^0.8.22; import { IHorizonStakingTypes } from "./internal/IHorizonStakingTypes.sol"; import { IHorizonStakingMain } from "./internal/IHorizonStakingMain.sol"; import { IHorizonStakingBase } from "./internal/IHorizonStakingBase.sol"; -import { IHorizonStakingExtension } from "./internal/IHorizonStakingExtension.sol"; /** * @title Complete interface for the Horizon Staking contract * @author Edge & Node - * @notice This interface exposes all functions implemented by the {HorizonStaking} contract and its extension - * {HorizonStakingExtension} as well as the custom data types used by the contract. + * @notice This interface exposes all functions implemented by the {HorizonStaking} contract + * as well as the custom data types used by the contract. * @dev Use this interface to interact with the Horizon Staking contract. * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ -interface IHorizonStaking is IHorizonStakingTypes, IHorizonStakingBase, IHorizonStakingMain, IHorizonStakingExtension {} +interface IHorizonStaking is IHorizonStakingTypes, IHorizonStakingBase, IHorizonStakingMain {} diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol index c48f20099..4bc81d44f 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol @@ -13,7 +13,7 @@ import { ILinkedList } from "./ILinkedList.sol"; /** * @title Interface for the {HorizonStakingBase} contract. * @author Edge & Node - * @notice Provides getters for {HorizonStaking} and {HorizonStakingExtension} storage variables. + * @notice Provides getters for {HorizonStaking} storage variables. * @dev Most functions operate over {HorizonStaking} provisions. To uniquely identify a provision * functions take `serviceProvider` and `verifier` addresses. * @custom:security-contact Please email security+contracts@thegraph.com if you find any @@ -21,19 +21,15 @@ import { ILinkedList } from "./ILinkedList.sol"; */ interface IHorizonStakingBase { /** - * @notice Emitted when a service provider stakes tokens. - * @dev TRANSITION PERIOD: After transition period move to IHorizonStakingMain. Temporarily it - * needs to be here since it's emitted by {_stake} which is used by both {HorizonStaking} - * and {HorizonStakingExtension}. - * @param serviceProvider The address of the service provider. - * @param tokens The amount of tokens staked. + * @notice Thrown when using an invalid thaw request type. */ - event HorizonStakeDeposited(address indexed serviceProvider, uint256 tokens); + error HorizonStakingInvalidThawRequestType(); /** - * @notice Thrown when using an invalid thaw request type. + * @notice Gets the address of the subgraph data service. + * @return The address of the subgraph data service. */ - error HorizonStakingInvalidThawRequestType(); + function getSubgraphService() external view returns (address); /** * @notice Gets the details of a service provider. diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol deleted file mode 100644 index d487b2eca..000000000 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol +++ /dev/null @@ -1,215 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later - -pragma solidity ^0.8.22; - -// TODO: Re-enable and fix issues when publishing a new version -// solhint-disable gas-indexed-events - -import { IRewardsIssuer } from "../../contracts/rewards/IRewardsIssuer.sol"; - -/** - * @title Interface for {HorizonStakingExtension} contract. - * @author Edge & Node - * @notice Provides functions for managing legacy allocations. - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -interface IHorizonStakingExtension is IRewardsIssuer { - /** - * @dev Allocate GRT tokens for the purpose of serving queries of a subgraph deployment - * An allocation is created in the allocate() function and closed in closeAllocation() - * @param indexer The indexer address - * @param subgraphDeploymentID The subgraph deployment ID - * @param tokens The amount of tokens allocated to the subgraph deployment - * @param createdAtEpoch The epoch when the allocation was created - * @param closedAtEpoch The epoch when the allocation was closed - * @param collectedFees The amount of collected fees for the allocation - * @param __DEPRECATED_effectiveAllocation Deprecated field. - * @param accRewardsPerAllocatedToken Snapshot used for reward calculation - * @param distributedRebates The amount of collected rebates that have been rebated - */ - struct Allocation { - address indexer; - bytes32 subgraphDeploymentID; - uint256 tokens; - uint256 createdAtEpoch; - uint256 closedAtEpoch; - uint256 collectedFees; - uint256 __DEPRECATED_effectiveAllocation; - uint256 accRewardsPerAllocatedToken; - uint256 distributedRebates; - } - - /** - * @dev Possible states an allocation can be. - * States: - * - Null = indexer == address(0) - * - Active = not Null && tokens > 0 - * - Closed = Active && closedAtEpoch != 0 - */ - enum AllocationState { - Null, - Active, - Closed - } - - /** - * @notice Emitted when `indexer` close an allocation in `epoch` for `allocationID`. - * An amount of `tokens` get unallocated from `subgraphDeploymentID`. - * This event also emits the POI (proof of indexing) submitted by the indexer. - * `isPublic` is true if the sender was someone other than the indexer. - * @param indexer The indexer address - * @param subgraphDeploymentID The subgraph deployment ID - * @param epoch The protocol epoch the allocation was closed on - * @param tokens The amount of tokens unallocated from the allocation - * @param allocationID The allocation identifier - * @param sender The address closing the allocation - * @param poi The proof of indexing submitted by the sender - * @param isPublic True if the allocation was force closed by someone other than the indexer/operator - */ - event AllocationClosed( - address indexed indexer, - bytes32 indexed subgraphDeploymentID, - uint256 epoch, - uint256 tokens, - address indexed allocationID, - address sender, - bytes32 poi, - bool isPublic - ); - - /** - * @notice Emitted when `indexer` collects a rebate on `subgraphDeploymentID` for `allocationID`. - * `epoch` is the protocol epoch the rebate was collected on - * The rebate is for `tokens` amount which are being provided by `assetHolder`; `queryFees` - * is the amount up for rebate after `curationFees` are distributed and `protocolTax` is burnt. - * `queryRebates` is the amount distributed to the `indexer` with `delegationFees` collected - * and sent to the delegation pool. - * @param assetHolder The address of the asset holder, the entity paying the query fees - * @param indexer The indexer address - * @param subgraphDeploymentID The subgraph deployment ID - * @param allocationID The allocation identifier - * @param epoch The protocol epoch the rebate was collected on - * @param tokens The amount of tokens collected - * @param protocolTax The amount of tokens burnt as protocol tax - * @param curationFees The amount of tokens distributed to the curation pool - * @param queryFees The amount of tokens collected as query fees - * @param queryRebates The amount of tokens distributed to the indexer - * @param delegationRewards The amount of tokens collected from the delegation pool - */ - event RebateCollected( - address assetHolder, - address indexed indexer, - bytes32 indexed subgraphDeploymentID, - address indexed allocationID, - uint256 epoch, - uint256 tokens, - uint256 protocolTax, - uint256 curationFees, - uint256 queryFees, - uint256 queryRebates, - uint256 delegationRewards - ); - - /** - * @notice Emitted when `indexer` was slashed for a total of `tokens` amount. - * Tracks `reward` amount of tokens given to `beneficiary`. - * @param indexer The indexer address - * @param tokens The amount of tokens slashed - * @param reward The amount of reward tokens to send to a beneficiary - * @param beneficiary The address of a beneficiary to receive a reward for the slashing - */ - event StakeSlashed(address indexed indexer, uint256 tokens, uint256 reward, address beneficiary); - - /** - * @notice Close an allocation and free the staked tokens. - * To be eligible for rewards a proof of indexing must be presented. - * Presenting a bad proof is subject to slashable condition. - * To opt out of rewards set _poi to 0x0 - * @param allocationID The allocation identifier - * @param poi Proof of indexing submitted for the allocated period - */ - function closeAllocation(address allocationID, bytes32 poi) external; - - /** - * @notice Collect and rebate query fees to the indexer - * This function will accept calls with zero tokens. - * We use an exponential rebate formula to calculate the amount of tokens to rebate to the indexer. - * This implementation allows collecting multiple times on the same allocation, keeping track of the - * total amount rebated, the total amount collected and compensating the indexer for the difference. - * @param tokens Amount of tokens to collect - * @param allocationID Allocation where the tokens will be assigned - */ - function collect(uint256 tokens, address allocationID) external; - - /** - * @notice Slash the indexer stake. Delegated tokens are not subject to slashing. - * Note that depending on the state of the indexer's stake, the slashed amount might be smaller than the - * requested slash amount. This can happen if the indexer has moved a significant part of their stake to - * a provision. Any outstanding slashing amount should be settled using Horizon's slash function - * {IHorizonStaking.slash}. - * @dev Can only be called by the slasher role. - * @param indexer Address of indexer to slash - * @param tokens Amount of tokens to slash from the indexer stake - * @param reward Amount of reward tokens to send to a beneficiary - * @param beneficiary Address of a beneficiary to receive a reward for the slashing - */ - function legacySlash(address indexer, uint256 tokens, uint256 reward, address beneficiary) external; - - /** - * @notice (Legacy) Return true if operator is allowed for the service provider on the subgraph data service. - * @param operator Address of the operator - * @param indexer Address of the service provider - * @return True if operator is allowed for indexer, false otherwise - */ - function isOperator(address operator, address indexer) external view returns (bool); - - /** - * @notice Getter that returns if an indexer has any stake. - * @param indexer Address of the indexer - * @return True if indexer has staked tokens - */ - function hasStake(address indexer) external view returns (bool); - - /** - * @notice Get the total amount of tokens staked by the indexer. - * @param indexer Address of the indexer - * @return Amount of tokens staked by the indexer - */ - function getIndexerStakedTokens(address indexer) external view returns (uint256); - - /** - * @notice Return the allocation by ID. - * @param allocationID Address used as allocation identifier - * @return Allocation data - */ - function getAllocation(address allocationID) external view returns (Allocation memory); - - /** - * @notice Return the current state of an allocation - * @param allocationID Allocation identifier - * @return AllocationState enum with the state of the allocation - */ - function getAllocationState(address allocationID) external view returns (AllocationState); - - /** - * @notice Return if allocationID is used. - * @param allocationID Address used as signer by the indexer for an allocation - * @return True if allocationID already used - */ - function isAllocation(address allocationID) external view returns (bool); - - /** - * @notice Return the time in blocks to unstake - * Deprecated, now enforced by each data service (verifier) - * @return Thawing period in blocks - */ - function __DEPRECATED_getThawingPeriod() external view returns (uint64); - - /** - * @notice Return the address of the subgraph data service. - * @dev TRANSITION PERIOD: After transition period move to main HorizonStaking contract - * @return Address of the subgraph data service - */ - function getSubgraphService() external view returns (address); -} diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol index 19c1e1cf8..ddc595409 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol @@ -12,13 +12,8 @@ import { IHorizonStakingTypes } from "./IHorizonStakingTypes.sol"; * @title Inferface for the {HorizonStaking} contract. * @author Edge & Node * @notice Provides functions for managing stake, provisions, delegations, and slashing. - * @dev Note that this interface only includes the functions implemented by {HorizonStaking} contract, - * and not those implemented by {HorizonStakingExtension}. - * Do not use this interface to interface with the {HorizonStaking} contract, use {IHorizonStaking} for - * the complete interface. * @dev Most functions operate over {HorizonStaking} provisions. To uniquely identify a provision * functions take `serviceProvider` and `verifier` addresses. - * @dev TRANSITION PERIOD: After transition period rename to IHorizonStaking. * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ @@ -26,15 +21,14 @@ interface IHorizonStakingMain { // -- Events: stake -- /** - * @notice Emitted when a service provider unstakes tokens during the transition period. - * @param serviceProvider The address of the service provider - * @param tokens The amount of tokens now locked (including previously locked tokens) - * @param until The block number until the stake is locked + * @notice Emitted when a service provider stakes tokens. + * @param serviceProvider The address of the service provider. + * @param tokens The amount of tokens staked. */ - event HorizonStakeLocked(address indexed serviceProvider, uint256 tokens, uint256 until); + event HorizonStakeDeposited(address indexed serviceProvider, uint256 tokens); /** - * @notice Emitted when a service provider withdraws tokens during the transition period. + * @notice Emitted when a service provider unstakes tokens. * @param serviceProvider The address of the service provider * @param tokens The amount of tokens withdrawn */ @@ -219,7 +213,7 @@ interface IHorizonStakingMain { /** * @notice Emitted when `delegator` withdrew delegated `tokens` from `indexer` using `withdrawDelegated`. - * @dev This event is for the legacy `withdrawDelegated` function. + * @dev This event is for the legacy `withdrawDelegated` function, only emitted for pre-horizon undelegations. * @param indexer The address of the indexer * @param delegator The address of the delegator * @param tokens The amount of tokens withdrawn @@ -324,12 +318,6 @@ interface IHorizonStakingMain { */ event AllowedLockedVerifierSet(address indexed verifier, bool allowed); - /** - * @notice Emitted when the legacy global thawing period is set to zero. - * @dev This marks the end of the transition period. - */ - event ThawingPeriodCleared(); - /** * @notice Emitted when the delegation slashing global flag is set. */ @@ -373,13 +361,6 @@ interface IHorizonStakingMain { */ error HorizonStakingNotAuthorized(address serviceProvider, address verifier, address caller); - /** - * @notice Thrown when attempting to create a provision with a verifier other than the - * subgraph data service. This restriction only applies during the transition period. - * @param verifier The verifier address - */ - error HorizonStakingInvalidVerifier(address verifier); - /** * @notice Thrown when attempting to create a provision with an invalid maximum verifier cut. * @param maxVerifierCut The maximum verifier cut @@ -407,14 +388,6 @@ interface IHorizonStakingMain { */ error HorizonStakingInsufficientIdleStake(uint256 tokens, uint256 minTokens); - /** - * @notice Thrown during the transition period when the service provider has insufficient stake to - * cover their existing legacy allocations. - * @param tokens The actual token amount - * @param minTokens The minimum required token amount - */ - error HorizonStakingInsufficientStakeForLegacyAllocations(uint256 tokens, uint256 minTokens); - // -- Errors: delegation -- /** @@ -480,18 +453,12 @@ interface IHorizonStakingMain { error HorizonStakingTooManyThawRequests(); /** - * @notice Thrown when attempting to withdraw tokens that have not thawed (legacy undelegate). + * @notice Thrown when attempting to withdraw tokens that have not thawed. + * @dev This error is only thrown for pre-horizon undelegations. */ error HorizonStakingNothingToWithdraw(); // -- Errors: misc -- - /** - * @notice Thrown during the transition period when attempting to withdraw tokens that are still thawing. - * @dev Note this thawing refers to the global thawing period applied to legacy allocated tokens, - * it does not refer to thaw requests. - * @param until The block number until the stake is locked - */ - error HorizonStakingStillThawing(uint256 until); /** * @notice Thrown when a service provider attempts to operate on verifiers that are not allowed. @@ -511,11 +478,6 @@ interface IHorizonStakingMain { */ error HorizonStakingInvalidDelegationFeeCut(uint256 feeCut); - /** - * @notice Thrown when a legacy slash fails. - */ - error HorizonStakingLegacySlashFailed(); - /** * @notice Thrown when there attempting to slash a provision with no tokens to slash. */ @@ -571,19 +533,12 @@ interface IHorizonStakingMain { /** * @notice Move idle stake back to the owner's account. - * Stake is removed from the protocol: - * - During the transition period it's locked for a period of time before it can be withdrawn - * by calling {withdraw}. - * - After the transition period it's immediately withdrawn. - * Note that after the transition period if there are tokens still locked they will have to be - * withdrawn by calling {withdraw}. + * Stake is immediately removed from the protocol. * @dev Requirements: * - `_tokens` cannot be zero. - * - `_serviceProvider` must have enough idle stake to cover the staking amount and any - * legacy allocation. + * - `_serviceProvider` must have enough idle stake to cover the staking amount. * - * Emits a {HorizonStakeLocked} event during the transition period. - * Emits a {HorizonStakeWithdrawn} event after the transition period. + * Emits a {HorizonStakeWithdrawn} event. * * @param tokens Amount of tokens to unstake */ @@ -592,8 +547,12 @@ interface IHorizonStakingMain { /** * @notice Withdraw service provider tokens once the thawing period (initiated by {unstake}) has passed. * All thawed tokens are withdrawn. - * @dev This is only needed during the transition period while we still have - * a global lock. After that, unstake() will automatically withdraw. + * This function is for backwards compatibility with the legacy staking contract. + * It only allows withdrawing tokens unstaked before horizon upgrade. + * @dev This function can't be removed in case there are still pre-horizon unstakes. + * + * Emits a {HorizonStakeWithdrawn} event. + * */ function withdraw() external; @@ -603,8 +562,6 @@ interface IHorizonStakingMain { * service, where the data service is the verifier. * This function can be called by the service provider or by an operator authorized by the provider * for this specific verifier. - * @dev During the transition period, only the subgraph data service can be used as a verifier. This - * prevents an escape hatch for legacy allocation stake. * @dev Requirements: * - `tokens` cannot be zero. * - The `serviceProvider` must have enough idle stake to cover the tokens to provision. @@ -826,7 +783,7 @@ interface IHorizonStakingMain { * - `newServiceProvider` and `newVerifier` must not be the zero address. * - `newServiceProvider` must have previously provisioned stake to `newVerifier`. * - * Emits {ThawRequestFulfilled}, {ThawRequestsFulfilled} and {DelegatedTokensWithdrawn} events. + * Emits {ThawRequestFulfilled} and {ThawRequestsFulfilled} events. * * @param oldServiceProvider The old service provider address * @param oldVerifier The old verifier address @@ -883,6 +840,7 @@ interface IHorizonStakingMain { * @notice Withdraw undelegated tokens from the subgraph data service provision after thawing. * This function is for backwards compatibility with the legacy staking contract. * It only allows withdrawing tokens undelegated before horizon upgrade. + * @dev This function can't be removed in case there are still pre-horizon undelegations. * @dev See {delegate}. * @param serviceProvider The service provider address * @param deprecated Deprecated parameter kept for backwards compatibility @@ -971,14 +929,6 @@ interface IHorizonStakingMain { */ function setDelegationSlashingEnabled() external; - /** - * @notice Clear the legacy global thawing period. - * This signifies the end of the transition period, after which no legacy allocations should be left. - * @dev This function can only be called by the contract governor. - * @dev Emits a {ThawingPeriodCleared} event. - */ - function clearThawingPeriod() external; - /** * @notice Sets the global maximum thawing period allowed for provisions. * @param maxThawingPeriod The new maximum thawing period, in seconds @@ -1004,8 +954,28 @@ interface IHorizonStakingMain { function isAuthorized(address serviceProvider, address verifier, address operator) external view returns (bool); /** - * @notice Get the address of the staking extension. - * @return The address of the staking extension + * @notice Withdraw service provider legacy locked tokens. + * This is a permissionless function that allows anyone to withdraw on behalf of a service provider. + * It only allows withdrawing tokens that were unstaked before the Horizon upgrade. + * @dev Tokens are always sent to the service provider, not the caller. + * + * Emits a {HorizonStakeWithdrawn} event. + * + * @param serviceProvider Address of service provider to withdraw funds from + */ + function forceWithdraw(address serviceProvider) external; + + /** + * @notice Withdraw delegator legacy undelegated tokens. + * This is a permissionless function that allows anyone to withdraw on behalf of a delegator. + * It only allows withdrawing tokens that were undelegated before the Horizon upgrade. + * @dev Tokens are always sent to the delegator, not the caller. + * + * Emits a {StakeDelegatedWithdrawn} event. + * + * @param serviceProvider The service provider address + * @param delegator The delegator address to withdraw funds for + * @return The amount of tokens withdrawn */ - function getStakingExtension() external view returns (address); + function forceWithdrawDelegated(address serviceProvider, address delegator) external returns (uint256); } diff --git a/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol b/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol index d805d9f70..555874b44 100644 --- a/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol +++ b/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol @@ -19,7 +19,7 @@ interface IDisputeManager { Null, IndexingDispute, QueryDispute, - LegacyDispute, + __DEPRECATED_LegacyDispute, IndexingFeeDispute } @@ -173,25 +173,6 @@ interface IDisputeManager { uint256 cancellableAt ); - /** - * @notice Emitted when a legacy dispute is created for `allocationId` and `fisherman`. - * The event emits the amount of `tokensSlash` to slash and `tokensRewards` to reward the fisherman. - * @param disputeId The dispute id - * @param indexer The indexer address - * @param fisherman The fisherman address to be credited with the rewards - * @param allocationId The allocation id - * @param tokensSlash The amount of tokens to slash - * @param tokensRewards The amount of tokens to reward the fisherman - */ - event LegacyDisputeCreated( - bytes32 indexed disputeId, - address indexed indexer, - address indexed fisherman, - address allocationId, - uint256 tokensSlash, - uint256 tokensRewards - ); - /** * @notice Emitted when arbitrator accepts a `disputeId` to `indexer` created by `fisherman`. * The event emits the amount `tokens` transferred to the fisherman, the deposit plus reward. @@ -511,39 +492,6 @@ interface IDisputeManager { */ function createIndexingDispute(address allocationId, bytes32 poi, uint256 blockNumber) external returns (bytes32); - /** - * @notice Creates and auto-accepts a legacy dispute. - * This disputes can be created to settle outstanding slashing amounts with an indexer that has been - * "legacy slashed" during or shortly after the transition period. See {HorizonStakingExtension.legacySlash} - * for more details. - * - * Note that this type of dispute: - * - can only be created by the arbitrator - * - does not require a bond - * - is automatically accepted when created - * - * Additionally, note that this type of disputes allow the arbitrator to directly set the slash and rewards - * amounts, bypassing the usual mechanisms that impose restrictions on those. This is done to give arbitrators - * maximum flexibility to ensure outstanding slashing amounts are settled fairly. This function needs to be removed - * after the transition period. - * - * Requirements: - * - Indexer must have been legacy slashed during or shortly after the transition period - * - Indexer must have provisioned funds to the Subgraph Service - * - * @param allocationId The allocation to dispute - * @param fisherman The fisherman address to be credited with the rewards - * @param tokensSlash The amount of tokens to slash - * @param tokensRewards The amount of tokens to reward the fisherman - * @return The dispute id - */ - function createAndAcceptLegacyDispute( - address allocationId, - address fisherman, - uint256 tokensSlash, - uint256 tokensRewards - ) external returns (bytes32); - /** * @notice Create an indexing fee (version 1) dispute for the arbitrator to resolve. * The disputes are created in reference to a version 1 indexing agreement and specifically diff --git a/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol b/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol index cff466423..f169dce42 100644 --- a/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol +++ b/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol @@ -212,16 +212,6 @@ interface ISubgraphService is IDataServiceFees { */ function resizeAllocation(address indexer, address allocationId, uint256 tokens) external; - /** - * @notice Imports a legacy allocation id into the subgraph service - * This is a governor only action that is required to prevent indexers from re-using allocation ids from the - * legacy staking contract. - * @param indexer The address of the indexer - * @param allocationId The id of the allocation - * @param subgraphDeploymentId The id of the subgraph deployment - */ - function migrateLegacyAllocation(address indexer, address allocationId, bytes32 subgraphDeploymentId) external; - /** * @notice Sets a pause guardian * @param pauseGuardian The address of the pause guardian diff --git a/packages/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol b/packages/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol index 5c04767c9..3454e7b8f 100644 --- a/packages/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol +++ b/packages/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol @@ -83,18 +83,6 @@ interface IAllocationManager { bool forceClosed ); - /** - * @notice Emitted when a legacy allocation is migrated into the subgraph service - * @param indexer The address of the indexer - * @param allocationId The id of the allocation - * @param subgraphDeploymentId The id of the subgraph deployment - */ - event LegacyAllocationMigrated( - address indexed indexer, - address indexed allocationId, - bytes32 indexed subgraphDeploymentId - ); - /** * @notice Emitted when the maximum POI staleness is updated * @param maxPOIStaleness The max POI staleness in seconds diff --git a/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol b/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol index c5bf7f8c7..b6422fad8 100644 --- a/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol +++ b/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol @@ -23,14 +23,8 @@ interface ILegacyAllocation { } /** - * @notice Thrown when attempting to migrate an allocation with an existing id + * @notice Thrown when attempting to create an allocation with an existing legacy id * @param allocationId The allocation id */ error LegacyAllocationAlreadyExists(address allocationId); - - /** - * @notice Thrown when trying to get a non-existent allocation - * @param allocationId The allocation id - */ - error LegacyAllocationDoesNotExist(address allocationId); } diff --git a/packages/issuance/package.json b/packages/issuance/package.json index dba25ffd1..6223811a4 100644 --- a/packages/issuance/package.json +++ b/packages/issuance/package.json @@ -25,7 +25,7 @@ "build:dep": "pnpm --filter '@graphprotocol/issuance^...' run build:self", "build:self": "pnpm compile && pnpm typechain", "clean": "rm -rf artifacts/ forge-artifacts/ cache_forge/ coverage/ cache/ types/ typechain-src/ .eslintcache test/node_modules/", - "compile": "hardhat compile --quiet", + "compile": "hardhat compile --quiet --no-tests", "typechain": "typechain --target ethers-v6 --out-dir typechain-src 'artifacts/contracts/**/!(*.dbg).json' && tsc -p tsconfig.typechain.json && rm -rf typechain-src && echo '{\"type\":\"commonjs\"}' > types/package.json", "test": "forge test", "test:coverage": "forge coverage", @@ -33,7 +33,7 @@ "lint": "pnpm lint:ts; pnpm lint:sol; pnpm lint:forge; pnpm lint:md; pnpm lint:json", "lint:ts": "eslint '**/*.{js,ts,cjs,mjs,jsx,tsx}' --fix --cache; prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'", "lint:sol": "solhint --fix --noPrompt --noPoster 'contracts/**/*.sol'; prettier -w --cache --log-level warn 'contracts/**/*.sol'", - "lint:forge": "forge lint", + "lint:forge": "forge lint contracts/", "lint:md": "markdownlint --fix --ignore-path ../../.gitignore '**/*.md'; prettier -w --cache --log-level warn '**/*.md'", "lint:json": "prettier -w --cache --log-level warn '**/*.json'", "verify": "hardhat verify", diff --git a/packages/subgraph-service/contracts/DisputeManager.sol b/packages/subgraph-service/contracts/DisputeManager.sol index 83cfcdf1f..1ee798c5b 100644 --- a/packages/subgraph-service/contracts/DisputeManager.sol +++ b/packages/subgraph-service/contracts/DisputeManager.sol @@ -15,7 +15,7 @@ import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; -import { MathUtils } from "@graphprotocol/horizon/contracts/libraries/MathUtils.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { Attestation } from "./libraries/Attestation.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; @@ -220,46 +220,6 @@ contract DisputeManager is return (dId1, dId2); } - /// @inheritdoc IDisputeManager - function createAndAcceptLegacyDispute( - address allocationId, - address fisherman, - uint256 tokensSlash, - uint256 tokensRewards - ) external override onlyArbitrator returns (bytes32) { - // Create a disputeId - bytes32 disputeId = keccak256(abi.encodePacked(allocationId, "legacy")); - - // Get the indexer for the legacy allocation - address indexer = _graphStaking().getAllocation(allocationId).indexer; - require(indexer != address(0), DisputeManagerIndexerNotFound(allocationId)); - - // Store dispute - disputes[disputeId] = Dispute( - indexer, - fisherman, - 0, - 0, - DisputeType.LegacyDispute, - IDisputeManager.DisputeStatus.Accepted, - block.timestamp, - block.timestamp + disputePeriod, - 0 - ); - - // Slash the indexer - ISubgraphService subgraphService_ = _getSubgraphService(); - subgraphService_.slash(indexer, abi.encode(tokensSlash, tokensRewards)); - - // Reward the fisherman - _graphToken().pushTokens(fisherman, tokensRewards); - - emit LegacyDisputeCreated(disputeId, indexer, fisherman, allocationId, tokensSlash, tokensRewards); - emit DisputeAccepted(disputeId, indexer, fisherman, tokensRewards); - - return disputeId; - } - /// @inheritdoc IDisputeManager function acceptDispute( bytes32 disputeId, @@ -672,8 +632,8 @@ contract DisputeManager is // - The applied cut is the minimum between the provision's maxVerifierCut and the current fishermanRewardCut. This // protects the indexer from sudden changes to the fishermanRewardCut while ensuring the slashing does not revert due // to excessive rewards being requested. - uint256 maxRewardableTokens = MathUtils.min(_tokensSlash, provision.tokens); - uint256 effectiveCut = MathUtils.min(provision.maxVerifierCut, fishermanRewardCut); + uint256 maxRewardableTokens = Math.min(_tokensSlash, provision.tokens); + uint256 effectiveCut = Math.min(provision.maxVerifierCut, fishermanRewardCut); uint256 tokensRewards = effectiveCut.mulPPM(maxRewardableTokens); subgraphService_.slash(_indexer, abi.encode(_tokensSlash, tokensRewards)); diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index 8d591f52e..1993d2e5e 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -352,15 +352,6 @@ contract SubgraphService is _resizeAllocation(allocationId, tokens, _delegationRatio); } - /// @inheritdoc ISubgraphService - function migrateLegacyAllocation( - address indexer, - address allocationId, - bytes32 subgraphDeploymentId - ) external override onlyOwner { - _migrateLegacyAllocation(indexer, allocationId, subgraphDeploymentId); - } - /// @inheritdoc ISubgraphService function setPauseGuardian(address pauseGuardian, bool allowed) external override onlyOwner { _setPauseGuardian(pauseGuardian, allowed); @@ -386,7 +377,6 @@ contract SubgraphService is _setStakeToFeesRatio(stakeToFeesRatio_); } - // forge-lint: disable-next-item(mixed-case-function) /// @inheritdoc ISubgraphService function setMaxPOIStaleness(uint256 maxPoiStaleness_) external override onlyOwner { _setMaxPoiStaleness(maxPoiStaleness_); diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol index 0519b3e3f..2a15a8350 100644 --- a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -252,7 +252,7 @@ library AllocationHandler { // Ensure allocation id is not reused // need to check both subgraph service (on allocations.create()) and legacy allocations - _legacyAllocations.revertIfExists(params.graphStaking, params._allocationId); + _legacyAllocations.revertIfExists(params._allocationId); IAllocation.State memory allocation = _allocations.create( params._indexer, diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index 2f52dc8c0..61ff8a436 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -15,7 +15,9 @@ import { IndexingAgreementDecoder } from "./IndexingAgreementDecoder.sol"; /** * @title IndexingAgreement library * @author Edge & Node - * @notice Manages indexing agreement lifecycle — acceptance, updates, cancellation, and fee collection. + * @notice Manages indexing agreement lifecycle: acceptance, updates, cancellation and fee collection. + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. */ library IndexingAgreement { using IndexingAgreement for StorageManager; @@ -99,7 +101,7 @@ library IndexingAgreement { * @custom:storage-location erc7201:graphprotocol.subgraph-service.storage.StorageManager.IndexingAgreement */ struct StorageManager { - mapping(bytes16 => IIndexingAgreement.State) agreements; + mapping(bytes16 agreementId => IIndexingAgreement.State) agreements; mapping(bytes16 agreementId => IndexingAgreementTermsV1 data) termsV1; mapping(address allocationId => bytes16 agreementId) allocationToActiveAgreementId; } diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol index 91c4071e8..7478089c6 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol @@ -6,7 +6,7 @@ import { IndexingAgreement } from "./IndexingAgreement.sol"; /** * @title IndexingAgreementDecoderRaw library * @author Edge & Node - * @notice Raw ABI decoder for indexing agreement data structures, propagating native revert on malformed input. + * @notice Low-level decoder for indexing agreement data structures, propagating native revert on malformed input. */ library IndexingAgreementDecoderRaw { /** diff --git a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol index 47f04c3a9..f281bea83 100644 --- a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol +++ b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.27; -import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; /** @@ -14,59 +13,17 @@ import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph- library LegacyAllocation { using LegacyAllocation for ILegacyAllocation.State; - /** - * @notice Migrate a legacy allocation - * @dev Requirements: - * - The allocation must not have been previously migrated - * @param self The legacy allocation list mapping - * @param indexer The indexer that owns the allocation - * @param allocationId The allocation id - * @param subgraphDeploymentId The subgraph deployment id the allocation is for - * @custom:error LegacyAllocationAlreadyMigrated if the allocation has already been migrated - */ - function migrate( - mapping(address => ILegacyAllocation.State) storage self, - address indexer, - address allocationId, - bytes32 subgraphDeploymentId - ) internal { - require(!self[allocationId].exists(), ILegacyAllocation.LegacyAllocationAlreadyExists(allocationId)); - - self[allocationId] = ILegacyAllocation.State({ indexer: indexer, subgraphDeploymentId: subgraphDeploymentId }); - } - - /** - * @notice Get a legacy allocation - * @param self The legacy allocation list mapping - * @param allocationId The allocation id - * @return The legacy allocation details - */ - function get( - mapping(address => ILegacyAllocation.State) storage self, - address allocationId - ) internal view returns (ILegacyAllocation.State memory) { - return _get(self, allocationId); - } - /** * @notice Revert if a legacy allocation exists - * @dev We first check the migrated mapping then the old staking contract. - * @dev TRANSITION PERIOD: after the transition period when all the allocations are migrated we can - * remove the call to the staking contract. + * @dev We check the migrated allocations mapping. * @param self The legacy allocation list mapping - * @param graphStaking The Horizon Staking contract * @param allocationId The allocation id */ function revertIfExists( mapping(address => ILegacyAllocation.State) storage self, - IHorizonStaking graphStaking, address allocationId ) internal view { require(!self[allocationId].exists(), ILegacyAllocation.LegacyAllocationAlreadyExists(allocationId)); - require( - !graphStaking.isAllocation(allocationId), - ILegacyAllocation.LegacyAllocationAlreadyExists(allocationId) - ); } /** @@ -77,19 +34,4 @@ library LegacyAllocation { function exists(ILegacyAllocation.State memory self) internal pure returns (bool) { return self.indexer != address(0); } - - /** - * @notice Get a legacy allocation - * @param self The legacy allocation list mapping - * @param allocationId The allocation id - * @return The legacy allocation details - */ - function _get( - mapping(address => ILegacyAllocation.State) storage self, - address allocationId - ) private view returns (ILegacyAllocation.State storage) { - ILegacyAllocation.State storage allocation = self[allocationId]; - require(allocation.exists(), ILegacyAllocation.LegacyAllocationDoesNotExist(allocationId)); - return allocation; - } } diff --git a/packages/subgraph-service/contracts/utilities/AllocationManager.sol b/packages/subgraph-service/contracts/utilities/AllocationManager.sol index 92179147e..69d980b4d 100644 --- a/packages/subgraph-service/contracts/utilities/AllocationManager.sol +++ b/packages/subgraph-service/contracts/utilities/AllocationManager.sol @@ -44,7 +44,6 @@ abstract contract AllocationManager is keccak256("AllocationIdProof(address indexer,address allocationId)"); // solhint-disable-previous-line gas-small-strings - // forge-lint: disable-next-item(mixed-case-function) /** * @notice Initializes the contract and parent contracts * @param _name The name to use for EIP712 domain separation @@ -55,25 +54,11 @@ abstract contract AllocationManager is __AllocationManager_init_unchained(); } - // forge-lint: disable-next-item(mixed-case-function) /** * @notice Initializes the contract */ function __AllocationManager_init_unchained() internal onlyInitializing {} - /** - * @notice Imports a legacy allocation id into the subgraph service - * This is a governor only action that is required to prevent indexers from re-using allocation ids from the - * legacy staking contract. It will revert with LegacyAllocationAlreadyMigrated if the allocation has already been migrated. - * @param _indexer The address of the indexer - * @param _allocationId The id of the allocation - * @param _subgraphDeploymentId The id of the subgraph deployment - */ - function _migrateLegacyAllocation(address _indexer, address _allocationId, bytes32 _subgraphDeploymentId) internal { - _legacyAllocations.migrate(_indexer, _allocationId, _subgraphDeploymentId); - emit LegacyAllocationMigrated(_indexer, _allocationId, _subgraphDeploymentId); - } - /** * @notice Create an allocation * @dev The `_allocationProof` is a 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationId)` diff --git a/packages/subgraph-service/package.json b/packages/subgraph-service/package.json index 8161ecb45..068e81b8a 100644 --- a/packages/subgraph-service/package.json +++ b/packages/subgraph-service/package.json @@ -21,7 +21,7 @@ "lint": "pnpm lint:ts; pnpm lint:sol; pnpm lint:forge; pnpm lint:md; pnpm lint:json", "lint:ts": "eslint --fix --cache '**/*.{js,ts,cjs,mjs,jsx,tsx}'; prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'", "lint:sol": "solhint --fix --noPrompt --noPoster 'contracts/**/*.sol'; prettier -w --cache --log-level warn '**/*.sol'", - "lint:forge": "forge lint", + "lint:forge": "forge lint contracts/", "lint:md": "markdownlint --fix --ignore-path ../../.gitignore '**/*.md'; prettier -w --cache --log-level warn '**/*.md'", "lint:json": "prettier -w --cache --log-level warn '**/*.json'", "clean": "rm -rf build dist cache cache_forge typechain-types", diff --git a/packages/subgraph-service/scripts/integration b/packages/subgraph-service/scripts/integration index d5d7f1c0d..58a7ba4fe 100755 --- a/packages/subgraph-service/scripts/integration +++ b/packages/subgraph-service/scripts/integration @@ -124,13 +124,6 @@ npx hardhat deploy:migrate --network localhost --horizon-config integration --st cd ../subgraph-service npx hardhat test:seed --network localhost -# Run integration tests - During transition period -npx hardhat test:integration --phase during-transition-period --network localhost - -# Clear thawing period -cd ../horizon -npx hardhat transition:clear-thawing --network localhost --governor-index 1 - # Run integration tests - After transition period cd ../subgraph-service npx hardhat test:integration --phase after-transition-period --network localhost diff --git a/packages/subgraph-service/tasks/test/integration.ts b/packages/subgraph-service/tasks/test/integration.ts index 130058e90..ef63c42f4 100644 --- a/packages/subgraph-service/tasks/test/integration.ts +++ b/packages/subgraph-service/tasks/test/integration.ts @@ -4,13 +4,9 @@ import { TASK_TEST } from 'hardhat/builtin-tasks/task-names' import { task } from 'hardhat/config' task('test:integration', 'Runs all integration tests') - .addParam( - 'phase', - 'Test phase to run: "during-transition-period", "after-transition-period", "after-delegation-slashing-enabled"', - ) + .addParam('phase', 'Test phase to run: "after-transition-period", "after-delegation-slashing-enabled"') .setAction(async (taskArgs, hre) => { // Get test files for each phase - const duringTransitionPeriodFiles = await glob('test/integration/during-transition-period/**/*.{js,ts}') const afterTransitionPeriodFiles = await glob('test/integration/after-transition-period/**/*.{js,ts}') // Display banner for the current test phase @@ -18,15 +14,12 @@ task('test:integration', 'Runs all integration tests') // Run tests for the current phase switch (taskArgs.phase) { - case 'during-transition-period': - await hre.run(TASK_TEST, { testFiles: duringTransitionPeriodFiles }) - break case 'after-transition-period': await hre.run(TASK_TEST, { testFiles: afterTransitionPeriodFiles }) break default: throw new Error( - 'Invalid phase. Must be "during-transition-period", "after-transition-period", "after-delegation-slashing-enabled", or "all"', + 'Invalid phase. Must be "after-transition-period", "after-delegation-slashing-enabled", or "all"', ) } }) diff --git a/packages/subgraph-service/test/integration/during-transition-period/dispute-manager.test.ts b/packages/subgraph-service/test/integration/during-transition-period/dispute-manager.test.ts deleted file mode 100644 index a24f9703a..000000000 --- a/packages/subgraph-service/test/integration/during-transition-period/dispute-manager.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { - DisputeManager, - HorizonStaking, - L2GraphToken, - LegacyDisputeManager, - SubgraphService, -} from '@graphprotocol/interfaces' -import { generateLegacyIndexingDisputeId, generateLegacyTypeDisputeId } from '@graphprotocol/toolshed' -import { indexersData as indexers } from '@graphprotocol/toolshed/fixtures' -import { setGRTBalance } from '@graphprotocol/toolshed/hardhat' -import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import { ethers } from 'hardhat' -import hre from 'hardhat' - -describe('Dispute Manager', () => { - let disputeManager: DisputeManager - let legacyDisputeManager: LegacyDisputeManager - let graphToken: L2GraphToken - let staking: HorizonStaking - let subgraphService: SubgraphService - - let snapshotId: string - - // Test addresses - let governor: HardhatEthersSigner - let fisherman: HardhatEthersSigner - let arbitrator: HardhatEthersSigner - let indexer: HardhatEthersSigner - - let disputeDeposit: bigint - - // Allocation variables - let allocationId: string - - before(async () => { - // Get contracts - const graph = hre.graph() - disputeManager = graph.subgraphService.contracts.DisputeManager - legacyDisputeManager = graph.subgraphService.contracts.LegacyDisputeManager - graphToken = graph.horizon.contracts.GraphToken - staking = graph.horizon.contracts.HorizonStaking - subgraphService = graph.subgraphService.contracts.SubgraphService - - // Get signers - governor = await graph.accounts.getGovernor() - arbitrator = await graph.accounts.getArbitrator() - ;[fisherman] = await graph.accounts.getTestAccounts() - - // Get indexer - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - - // Get allocation - const allocation = indexerFixture.legacyAllocations[0] - allocationId = allocation.allocationID - - // Get dispute deposit - disputeDeposit = ethers.parseEther('10000') - - // Set GRT balance for fisherman - await setGRTBalance(graph.provider, graphToken.target, fisherman.address, ethers.parseEther('1000000')) - - // Set arbitrator - await legacyDisputeManager.connect(governor).setArbitrator(arbitrator.address) - }) - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Legacy dispute type', () => { - describe('Arbitrator', () => { - it('should allow arbitrator to create and accept a legacy dispute on the new dispute manager after slashing on the legacy dispute manager', async () => { - // Create an indexing dispute on legacy dispute manager - await graphToken.connect(fisherman).approve(legacyDisputeManager.target, disputeDeposit) - await legacyDisputeManager.connect(fisherman).createIndexingDispute(allocationId, disputeDeposit) - const legacyDisputeId = generateLegacyIndexingDisputeId(allocationId) - - // Accept the dispute on the legacy dispute manager - await legacyDisputeManager.connect(arbitrator).acceptDispute(legacyDisputeId) - - // Get fisherman's balance before creating dispute - const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) - - // Get indexer's provision before creating dispute - const provision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) - - // Create and accept legacy dispute using the same allocation ID - const tokensToSlash = ethers.parseEther('100000') - const tokensToReward = tokensToSlash / 2n - await disputeManager - .connect(arbitrator) - .createAndAcceptLegacyDispute(allocationId, fisherman.address, tokensToSlash, tokensToReward) - - // Get dispute ID from event - const disputeId = generateLegacyTypeDisputeId(allocationId) - - // Verify dispute was created and accepted - const dispute = await disputeManager.disputes(disputeId) - expect(dispute.indexer).to.equal(indexer.address, 'Indexer address mismatch') - expect(dispute.fisherman).to.equal(fisherman.address, 'Fisherman address mismatch') - expect(dispute.disputeType).to.equal(3, 'Dispute type should be legacy') - expect(dispute.status).to.equal(1, 'Dispute status should be accepted') - - // Verify indexer's stake was slashed - const updatedProvision = await staking.getProviderTokensAvailable( - indexer.address, - await subgraphService.getAddress(), - ) - expect(updatedProvision).to.equal(provision - tokensToSlash, 'Indexer stake should be slashed') - - // Verify fisherman got the reward - const fishermanBalance = await graphToken.balanceOf(fisherman.address) - expect(fishermanBalance).to.equal( - fishermanBalanceBefore + tokensToReward, - 'Fisherman balance should be increased by the reward', - ) - }) - - it('should not allow creating a legacy dispute for non-existent allocation', async () => { - const tokensToSlash = ethers.parseEther('1000') - const tokensToReward = tokensToSlash / 2n - - // Attempt to create legacy dispute with non-existent allocation - await expect( - disputeManager - .connect(arbitrator) - .createAndAcceptLegacyDispute( - ethers.Wallet.createRandom().address, - fisherman.address, - tokensToSlash, - tokensToReward, - ), - ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerIndexerNotFound') - }) - }) - - it('should not allow non-arbitrator to create a legacy dispute', async () => { - const tokensToSlash = ethers.parseEther('1000') - const tokensToReward = tokensToSlash / 2n - - // Attempt to create legacy dispute as fisherman - await expect( - disputeManager - .connect(fisherman) - .createAndAcceptLegacyDispute(allocationId, fisherman.address, tokensToSlash, tokensToReward), - ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerNotArbitrator') - }) - }) -}) diff --git a/packages/subgraph-service/test/integration/during-transition-period/governance.test.ts b/packages/subgraph-service/test/integration/during-transition-period/governance.test.ts deleted file mode 100644 index ad638b306..000000000 --- a/packages/subgraph-service/test/integration/during-transition-period/governance.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { SubgraphService } from '@graphprotocol/interfaces' -import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import { ethers } from 'hardhat' -import hre from 'hardhat' - -describe('Governance', () => { - let subgraphService: SubgraphService - let snapshotId: string - - // Test addresses - let governor: HardhatEthersSigner - let indexer: HardhatEthersSigner - let nonOwner: HardhatEthersSigner - let allocationId: string - let subgraphDeploymentId: string - - const graph = hre.graph() - - before(() => { - subgraphService = graph.subgraphService.contracts.SubgraphService - }) - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - - // Get signers - governor = await graph.accounts.getGovernor() - ;[indexer, nonOwner] = await graph.accounts.getTestAccounts() - - // Generate test addresses - allocationId = ethers.Wallet.createRandom().address - subgraphDeploymentId = ethers.keccak256(ethers.toUtf8Bytes('test-subgraph-deployment')) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Legacy Allocation Migration', () => { - it('should migrate legacy allocation', async () => { - // Migrate legacy allocation - await subgraphService - .connect(governor) - .migrateLegacyAllocation(indexer.address, allocationId, subgraphDeploymentId) - - // Verify the legacy allocation was migrated - const legacyAllocation = await subgraphService.getLegacyAllocation(allocationId) - expect(legacyAllocation.indexer).to.equal(indexer.address) - expect(legacyAllocation.subgraphDeploymentId).to.equal(subgraphDeploymentId) - }) - - it('should not allow non-owner to migrate legacy allocation', async () => { - // Attempt to migrate legacy allocation as non-owner - await expect( - subgraphService.connect(nonOwner).migrateLegacyAllocation(indexer.address, allocationId, subgraphDeploymentId), - ).to.be.revertedWithCustomError(subgraphService, 'OwnableUnauthorizedAccount') - }) - - it('should not allow migrating a legacy allocation that was already migrated', async () => { - // First migration - await subgraphService - .connect(governor) - .migrateLegacyAllocation(indexer.address, allocationId, subgraphDeploymentId) - - // Attempt to migrate the same allocation again - await expect( - subgraphService.connect(governor).migrateLegacyAllocation(indexer.address, allocationId, subgraphDeploymentId), - ) - .to.be.revertedWithCustomError(subgraphService, 'LegacyAllocationAlreadyExists') - .withArgs(allocationId) - }) - }) -}) diff --git a/packages/subgraph-service/test/integration/during-transition-period/indexer.test.ts b/packages/subgraph-service/test/integration/during-transition-period/indexer.test.ts deleted file mode 100644 index 7fd508c40..000000000 --- a/packages/subgraph-service/test/integration/during-transition-period/indexer.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { SubgraphService } from '@graphprotocol/interfaces' -import { encodeStartServiceData, generateAllocationProof } from '@graphprotocol/toolshed' -import { indexersData as indexers } from '@graphprotocol/toolshed/fixtures' -import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import { ethers } from 'hardhat' -import hre from 'hardhat' - -describe('Indexer', () => { - let subgraphService: SubgraphService - let snapshotId: string - let chainId: number - - // Test addresses - let governor: HardhatEthersSigner - let indexer: HardhatEthersSigner - let allocationId: string - let subgraphDeploymentId: string - let allocationPrivateKey: string - let subgraphServiceAddress: string - - const graph = hre.graph() - - before(async () => { - // Get contracts - subgraphService = graph.subgraphService.contracts.SubgraphService - - // Get governor and non-owner - governor = await graph.accounts.getGovernor() - - // Get chain id - chainId = Number((await hre.ethers.provider.getNetwork()).chainId) - - // Get subgraph service address - subgraphServiceAddress = await subgraphService.getAddress() - }) - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Allocation', () => { - beforeEach(async () => { - // Get indexer - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - - // Generate test addresses - const allocation = indexerFixture.legacyAllocations[0] - allocationId = allocation.allocationID - subgraphDeploymentId = allocation.subgraphDeploymentID - allocationPrivateKey = allocation.allocationPrivateKey - }) - - it('should not be able to create an allocation with an AllocationID that already exists in HorizonStaking contract', async () => { - // Build allocation proof - const signature = await generateAllocationProof( - indexer.address, - allocationPrivateKey, - subgraphServiceAddress, - chainId, - ) - - // Attempt to create an allocation with the same ID - const data = encodeStartServiceData(subgraphDeploymentId, 1000n, allocationId, signature) - - await expect(subgraphService.connect(indexer).startService(indexer.address, data)) - .to.be.revertedWithCustomError(subgraphService, 'LegacyAllocationAlreadyExists') - .withArgs(allocationId) - }) - - it('should not be able to create an allocation that was already migrated by the owner', async () => { - // Migrate legacy allocation - await subgraphService - .connect(governor) - .migrateLegacyAllocation(indexer.address, allocationId, subgraphDeploymentId) - - // Build allocation proof - const signature = await generateAllocationProof( - indexer.address, - allocationPrivateKey, - subgraphServiceAddress, - chainId, - ) - - // Attempt to create the same allocation - const data = encodeStartServiceData(subgraphDeploymentId, 1000n, allocationId, signature) - - await expect(subgraphService.connect(indexer).startService(indexer.address, data)) - .to.be.revertedWithCustomError(subgraphService, 'LegacyAllocationAlreadyExists') - .withArgs(allocationId) - }) - }) -}) diff --git a/packages/subgraph-service/test/integration/during-transition-period/legacy-dispute-manager.test.ts b/packages/subgraph-service/test/integration/during-transition-period/legacy-dispute-manager.test.ts deleted file mode 100644 index 51cfc557c..000000000 --- a/packages/subgraph-service/test/integration/during-transition-period/legacy-dispute-manager.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { HorizonStaking, L2GraphToken, LegacyDisputeManager } from '@graphprotocol/interfaces' -import { - generateAttestationData, - generateLegacyIndexingDisputeId, - generateLegacyQueryDisputeId, -} from '@graphprotocol/toolshed' -import { indexersData as indexers } from '@graphprotocol/toolshed/fixtures' -import { setGRTBalance } from '@graphprotocol/toolshed/hardhat' -import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import { ethers } from 'hardhat' -import hre from 'hardhat' - -describe('Legacy Dispute Manager', () => { - let legacyDisputeManager: LegacyDisputeManager - let graphToken: L2GraphToken - let staking: HorizonStaking - - let snapshotId: string - - let governor: HardhatEthersSigner - let arbitrator: HardhatEthersSigner - let indexer: HardhatEthersSigner - let fisherman: HardhatEthersSigner - - let disputeDeposit: bigint - - const graph = hre.graph() - - // We have to use Aribtrm Sepolia since we're testing an already deployed contract but running on a hardhat fork - const chainId = 421614 - - before(async () => { - governor = await graph.accounts.getGovernor() - ;[arbitrator, fisherman] = await graph.accounts.getTestAccounts() - - // Get contract instances with correct types - legacyDisputeManager = graph.subgraphService.contracts.LegacyDisputeManager - graphToken = graph.horizon.contracts.GraphToken - staking = graph.horizon.contracts.HorizonStaking - - // Set GRT balances - await setGRTBalance(graph.provider, graphToken.target, fisherman.address, ethers.parseEther('100000')) - }) - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - - // Legacy dispute manager - disputeDeposit = ethers.parseEther('10000') - - // Set arbitrator - await legacyDisputeManager.connect(governor).setArbitrator(arbitrator.address) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Indexing Disputes', () => { - let allocationId: string - - beforeEach(async () => { - // Get Indexer - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - - // Get allocation - allocationId = indexerFixture.legacyAllocations[0].allocationID - }) - - it('should allow creating and accepting indexing disputes', async () => { - // Create an indexing dispute - await graphToken.connect(fisherman).approve(legacyDisputeManager.target, disputeDeposit) - await legacyDisputeManager.connect(fisherman).createIndexingDispute(allocationId, disputeDeposit) - const disputeId = generateLegacyIndexingDisputeId(allocationId) - - // Verify dispute was created - const disputeExists = await legacyDisputeManager.isDisputeCreated(disputeId) - expect(disputeExists).to.be.true - - // Get state before slashing - const idxSlashingPercentage = 25000n - const indexerStakeBefore = (await staking.getServiceProvider(indexer.address)).tokensStaked - const slashedAmount = (indexerStakeBefore * idxSlashingPercentage) / 1_000_000n - const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) - - // Accept the dispute - await legacyDisputeManager.connect(arbitrator).acceptDispute(disputeId) - - // Verify indexer was slashed for 2.5% of their stake - const indexerStake = (await staking.getServiceProvider(indexer.address)).tokensStaked - expect(indexerStake).to.equal(indexerStakeBefore - slashedAmount, 'Indexer stake was not slashed correctly') - - // Verify fisherman received their deposit and 50% of the slashed amount - const fishermanBalance = await graphToken.balanceOf(fisherman.address) - expect(fishermanBalance).to.equal( - fishermanBalanceBefore + slashedAmount / 2n + disputeDeposit, - 'Fisherman balance was not updated correctly', - ) - }) - }) - - describe('Query Disputes', () => { - let allocationPrivateKey: string - let subgraphDeploymentId: string - - beforeEach(async () => { - // Get Indexer - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - - // Get allocation - const allocation = indexerFixture.legacyAllocations[0] - allocationPrivateKey = allocation.allocationPrivateKey - subgraphDeploymentId = allocation.subgraphDeploymentID - }) - - it('should allow creating and accepting query disputes', async () => { - // Create attestation data - const queryHash = ethers.keccak256(ethers.toUtf8Bytes('test-query')) - const responseHash = ethers.keccak256(ethers.toUtf8Bytes('test-response')) - const attestationData = await generateAttestationData( - queryHash, - responseHash, - subgraphDeploymentId, - allocationPrivateKey, - await legacyDisputeManager.getAddress(), - chainId, - ) - - // Create a query dispute - await graphToken.connect(fisherman).approve(legacyDisputeManager.target, disputeDeposit) - await legacyDisputeManager.connect(fisherman).createQueryDispute(attestationData, disputeDeposit) - const disputeId = generateLegacyQueryDisputeId( - queryHash, - responseHash, - subgraphDeploymentId, - indexer.address, - fisherman.address, - ) - - // Verify dispute was created - const disputeExists = await legacyDisputeManager.isDisputeCreated(disputeId) - expect(disputeExists).to.be.true - - // Get state before slashing - const qrySlashingPercentage = 25000n - const indexerStakeBefore = (await staking.getServiceProvider(indexer.address)).tokensStaked - const slashedAmount = (indexerStakeBefore * qrySlashingPercentage) / 1_000_000n - const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) - - // Accept the dispute - await legacyDisputeManager.connect(arbitrator).acceptDispute(disputeId) - - // Verify indexer was slashed for 2.5% of their stake - const indexerStake = (await staking.getServiceProvider(indexer.address)).tokensStaked - expect(indexerStake).to.equal(indexerStakeBefore - slashedAmount, 'Indexer stake was not slashed correctly') - - // Verify fisherman received their deposit and 50% of the slashed amount - const fishermanBalance = await graphToken.balanceOf(fisherman.address) - expect(fishermanBalance).to.equal( - fishermanBalanceBefore + slashedAmount / 2n + disputeDeposit, - 'Fisherman balance was not updated correctly', - ) - }) - }) - - describe('Query Dispute Conflict', () => { - let allocationPrivateKey: string - let subgraphDeploymentId: string - - beforeEach(async () => { - // Get Indexer - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - - // Get allocation - const allocation = indexerFixture.legacyAllocations[0] - allocationPrivateKey = allocation.allocationPrivateKey - subgraphDeploymentId = allocation.subgraphDeploymentID - }) - - it('should allow creating conflicting query disputes', async () => { - // Create first attestation data - const queryHash = ethers.keccak256(ethers.toUtf8Bytes('test-query')) - const responseHash1 = ethers.keccak256(ethers.toUtf8Bytes('test-response-1')) - const attestationData1 = await generateAttestationData( - queryHash, - responseHash1, - subgraphDeploymentId, - allocationPrivateKey, - await legacyDisputeManager.getAddress(), - chainId, - ) - - // Create second attestation data with different query/response - const responseHash2 = ethers.keccak256(ethers.toUtf8Bytes('test-response-2')) - const attestationData2 = await generateAttestationData( - queryHash, - responseHash2, - subgraphDeploymentId, - allocationPrivateKey, - await legacyDisputeManager.getAddress(), - chainId, - ) - - // Create query dispute - await legacyDisputeManager.connect(fisherman).createQueryDisputeConflict(attestationData1, attestationData2) - - // Create dispute IDs - const disputeId1 = generateLegacyQueryDisputeId( - queryHash, - responseHash1, - subgraphDeploymentId, - indexer.address, - fisherman.address, - ) - const disputeId2 = generateLegacyQueryDisputeId( - queryHash, - responseHash2, - subgraphDeploymentId, - indexer.address, - fisherman.address, - ) - - // Verify both disputes were created - const disputeExists1 = await legacyDisputeManager.isDisputeCreated(disputeId1) - const disputeExists2 = await legacyDisputeManager.isDisputeCreated(disputeId2) - expect(disputeExists1).to.be.true - expect(disputeExists2).to.be.true - - // Get state before slashing - const qrySlashingPercentage = 25000n - const indexerStakeBefore = (await staking.getServiceProvider(indexer.address)).tokensStaked - const slashedAmount = (indexerStakeBefore * qrySlashingPercentage) / 1_000_000n - const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) - - // Accept one dispute - await legacyDisputeManager.connect(arbitrator).acceptDispute(disputeId1) - - // Verify indexer was slashed for 2.5% of their stake - const indexerStake = (await staking.getServiceProvider(indexer.address)).tokensStaked - expect(indexerStake).to.equal(indexerStakeBefore - slashedAmount, 'Indexer stake was not slashed correctly') - - // Verify fisherman received 50% of the slashed amount - const fishermanBalance = await graphToken.balanceOf(fisherman.address) - expect(fishermanBalance).to.equal( - fishermanBalanceBefore + slashedAmount / 2n, - 'Fisherman balance was not updated correctly', - ) - }) - }) -}) diff --git a/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol b/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol index e64b875ff..31f18bbe0 100644 --- a/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol +++ b/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol @@ -6,7 +6,6 @@ import { GraphPayments } from "@graphprotocol/horizon/contracts/payments/GraphPa import { GraphProxy } from "@graphprotocol/contracts/contracts/upgrades/GraphProxy.sol"; import { GraphProxyAdmin } from "@graphprotocol/contracts/contracts/upgrades/GraphProxyAdmin.sol"; import { HorizonStaking } from "@graphprotocol/horizon/contracts/staking/HorizonStaking.sol"; -import { HorizonStakingExtension } from "@graphprotocol/horizon/contracts/staking/HorizonStakingExtension.sol"; import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; import { GraphTallyCollector } from "@graphprotocol/horizon/contracts/payments/collectors/GraphTallyCollector.sol"; @@ -43,7 +42,6 @@ abstract contract SubgraphBaseTest is Utils, Constants { RecurringCollector recurringCollector; HorizonStaking private stakingBase; - HorizonStakingExtension private stakingExtension; MockCuration curation; MockGRTToken token; @@ -180,8 +178,7 @@ abstract contract SubgraphBaseTest is Utils, Constants { ); subgraphService = SubgraphService(subgraphServiceProxy); - stakingExtension = new HorizonStakingExtension(address(controller), address(subgraphService)); - stakingBase = new HorizonStaking(address(controller), address(stakingExtension), address(subgraphService)); + stakingBase = new HorizonStaking(address(controller), address(subgraphService)); graphPayments = new GraphPayments{ salt: saltGraphPayments }(address(controller), PROTOCOL_PAYMENT_CUT); escrow = new PaymentsEscrow{ salt: saltEscrow }(address(controller), WITHDRAW_ESCROW_THAWING_PERIOD); diff --git a/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol b/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol index 8354e1cf0..7662dc1c3 100644 --- a/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol +++ b/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import { MathUtils } from "@graphprotocol/horizon/contracts/libraries/MathUtils.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; import { IDisputeManager } from "@graphprotocol/interfaces/contracts/subgraph-service/IDisputeManager.sol"; import { IAttestation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAttestation.sol"; @@ -203,81 +203,6 @@ contract DisputeManagerTest is SubgraphServiceSharedTest { return _disputeId; } - struct Balances { - uint256 indexer; - uint256 fisherman; - uint256 arbitrator; - uint256 disputeManager; - uint256 staking; - } - - function _createAndAcceptLegacyDispute( - address _allocationId, - address _fisherman, - uint256 _tokensSlash, - uint256 _tokensRewards - ) internal returns (bytes32) { - (, address arbitrator, ) = vm.readCallers(); - address indexer = staking.getAllocation(_allocationId).indexer; - - Balances memory beforeBalances = Balances({ - indexer: token.balanceOf(indexer), - fisherman: token.balanceOf(_fisherman), - arbitrator: token.balanceOf(arbitrator), - disputeManager: token.balanceOf(address(disputeManager)), - staking: token.balanceOf(address(staking)) - }); - - vm.expectEmit(address(disputeManager)); - emit IDisputeManager.LegacyDisputeCreated( - keccak256(abi.encodePacked(_allocationId, "legacy")), - indexer, - _fisherman, - _allocationId, - _tokensSlash, - _tokensRewards - ); - vm.expectEmit(address(disputeManager)); - emit IDisputeManager.DisputeAccepted( - keccak256(abi.encodePacked(_allocationId, "legacy")), - indexer, - _fisherman, - _tokensRewards - ); - bytes32 _disputeId = disputeManager.createAndAcceptLegacyDispute( - _allocationId, - _fisherman, - _tokensSlash, - _tokensRewards - ); - - Balances memory afterBalances = Balances({ - indexer: token.balanceOf(indexer), - fisherman: token.balanceOf(_fisherman), - arbitrator: token.balanceOf(arbitrator), - disputeManager: token.balanceOf(address(disputeManager)), - staking: token.balanceOf(address(staking)) - }); - - assertEq(afterBalances.indexer, beforeBalances.indexer); - assertEq(afterBalances.fisherman, beforeBalances.fisherman + _tokensRewards); - assertEq(afterBalances.arbitrator, beforeBalances.arbitrator); - assertEq(afterBalances.disputeManager, beforeBalances.disputeManager); - assertEq(afterBalances.staking, beforeBalances.staking - _tokensSlash); - - IDisputeManager.Dispute memory dispute = _getDispute(_disputeId); - assertEq(dispute.indexer, indexer); - assertEq(dispute.fisherman, _fisherman); - assertEq(dispute.deposit, 0); - assertEq(dispute.relatedDisputeId, bytes32(0)); - assertEq(uint8(dispute.disputeType), uint8(IDisputeManager.DisputeType.LegacyDispute)); - assertEq(uint8(dispute.status), uint8(IDisputeManager.DisputeStatus.Accepted)); - assertEq(dispute.createdAt, block.timestamp); - assertEq(dispute.stakeSnapshot, 0); - - return _disputeId; - } - struct BeforeValuesCreateQueryDisputeConflict { IAttestation.State attestation1; IAttestation.State attestation2; @@ -423,10 +348,7 @@ contract DisputeManagerTest is SubgraphServiceSharedTest { uint32 provisionMaxVerifierCut = staking .getProvision(dispute.indexer, address(subgraphService)) .maxVerifierCut; - uint256 fishermanRewardPercentage = MathUtils.min( - disputeManager.fishermanRewardCut(), - provisionMaxVerifierCut - ); + uint256 fishermanRewardPercentage = Math.min(disputeManager.fishermanRewardCut(), provisionMaxVerifierCut); fishermanReward = _tokensSlash.mulPPM(fishermanRewardPercentage); } diff --git a/packages/subgraph-service/test/unit/disputeManager/disputes/legacy.t.sol b/packages/subgraph-service/test/unit/disputeManager/disputes/legacy.t.sol deleted file mode 100644 index c6f57df93..000000000 --- a/packages/subgraph-service/test/unit/disputeManager/disputes/legacy.t.sol +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; -import { IDisputeManager } from "@graphprotocol/interfaces/contracts/subgraph-service/IDisputeManager.sol"; -import { DisputeManagerTest } from "../DisputeManager.t.sol"; - -contract DisputeManagerLegacyDisputeTest is DisputeManagerTest { - using PPMMath for uint256; - - bytes32 private requestCid = keccak256(abi.encodePacked("Request CID")); - bytes32 private responseCid = keccak256(abi.encodePacked("Response CID")); - bytes32 private subgraphDeploymentId = keccak256(abi.encodePacked("Subgraph Deployment ID")); - - /* - * TESTS - */ - - function test_LegacyDispute( - uint256 tokensStaked, - uint256 tokensProvisioned, - uint256 tokensSlash, - uint256 tokensRewards - ) public { - vm.assume(tokensStaked <= MAX_TOKENS); - vm.assume(tokensStaked >= MINIMUM_PROVISION_TOKENS); - tokensProvisioned = bound(tokensProvisioned, MINIMUM_PROVISION_TOKENS, tokensStaked); - tokensSlash = bound(tokensSlash, 2, tokensProvisioned); - tokensRewards = bound(tokensRewards, 1, tokensSlash.mulPPM(FISHERMAN_REWARD_PERCENTAGE)); - - // setup indexer state - resetPrank(users.indexer); - _stake(tokensStaked); - _setStorageAllocationHardcoded(users.indexer, allocationId, tokensStaked - tokensProvisioned); - _provision(users.indexer, tokensProvisioned, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); - - resetPrank(users.arbitrator); - _createAndAcceptLegacyDispute(allocationId, users.fisherman, tokensSlash, tokensRewards); - } - - function test_LegacyDispute_RevertIf_NotArbitrator() public useIndexer { - vm.expectRevert(abi.encodeWithSelector(IDisputeManager.DisputeManagerNotArbitrator.selector)); - disputeManager.createAndAcceptLegacyDispute(allocationId, users.fisherman, 0, 0); - } - - function test_LegacyDispute_RevertIf_AllocationNotFound() public useIndexer { - resetPrank(users.arbitrator); - vm.expectRevert(abi.encodeWithSelector(IDisputeManager.DisputeManagerIndexerNotFound.selector, address(0))); - disputeManager.createAndAcceptLegacyDispute(address(0), users.fisherman, 0, 0); - } -} diff --git a/packages/subgraph-service/test/unit/libraries/LegacyAllocationLibrary.t.sol b/packages/subgraph-service/test/unit/libraries/LegacyAllocationLibrary.t.sol deleted file mode 100644 index 5cb34703e..000000000 --- a/packages/subgraph-service/test/unit/libraries/LegacyAllocationLibrary.t.sol +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import { Test } from "forge-std/Test.sol"; -import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; -import { LegacyAllocationHarness } from "../mocks/LegacyAllocationHarness.sol"; - -contract LegacyAllocationLibraryTest is Test { - LegacyAllocationHarness private harness; - address private allocationId; - - function setUp() public { - harness = new LegacyAllocationHarness(); - allocationId = makeAddr("allocationId"); - } - - function test_LegacyAllocation_Get() public { - // forge-lint: disable-next-line(unsafe-typecast) - harness.migrate(address(1), allocationId, bytes32("sdid")); - - ILegacyAllocation.State memory alloc = harness.get(allocationId); - assertEq(alloc.indexer, address(1)); - // forge-lint: disable-next-line(unsafe-typecast) - assertEq(alloc.subgraphDeploymentId, bytes32("sdid")); - } - - function test_LegacyAllocation_Get_RevertWhen_NotExists() public { - address nonExistent = makeAddr("nonExistent"); - vm.expectRevert(abi.encodeWithSelector(ILegacyAllocation.LegacyAllocationDoesNotExist.selector, nonExistent)); - harness.get(nonExistent); - } -} diff --git a/packages/subgraph-service/test/unit/mocks/LegacyAllocationHarness.sol b/packages/subgraph-service/test/unit/mocks/LegacyAllocationHarness.sol deleted file mode 100644 index 30b4147aa..000000000 --- a/packages/subgraph-service/test/unit/mocks/LegacyAllocationHarness.sol +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; -import { LegacyAllocation } from "../../../contracts/libraries/LegacyAllocation.sol"; - -/// @notice Test harness to exercise LegacyAllocation library guard branches directly -contract LegacyAllocationHarness { - using LegacyAllocation for mapping(address => ILegacyAllocation.State); - - mapping(address => ILegacyAllocation.State) private _legacyAllocations; - - function migrate(address indexer, address allocationId, bytes32 subgraphDeploymentId) external { - _legacyAllocations.migrate(indexer, allocationId, subgraphDeploymentId); - } - - function get(address allocationId) external view returns (ILegacyAllocation.State memory) { - return _legacyAllocations.get(allocationId); - } -} diff --git a/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol b/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol index 95c0371e9..c48622106 100644 --- a/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol +++ b/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; import { SubgraphBaseTest } from "../SubgraphBaseTest.t.sol"; @@ -81,68 +80,6 @@ abstract contract HorizonStakingSharedTest is SubgraphBaseTest { staking.setProvisionParameters(_indexer, _verifier, _maxVerifierCut, _thawingPeriod); } - function _setStorageAllocationHardcoded(address indexer, address allocationId, uint256 tokens) internal { - IHorizonStakingExtension.Allocation memory allocation = IHorizonStakingExtension.Allocation({ - indexer: indexer, - // forge-lint: disable-next-line(unsafe-typecast) - subgraphDeploymentID: bytes32("0x12344321"), - tokens: tokens, - createdAtEpoch: 1234, - closedAtEpoch: 1235, - collectedFees: 1234, - __DEPRECATED_effectiveAllocation: 1222234, - accRewardsPerAllocatedToken: 1233334, - distributedRebates: 1244434 - }); - - // __DEPRECATED_allocations - uint256 allocationsSlot = 15; - bytes32 allocationBaseSlot = keccak256(abi.encode(allocationId, allocationsSlot)); - vm.store(address(staking), allocationBaseSlot, bytes32(uint256(uint160(allocation.indexer)))); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 1), allocation.subgraphDeploymentID); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 2), bytes32(tokens)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 3), bytes32(allocation.createdAtEpoch)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 4), bytes32(allocation.closedAtEpoch)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 5), bytes32(allocation.collectedFees)); - vm.store( - address(staking), - bytes32(uint256(allocationBaseSlot) + 6), - bytes32(allocation.__DEPRECATED_effectiveAllocation) - ); - vm.store( - address(staking), - bytes32(uint256(allocationBaseSlot) + 7), - bytes32(allocation.accRewardsPerAllocatedToken) - ); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 8), bytes32(allocation.distributedRebates)); - - // _serviceProviders - uint256 serviceProviderSlot = 14; - bytes32 serviceProviderBaseSlot = keccak256(abi.encode(allocation.indexer, serviceProviderSlot)); - uint256 currentTokensStaked = uint256(vm.load(address(staking), serviceProviderBaseSlot)); - uint256 currentTokensProvisioned = uint256( - vm.load(address(staking), bytes32(uint256(serviceProviderBaseSlot) + 1)) - ); - vm.store( - address(staking), - bytes32(uint256(serviceProviderBaseSlot) + 0), - bytes32(currentTokensStaked + tokens) - ); - vm.store( - address(staking), - bytes32(uint256(serviceProviderBaseSlot) + 1), - bytes32(currentTokensProvisioned + tokens) - ); - - // __DEPRECATED_subgraphAllocations - uint256 subgraphsAllocationsSlot = 16; - bytes32 subgraphAllocationsBaseSlot = keccak256( - abi.encode(allocation.subgraphDeploymentID, subgraphsAllocationsSlot) - ); - uint256 currentAllocatedTokens = uint256(vm.load(address(staking), subgraphAllocationsBaseSlot)); - vm.store(address(staking), subgraphAllocationsBaseSlot, bytes32(currentAllocatedTokens + tokens)); - } - function _stakeTo(address _indexer, uint256 _tokens) internal { token.approve(address(staking), _tokens); staking.stakeTo(_indexer, _tokens); diff --git a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol index 7169e5fd8..74b0718bb 100644 --- a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol @@ -476,10 +476,16 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { } function _migrateLegacyAllocation(address _indexer, address _allocationId, bytes32 _subgraphDeploymentId) internal { - vm.expectEmit(address(subgraphService)); - emit IAllocationManager.LegacyAllocationMigrated(_indexer, _allocationId, _subgraphDeploymentId); + // migrate fn was removed, we simulate history by manually setting the storage state + uint256 legacyAllocationsSlot = 208; + bytes32 legacyAllocationBaseSlot = keccak256(abi.encode(_allocationId, legacyAllocationsSlot)); - subgraphService.migrateLegacyAllocation(_indexer, _allocationId, _subgraphDeploymentId); + vm.store(address(subgraphService), legacyAllocationBaseSlot, bytes32(uint256(uint160(_indexer)))); + vm.store( + address(subgraphService), + bytes32(uint256(legacyAllocationBaseSlot) + 1), + bytes32(_subgraphDeploymentId) + ); ILegacyAllocation.State memory afterLegacyAllocation = subgraphService.getLegacyAllocation(_allocationId); assertEq(afterLegacyAllocation.indexer, _indexer); diff --git a/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol b/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol index 5ccc9c2d2..5617f4d7b 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol @@ -165,8 +165,8 @@ contract SubgraphServiceAllocationStartTest is SubgraphServiceTest { _createProvision(users.indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); _register(users.indexer, abi.encode("url", "geoHash", address(0))); - // create dummy allo in staking contract - _setStorageAllocationHardcoded(users.indexer, allocationId, tokens); + // simulate legacy allocation migration + _migrateLegacyAllocation(users.indexer, allocationId, subgraphDeployment); bytes memory data = _generateData(tokens); vm.expectRevert(abi.encodeWithSelector(ILegacyAllocation.LegacyAllocationAlreadyExists.selector, allocationId)); diff --git a/packages/subgraph-service/test/unit/subgraphService/governance/legacy.t.sol b/packages/subgraph-service/test/unit/subgraphService/governance/legacy.t.sol deleted file mode 100644 index 65aadf2a5..000000000 --- a/packages/subgraph-service/test/unit/subgraphService/governance/legacy.t.sol +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; -import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; - -import { SubgraphServiceTest } from "../SubgraphService.t.sol"; - -contract SubgraphServiceLegacyAllocation is SubgraphServiceTest { - /* - * TESTS - */ - - function test_MigrateAllocation() public useGovernor { - _migrateLegacyAllocation(users.indexer, allocationId, subgraphDeployment); - } - - function test_MigrateAllocation_WhenNotGovernor() public useIndexer { - vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, users.indexer)); - subgraphService.migrateLegacyAllocation(users.indexer, allocationId, subgraphDeployment); - } - - function test_MigrateAllocation_RevertWhen_AlreadyMigrated() public useGovernor { - _migrateLegacyAllocation(users.indexer, allocationId, subgraphDeployment); - - vm.expectRevert(abi.encodeWithSelector(ILegacyAllocation.LegacyAllocationAlreadyExists.selector, allocationId)); - subgraphService.migrateLegacyAllocation(users.indexer, allocationId, subgraphDeployment); - } -} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol index a05c1d61e..6b14848ff 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -244,7 +244,6 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg // Now try to accept a different agreement on the same allocation // Create a new agreement with different nonce to ensure different agreement ID IRecurringCollector.RecurringCollectionAgreement - // forge-lint: disable-next-line(mixed-case-variable) memory newRCA = _generateAcceptableRecurringCollectionAgreement(ctx, indexerState.addr); newRCA.nonce = alternativeNonce; // Different nonce to ensure different agreement ID diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol index e2ff20260..08b8d4ac3 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -235,7 +235,6 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun return ctx; } - // forge-lint: disable-next-item(mixed-case-function) function _generateAcceptableSignedRCA( Context storage _ctx, address _indexerAddress @@ -265,7 +264,6 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun return _recurringCollectorHelper.sensibleRCA(rca); } - // forge-lint: disable-next-item(mixed-case-function) function _generateAcceptableSignedRCAU( Context storage _ctx, IRecurringCollector.RecurringCollectionAgreement memory _rca diff --git a/packages/toolshed/src/deployments/horizon/actions.ts b/packages/toolshed/src/deployments/horizon/actions.ts index 8fc9bd4df..144342a82 100644 --- a/packages/toolshed/src/deployments/horizon/actions.ts +++ b/packages/toolshed/src/deployments/horizon/actions.ts @@ -62,15 +62,6 @@ export function loadActions(contracts: GraphHorizonContracts) { */ provision: (signer: HardhatEthersSigner, args: Parameters) => provision(contracts, signer, args), - /** - * [Legacy] Collects query fees from the Horizon staking contract - * Note that it will approve HorizonStaking to spend the tokens - * @param signer - The signer that will execute the collect transaction - * @param args Parameters: - * - `[tokens, allocationID]` - The collect parameters - */ - collect: (signer: HardhatEthersSigner, args: Parameters) => - collect(contracts, signer, args), /** * Delegates tokens in the Horizon staking contract * Note that it will approve HorizonStaking to spend the tokens @@ -157,18 +148,6 @@ async function provision( await HorizonStaking.connect(signer).provision(serviceProvider, verifier, tokens, maxVerifierCut, thawingPeriod) } -async function collect( - contracts: GraphHorizonContracts, - signer: HardhatEthersSigner, - args: Parameters, -) { - const { GraphToken, HorizonStaking } = contracts - const [tokens, allocationID] = args - - await GraphToken.connect(signer).approve(HorizonStaking.target, tokens) - await HorizonStaking.connect(signer).collect(tokens, allocationID) -} - async function delegate( contracts: GraphHorizonContracts, signer: HardhatEthersSigner, From a3ea699e72e8ab6526a83a9757f44115fa0ba47f Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:25:41 +0000 Subject: [PATCH 04/11] feat: add legacy allocation ID collision check to HorizonStaking Add isAllocation() to HorizonStaking to check for allocation ID collisions with pre-Horizon legacy allocations. Updates LegacyAllocation.revertIfExists() to also check the staking contract. Original commits on rem-baseline-merge (edf40dca6, b5585744f) --- .../contracts/staking/HorizonStaking.sol | 28 +++++ .../staking/HorizonStakingStorage.sol | 5 +- .../GraphTallyCollector.t.sol | 2 +- .../unit/staking/legacy/isAllocation.t.sol | 107 ++++++++++++++++++ .../horizon/internal/IHorizonStakingMain.sol | 9 ++ .../horizon/internal/IHorizonStakingTypes.sol | 38 +++++++ .../contracts/libraries/AllocationHandler.sol | 2 +- .../contracts/libraries/LegacyAllocation.sol | 9 +- .../subgraphService/SubgraphService.t.sol | 39 +++++++ .../subgraphService/allocation/start.t.sol | 5 +- .../subgraphService/collect/query/query.t.sol | 2 +- 11 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 packages/horizon/test/unit/staking/legacy/isAllocation.t.sol diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index 588e06ecd..cdad9a32f 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -496,6 +496,15 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { return _isAuthorized(serviceProvider, verifier, operator); } + /* + * LEGACY + */ + + /// @inheritdoc IHorizonStakingMain + function isAllocation(address allocationID) external view override returns (bool) { + return _getLegacyAllocationState(allocationID) != LegacyAllocationState.Null; + } + /* * PRIVATE FUNCTIONS */ @@ -1170,6 +1179,25 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { } } + /** + * @notice Return the current state of a legacy allocation + * @param _allocationID Allocation identifier + * @return LegacyAllocationState enum with the state of the allocation + */ + function _getLegacyAllocationState(address _allocationID) private view returns (LegacyAllocationState) { + LegacyAllocation storage alloc = __DEPRECATED_allocations[_allocationID]; + + if (alloc.indexer == address(0)) { + return LegacyAllocationState.Null; + } + + if (alloc.createdAtEpoch != 0 && alloc.closedAtEpoch == 0) { + return LegacyAllocationState.Active; + } + + return LegacyAllocationState.Closed; + } + /** * @notice Determines the correct callback function for `deleteItem` based on the request type. * @param _requestType The type of thaw request (Provision or Delegation). diff --git a/packages/horizon/contracts/staking/HorizonStakingStorage.sol b/packages/horizon/contracts/staking/HorizonStakingStorage.sol index 21b8f58d4..c10ac5d29 100644 --- a/packages/horizon/contracts/staking/HorizonStakingStorage.sol +++ b/packages/horizon/contracts/staking/HorizonStakingStorage.sol @@ -63,8 +63,9 @@ abstract contract HorizonStakingV1Storage { mapping(address serviceProvider => IHorizonStakingTypes.ServiceProviderInternal details) internal _serviceProviders; /// @dev Allocation details. - /// Deprecated, now applied on the subgraph data service - mapping(address allocationId => bytes32 __DEPRECATED_allocation) internal __DEPRECATED_allocations; + /// Deprecated, now applied on the subgraph data service. + /// Kept for storage compatibility and to check for allocation id collisions. + mapping(address allocationId => IHorizonStakingTypes.LegacyAllocation allocation) internal __DEPRECATED_allocations; /// @dev Subgraph allocations, tracks the tokens allocated to a subgraph deployment /// Deprecated, now applied on the SubgraphService diff --git a/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol b/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol index bd022f1d3..4b05992f3 100644 --- a/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol +++ b/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol @@ -42,7 +42,7 @@ contract GraphTallyTest is HorizonStakingSharedTest, PaymentsEscrowSharedTest { * HELPERS */ - function _getSignerProof(uint256 _proofDeadline, uint256 _signer) internal view returns (bytes memory) { + function _getSignerProof(uint256 _proofDeadline, uint256 _signer) internal returns (bytes memory) { (, address msgSender, ) = vm.readCallers(); bytes32 messageHash = keccak256( abi.encodePacked( diff --git a/packages/horizon/test/unit/staking/legacy/isAllocation.t.sol b/packages/horizon/test/unit/staking/legacy/isAllocation.t.sol new file mode 100644 index 000000000..4e74e29c9 --- /dev/null +++ b/packages/horizon/test/unit/staking/legacy/isAllocation.t.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; +import { HorizonStakingSharedTest } from "../../shared/horizon-staking/HorizonStakingShared.t.sol"; + +contract HorizonStakingIsAllocationTest is HorizonStakingSharedTest { + /* + * TESTS + */ + + function test_IsAllocation_ReturnsFalse_WhenAllocationDoesNotExist() public { + address nonExistentAllocationId = makeAddr("nonExistentAllocation"); + assertFalse(staking.isAllocation(nonExistentAllocationId)); + } + + function test_IsAllocation_ReturnsTrue_WhenActiveAllocationExists() public { + address allocationId = makeAddr("activeAllocation"); + + // Set up an active legacy allocation in storage + _setLegacyAllocationInStaking( + allocationId, + users.indexer, + bytes32("subgraphDeploymentId"), + 1000 ether, // tokens + 1, // createdAtEpoch + 0 // closedAtEpoch (0 = still active) + ); + + assertTrue(staking.isAllocation(allocationId)); + } + + function test_IsAllocation_ReturnsTrue_WhenClosedAllocationExists() public { + address allocationId = makeAddr("closedAllocation"); + + // Set up a closed legacy allocation in storage + _setLegacyAllocationInStaking( + allocationId, + users.indexer, + bytes32("subgraphDeploymentId"), + 1000 ether, // tokens + 1, // createdAtEpoch + 10 // closedAtEpoch (non-zero = closed) + ); + + assertTrue(staking.isAllocation(allocationId)); + } + + function test_IsAllocation_ReturnsFalse_WhenIndexerIsZeroAddress() public { + address allocationId = makeAddr("zeroIndexerAllocation"); + + // Set up an allocation with zero indexer (should be considered Null) + _setLegacyAllocationInStaking( + allocationId, + address(0), // indexer is zero + bytes32("subgraphDeploymentId"), + 1000 ether, + 1, + 0 + ); + + assertFalse(staking.isAllocation(allocationId)); + } + + /* + * HELPERS + */ + + /** + * @notice Sets a legacy allocation directly in HorizonStaking storage + * @dev The __DEPRECATED_allocations mapping is at storage slot 10 in HorizonStakingStorage + * The LegacyAllocation struct has the following layout: + * - slot 0: indexer (address) + * - slot 1: subgraphDeploymentID (bytes32) + * - slot 2: tokens (uint256) + * - slot 3: createdAtEpoch (uint256) + * - slot 4: closedAtEpoch (uint256) + * - slot 5: collectedFees (uint256) + * - slot 6: __DEPRECATED_effectiveAllocation (uint256) + * - slot 7: accRewardsPerAllocatedToken (uint256) + * - slot 8: distributedRebates (uint256) + */ + function _setLegacyAllocationInStaking( + address _allocationId, + address _indexer, + bytes32 _subgraphDeploymentId, + uint256 _tokens, + uint256 _createdAtEpoch, + uint256 _closedAtEpoch + ) internal { + // Storage slot for __DEPRECATED_allocations mapping in HorizonStaking + // Use `forge inspect HorizonStaking storage-layout` to verify + uint256 allocationsSlot = 15; + bytes32 allocationBaseSlot = keccak256(abi.encode(_allocationId, allocationsSlot)); + + // Set indexer (slot 0) + vm.store(address(staking), allocationBaseSlot, bytes32(uint256(uint160(_indexer)))); + // Set subgraphDeploymentID (slot 1) + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 1), _subgraphDeploymentId); + // Set tokens (slot 2) + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 2), bytes32(_tokens)); + // Set createdAtEpoch (slot 3) + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 3), bytes32(_createdAtEpoch)); + // Set closedAtEpoch (slot 4) + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 4), bytes32(_closedAtEpoch)); + } +} diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol index ddc595409..1c87fee1e 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol @@ -978,4 +978,13 @@ interface IHorizonStakingMain { * @return The amount of tokens withdrawn */ function forceWithdrawDelegated(address serviceProvider, address delegator) external returns (uint256); + + /** + * @notice Return if allocationID is used. + * @dev This function is used to check for allocation id collisions with legacy allocations + * that were created before the Horizon upgrade. + * @param allocationID Address used as signer by the indexer for an allocation + * @return True if allocationID already used + */ + function isAllocation(address allocationID) external view returns (bool); } diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol index e8fff211b..c5fcb162c 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol @@ -200,4 +200,42 @@ interface IHorizonStakingTypes { uint256 tokensThawing; uint256 sharesThawing; } + + /** + * @notice Legacy allocation representation + * @dev Kept for storage compatibility and to check for allocation id collisions. + * @param indexer The indexer address + * @param subgraphDeploymentID The subgraph deployment ID + * @param tokens The amount of tokens allocated to the subgraph deployment + * @param createdAtEpoch The epoch when the allocation was created + * @param closedAtEpoch The epoch when the allocation was closed + * @param collectedFees The amount of collected fees for the allocation + * @param __DEPRECATED_effectiveAllocation Deprecated field + * @param accRewardsPerAllocatedToken Snapshot used for reward calculation + * @param distributedRebates The amount of collected rebates that have been rebated + */ + struct LegacyAllocation { + address indexer; + bytes32 subgraphDeploymentID; + uint256 tokens; + uint256 createdAtEpoch; + uint256 closedAtEpoch; + uint256 collectedFees; + uint256 __DEPRECATED_effectiveAllocation; + uint256 accRewardsPerAllocatedToken; + uint256 distributedRebates; + } + + /** + * @dev Possible states a legacy allocation can be. + * States: + * - Null = indexer == address(0) + * - Active = not Null && createdAtEpoch != 0 && closedAtEpoch == 0 + * - Closed = not Null && not Active (i.e. createdAtEpoch == 0 or closedAtEpoch != 0) + */ + enum LegacyAllocationState { + Null, + Active, + Closed + } } diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol index 2a15a8350..0519b3e3f 100644 --- a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -252,7 +252,7 @@ library AllocationHandler { // Ensure allocation id is not reused // need to check both subgraph service (on allocations.create()) and legacy allocations - _legacyAllocations.revertIfExists(params._allocationId); + _legacyAllocations.revertIfExists(params.graphStaking, params._allocationId); IAllocation.State memory allocation = _allocations.create( params._indexer, diff --git a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol index f281bea83..8439ed4fb 100644 --- a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol +++ b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.27; +import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; /** @@ -15,15 +16,21 @@ library LegacyAllocation { /** * @notice Revert if a legacy allocation exists - * @dev We check the migrated allocations mapping. + * @dev We check both the migrated allocations mapping and the legacy staking contract. * @param self The legacy allocation list mapping + * @param graphStaking The Horizon Staking contract * @param allocationId The allocation id */ function revertIfExists( mapping(address => ILegacyAllocation.State) storage self, + IHorizonStaking graphStaking, address allocationId ) internal view { require(!self[allocationId].exists(), ILegacyAllocation.LegacyAllocationAlreadyExists(allocationId)); + require( + !graphStaking.isAllocation(allocationId), + ILegacyAllocation.LegacyAllocationAlreadyExists(allocationId) + ); } /** diff --git a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol index 74b0718bb..5002900f1 100644 --- a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol @@ -492,6 +492,45 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { assertEq(afterLegacyAllocation.subgraphDeploymentId, _subgraphDeploymentId); } + /** + * @notice Sets a legacy allocation directly in HorizonStaking storage + * @dev The __DEPRECATED_allocations mapping is at storage slot 15 in HorizonStaking + * Use `forge inspect HorizonStaking storage-layout` to verify + * The LegacyAllocation struct has the following layout: + * - slot 0: indexer (address) + * - slot 1: subgraphDeploymentID (bytes32) + * - slot 2: tokens (uint256) + * - slot 3: createdAtEpoch (uint256) + * - slot 4: closedAtEpoch (uint256) + * - slot 5: collectedFees (uint256) + * - slot 6: __DEPRECATED_effectiveAllocation (uint256) + * - slot 7: accRewardsPerAllocatedToken (uint256) + * - slot 8: distributedRebates (uint256) + */ + function _setLegacyAllocationInStaking( + address _allocationId, + address _indexer, + bytes32 _subgraphDeploymentId + ) internal { + // Storage slot for __DEPRECATED_allocations mapping in HorizonStaking + uint256 allocationsSlot = 15; + bytes32 allocationBaseSlot = keccak256(abi.encode(_allocationId, allocationsSlot)); + + // Set indexer (slot 0) + vm.store(address(staking), allocationBaseSlot, bytes32(uint256(uint160(_indexer)))); + // Set subgraphDeploymentID (slot 1) + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 1), _subgraphDeploymentId); + // Set tokens (slot 2) - non-zero to indicate active allocation + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 2), bytes32(uint256(1000 ether))); + // Set createdAtEpoch (slot 3) - non-zero + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 3), bytes32(uint256(1))); + // Set closedAtEpoch (slot 4) - non-zero to indicate closed + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 4), bytes32(uint256(10))); + + // Verify the allocation is now visible via isAllocation + assertTrue(staking.isAllocation(_allocationId)); + } + /* * HELPERS */ diff --git a/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol b/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol index 5617f4d7b..68c3c6674 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol @@ -165,8 +165,9 @@ contract SubgraphServiceAllocationStartTest is SubgraphServiceTest { _createProvision(users.indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); _register(users.indexer, abi.encode("url", "geoHash", address(0))); - // simulate legacy allocation migration - _migrateLegacyAllocation(users.indexer, allocationId, subgraphDeployment); + // Set a legacy allocation directly in HorizonStaking storage + // This simulates an allocation that was created before Horizon and exists in the staking contract + _setLegacyAllocationInStaking(allocationId, users.indexer, subgraphDeployment); bytes memory data = _generateData(tokens); vm.expectRevert(abi.encodeWithSelector(ILegacyAllocation.LegacyAllocationAlreadyExists.selector, allocationId)); diff --git a/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol b/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol index 4915ac17f..76fae1307 100644 --- a/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol @@ -21,7 +21,7 @@ contract SubgraphServiceRegisterTest is SubgraphServiceTest { * HELPERS */ - function _getSignerProof(uint256 _proofDeadline, uint256 _signer) private view returns (bytes memory) { + function _getSignerProof(uint256 _proofDeadline, uint256 _signer) private returns (bytes memory) { (, address msgSender, ) = vm.readCallers(); bytes32 messageHash = keccak256( abi.encodePacked( From cdd6c1867e5b1fdcdfda309aaf89a504054db38d Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:39:46 +0000 Subject: [PATCH 05/11] fix: cap maxSecondsPerCollection instead of reverting Replace the hard revert (RecurringCollectorCollectionTooLate) with a Math.min cap in _getCollectionInfo. Collections past maxSecondsPerCollection now succeed with tokens capped at maxSecondsPerCollection worth of service, rather than failing entirely. Changes: - _getCollectionInfo caps elapsed seconds at maxSecondsPerCollection - Remove RecurringCollectorCollectionTooLate error from interface - Replace test_Collect_Revert_WhenCollectingTooLate with test_Collect_OK_WhenCollectingPastMaxSeconds - Update maxSecondsPerCollection NatSpec to reflect cap semantics - Fix zero-token test to use correct _sensibleAuthorizeAndAccept API --- .../collectors/MaxSecondsPerCollectionCap.md | 56 +++++++++++++++++++ .../collectors/RecurringCollector.sol | 18 +++--- .../recurring-collector/collect.t.sol | 49 ++++++++-------- .../contracts/horizon/IRecurringCollector.sol | 18 ++---- 4 files changed, 95 insertions(+), 46 deletions(-) create mode 100644 packages/horizon/contracts/payments/collectors/MaxSecondsPerCollectionCap.md diff --git a/packages/horizon/contracts/payments/collectors/MaxSecondsPerCollectionCap.md b/packages/horizon/contracts/payments/collectors/MaxSecondsPerCollectionCap.md new file mode 100644 index 000000000..c3926b31c --- /dev/null +++ b/packages/horizon/contracts/payments/collectors/MaxSecondsPerCollectionCap.md @@ -0,0 +1,56 @@ +# maxSecondsPerCollection: Cap, Not Deadline + +## Problem + +`_requireValidCollect` treats `maxSecondsPerCollection` as a hard deadline: + +```solidity +require( + _collectionSeconds <= _agreement.maxSecondsPerCollection, + RecurringCollectorCollectionTooLate(...) +); +uint256 maxTokens = _agreement.maxOngoingTokensPerSecond * _collectionSeconds; +``` + +If the indexer collects even 1 second past `maxSecondsPerCollection`, the transaction reverts and the agreement becomes permanently stuck. The only recovery is a zero-token collect that bypasses temporal validation entirely (since `_requireValidCollect` is inside `if (tokens != 0)`), which works but is an unnatural mechanism. + +## Fix + +Cap `collectionSeconds` at `maxSecondsPerCollection` in `_getCollectionInfo`, so all callers (RC's `_collect` and SS's `IndexingAgreement.collect`) receive consistent capped seconds: + +```solidity +uint256 elapsed = collectionEnd - collectionStart; +return (true, Math.min(elapsed, uint256(_agreement.maxSecondsPerCollection)), ...); +``` + +The payer's per-collection exposure is still bounded by `maxOngoingTokensPerSecond * maxSecondsPerCollection`. The indexer can collect after the window closes, but receives no more tokens than if they had collected exactly at the deadline. + +## Why this is correct + +1. **`_getMaxNextClaim` already caps.** The view function (used by escrow to compute worst-case exposure) clamps `windowSeconds` at `maxSecondsPerCollection` rather than returning 0. The mutation function should be consistent. + +2. **`collectionSeconds` is derived from on-chain state**, not caller-supplied. The indexer's only leverage is _when_ they call. Capping means they can't extract more by waiting longer. + +3. **No stuck agreements.** A missed window no longer requires cancellation or a zero-token hack to recover. + +4. **`minSecondsPerCollection` is unaffected.** If elapsed time exceeds `maxSecondsPerCollection`, it trivially exceeds `minSecondsPerCollection` (since `max > min` is enforced at accept time). + +5. **Initial tokens preserved.** `maxInitialTokens` is added on top of the capped ongoing amount on first collection. With a hard deadline, a late first collection reverts and the indexer loses both the initial bonus and the ongoing amount — misaligning incentives. With a cap, the initial bonus is always available. + +6. **Late collection loses unclaimed seconds, not ability to collect.** After a capped collection, `lastCollectionAt` resets to `block.timestamp`, not `lastCollectionAt + maxSecondsPerCollection`. The indexer permanently loses tokens for the gap beyond the cap. This incentivizes timely collection without the cliff-edge of a hard revert. + +## Zero-token temporal validation enforced + +`_requireValidCollect` was previously inside `if (tokens != 0)`, allowing zero-token collections to update `lastCollectionAt` without temporal checks. With the cap in place there is no legitimate bypass scenario, so temporal validation now runs unconditionally. + +This also makes `lastCollectionAt` (publicly readable via `getAgreement`) trustworthy as a liveness signal. Previously it could be advanced to `block.timestamp` without any real collection. Now it can only be updated through a validated collection, making it reliable for external consumers (e.g. payers or SAM operators checking indexer activity to decide whether to cancel). + +## Zero-POI special case removed + +The old code special-cased `entities == 0 && poi == bytes32(0)` to force `tokens = 0`, bypassing `_tokensToCollect` and RC temporal validation. This existed as a reset mechanism for stuck agreements. With the cap, there are no stuck agreements, so the special case is removed. Every collection now goes through `_tokensToCollect` and RC validation uniformly, and every POI is disputable. + +## Contrast with indexing rewards + +Indexing rewards require a zero-POI "heartbeat" to keep allocations alive because reward rates change per epoch and snapshots are influenced by other participants' activity. That reset mechanism exists because the system is inherently snapshot-driven. + +RCA indexing fees have no snapshots. The rate (`tokensPerSecond`, `tokensPerEntityPerSecond`) is fixed at agreement accept/update time. No external state changes the per-second rate between collections. The amount owed for N seconds of service is deterministic regardless of when collection happens, so capping is strictly correct — there is no reason to penalize a late collection beyond limiting it to `maxSecondsPerCollection` worth of tokens. diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 5a4b7876d..ef99d1336 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -459,16 +459,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC ) ); } - require( - // solhint-disable-next-line gas-strict-inequalities - _collectionSeconds <= _agreement.maxSecondsPerCollection, - RecurringCollectorCollectionTooLate( - _agreementId, - uint64(_collectionSeconds), - _agreement.maxSecondsPerCollection - ) - ); - + // _collectionSeconds is already capped at maxSecondsPerCollection by _getCollectionInfo uint256 maxTokens = _agreement.maxOngoingTokensPerSecond * _collectionSeconds; maxTokens += _agreement.lastCollectionAt == 0 ? _agreement.maxInitialTokens : 0; @@ -631,7 +622,12 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC return (false, 0, AgreementNotCollectableReason.ZeroCollectionSeconds); } - return (true, collectionEnd - collectionStart, AgreementNotCollectableReason.None); + uint256 elapsed = collectionEnd - collectionStart; + return ( + true, + Math.min(elapsed, uint256(_agreement.maxSecondsPerCollection)), + AgreementNotCollectableReason.None + ); } /** diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol index 818019277..2fa361461 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -168,7 +168,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); } - function test_Collect_Revert_WhenCollectingTooLate( + function test_Collect_OK_WhenCollectingPastMaxSeconds( FuzzyTestCollect calldata fuzzy, uint256 unboundedFirstCollectionSeconds, uint256 unboundedSecondCollectionSeconds @@ -177,8 +177,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { fuzzy.fuzzyTestAccept ); - // skip to collectable time - + // First valid collection to establish lastCollectionAt skip( boundSkip( unboundedFirstCollectionSeconds, @@ -186,7 +185,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { accepted.rca.maxSecondsPerCollection ) ); - bytes memory data = _generateCollectData( + bytes memory firstData = _generateCollectData( _generateCollectParams( accepted.rca, agreementId, @@ -195,10 +194,10 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { fuzzy.collectParams.dataServiceCut ) ); - vm.prank(accepted.rca.dataService); - _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + vm.prank(acceptedRca.dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), firstData); - // skip beyond collectable time but still within the agreement endsAt + // Skip PAST maxSecondsPerCollection (but still within agreement endsAt) uint256 collectionSeconds = boundSkip( unboundedSecondCollectionSeconds, accepted.rca.maxSecondsPerCollection + 1, @@ -206,24 +205,30 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ); skip(collectionSeconds); - data = _generateCollectData( - _generateCollectParams( - accepted.rca, - agreementId, - fuzzy.collectParams.collectionId, - bound(fuzzy.collectParams.tokens, 1, type(uint256).max), - fuzzy.collectParams.dataServiceCut - ) + // Request more tokens than the cap allows + uint256 cappedMaxTokens = acceptedRca.maxOngoingTokensPerSecond * acceptedRca.maxSecondsPerCollection; + uint256 requestedTokens = cappedMaxTokens + 1; + + IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( + acceptedRca, + agreementId, + fuzzy.collectParams.collectionId, + requestedTokens, + fuzzy.collectParams.dataServiceCut ); - bytes memory expectedErr = abi.encodeWithSelector( - IRecurringCollector.RecurringCollectorCollectionTooLate.selector, + bytes memory data = _generateCollectData(collectParams); + + // Collection should SUCCEED with tokens capped at maxSecondsPerCollection worth + _expectCollectCallAndEmit( + acceptedRca, agreementId, - collectionSeconds, - accepted.rca.maxSecondsPerCollection + _paymentType(fuzzy.unboundedPaymentType), + collectParams, + cappedMaxTokens ); - vm.expectRevert(expectedErr); - vm.prank(accepted.rca.dataService); - _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + vm.prank(acceptedRca.dataService); + uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + assertEq(collected, cappedMaxTokens, "Tokens should be capped at maxSecondsPerCollection worth"); } function test_Collect_OK_WhenCollectingTooMuch( diff --git a/packages/interfaces/contracts/horizon/IRecurringCollector.sol b/packages/interfaces/contracts/horizon/IRecurringCollector.sol index e8530cc85..e3ca616a3 100644 --- a/packages/interfaces/contracts/horizon/IRecurringCollector.sol +++ b/packages/interfaces/contracts/horizon/IRecurringCollector.sol @@ -59,7 +59,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second * except for the first collection * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections - * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + * @param maxSecondsPerCollection The maximum seconds of service that can be collected in a single collection * @param nonce A unique nonce for preventing collisions (user-chosen) * @param metadata Arbitrary metadata to extend functionality if a data service requires it * @@ -99,7 +99,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second * except for the first collection * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections - * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + * @param maxSecondsPerCollection The maximum seconds of service that can be collected in a single collection * @param nonce The nonce for preventing replay attacks (must be current nonce + 1) * @param metadata Arbitrary metadata to extend functionality if a data service requires it */ @@ -130,7 +130,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second * except for the first collection * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections - * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + * @param maxSecondsPerCollection The maximum seconds of service that can be collected in a single collection * @param updateNonce The current nonce for updates (prevents replay attacks) * @param canceledAt The timestamp when the agreement was canceled * @param state The state of the agreement @@ -180,7 +180,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections - * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + * @param maxSecondsPerCollection The maximum seconds of service that can be collected in a single collection */ event AgreementAccepted( address indexed dataService, @@ -224,7 +224,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections - * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + * @param maxSecondsPerCollection The maximum seconds of service that can be collected in a single collection */ event AgreementUpdated( address indexed dataService, @@ -373,14 +373,6 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { */ error RecurringCollectorCollectionTooSoon(bytes16 agreementId, uint32 secondsSinceLast, uint32 minSeconds); - /** - * @notice Thrown when calling collect() too late - * @param agreementId The agreement ID - * @param secondsSinceLast Seconds since last collection - * @param maxSeconds Maximum seconds between collections - */ - error RecurringCollectorCollectionTooLate(bytes16 agreementId, uint64 secondsSinceLast, uint32 maxSeconds); - /** * @notice Thrown when calling update() with an invalid nonce * @param agreementId The agreement ID From 4054e7917f0dfa6604ab27f78ff836292c6f3b47 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:36:08 +0000 Subject: [PATCH 06/11] fix: enforce temporal validation on zero-token collections and remove zero-POI special case Move _requireValidCollect() call outside the tokens != 0 guard so temporal constraints (min/maxSecondsPerCollection) are always enforced, even for zero-token collections. This prevents advancing lastCollectionAt without passing temporal validation. Remove the zero-POI special case in IndexingAgreement that bypassed token calculation when entities == 0 && poi == bytes32(0). The temporal validation now handles this consistently. --- .../collectors/RecurringCollector.sol | 13 ++++-- .../recurring-collector/collect.t.sol | 43 +++++++++++++++++ .../contracts/libraries/IndexingAgreement.sol | 4 +- .../contracts/libraries/LastPoiBlockNumber.md | 46 +++++++++++++++++++ 4 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 packages/subgraph-service/contracts/libraries/LastPoiBlockNumber.md diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index ef99d1336..5af3bf863 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -343,10 +343,17 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC require(tokensAvailable > 0, RecurringCollectorUnauthorizedDataService(agreement.dataService)); } - uint256 tokensToCollect = 0; - if (_params.tokens != 0) { - tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens, collectionSeconds); + // Always validate temporal constraints (min/maxSecondsPerCollection) even for + // zero-token collections, to prevent bypassing temporal windows while updating + // lastCollectionAt. + uint256 tokensToCollect = _requireValidCollect( + agreement, + _params.agreementId, + _params.tokens, + collectionSeconds + ); + if (_params.tokens != 0) { uint256 slippage = _params.tokens - tokensToCollect; /* solhint-disable gas-strict-inequalities */ require( diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol index 2fa361461..95530e4b3 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -448,5 +448,48 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); assertEq(collected, maxAllowed); } + function test_Collect_Revert_WhenZeroTokensBypassesTemporalValidation(FuzzyTestCollect calldata fuzzy) public { + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); + + // First valid collection to establish lastCollectionAt + skip(accepted.rca.minSecondsPerCollection); + bytes memory firstData = _generateCollectData( + _generateCollectParams( + accepted.rca, + agreementId, + fuzzy.collectParams.collectionId, + 1, + fuzzy.collectParams.dataServiceCut + ) + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), firstData); + + // Attempt zero-token collection immediately (before minSecondsPerCollection). + // This MUST revert with CollectionTooSoon — zero tokens should NOT bypass + // the temporal validation that guards minSecondsPerCollection. + skip(1); + IRecurringCollector.CollectParams memory zeroParams = _generateCollectParams( + accepted.rca, + agreementId, + fuzzy.collectParams.collectionId, + 0, // zero tokens + fuzzy.collectParams.dataServiceCut + ); + bytes memory zeroData = _generateCollectData(zeroParams); + + vm.expectRevert( + abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorCollectionTooSoon.selector, + agreementId, + uint32(1), // only 1 second elapsed + accepted.rca.minSecondsPerCollection + ) + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), zeroData); + } /* solhint-enable graph/func-name-mixedcase */ } diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index 61ff8a436..19a7eaf4a 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -562,9 +562,7 @@ library IndexingAgreement { CollectIndexingFeeDataV1 memory data = IndexingAgreementDecoder.decodeCollectIndexingFeeDataV1(params.data); - uint256 expectedTokens = (data.entities == 0 && data.poi == bytes32(0)) - ? 0 - : _tokensToCollect(self, params.agreementId, data.entities, collectionSeconds); + uint256 expectedTokens = _tokensToCollect(self, params.agreementId, data.entities, collectionSeconds); // `tokensCollected` <= `expectedTokens` because the recurring collector will further narrow // down the tokens allowed, based on the RCA terms. diff --git a/packages/subgraph-service/contracts/libraries/LastPoiBlockNumber.md b/packages/subgraph-service/contracts/libraries/LastPoiBlockNumber.md new file mode 100644 index 000000000..9eccac7f8 --- /dev/null +++ b/packages/subgraph-service/contracts/libraries/LastPoiBlockNumber.md @@ -0,0 +1,46 @@ +# lastPoiBlockNumber: On-Chain Indexing Progress + +## Motivation + +`lastCollectionAt` (RC, timestamp) tells you _when_ the indexer last collected. It doesn't tell you _how far_ they've indexed. `poiBlockNumber` — already presented in every collection and emitted in `IndexingFeesCollectedV1` — tells you that, but is not stored. + +Storing it gives an on-chain liveness signal for indexing progress, useful for: + +- **Staleness detection**: payers or SAM operators comparing `lastPoiBlockNumber` to current block to decide whether to cancel +- **Race condition mitigation**: gating cancellation with a freshness check, so an off-chain "cancel for lack of progress" decision doesn't race with an on-chain collection that proves progress + +## Where + +`IndexingAgreement.StorageManager` — either as a new field in `IIndexingAgreement.State` or a new mapping: + +```solidity +struct StorageManager { + mapping(bytes16 agreementId => IIndexingAgreement.State) agreements; + mapping(bytes16 agreementId => IndexingAgreementTermsV1 data) termsV1; + mapping(address allocationId => bytes16 agreementId) allocationToActiveAgreementId; +} +``` + +Adding to `State` is simplest — it's already returned by `get()`, so external consumers get it for free: + +```solidity +struct State { + address allocationId; + IndexingAgreementVersion version; + uint256 lastPoiBlockNumber; +} +``` + +## How + +One line in `IndexingAgreement.collect()`, after RC collection succeeds: + +```solidity +self.agreements[params.agreementId].lastPoiBlockNumber = data.poiBlockNumber; +``` + +The data is already decoded from `CollectIndexingFeeDataV1` at that point. + +## Not in RC + +RC is a payment primitive — it tracks temporal state (`lastCollectionAt`) and rate limits. POI block numbers are domain-specific to indexing. Keeping them in SS preserves clean layering and RC reusability for other data services. From c1db48669ed0255cef0827173dd879821e8b2d65 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:48:30 +0000 Subject: [PATCH 07/11] feat: idempotent PaymentsEscrow thaw/cancel/withdraw primitives Make thaw, cancelThaw, and withdraw idempotent so callers need not track escrow state before invoking these operations. Thaw accepts an evenIfTimerReset flag and a tokensThawing parameter for precise control. Refactor IPaymentsEscrow interface to match. --- .../contracts/payments/PaymentsEscrow.sol | 120 ++++++--- .../test/unit/escrow/GraphEscrow.t.sol | 71 +++-- .../horizon/test/unit/escrow/collect.t.sol | 22 +- .../test/unit/escrow/constructor.t.sol | 1 - .../horizon/test/unit/escrow/deposit.t.sol | 4 +- .../horizon/test/unit/escrow/getters.t.sol | 35 ++- .../horizon/test/unit/escrow/isolation.t.sol | 58 +++-- .../horizon/test/unit/escrow/paused.t.sol | 7 + packages/horizon/test/unit/escrow/thaw.t.sol | 244 ++++++++++++++++-- .../horizon/test/unit/escrow/withdraw.t.sol | 61 ++++- .../PaymentsEscrowShared.t.sol | 8 +- .../contracts/horizon/IPaymentsEscrow.sol | 113 ++++---- .../toolshed/IPaymentsEscrowToolshed.sol | 17 +- 13 files changed, 525 insertions(+), 236 deletions(-) diff --git a/packages/horizon/contracts/payments/PaymentsEscrow.sol b/packages/horizon/contracts/payments/PaymentsEscrow.sol index edf98627f..88d21f75b 100644 --- a/packages/horizon/contracts/payments/PaymentsEscrow.sol +++ b/packages/horizon/contracts/payments/PaymentsEscrow.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.27; -// TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-strict-inequalities import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; @@ -36,7 +35,7 @@ contract PaymentsEscrow is Initializable, MulticallUpgradeable, GraphDirectory, /// @notice Escrow account details for payer-collector-receiver tuples mapping(address payer => mapping(address collector => mapping(address receiver => IPaymentsEscrow.EscrowAccount escrowAccount))) - public escrowAccounts; + private _escrowAccounts; // forge-lint: disable-next-item(unwrapped-modifier-logic) /** @@ -79,43 +78,41 @@ contract PaymentsEscrow is Initializable, MulticallUpgradeable, GraphDirectory, } /// @inheritdoc IPaymentsEscrow - function thaw(address collector, address receiver, uint256 tokens) external override notPaused { - require(tokens > 0, PaymentsEscrowInvalidZeroTokens()); - - EscrowAccount storage account = escrowAccounts[msg.sender][collector][receiver]; - require(account.balance >= tokens, PaymentsEscrowInsufficientBalance(account.balance, tokens)); - - account.tokensThawing = tokens; - account.thawEndTimestamp = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; - - emit Thaw(msg.sender, collector, receiver, tokens, account.thawEndTimestamp); + function thaw( + address collector, + address receiver, + uint256 tokens + ) external override notPaused returns (uint256 tokensThawing) { + return _thaw(collector, receiver, tokens, true); } /// @inheritdoc IPaymentsEscrow - function cancelThaw(address collector, address receiver) external override notPaused { - EscrowAccount storage account = escrowAccounts[msg.sender][collector][receiver]; - require(account.tokensThawing != 0, PaymentsEscrowNotThawing()); - - uint256 tokensThawing = account.tokensThawing; - uint256 thawEndTimestamp = account.thawEndTimestamp; - account.tokensThawing = 0; - account.thawEndTimestamp = 0; + function thaw( + address collector, + address receiver, + uint256 tokens, + bool evenIfTimerReset + ) external override notPaused returns (uint256 tokensThawing) { + return _thaw(collector, receiver, tokens, evenIfTimerReset); + } - emit CancelThaw(msg.sender, collector, receiver, tokensThawing, thawEndTimestamp); + /// @inheritdoc IPaymentsEscrow + function cancelThaw( + address collector, + address receiver + ) external override notPaused returns (uint256 tokensThawing) { + return _thaw(collector, receiver, 0, true); } /// @inheritdoc IPaymentsEscrow - function withdraw(address collector, address receiver) external override notPaused { - EscrowAccount storage account = escrowAccounts[msg.sender][collector][receiver]; - require(account.thawEndTimestamp != 0, PaymentsEscrowNotThawing()); - require( - account.thawEndTimestamp < block.timestamp, - PaymentsEscrowStillThawing(block.timestamp, account.thawEndTimestamp) - ); + function withdraw(address collector, address receiver) external override notPaused returns (uint256 tokens) { + EscrowAccount storage account = _escrowAccounts[msg.sender][collector][receiver]; + uint256 thawEnd = account.thawEndTimestamp; - // Amount is the minimum between the amount being thawed and the actual balance - uint256 tokens = account.tokensThawing > account.balance ? account.balance : account.tokensThawing; + // No-op if not thawing or thaw period has not elapsed + if (thawEnd == 0 || block.timestamp <= thawEnd) return 0; + tokens = account.tokensThawing; account.balance -= tokens; account.tokensThawing = 0; account.thawEndTimestamp = 0; @@ -134,18 +131,16 @@ contract PaymentsEscrow is Initializable, MulticallUpgradeable, GraphDirectory, address receiverDestination ) external override notPaused { // Check if there are enough funds in the escrow account - EscrowAccount storage account = escrowAccounts[payer][msg.sender][receiver]; + EscrowAccount storage account = _escrowAccounts[payer][msg.sender][receiver]; require(account.balance >= tokens, PaymentsEscrowInsufficientBalance(account.balance, tokens)); // Reduce amount from account balance account.balance -= tokens; - // Cap tokensThawing to the new balance to keep state consistent - if (account.tokensThawing > account.balance) { + // Cap tokensThawing so the invariant tokensThawing <= balance is preserved + if (account.balance < account.tokensThawing) { account.tokensThawing = account.balance; - if (account.tokensThawing == 0) { - account.thawEndTimestamp = 0; - } + if (account.tokensThawing == 0) account.thawEndTimestamp = 0; } uint256 escrowBalanceBefore = _graphToken().balanceOf(address(this)); @@ -164,9 +159,12 @@ contract PaymentsEscrow is Initializable, MulticallUpgradeable, GraphDirectory, } /// @inheritdoc IPaymentsEscrow - function getBalance(address payer, address collector, address receiver) external view override returns (uint256) { - EscrowAccount storage account = escrowAccounts[payer][collector][receiver]; - return account.balance > account.tokensThawing ? account.balance - account.tokensThawing : 0; + function getEscrowAccount( + address payer, + address collector, + address receiver + ) external view override returns (EscrowAccount memory) { + return _escrowAccounts[payer][collector][receiver]; } /** @@ -178,8 +176,50 @@ contract PaymentsEscrow is Initializable, MulticallUpgradeable, GraphDirectory, * @param _tokens The amount of tokens to deposit */ function _deposit(address _payer, address _collector, address _receiver, uint256 _tokens) private { - escrowAccounts[_payer][_collector][_receiver].balance += _tokens; + _escrowAccounts[_payer][_collector][_receiver].balance += _tokens; _graphToken().pullTokens(msg.sender, _tokens); emit Deposit(_payer, _collector, _receiver, _tokens); } + + /** + * @notice Shared implementation for thaw and cancelThaw. + * Sets tokensThawing to `min(tokensToThaw, balance)`. Resets the timer when the + * thaw amount increases. When `evenIfTimerReset` is false and the operation would + * increase the thaw amount (resetting the timer), the call is a no-op. + * @param collector The address of the collector + * @param receiver The address of the receiver + * @param tokensToThaw The desired amount of tokens to thaw + * @param evenIfTimerReset If true, always proceed. If false, skip increases that would reset the timer. + * @return tokensThawing The resulting amount of tokens thawing + */ + function _thaw( + address collector, + address receiver, + uint256 tokensToThaw, + bool evenIfTimerReset + ) private returns (uint256 tokensThawing) { + EscrowAccount storage account = _escrowAccounts[msg.sender][collector][receiver]; + uint256 currentThawing = account.tokensThawing; + + tokensThawing = tokensToThaw < account.balance ? tokensToThaw : account.balance; + + if (tokensThawing == currentThawing) return tokensThawing; + + uint256 thawEndTimestamp; + if (tokensThawing < currentThawing) { + // Decreasing (or canceling): preserve timer, clear if fully canceled + account.tokensThawing = tokensThawing; + if (tokensThawing == 0) account.thawEndTimestamp = 0; + else thawEndTimestamp = account.thawEndTimestamp; + } else { + thawEndTimestamp = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; + uint256 currentThawEnd = account.thawEndTimestamp; + // Increasing: reset timer (skip if evenIfTimerReset=false and timer would change) + if (!evenIfTimerReset && currentThawEnd != 0 && currentThawEnd != thawEndTimestamp) return currentThawing; + account.tokensThawing = tokensThawing; + account.thawEndTimestamp = thawEndTimestamp; + } + + emit Thawing(msg.sender, collector, receiver, tokensThawing, thawEndTimestamp); + } } diff --git a/packages/horizon/test/unit/escrow/GraphEscrow.t.sol b/packages/horizon/test/unit/escrow/GraphEscrow.t.sol index 3f88b468c..8673ffee4 100644 --- a/packages/horizon/test/unit/escrow/GraphEscrow.t.sol +++ b/packages/horizon/test/unit/escrow/GraphEscrow.t.sol @@ -48,59 +48,58 @@ contract GraphEscrowTest is HorizonStakingSharedTest, PaymentsEscrowSharedTest { function _thawEscrow(address collector, address receiver, uint256 amount) internal { (, address msgSender, ) = vm.readCallers(); - uint256 expectedThawEndTimestamp = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; + IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.getEscrowAccount(msgSender, collector, receiver); + + // Timer resets when increasing, preserves when decreasing, starts when new + uint256 expectedThawEndTimestamp = (accountBefore.thawEndTimestamp == 0 || amount > accountBefore.tokensThawing) + ? block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD + : accountBefore.thawEndTimestamp; + vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.Thaw(msgSender, collector, receiver, amount, expectedThawEndTimestamp); + emit IPaymentsEscrow.Thawing(msgSender, collector, receiver, amount, expectedThawEndTimestamp); escrow.thaw(collector, receiver, amount); - (, uint256 amountThawing, uint256 thawEndTimestamp) = escrow.escrowAccounts(msgSender, collector, receiver); - assertEq(amountThawing, amount); - assertEq(thawEndTimestamp, expectedThawEndTimestamp); + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount(msgSender, collector, receiver); + assertEq(account.tokensThawing, amount); + assertEq(account.thawEndTimestamp, expectedThawEndTimestamp); } function _cancelThawEscrow(address collector, address receiver) internal { (, address msgSender, ) = vm.readCallers(); - (, uint256 amountThawingBefore, uint256 thawEndTimestampBefore) = escrow.escrowAccounts( - msgSender, - collector, - receiver - ); + IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.getEscrowAccount(msgSender, collector, receiver); - vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.CancelThaw(msgSender, collector, receiver, amountThawingBefore, thawEndTimestampBefore); + if (accountBefore.tokensThawing != 0) { + vm.expectEmit(address(escrow)); + emit IPaymentsEscrow.Thawing(msgSender, collector, receiver, 0, 0); + } escrow.cancelThaw(collector, receiver); - (, uint256 amountThawing, uint256 thawEndTimestamp) = escrow.escrowAccounts(msgSender, collector, receiver); - assertEq(amountThawing, 0); - assertEq(thawEndTimestamp, 0); + IPaymentsEscrow.EscrowAccount memory accountAfter = escrow.getEscrowAccount(msgSender, collector, receiver); + assertEq(accountAfter.tokensThawing, 0); + assertEq(accountAfter.thawEndTimestamp, 0); } function _withdrawEscrow(address collector, address receiver) internal { (, address msgSender, ) = vm.readCallers(); - (uint256 balanceBefore, uint256 amountThawingBefore, ) = escrow.escrowAccounts(msgSender, collector, receiver); + IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.getEscrowAccount(msgSender, collector, receiver); uint256 tokenBalanceBeforeSender = token.balanceOf(msgSender); uint256 tokenBalanceBeforeEscrow = token.balanceOf(address(escrow)); - uint256 amountToWithdraw = amountThawingBefore > balanceBefore ? balanceBefore : amountThawingBefore; + uint256 expectedWithdraw = accountBefore.tokensThawing; vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.Withdraw(msgSender, collector, receiver, amountToWithdraw); - escrow.withdraw(collector, receiver); + emit IPaymentsEscrow.Withdraw(msgSender, collector, receiver, expectedWithdraw); + uint256 tokens = escrow.withdraw(collector, receiver); + assertEq(tokens, expectedWithdraw); - (uint256 balanceAfter, uint256 tokensThawingAfter, uint256 thawEndTimestampAfter) = escrow.escrowAccounts( - msgSender, - collector, - receiver - ); - uint256 tokenBalanceAfterSender = token.balanceOf(msgSender); - uint256 tokenBalanceAfterEscrow = token.balanceOf(address(escrow)); + IPaymentsEscrow.EscrowAccount memory accountAfter = escrow.getEscrowAccount(msgSender, collector, receiver); - assertEq(balanceAfter, balanceBefore - amountToWithdraw); - assertEq(tokensThawingAfter, 0); - assertEq(thawEndTimestampAfter, 0); + assertEq(accountAfter.balance, accountBefore.balance - expectedWithdraw); + assertEq(accountAfter.tokensThawing, 0); + assertEq(accountAfter.thawEndTimestamp, 0); - assertEq(tokenBalanceAfterSender, tokenBalanceBeforeSender + amountToWithdraw); - assertEq(tokenBalanceAfterEscrow, tokenBalanceBeforeEscrow - amountToWithdraw); + assertEq(token.balanceOf(msgSender), tokenBalanceBeforeSender + expectedWithdraw); + assertEq(token.balanceOf(address(escrow)), tokenBalanceBeforeEscrow - expectedWithdraw); } struct CollectPaymentData { @@ -146,10 +145,7 @@ contract GraphEscrowTest is HorizonStakingSharedTest, PaymentsEscrowSharedTest { receiverExpectedPayment: 0 }); - { - (uint256 payerEscrowBalance, , ) = escrow.escrowAccounts(_payer, _collector, _receiver); - previousBalances.payerEscrowBalance = payerEscrowBalance; - } + previousBalances.payerEscrowBalance = escrow.getEscrowAccount(_payer, _collector, _receiver).balance; vm.expectEmit(address(escrow)); emit IPaymentsEscrow.EscrowCollected( @@ -192,10 +188,7 @@ contract GraphEscrowTest is HorizonStakingSharedTest, PaymentsEscrowSharedTest { dataServiceBalance: token.balanceOf(_dataService), payerEscrowBalance: 0 }); - { - (uint256 afterPayerEscrowBalance, , ) = escrow.escrowAccounts(_payer, _collector, _receiver); - afterBalances.payerEscrowBalance = afterPayerEscrowBalance; - } + afterBalances.payerEscrowBalance = escrow.getEscrowAccount(_payer, _collector, _receiver).balance; // Check receiver balance after payment assertEq( diff --git a/packages/horizon/test/unit/escrow/collect.t.sol b/packages/horizon/test/unit/escrow/collect.t.sol index 9d229e1ab..b5523fe11 100644 --- a/packages/horizon/test/unit/escrow/collect.t.sol +++ b/packages/horizon/test/unit/escrow/collect.t.sol @@ -154,8 +154,12 @@ contract GraphEscrowCollectTest is GraphEscrowTest { ); // Balance should be zero - (uint256 balance, , ) = escrow.escrowAccounts(users.gateway, users.verifier, users.indexer); - assertEq(balance, 0); + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + users.gateway, + users.verifier, + users.indexer + ); + assertEq(account.balance, 0); } function testCollect_CapsTokensThawingToZero_ResetsThawEndTimestamp(uint256 tokens) public useIndexer { @@ -184,14 +188,14 @@ contract GraphEscrowCollectTest is GraphEscrowTest { ); // tokensThawing and thawEndTimestamp should be reset - (uint256 balance, uint256 tokensThawingResult, uint256 thawEndTimestamp) = escrow.escrowAccounts( + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( users.gateway, users.verifier, users.indexer ); - assertEq(balance, 0); - assertEq(tokensThawingResult, 0, "tokensThawing should be capped to 0"); - assertEq(thawEndTimestamp, 0, "thawEndTimestamp should reset when tokensThawing is 0"); + assertEq(account.balance, 0); + assertEq(account.tokensThawing, 0, "tokensThawing should be capped to 0"); + assertEq(account.thawEndTimestamp, 0, "thawEndTimestamp should reset when tokensThawing is 0"); } function testCollect_CapsTokensThawingBelowBalance(uint256 depositAmount, uint256 collectAmount) public useIndexer { @@ -220,14 +224,14 @@ contract GraphEscrowCollectTest is GraphEscrowTest { users.indexer ); - (uint256 balance, uint256 tokensThawingResult, ) = escrow.escrowAccounts( + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( users.gateway, users.verifier, users.indexer ); uint256 remainingBalance = depositAmount - collectAmount; - assertEq(balance, remainingBalance); - assertEq(tokensThawingResult, remainingBalance, "tokensThawing should cap at remaining balance"); + assertEq(account.balance, remainingBalance); + assertEq(account.tokensThawing, remainingBalance, "tokensThawing should cap at remaining balance"); } function testCollect_RevertWhen_InconsistentCollection(uint256 tokens) public useGateway { diff --git a/packages/horizon/test/unit/escrow/constructor.t.sol b/packages/horizon/test/unit/escrow/constructor.t.sol index c1b097010..430d9926d 100644 --- a/packages/horizon/test/unit/escrow/constructor.t.sol +++ b/packages/horizon/test/unit/escrow/constructor.t.sol @@ -21,7 +21,6 @@ contract GraphEscrowConstructorTest is Test { controller.setContractProxy(keccak256("RewardsManager"), makeAddr("RewardsManager")); controller.setContractProxy(keccak256("GraphTokenGateway"), makeAddr("GraphTokenGateway")); controller.setContractProxy(keccak256("GraphProxyAdmin"), makeAddr("GraphProxyAdmin")); - controller.setContractProxy(keccak256("Curation"), makeAddr("Curation")); } function testConstructor_MaxWaitPeriodBoundary() public { diff --git a/packages/horizon/test/unit/escrow/deposit.t.sol b/packages/horizon/test/unit/escrow/deposit.t.sol index 0f1fe450e..5552b9bd6 100644 --- a/packages/horizon/test/unit/escrow/deposit.t.sol +++ b/packages/horizon/test/unit/escrow/deposit.t.sol @@ -9,7 +9,7 @@ contract GraphEscrowDepositTest is GraphEscrowTest { */ function testDeposit_Tokens(uint256 amount) public useGateway useDeposit(amount) { - (uint256 indexerEscrowBalance, , ) = escrow.escrowAccounts(users.gateway, users.verifier, users.indexer); + uint256 indexerEscrowBalance = escrow.getEscrowAccount(users.gateway, users.verifier, users.indexer).balance; assertEq(indexerEscrowBalance, amount); } @@ -29,7 +29,7 @@ contract GraphEscrowDepositTest is GraphEscrowTest { _depositTokens(users.verifier, users.indexer, amount1); _depositTokens(users.verifier, users.indexer, amount2); - (uint256 balance, , ) = escrow.escrowAccounts(users.gateway, users.verifier, users.indexer); + uint256 balance = escrow.getEscrowAccount(users.gateway, users.verifier, users.indexer).balance; assertEq(balance, amount1 + amount2); } } diff --git a/packages/horizon/test/unit/escrow/getters.t.sol b/packages/horizon/test/unit/escrow/getters.t.sol index 770b8b7c3..8dc85da38 100644 --- a/packages/horizon/test/unit/escrow/getters.t.sol +++ b/packages/horizon/test/unit/escrow/getters.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; import { GraphEscrowTest } from "./GraphEscrow.t.sol"; @@ -10,12 +11,17 @@ contract GraphEscrowGettersTest is GraphEscrowTest { * TESTS */ - function testGetBalance(uint256 amount) public useGateway useDeposit(amount) { - uint256 balance = escrow.getBalance(users.gateway, users.verifier, users.indexer); - assertEq(balance, amount); + function testGetEscrowAccount(uint256 amount) public useGateway useDeposit(amount) { + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + users.gateway, + users.verifier, + users.indexer + ); + assertEq(account.balance, amount); + assertEq(account.tokensThawing, 0); } - function testGetBalance_WhenThawing( + function testGetEscrowAccount_WhenThawing( uint256 amountDeposit, uint256 amountThawing ) public useGateway useDeposit(amountDeposit) { @@ -25,11 +31,15 @@ contract GraphEscrowGettersTest is GraphEscrowTest { // thaw some funds _thawEscrow(users.verifier, users.indexer, amountThawing); - uint256 balance = escrow.getBalance(users.gateway, users.verifier, users.indexer); - assertEq(balance, amountDeposit - amountThawing); + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + users.gateway, + users.verifier, + users.indexer + ); + assertEq(account.balance - account.tokensThawing, amountDeposit - amountThawing); } - function testGetBalance_WhenCollectedOverThawing( + function testGetEscrowAccount_WhenCollectedOverThawing( uint256 amountDeposit, uint256 amountThawing, uint256 amountCollected @@ -69,8 +79,13 @@ contract GraphEscrowGettersTest is GraphEscrowTest { users.indexer ); - // balance should always be 0 since thawing funds > available funds - uint256 balance = escrow.getBalance(users.gateway, users.verifier, users.indexer); - assertEq(balance, 0); + // tokensThawing > balance after collection, so effective available is 0 + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + users.gateway, + users.verifier, + users.indexer + ); + assertEq(account.balance, amountDeposit - amountCollected); + assertTrue(account.tokensThawing >= account.balance); } } diff --git a/packages/horizon/test/unit/escrow/isolation.t.sol b/packages/horizon/test/unit/escrow/isolation.t.sol index 552ec77b7..aee9add28 100644 --- a/packages/horizon/test/unit/escrow/isolation.t.sol +++ b/packages/horizon/test/unit/escrow/isolation.t.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; + import { GraphEscrowTest } from "./GraphEscrow.t.sol"; contract GraphEscrowIsolationTest is GraphEscrowTest { @@ -17,11 +19,19 @@ contract GraphEscrowIsolationTest is GraphEscrowTest { _depositTokens(collector1, users.indexer, amount); _depositTokens(collector2, users.indexer, amount * 2); - (uint256 balance1, , ) = escrow.escrowAccounts(users.gateway, collector1, users.indexer); - (uint256 balance2, , ) = escrow.escrowAccounts(users.gateway, collector2, users.indexer); + IPaymentsEscrow.EscrowAccount memory account1 = escrow.getEscrowAccount( + users.gateway, + collector1, + users.indexer + ); + IPaymentsEscrow.EscrowAccount memory account2 = escrow.getEscrowAccount( + users.gateway, + collector2, + users.indexer + ); - assertEq(balance1, amount); - assertEq(balance2, amount * 2); + assertEq(account1.balance, amount); + assertEq(account2.balance, amount * 2); } function testIsolation_DifferentReceiversSamePayerCollector(uint256 amount) public useGateway { @@ -33,11 +43,19 @@ contract GraphEscrowIsolationTest is GraphEscrowTest { _depositTokens(users.verifier, receiver1, amount); _depositTokens(users.verifier, receiver2, amount * 2); - (uint256 balance1, , ) = escrow.escrowAccounts(users.gateway, users.verifier, receiver1); - (uint256 balance2, , ) = escrow.escrowAccounts(users.gateway, users.verifier, receiver2); + IPaymentsEscrow.EscrowAccount memory account1 = escrow.getEscrowAccount( + users.gateway, + users.verifier, + receiver1 + ); + IPaymentsEscrow.EscrowAccount memory account2 = escrow.getEscrowAccount( + users.gateway, + users.verifier, + receiver2 + ); - assertEq(balance1, amount); - assertEq(balance2, amount * 2); + assertEq(account1.balance, amount); + assertEq(account2.balance, amount * 2); } function testIsolation_ThawOneTupleDoesNotAffectAnother(uint256 amount) public useGateway { @@ -50,27 +68,31 @@ contract GraphEscrowIsolationTest is GraphEscrowTest { escrow.thaw(users.verifier, users.indexer, amount / 2); // Second tuple should be unaffected - (, uint256 tokensThawing2, uint256 thawEndTimestamp2) = escrow.escrowAccounts( + IPaymentsEscrow.EscrowAccount memory account2 = escrow.getEscrowAccount( users.gateway, users.verifier, users.delegator ); - assertEq(tokensThawing2, 0); - assertEq(thawEndTimestamp2, 0); + assertEq(account2.tokensThawing, 0); + assertEq(account2.thawEndTimestamp, 0); // First tuple should have thawing - (, uint256 tokensThawing1, ) = escrow.escrowAccounts(users.gateway, users.verifier, users.indexer); - assertEq(tokensThawing1, amount / 2); + IPaymentsEscrow.EscrowAccount memory account1 = escrow.getEscrowAccount( + users.gateway, + users.verifier, + users.indexer + ); + assertEq(account1.tokensThawing, amount / 2); } - function testIsolation_EscrowAccounts_NeverUsedAccount() public view { - (uint256 balance, uint256 tokensThawing, uint256 thawEndTimestamp) = escrow.escrowAccounts( + function testIsolation_GetEscrowAccount_NeverUsedAccount() public view { + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( address(0xdead), address(0xbeef), address(0xface) ); - assertEq(balance, 0); - assertEq(tokensThawing, 0); - assertEq(thawEndTimestamp, 0); + assertEq(account.balance, 0); + assertEq(account.tokensThawing, 0); + assertEq(account.thawEndTimestamp, 0); } } diff --git a/packages/horizon/test/unit/escrow/paused.t.sol b/packages/horizon/test/unit/escrow/paused.t.sol index 010268c80..276451d14 100644 --- a/packages/horizon/test/unit/escrow/paused.t.sol +++ b/packages/horizon/test/unit/escrow/paused.t.sol @@ -50,6 +50,13 @@ contract GraphEscrowPausedTest is GraphEscrowTest { escrow.cancelThaw(users.verifier, users.indexer); } + function testPaused_RevertWhen_ThawWithEvenIfTimerReset( + uint256 tokens + ) public useGateway useDeposit(tokens) usePaused(true) { + vm.expectRevert(abi.encodeWithSelector(IPaymentsEscrow.PaymentsEscrowIsPaused.selector)); + escrow.thaw(users.verifier, users.indexer, tokens, false); + } + function testPaused_RevertWhen_WithdrawTokens( uint256 tokens, uint256 thawAmount diff --git a/packages/horizon/test/unit/escrow/thaw.t.sol b/packages/horizon/test/unit/escrow/thaw.t.sol index ca8569176..25a2ab81e 100644 --- a/packages/horizon/test/unit/escrow/thaw.t.sol +++ b/packages/horizon/test/unit/escrow/thaw.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; import { GraphEscrowTest } from "./GraphEscrow.t.sol"; contract GraphEscrowThawTest is GraphEscrowTest { @@ -21,47 +22,104 @@ contract GraphEscrowThawTest is GraphEscrowTest { vm.assume(amount > 0); _thawEscrow(users.verifier, users.indexer, amount); - uint256 availableBalance = escrow.getBalance(users.gateway, users.verifier, users.indexer); - assertEq(availableBalance, 0); + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + users.gateway, + users.verifier, + users.indexer + ); + assertEq(account.balance - account.tokensThawing, 0); } - function testThaw_Tokens_SuccesiveCalls(uint256 amount) public useGateway { - amount = bound(amount, 2, type(uint256).max - 10); + function testThaw_Tokens_SuccesiveCalls_PreservesTimer(uint256 amount) public useGateway { + amount = bound(amount, 3, type(uint256).max - 10); _depositTokens(users.verifier, users.indexer, amount); uint256 firstAmountToThaw = (amount + 2 - 1) / 2; uint256 secondAmountToThaw = (amount + 10 - 1) / 10; - _thawEscrow(users.verifier, users.indexer, firstAmountToThaw); - _thawEscrow(users.verifier, users.indexer, secondAmountToThaw); (, address msgSender, ) = vm.readCallers(); - (, uint256 amountThawing, uint256 thawEndTimestamp) = escrow.escrowAccounts( + + escrow.thaw(users.verifier, users.indexer, firstAmountToThaw); + uint256 expectedThawEnd = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; + + // Advance time — second thaw should preserve the original timer + vm.warp(block.timestamp + 1 hours); + + vm.expectEmit(address(escrow)); + emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); + escrow.thaw(users.verifier, users.indexer, secondAmountToThaw); + + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( msgSender, users.verifier, users.indexer ); - assertEq(amountThawing, secondAmountToThaw); - assertEq(thawEndTimestamp, block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD); + assertEq(account.tokensThawing, secondAmountToThaw); + assertEq(account.thawEndTimestamp, expectedThawEnd, "Timer should be preserved, not reset"); } - function testThaw_Tokens_RevertWhen_AmountIsZero() public useGateway { - bytes memory expectedError = abi.encodeWithSignature("PaymentsEscrowInvalidZeroTokens()"); - vm.expectRevert(expectedError); + function testThaw_Tokens_SuccesiveCalls_ResetsTimerOnIncrease(uint256 amount) public useGateway { + amount = bound(amount, 10, type(uint256).max - 10); + _depositTokens(users.verifier, users.indexer, amount); + + uint256 firstAmountToThaw = (amount + 10 - 1) / 10; // ~10% of amount + uint256 secondAmountToThaw = (amount + 2 - 1) / 2; // ~50% of amount + + (, address msgSender, ) = vm.readCallers(); + + escrow.thaw(users.verifier, users.indexer, firstAmountToThaw); + + // Advance time — second thaw with larger amount should reset the timer + vm.warp(block.timestamp + 1 hours); + + uint256 expectedThawEnd = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; + vm.expectEmit(address(escrow)); + emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); + escrow.thaw(users.verifier, users.indexer, secondAmountToThaw); + + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + msgSender, + users.verifier, + users.indexer + ); + assertEq(account.tokensThawing, secondAmountToThaw); + assertEq(account.thawEndTimestamp, expectedThawEnd, "Timer should reset on increase"); + } + + function testThaw_ZeroAmountCancelsAll(uint256 amount) public useGateway useDeposit(amount) { + escrow.thaw(users.verifier, users.indexer, amount); + + (, address msgSender, ) = vm.readCallers(); + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + msgSender, + users.verifier, + users.indexer + ); + assertEq(account.tokensThawing, amount); + + // thaw(0) cancels all thawing — event should reflect cleared state + vm.expectEmit(address(escrow)); + emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, 0, 0); escrow.thaw(users.verifier, users.indexer, 0); + + account = escrow.getEscrowAccount(msgSender, users.verifier, users.indexer); + assertEq(account.tokensThawing, 0); + assertEq(account.thawEndTimestamp, 0); } - function testThaw_RevertWhen_InsufficientAmount( - uint256 amount, - uint256 overAmount - ) public useGateway useDeposit(amount) { + function testThaw_CapsAtBalance(uint256 amount, uint256 overAmount) public useGateway useDeposit(amount) { overAmount = bound(overAmount, amount + 1, type(uint256).max); - bytes memory expectedError = abi.encodeWithSignature( - "PaymentsEscrowInsufficientBalance(uint256,uint256)", - amount, - overAmount + + uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, overAmount); + assertEq(tokensThawing, amount, "Should cap at balance"); + + (, address msgSender, ) = vm.readCallers(); + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + msgSender, + users.verifier, + users.indexer ); - vm.expectRevert(expectedError); - escrow.thaw(users.verifier, users.indexer, overAmount); + assertEq(account.tokensThawing, amount); } function testThaw_CancelRequest(uint256 amount) public useGateway useDeposit(amount) { @@ -69,9 +127,143 @@ contract GraphEscrowThawTest is GraphEscrowTest { _cancelThawEscrow(users.verifier, users.indexer); } - function testThaw_CancelRequest_RevertWhen_NoThawing(uint256 amount) public useGateway useDeposit(amount) { - bytes memory expectedError = abi.encodeWithSignature("PaymentsEscrowNotThawing()"); - vm.expectRevert(expectedError); - escrow.cancelThaw(users.verifier, users.indexer); + function testThaw_CancelRequest_NoopWhenNotThawing(uint256 amount) public useGateway useDeposit(amount) { + uint256 tokensThawing = escrow.cancelThaw(users.verifier, users.indexer); + assertEq(tokensThawing, 0); + } + + function testThaw_NoopWhenRequestedEqualsCurrentThawing(uint256 amount) public useGateway useDeposit(amount) { + // First thaw + escrow.thaw(users.verifier, users.indexer, amount); + + (, address msgSender, ) = vm.readCallers(); + IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.getEscrowAccount( + msgSender, + users.verifier, + users.indexer + ); + + // Same amount again should be a no-op — returns early without state change + uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, amount); + assertEq(tokensThawing, amount); + + IPaymentsEscrow.EscrowAccount memory accountAfter = escrow.getEscrowAccount( + msgSender, + users.verifier, + users.indexer + ); + assertEq(accountAfter.tokensThawing, accountBefore.tokensThawing); + assertEq(accountAfter.thawEndTimestamp, accountBefore.thawEndTimestamp); + } + + /* + * evenIfTimerReset = false tests + */ + + function testThaw_EvenIfTimerResetFalse_ProceedsWithNewThaw(uint256 amount) public useGateway useDeposit(amount) { + // When no existing thaw, evenIfTimerReset=false should proceed normally + (, address msgSender, ) = vm.readCallers(); + uint256 expectedThawEnd = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; + + vm.expectEmit(address(escrow)); + emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, amount, expectedThawEnd); + uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, amount, false); + assertEq(tokensThawing, amount); + } + + function testThaw_EvenIfTimerResetFalse_ProceedsWithDecrease(uint256 amount) public useGateway { + amount = bound(amount, 10, MAX_STAKING_TOKENS); + _depositTokens(users.verifier, users.indexer, amount); + + uint256 firstAmountToThaw = (amount + 2 - 1) / 2; // ~50% + uint256 secondAmountToThaw = (amount + 10 - 1) / 10; // ~10% + + // Thaw first amount + escrow.thaw(users.verifier, users.indexer, firstAmountToThaw); + uint256 expectedThawEnd = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; + + vm.warp(block.timestamp + 1 hours); + + // Decrease with evenIfTimerReset=false should proceed and preserve timer + (, address msgSender, ) = vm.readCallers(); + vm.expectEmit(address(escrow)); + emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); + uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, secondAmountToThaw, false); + assertEq(tokensThawing, secondAmountToThaw); + + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + msgSender, + users.verifier, + users.indexer + ); + assertEq(account.thawEndTimestamp, expectedThawEnd, "Timer should be preserved on decrease"); + } + + function testThaw_EvenIfTimerResetFalse_SkipsIncreaseWhenTimerWouldReset(uint256 amount) public useGateway { + amount = bound(amount, 10, MAX_STAKING_TOKENS); + _depositTokens(users.verifier, users.indexer, amount); + + uint256 firstAmountToThaw = (amount + 10 - 1) / 10; // ~10% + uint256 secondAmountToThaw = (amount + 2 - 1) / 2; // ~50% + + // Thaw first amount + escrow.thaw(users.verifier, users.indexer, firstAmountToThaw); + uint256 originalThawEnd = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; + + // Advance time so timer would change + vm.warp(block.timestamp + 1 hours); + + // Increase with evenIfTimerReset=false should be a no-op + uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, secondAmountToThaw, false); + assertEq(tokensThawing, firstAmountToThaw, "Should return current thawing, not new amount"); + + // State should be unchanged + (, address msgSender, ) = vm.readCallers(); + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + msgSender, + users.verifier, + users.indexer + ); + assertEq(account.tokensThawing, firstAmountToThaw); + assertEq(account.thawEndTimestamp, originalThawEnd, "Timer should remain unchanged"); + } + + function testThaw_EvenIfTimerResetFalse_ProceedsWhenTimerUnchanged(uint256 amount) public useGateway { + amount = bound(amount, 10, MAX_STAKING_TOKENS); + _depositTokens(users.verifier, users.indexer, amount); + + uint256 firstAmountToThaw = (amount + 10 - 1) / 10; // ~10% + uint256 secondAmountToThaw = (amount + 2 - 1) / 2; // ~50% + + // Thaw first amount + escrow.thaw(users.verifier, users.indexer, firstAmountToThaw); + + // Increase immediately in the same block — timer wouldn't change + (, address msgSender, ) = vm.readCallers(); + uint256 expectedThawEnd = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; + vm.expectEmit(address(escrow)); + emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); + uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, secondAmountToThaw, false); + assertEq(tokensThawing, secondAmountToThaw, "Should proceed when timer unchanged"); + } + + function testThaw_EvenIfTimerResetFalse_CancelsThawing(uint256 amount) public useGateway useDeposit(amount) { + // Thaw first + escrow.thaw(users.verifier, users.indexer, amount); + + // Cancel (thaw 0) with evenIfTimerReset=false should still work (decrease path) + (, address msgSender, ) = vm.readCallers(); + vm.expectEmit(address(escrow)); + emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, 0, 0); + uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, 0, false); + assertEq(tokensThawing, 0); + + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + msgSender, + users.verifier, + users.indexer + ); + assertEq(account.tokensThawing, 0); + assertEq(account.thawEndTimestamp, 0); } } diff --git a/packages/horizon/test/unit/escrow/withdraw.t.sol b/packages/horizon/test/unit/escrow/withdraw.t.sol index 18a000af4..d212576b7 100644 --- a/packages/horizon/test/unit/escrow/withdraw.t.sol +++ b/packages/horizon/test/unit/escrow/withdraw.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; import { GraphEscrowTest } from "./GraphEscrow.t.sol"; contract GraphEscrowWithdrawTest is GraphEscrowTest { @@ -20,23 +21,44 @@ contract GraphEscrowWithdrawTest is GraphEscrowTest { vm.stopPrank(); } - function testWithdraw_RevertWhen_NotThawing(uint256 amount) public useGateway useDeposit(amount) { - bytes memory expectedError = abi.encodeWithSignature("PaymentsEscrowNotThawing()"); - vm.expectRevert(expectedError); - escrow.withdraw(users.verifier, users.indexer); + function testWithdraw_NoopWhenNotThawing(uint256 amount) public useGateway useDeposit(amount) { + uint256 tokens = escrow.withdraw(users.verifier, users.indexer); + assertEq(tokens, 0); } - function testWithdraw_RevertWhen_StillThawing( + function testWithdraw_NoopWhenStillThawing( uint256 amount, uint256 thawAmount ) public useGateway depositAndThawTokens(amount, thawAmount) { - bytes memory expectedError = abi.encodeWithSignature( - "PaymentsEscrowStillThawing(uint256,uint256)", - block.timestamp, - block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD + uint256 tokens = escrow.withdraw(users.verifier, users.indexer); + assertEq(tokens, 0); + + // Account unchanged + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + users.gateway, + users.verifier, + users.indexer ); - vm.expectRevert(expectedError); - escrow.withdraw(users.verifier, users.indexer); + assertEq(account.tokensThawing, thawAmount); + } + + function testWithdraw_NoopAtExactThawEndTimestamp( + uint256 amount, + uint256 thawAmount + ) public useGateway depositAndThawTokens(amount, thawAmount) { + // Advance time to exactly the thaw end timestamp (boundary: block.timestamp <= thawEnd) + skip(WITHDRAW_ESCROW_THAWING_PERIOD); + + uint256 tokens = escrow.withdraw(users.verifier, users.indexer); + assertEq(tokens, 0, "Should not withdraw when timestamp equals thawEnd"); + + // Account unchanged + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + users.gateway, + users.verifier, + users.indexer + ); + assertEq(account.tokensThawing, thawAmount); } function testWithdraw_SucceedsOneSecondAfterThawEnd( @@ -55,7 +77,7 @@ contract GraphEscrowWithdrawTest is GraphEscrowTest { uint256 amountCollected ) public useGateway depositAndThawTokens(amountDeposited, amountThawed) { vm.assume(amountCollected > 0); - vm.assume(amountCollected < amountDeposited); + vm.assume(amountCollected <= amountDeposited); // burn some tokens to prevent overflow resetPrank(users.indexer); @@ -76,8 +98,19 @@ contract GraphEscrowWithdrawTest is GraphEscrowTest { // Advance time to simulate the thawing period skip(WITHDRAW_ESCROW_THAWING_PERIOD + 1); - // withdraw the remaining thawed balance + // After collect, tokensThawing is capped at remaining balance. + // Withdraw succeeds if tokens remain, otherwise is a no-op. resetPrank(users.gateway); - _withdrawEscrow(users.verifier, users.indexer); + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + users.gateway, + users.verifier, + users.indexer + ); + if (account.tokensThawing != 0) { + _withdrawEscrow(users.verifier, users.indexer); + } else { + uint256 tokens = escrow.withdraw(users.verifier, users.indexer); + assertEq(tokens, 0); + } } } diff --git a/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol b/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol index 8e51aed9f..bdf7e2f3b 100644 --- a/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol +++ b/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol @@ -21,26 +21,26 @@ abstract contract PaymentsEscrowSharedTest is GraphBaseTest { function _depositTokens(address _collector, address _receiver, uint256 _tokens) internal { (, address msgSender, ) = vm.readCallers(); - (uint256 escrowBalanceBefore, , ) = escrow.escrowAccounts(msgSender, _collector, _receiver); + uint256 escrowBalanceBefore = escrow.getEscrowAccount(msgSender, _collector, _receiver).balance; token.approve(address(escrow), _tokens); vm.expectEmit(address(escrow)); emit IPaymentsEscrow.Deposit(msgSender, _collector, _receiver, _tokens); escrow.deposit(_collector, _receiver, _tokens); - (uint256 escrowBalanceAfter, , ) = escrow.escrowAccounts(msgSender, _collector, _receiver); + uint256 escrowBalanceAfter = escrow.getEscrowAccount(msgSender, _collector, _receiver).balance; assertEq(escrowBalanceAfter - _tokens, escrowBalanceBefore); } function _depositToTokens(address _payer, address _collector, address _receiver, uint256 _tokens) internal { - (uint256 escrowBalanceBefore, , ) = escrow.escrowAccounts(_payer, _collector, _receiver); + uint256 escrowBalanceBefore = escrow.getEscrowAccount(_payer, _collector, _receiver).balance; token.approve(address(escrow), _tokens); vm.expectEmit(address(escrow)); emit IPaymentsEscrow.Deposit(_payer, _collector, _receiver, _tokens); escrow.depositTo(_payer, _collector, _receiver, _tokens); - (uint256 escrowBalanceAfter, , ) = escrow.escrowAccounts(_payer, _collector, _receiver); + uint256 escrowBalanceAfter = escrow.getEscrowAccount(_payer, _collector, _receiver).balance; assertEq(escrowBalanceAfter - _tokens, escrowBalanceBefore); } } diff --git a/packages/interfaces/contracts/horizon/IPaymentsEscrow.sol b/packages/interfaces/contracts/horizon/IPaymentsEscrow.sol index 9dbe9906a..aa904984d 100644 --- a/packages/interfaces/contracts/horizon/IPaymentsEscrow.sol +++ b/packages/interfaces/contracts/horizon/IPaymentsEscrow.sol @@ -40,14 +40,15 @@ interface IPaymentsEscrow { event Deposit(address indexed payer, address indexed collector, address indexed receiver, uint256 tokens); /** - * @notice Emitted when a payer cancels an escrow thawing + * @notice Emitted when the thawing state changes for a payer-collector-receiver tuple. + * Covers starting, increasing, reducing, and canceling a thaw. * @param payer The address of the payer * @param collector The address of the collector * @param receiver The address of the receiver - * @param tokensThawing The amount of tokens that were being thawed - * @param thawEndTimestamp The timestamp at which the thawing period was ending + * @param tokensThawing The amount of tokens thawing after the change + * @param thawEndTimestamp The thaw end timestamp after the change (zero if no longer thawing) */ - event CancelThaw( + event Thawing( address indexed payer, address indexed collector, address indexed receiver, @@ -55,22 +56,6 @@ interface IPaymentsEscrow { uint256 thawEndTimestamp ); - /** - * @notice Emitted when a payer thaws funds from the escrow for a payer-collector-receiver tuple - * @param payer The address of the payer - * @param collector The address of the collector - * @param receiver The address of the receiver - * @param tokens The amount of tokens being thawed - * @param thawEndTimestamp The timestamp at which the thawing period ends - */ - event Thaw( - address indexed payer, - address indexed collector, - address indexed receiver, - uint256 tokens, - uint256 thawEndTimestamp - ); - /** * @notice Emitted when a payer withdraws funds from the escrow for a payer-collector-receiver tuple * @param payer The address of the payer @@ -112,18 +97,6 @@ interface IPaymentsEscrow { */ error PaymentsEscrowInsufficientBalance(uint256 balance, uint256 minBalance); - /** - * @notice Thrown when a thawing is expected to be in progress but it is not - */ - error PaymentsEscrowNotThawing(); - - /** - * @notice Thrown when a thawing is still in progress - * @param currentTimestamp The current timestamp - * @param thawEndTimestamp The timestamp at which the thawing period ends - */ - error PaymentsEscrowStillThawing(uint256 currentTimestamp, uint256 thawEndTimestamp); - /** * @notice Thrown when setting the thawing period to a value greater than the maximum * @param thawingPeriod The thawing period @@ -139,11 +112,6 @@ interface IPaymentsEscrow { */ error PaymentsEscrowInconsistentCollection(uint256 balanceBefore, uint256 balanceAfter, uint256 tokens); - /** - * @notice Thrown when operating a zero token amount is not allowed. - */ - error PaymentsEscrowInvalidZeroTokens(); - /** * @notice The maximum thawing period for escrow funds withdrawal * @return The maximum thawing period in seconds @@ -183,45 +151,59 @@ interface IPaymentsEscrow { function depositTo(address payer, address collector, address receiver, uint256 tokens) external; /** - * @notice Thaw a specific amount of escrow from a payer-collector-receiver's escrow account. + * @notice Sets the thawing amount for a payer-collector-receiver's escrow account. * The payer is the transaction caller. - * Note that repeated calls to this function will overwrite the previous thawing amount - * and reset the thawing period. - * @dev Requirements: - * - `tokens` must be less than or equal to the available balance - * - * Emits a {Thaw} event. - * + * Idempotent: if the target matches current thawing, this is a no-op. + * Capped at balance: if `tokens` exceeds balance, thaws the entire balance. + * Resets the thaw timer when the amount increases; preserves it when it decreases. + * `thaw(collector, receiver, 0)` cancels all thawing. + * @param collector The address of the collector + * @param receiver The address of the receiver + * @param tokens The desired amount of tokens to thaw + * @return tokensThawing The resulting amount of tokens thawing after the operation + * @dev Emits a {Thawing} event if the thawing state changes. + */ + function thaw(address collector, address receiver, uint256 tokens) external returns (uint256 tokensThawing); + + /** + * @notice Sets the thawing amount with a guard against timer reset. + * When `evenIfTimerReset` is false and the operation would increase the thaw amount + * (which resets the timer), the call is a no-op and returns the current tokensThawing. + * Decreases and cancellations always proceed regardless of this flag. * @param collector The address of the collector * @param receiver The address of the receiver - * @param tokens The amount of tokens to thaw + * @param tokens The desired amount of tokens to thaw + * @param evenIfTimerReset If true, always proceed. If false, skip increases that would reset the timer. + * @return tokensThawing The resulting amount of tokens thawing after the operation + * @dev Emits a {Thawing} event if the thawing state changes. */ - function thaw(address collector, address receiver, uint256 tokens) external; + function thaw( + address collector, + address receiver, + uint256 tokens, + bool evenIfTimerReset + ) external returns (uint256 tokensThawing); /** - * @notice Cancels the thawing of escrow from a payer-collector-receiver's escrow account. + * @notice Cancels all thawing. Equivalent to `thaw(collector, receiver, 0)`. + * Idempotent: if nothing is thawing, this is a no-op. * @param collector The address of the collector * @param receiver The address of the receiver - * @dev Requirements: - * - The payer must be thawing funds - * Emits a {CancelThaw} event. + * @return tokensThawing The resulting amount of tokens thawing (always 0) + * @dev Emits a {Thawing} event if any tokens were thawing. */ - function cancelThaw(address collector, address receiver) external; + function cancelThaw(address collector, address receiver) external returns (uint256 tokensThawing); /** * @notice Withdraws all thawed escrow from a payer-collector-receiver's escrow account. * The payer is the transaction caller. - * Note that the withdrawn funds might be less than the thawed amount if there were - * payment collections in the meantime. - * @dev Requirements: - * - Funds must be thawed - * - * Emits a {Withdraw} event - * + * Idempotent: returns 0 if nothing is thawing or thaw period has not elapsed. * @param collector The address of the collector * @param receiver The address of the receiver + * @return tokens The amount of tokens withdrawn + * @dev Emits a {Withdraw} event if tokens were withdrawn. */ - function withdraw(address collector, address receiver) external; + function withdraw(address collector, address receiver) external returns (uint256 tokens); /** * @notice Collects funds from the payer-collector-receiver's escrow and sends them to {GraphPayments} for @@ -249,12 +231,15 @@ interface IPaymentsEscrow { ) external; /** - * @notice Get the balance of a payer-collector-receiver tuple - * This function will return 0 if the current balance is less than the amount of funds being thawed. + * @notice Get the full escrow account for a payer-collector-receiver tuple * @param payer The address of the payer * @param collector The address of the collector * @param receiver The address of the receiver - * @return The balance of the payer-collector-receiver tuple + * @return The escrow account details */ - function getBalance(address payer, address collector, address receiver) external view returns (uint256); + function getEscrowAccount( + address payer, + address collector, + address receiver + ) external view returns (EscrowAccount memory); } diff --git a/packages/interfaces/contracts/toolshed/IPaymentsEscrowToolshed.sol b/packages/interfaces/contracts/toolshed/IPaymentsEscrowToolshed.sol index c7b9b81f2..c62b16173 100644 --- a/packages/interfaces/contracts/toolshed/IPaymentsEscrowToolshed.sol +++ b/packages/interfaces/contracts/toolshed/IPaymentsEscrowToolshed.sol @@ -1,14 +1,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.22; -// solhint-disable use-natspec - import { IPaymentsEscrow } from "../horizon/IPaymentsEscrow.sol"; -interface IPaymentsEscrowToolshed is IPaymentsEscrow { - function escrowAccounts( - address payer, - address collector, - address receiver - ) external view returns (EscrowAccount memory); -} +/** + * @title IPaymentsEscrowToolshed + * @author Edge & Node + * @notice Aggregate interface for PaymentsEscrow TypeScript type generation. + * @dev Combines all PaymentsEscrow interfaces into a single artifact for Wagmi and ethers + * type generation. Not intended for use in Solidity code. + */ +interface IPaymentsEscrowToolshed is IPaymentsEscrow {} From 40ff0d2d1f008a72bc9430860eb90ba44937ccb3 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:48:46 +0000 Subject: [PATCH 08/11] feat: contract approver model for RecurringCollector accept/update Add IContractApprover interface enabling contracts to approve RCA accept/update by implementing an on-chain callback. When the signature parameter is empty, accept() and update() fall back to calling IContractApprover.approveAgreement() on the payer contract instead of verifying an ECDSA signature. Also adds getMaxNextClaim() view and removes the SignedRCA/SignedRCAU wrapper structs in favor of separate (struct, bytes) parameters. --- .../collectors/RecurringCollector.sol | 322 ++++++++++++------ .../MockContractApprover.t.sol | 39 +++ .../PaymentsEscrowMock.t.sol | 18 +- .../RecurringCollectorHelper.t.sol | 29 +- .../payments/recurring-collector/accept.t.sol | 36 +- .../recurring-collector/acceptUnsigned.t.sol | 169 +++++++++ .../payments/recurring-collector/base.t.sol | 20 +- .../payments/recurring-collector/cancel.t.sol | 13 +- .../recurring-collector/collect.t.sol | 151 ++++---- .../payments/recurring-collector/shared.t.sol | 67 ++-- .../payments/recurring-collector/update.t.sol | 185 +++++----- .../recurring-collector/updateUnsigned.t.sol | 250 ++++++++++++++ .../contracts/horizon/IContractApprover.sol | 32 ++ .../contracts/horizon/IRecurringCollector.sol | 77 +++-- 14 files changed, 1029 insertions(+), 379 deletions(-) create mode 100644 packages/horizon/test/unit/payments/recurring-collector/MockContractApprover.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/acceptUnsigned.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/updateUnsigned.t.sol create mode 100644 packages/interfaces/contracts/horizon/IContractApprover.sol diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 5af3bf863..2c2b96a9a 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,49 +73,58 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC } } - /* solhint-disable function-max-lines */ /** * @inheritdoc IRecurringCollector * @notice Accept a Recurring Collection Agreement. - * See {IRecurringCollector.accept}. * @dev Caller must be the data service the RCA was issued to. */ - function accept(SignedRCA calldata signedRCA) external returns (bytes16) { + function accept(RecurringCollectionAgreement calldata rca, bytes calldata signature) external returns (bytes16) { + if (signature.length > 0) { + // ECDSA-signed path: check deadline and verify signature + /* solhint-disable gas-strict-inequalities */ + require( + rca.deadline >= block.timestamp, + RecurringCollectorAgreementDeadlineElapsed(block.timestamp, rca.deadline) + ); + /* solhint-enable gas-strict-inequalities */ + _requireAuthorizedRCASigner(rca, signature); + } else { + // Contract-approved path: verify payer is a contract and confirms the agreement + require(0 < rca.payer.code.length, RecurringCollectorApproverNotContract(rca.payer)); + bytes32 agreementHash = _hashRCA(rca); + require( + IContractApprover(rca.payer).approveAgreement(agreementHash) == + IContractApprover.approveAgreement.selector, + RecurringCollectorInvalidSigner() + ); + } + return _validateAndStoreAgreement(rca); + } + + /** + * @notice Validates RCA fields and stores the agreement. + * @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( - signedRCA.rca.payer, - signedRCA.rca.dataService, - signedRCA.rca.serviceProvider, - signedRCA.rca.deadline, - signedRCA.rca.nonce + _rca.payer, + _rca.dataService, + _rca.serviceProvider, + _rca.deadline, + _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, - RecurringCollectorAgreementDeadlineElapsed(block.timestamp, signedRCA.rca.deadline) - ); - /* solhint-enable gas-strict-inequalities */ - - // check that the voucher is signed by the payer (or proxy) - _requireAuthorizedRCASigner(signedRCA); + require(msg.sender == _rca.dataService, RecurringCollectorUnauthorizedCaller(msg.sender, _rca.dataService)); require( - signedRCA.rca.dataService != address(0) && - signedRCA.rca.payer != address(0) && - signedRCA.rca.serviceProvider != address(0), + _rca.dataService != address(0) && _rca.payer != address(0) && _rca.serviceProvider != address(0), RecurringCollectorAgreementAddressNotSet() ); - _requireValidCollectionWindowParams( - signedRCA.rca.endsAt, - signedRCA.rca.minSecondsPerCollection, - signedRCA.rca.maxSecondsPerCollection - ); + _requireValidCollectionWindowParams(_rca.endsAt, _rca.minSecondsPerCollection, _rca.maxSecondsPerCollection); AgreementData storage agreement = _getAgreementStorage(agreementId); // check that the agreement is not already accepted @@ -126,14 +136,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( @@ -186,80 +196,53 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC ); } - /* solhint-disable function-max-lines */ /** * @inheritdoc IRecurringCollector * @notice Update a Recurring Collection Agreement. - * See {IRecurringCollector.update}. * @dev Caller must be the data service for the agreement. * @dev Note: Updated pricing terms apply immediately and will affect the next collection * for the entire period since lastCollectionAt. */ - function update(SignedRCAU calldata signedRCAU) external { - /* solhint-disable gas-strict-inequalities */ - require( - signedRCAU.rcau.deadline >= block.timestamp, - RecurringCollectorAgreementDeadlineElapsed(block.timestamp, signedRCAU.rcau.deadline) - ); - /* solhint-enable gas-strict-inequalities */ - - AgreementData storage agreement = _getAgreementStorage(signedRCAU.rcau.agreementId); - require( - agreement.state == AgreementState.Accepted, - RecurringCollectorAgreementIncorrectState(signedRCAU.rcau.agreementId, agreement.state) - ); - require( - agreement.dataService == msg.sender, - RecurringCollectorDataServiceNotAuthorized(signedRCAU.rcau.agreementId, msg.sender) - ); - - // check that the voucher is signed by the payer (or proxy) - _requireAuthorizedRCAUSigner(signedRCAU, agreement.payer); - - // validate nonce to prevent replay attacks - uint32 expectedNonce = agreement.updateNonce + 1; - require( - signedRCAU.rcau.nonce == expectedNonce, - RecurringCollectorInvalidUpdateNonce(signedRCAU.rcau.agreementId, expectedNonce, signedRCAU.rcau.nonce) - ); - - _requireValidCollectionWindowParams( - signedRCAU.rcau.endsAt, - signedRCAU.rcau.minSecondsPerCollection, - signedRCAU.rcau.maxSecondsPerCollection - ); + function update(RecurringCollectionAgreementUpdate calldata rcau, bytes calldata signature) external { + AgreementData storage agreement = _requireValidUpdateTarget(rcau.agreementId); - // update the agreement - agreement.endsAt = signedRCAU.rcau.endsAt; - agreement.maxInitialTokens = signedRCAU.rcau.maxInitialTokens; - agreement.maxOngoingTokensPerSecond = signedRCAU.rcau.maxOngoingTokensPerSecond; - agreement.minSecondsPerCollection = signedRCAU.rcau.minSecondsPerCollection; - agreement.maxSecondsPerCollection = signedRCAU.rcau.maxSecondsPerCollection; - agreement.updateNonce = signedRCAU.rcau.nonce; + if (signature.length > 0) { + // ECDSA-signed path: check deadline and verify signature + /* solhint-disable gas-strict-inequalities */ + require( + rcau.deadline >= block.timestamp, + RecurringCollectorAgreementDeadlineElapsed(block.timestamp, rcau.deadline) + ); + /* solhint-enable gas-strict-inequalities */ + _requireAuthorizedRCAUSigner(rcau, signature, agreement.payer); + } else { + // Contract-approved path: verify payer is a contract and confirms the update + require(0 < agreement.payer.code.length, RecurringCollectorApproverNotContract(agreement.payer)); + bytes32 updateHash = _hashRCAU(rcau); + require( + IContractApprover(agreement.payer).approveAgreement(updateHash) == + IContractApprover.approveAgreement.selector, + RecurringCollectorInvalidSigner() + ); + } - emit AgreementUpdated( - agreement.dataService, - agreement.payer, - agreement.serviceProvider, - signedRCAU.rcau.agreementId, - uint64(block.timestamp), - agreement.endsAt, - agreement.maxInitialTokens, - agreement.maxOngoingTokensPerSecond, - agreement.minSecondsPerCollection, - agreement.maxSecondsPerCollection - ); + _validateAndStoreUpdate(agreement, rcau); } - /* solhint-enable function-max-lines */ /// @inheritdoc IRecurringCollector - function recoverRCASigner(SignedRCA calldata signedRCA) external view returns (address) { - return _recoverRCASigner(signedRCA); + function recoverRCASigner( + RecurringCollectionAgreement calldata rca, + bytes calldata signature + ) external view returns (address) { + return _recoverRCASigner(rca, signature); } /// @inheritdoc IRecurringCollector - function recoverRCAUSigner(SignedRCAU calldata signedRCAU) external view returns (address) { - return _recoverRCAUSigner(signedRCAU); + function recoverRCAUSigner( + RecurringCollectionAgreementUpdate calldata rcau, + bytes calldata signature + ) external view returns (address) { + return _recoverRCAUSigner(rcau, signature); } /// @inheritdoc IRecurringCollector @@ -284,6 +267,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, @@ -475,22 +463,30 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /** * @notice See {recoverRCASigner} - * @param _signedRCA The signed RCA to recover the signer from + * @param _rca The RCA whose hash was signed + * @param _signature The ECDSA signature bytes * @return The address of the signer */ - function _recoverRCASigner(SignedRCA memory _signedRCA) private view returns (address) { - bytes32 messageHash = _hashRCA(_signedRCA.rca); - return ECDSA.recover(messageHash, _signedRCA.signature); + function _recoverRCASigner( + RecurringCollectionAgreement memory _rca, + bytes memory _signature + ) private view returns (address) { + bytes32 messageHash = _hashRCA(_rca); + return ECDSA.recover(messageHash, _signature); } /** * @notice See {recoverRCAUSigner} - * @param _signedRCAU The signed RCAU to recover the signer from + * @param _rcau The RCAU whose hash was signed + * @param _signature The ECDSA signature bytes * @return The address of the signer */ - function _recoverRCAUSigner(SignedRCAU memory _signedRCAU) private view returns (address) { - bytes32 messageHash = _hashRCAU(_signedRCAU.rcau); - return ECDSA.recover(messageHash, _signedRCAU.signature); + function _recoverRCAUSigner( + RecurringCollectionAgreementUpdate memory _rcau, + bytes memory _signature + ) private view returns (address) { + bytes32 messageHash = _hashRCAU(_rcau); + return ECDSA.recover(messageHash, _signature); } /** @@ -548,12 +544,16 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /** * @notice Requires that the signer for the RCA is authorized * by the payer of the RCA. - * @param _signedRCA The signed RCA to verify + * @param _rca The RCA whose hash was signed + * @param _signature The ECDSA signature bytes * @return The address of the authorized signer */ - function _requireAuthorizedRCASigner(SignedRCA memory _signedRCA) private view returns (address) { - address signer = _recoverRCASigner(_signedRCA); - require(_isAuthorized(_signedRCA.rca.payer, signer), RecurringCollectorInvalidSigner()); + function _requireAuthorizedRCASigner( + RecurringCollectionAgreement memory _rca, + bytes memory _signature + ) private view returns (address) { + address signer = _recoverRCASigner(_rca, _signature); + require(_isAuthorized(_rca.payer, signer), RecurringCollectorInvalidSigner()); return signer; } @@ -561,20 +561,81 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /** * @notice Requires that the signer for the RCAU is authorized * by the payer. - * @param _signedRCAU The signed RCAU to verify + * @param _rcau The RCAU whose hash was signed + * @param _signature The ECDSA signature bytes * @param _payer The address of the payer * @return The address of the authorized signer */ function _requireAuthorizedRCAUSigner( - SignedRCAU memory _signedRCAU, + RecurringCollectionAgreementUpdate memory _rcau, + bytes memory _signature, address _payer ) private view returns (address) { - address signer = _recoverRCAUSigner(_signedRCAU); + address signer = _recoverRCAUSigner(_rcau, _signature); require(_isAuthorized(_payer, signer), RecurringCollectorInvalidSigner()); return signer; } + /** + * @notice Validates that an agreement is in a valid state for updating and that the caller is authorized. + * @param _agreementId The ID of the agreement to validate + * @return The storage reference to the agreement data + */ + function _requireValidUpdateTarget(bytes16 _agreementId) private view returns (AgreementData storage) { + AgreementData storage agreement = _getAgreementStorage(_agreementId); + require( + agreement.state == AgreementState.Accepted, + RecurringCollectorAgreementIncorrectState(_agreementId, agreement.state) + ); + require( + agreement.dataService == msg.sender, + RecurringCollectorDataServiceNotAuthorized(_agreementId, msg.sender) + ); + return agreement; + } + + /** + * @notice Validates and stores an update to a Recurring Collection Agreement. + * Shared validation/storage/emit logic for the update function. + * @param _agreement The storage reference to the agreement data + * @param _rcau The Recurring Collection Agreement Update to apply + */ + function _validateAndStoreUpdate( + AgreementData storage _agreement, + RecurringCollectionAgreementUpdate calldata _rcau + ) private { + // 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 + ); + } + /** * @notice Gets an agreement to be updated. * @param _agreementId The ID of the agreement to get @@ -646,6 +707,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..d9aaa5a41 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/MockContractApprover.t.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +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 approveAgreement(bytes32 agreementHash) external view override returns (bytes4) { + if (shouldRevert) { + revert("MockContractApprover: forced revert"); + } + if (useOverride) { + return overrideReturnValue; + } + if (!authorizedHashes[agreementHash]) { + return bytes4(0); + } + return IContractApprover.approveAgreement.selector; + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol index 99d4d47a4..3ef52caef 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol @@ -13,16 +13,26 @@ contract PaymentsEscrowMock is IPaymentsEscrow { function depositTo(address, address, address, uint256) external {} - function thaw(address, address, uint256) external {} + function thaw(address, address, uint256) external returns (uint256) { + return 0; + } - function cancelThaw(address, address) external {} + function thaw(address, address, uint256, bool /* evenIfTimerReset */) external returns (uint256) { + return 0; + } - function withdraw(address, address) external {} + function cancelThaw(address, address) external returns (uint256) { + return 0; + } - function getBalance(address, address, address) external pure returns (uint256) { + function withdraw(address, address) external returns (uint256) { return 0; } + function getEscrowAccount(address, address, address) external pure returns (EscrowAccount memory) { + return EscrowAccount(0, 0, 0); + } + function MAX_WAIT_PERIOD() external pure returns (uint256) { return 0; } diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol index b483413ae..9a01754aa 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol @@ -18,38 +18,30 @@ contract RecurringCollectorHelper is AuthorizableHelper, Bounder { function generateSignedRCA( IRecurringCollector.RecurringCollectionAgreement memory rca, uint256 signerPrivateKey - ) public view returns (IRecurringCollector.SignedRCA memory) { + ) public view returns (IRecurringCollector.RecurringCollectionAgreement memory, bytes memory) { bytes32 messageHash = collector.hashRCA(rca); (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, messageHash); bytes memory signature = abi.encodePacked(r, s, v); - IRecurringCollector.SignedRCA memory signedRCA = IRecurringCollector.SignedRCA({ - rca: rca, - signature: signature - }); - return signedRCA; + return (rca, signature); } function generateSignedRCAU( IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, uint256 signerPrivateKey - ) public view returns (IRecurringCollector.SignedRCAU memory) { + ) public view returns (IRecurringCollector.RecurringCollectionAgreementUpdate memory, bytes memory) { bytes32 messageHash = collector.hashRCAU(rcau); (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, messageHash); bytes memory signature = abi.encodePacked(r, s, v); - IRecurringCollector.SignedRCAU memory signedRCAU = IRecurringCollector.SignedRCAU({ - rcau: rcau, - signature: signature - }); - return signedRCAU; + return (rcau, signature); } function generateSignedRCAUForAgreement( bytes16 agreementId, IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, uint256 signerPrivateKey - ) public view returns (IRecurringCollector.SignedRCAU memory) { + ) public view returns (IRecurringCollector.RecurringCollectionAgreementUpdate memory, bytes memory) { // Automatically set the correct nonce based on current agreement state IRecurringCollector.AgreementData memory agreement = collector.getAgreement(agreementId); rcau.nonce = agreement.updateNonce + 1; @@ -60,7 +52,7 @@ contract RecurringCollectorHelper is AuthorizableHelper, Bounder { function generateSignedRCAUWithCorrectNonce( IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, uint256 signerPrivateKey - ) public view returns (IRecurringCollector.SignedRCAU memory) { + ) public view returns (IRecurringCollector.RecurringCollectionAgreementUpdate memory, bytes memory) { // This is kept for backwards compatibility but should not be used with new interface // since we can't determine agreementId without it being passed separately return generateSignedRCAU(rcau, signerPrivateKey); @@ -69,7 +61,7 @@ contract RecurringCollectorHelper is AuthorizableHelper, Bounder { function generateSignedRCAWithCalculatedId( IRecurringCollector.RecurringCollectionAgreement memory rca, uint256 signerPrivateKey - ) public view returns (IRecurringCollector.SignedRCA memory, bytes16) { + ) public view returns (IRecurringCollector.RecurringCollectionAgreement memory, bytes memory, bytes16) { // Ensure we have sensible values rca = sensibleRCA(rca); @@ -82,8 +74,11 @@ contract RecurringCollectorHelper is AuthorizableHelper, Bounder { rca.nonce ); - IRecurringCollector.SignedRCA memory signedRCA = generateSignedRCA(rca, signerPrivateKey); - return (signedRCA, agreementId); + (IRecurringCollector.RecurringCollectionAgreement memory signedRca, bytes memory signature) = generateSignedRCA( + rca, + signerPrivateKey + ); + return (signedRca, signature, agreementId); } function withElapsedAcceptDeadline( diff --git a/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol index 345d1a4f7..8404db85e 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol @@ -17,35 +17,41 @@ contract RecurringCollectorAcceptTest is RecurringCollectorSharedTest { } function test_Accept_Revert_WhenAcceptanceDeadlineElapsed( - IRecurringCollector.SignedRCA memory fuzzySignedRCA, + IRecurringCollector.RecurringCollectionAgreement memory fuzzyRCA, + bytes memory fuzzySignature, uint256 unboundedSkip ) public { + // Ensure non-empty signature so the signed path is taken (which checks deadline first) + vm.assume(fuzzySignature.length > 0); // Generate deterministic agreement ID for validation bytes16 agreementId = _recurringCollector.generateAgreementId( - fuzzySignedRCA.rca.payer, - fuzzySignedRCA.rca.dataService, - fuzzySignedRCA.rca.serviceProvider, - fuzzySignedRCA.rca.deadline, - fuzzySignedRCA.rca.nonce + fuzzyRCA.payer, + fuzzyRCA.dataService, + fuzzyRCA.serviceProvider, + fuzzyRCA.deadline, + fuzzyRCA.nonce ); vm.assume(agreementId != bytes16(0)); skip(boundSkip(unboundedSkip, 1, type(uint64).max - block.timestamp)); - fuzzySignedRCA.rca = _recurringCollectorHelper.withElapsedAcceptDeadline(fuzzySignedRCA.rca); + fuzzyRCA = _recurringCollectorHelper.withElapsedAcceptDeadline(fuzzyRCA); bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorAgreementDeadlineElapsed.selector, block.timestamp, - fuzzySignedRCA.rca.deadline + fuzzyRCA.deadline ); vm.expectRevert(expectedErr); - vm.prank(fuzzySignedRCA.rca.dataService); - _recurringCollector.accept(fuzzySignedRCA); + vm.prank(fuzzyRCA.dataService); + _recurringCollector.accept(fuzzyRCA, fuzzySignature); } function test_Accept_Revert_WhenAlreadyAccepted(FuzzyTestAccept calldata fuzzyTestAccept) public { - (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( - fuzzyTestAccept - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + bytes memory signature, + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestAccept); bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, @@ -53,8 +59,8 @@ contract RecurringCollectorAcceptTest is RecurringCollectorSharedTest { IRecurringCollector.AgreementState.Accepted ); vm.expectRevert(expectedErr); - vm.prank(accepted.rca.dataService); - _recurringCollector.accept(accepted); + vm.prank(acceptedRca.dataService); + _recurringCollector.accept(acceptedRca, signature); } /* solhint-enable graph/func-name-mixedcase */ 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..b16e3f596 --- /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.27; + +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.accept(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.accept(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.accept(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.accept(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.accept(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.accept(rca, ""); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + agreementId, + IRecurringCollector.AgreementState.Accepted + ); + vm.expectRevert(expectedErr); + vm.prank(rca.dataService); + _recurringCollector.accept(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.accept(rca, ""); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/base.t.sol b/packages/horizon/test/unit/payments/recurring-collector/base.t.sol index d1837837a..c37ced83f 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/base.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/base.t.sol @@ -14,13 +14,13 @@ contract RecurringCollectorBaseTest is RecurringCollectorSharedTest { function test_RecoverRCASigner(FuzzyTestAccept memory fuzzyTestAccept) public view { uint256 signerKey = boundKey(fuzzyTestAccept.unboundedSignerKey); - IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA( - fuzzyTestAccept.rca, - signerKey - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory rca, + bytes memory signature + ) = _recurringCollectorHelper.generateSignedRCA(fuzzyTestAccept.rca, signerKey); assertEq( - _recurringCollector.recoverRCASigner(signedRCA), + _recurringCollector.recoverRCASigner(rca, signature), vm.addr(signerKey), "Recovered RCA signer does not match" ); @@ -28,13 +28,13 @@ contract RecurringCollectorBaseTest is RecurringCollectorSharedTest { function test_RecoverRCAUSigner(FuzzyTestUpdate memory fuzzyTestUpdate) public view { uint256 signerKey = boundKey(fuzzyTestUpdate.fuzzyTestAccept.unboundedSignerKey); - IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( - fuzzyTestUpdate.rcau, - signerKey - ); + ( + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + bytes memory signature + ) = _recurringCollectorHelper.generateSignedRCAU(fuzzyTestUpdate.rcau, signerKey); assertEq( - _recurringCollector.recoverRCAUSigner(signedRCAU), + _recurringCollector.recoverRCAUSigner(rcau, signature), vm.addr(signerKey), "Recovered RCAU signer does not match" ); diff --git a/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol index a6128a7b5..1ccb0ccc1 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol @@ -13,11 +13,14 @@ contract RecurringCollectorCancelTest is RecurringCollectorSharedTest { /* solhint-disable graph/func-name-mixedcase */ function test_Cancel(FuzzyTestAccept calldata fuzzyTestAccept, uint8 unboundedCanceler) public { - (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( - fuzzyTestAccept - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestAccept); - _cancel(accepted.rca, agreementId, _fuzzyCancelAgreementBy(unboundedCanceler)); + _cancel(acceptedRca, agreementId, _fuzzyCancelAgreementBy(unboundedCanceler)); } function test_Cancel_Revert_WhenNotAccepted( @@ -50,7 +53,7 @@ contract RecurringCollectorCancelTest is RecurringCollectorSharedTest { ) public { vm.assume(fuzzyTestAccept.rca.dataService != notDataService); - (, , bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzyTestAccept); + (, , , bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzyTestAccept); bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorDataServiceNotAuthorized.selector, diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol index 95530e4b3..d19f5caed 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -30,7 +30,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ) public { vm.assume(fuzzy.fuzzyTestAccept.rca.dataService != notDataService); - (, , bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + (, , , bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams; skip(1); @@ -48,9 +48,12 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { } function test_Collect_Revert_WhenUnauthorizedDataService(FuzzyTestCollect calldata fuzzy) public { - (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( - fuzzy.fuzzyTestAccept - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams; collectParams.agreementId = agreementId; collectParams.tokens = bound(collectParams.tokens, 1, type(uint256).max); @@ -61,8 +64,8 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { // Set up the scenario where service provider has no tokens staked with data service // This simulates an unauthorized data service attack _horizonStaking.setProvision( - accepted.rca.serviceProvider, - accepted.rca.dataService, + acceptedRca.serviceProvider, + acceptedRca.dataService, IHorizonStakingTypes.Provision({ tokens: 0, // No tokens staked - this triggers the vulnerability tokensThawing: 0, @@ -79,10 +82,10 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorUnauthorizedDataService.selector, - accepted.rca.dataService + acceptedRca.dataService ); vm.expectRevert(expectedErr); - vm.prank(accepted.rca.dataService); + vm.prank(acceptedRca.dataService); _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); } @@ -100,14 +103,17 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { } function test_Collect_Revert_WhenCanceledAgreementByServiceProvider(FuzzyTestCollect calldata fuzzy) public { - (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( - fuzzy.fuzzyTestAccept - ); - _cancel(accepted.rca, agreementId, IRecurringCollector.CancelAgreementBy.ServiceProvider); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + _cancel(acceptedRca, agreementId, IRecurringCollector.CancelAgreementBy.ServiceProvider); IRecurringCollector.CollectParams memory collectData = fuzzy.collectParams; collectData.tokens = bound(collectData.tokens, 1, type(uint256).max); IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( - accepted.rca, + acceptedRca, agreementId, collectData.collectionId, collectData.tokens, @@ -121,7 +127,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { IRecurringCollector.AgreementNotCollectableReason.InvalidAgreementState ); vm.expectRevert(expectedErr); - vm.prank(accepted.rca.dataService); + vm.prank(acceptedRca.dataService); _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); } @@ -129,28 +135,31 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { FuzzyTestCollect calldata fuzzy, uint256 unboundedCollectionSeconds ) public { - (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( - fuzzy.fuzzyTestAccept - ); - - skip(accepted.rca.minSecondsPerCollection); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + + skip(acceptedRca.minSecondsPerCollection); bytes memory data = _generateCollectData( _generateCollectParams( - accepted.rca, + acceptedRca, agreementId, fuzzy.collectParams.collectionId, 1, fuzzy.collectParams.dataServiceCut ) ); - vm.prank(accepted.rca.dataService); + vm.prank(acceptedRca.dataService); _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); - uint256 collectionSeconds = boundSkip(unboundedCollectionSeconds, 1, accepted.rca.minSecondsPerCollection - 1); + uint256 collectionSeconds = boundSkip(unboundedCollectionSeconds, 1, acceptedRca.minSecondsPerCollection - 1); skip(collectionSeconds); IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( - accepted.rca, + acceptedRca, agreementId, fuzzy.collectParams.collectionId, bound(fuzzy.collectParams.tokens, 1, type(uint256).max), @@ -161,10 +170,10 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { IRecurringCollector.RecurringCollectorCollectionTooSoon.selector, collectParams.agreementId, collectionSeconds, - accepted.rca.minSecondsPerCollection + acceptedRca.minSecondsPerCollection ); vm.expectRevert(expectedErr); - vm.prank(accepted.rca.dataService); + vm.prank(acceptedRca.dataService); _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); } @@ -173,21 +182,24 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { uint256 unboundedFirstCollectionSeconds, uint256 unboundedSecondCollectionSeconds ) public { - (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( - fuzzy.fuzzyTestAccept - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); // First valid collection to establish lastCollectionAt skip( boundSkip( unboundedFirstCollectionSeconds, - accepted.rca.minSecondsPerCollection, - accepted.rca.maxSecondsPerCollection + acceptedRca.minSecondsPerCollection, + acceptedRca.maxSecondsPerCollection ) ); bytes memory firstData = _generateCollectData( _generateCollectParams( - accepted.rca, + acceptedRca, agreementId, fuzzy.collectParams.collectionId, 1, @@ -200,8 +212,8 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { // Skip PAST maxSecondsPerCollection (but still within agreement endsAt) uint256 collectionSeconds = boundSkip( unboundedSecondCollectionSeconds, - accepted.rca.maxSecondsPerCollection + 1, - accepted.rca.endsAt - block.timestamp + acceptedRca.maxSecondsPerCollection + 1, + acceptedRca.endsAt - block.timestamp ); skip(collectionSeconds); @@ -238,51 +250,54 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { uint256 unboundedTokens, bool testInitialCollection ) public { - (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( - fuzzy.fuzzyTestAccept - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); if (!testInitialCollection) { // skip to collectable time skip( boundSkip( unboundedInitialCollectionSeconds, - accepted.rca.minSecondsPerCollection, - accepted.rca.maxSecondsPerCollection + acceptedRca.minSecondsPerCollection, + acceptedRca.maxSecondsPerCollection ) ); bytes memory initialData = _generateCollectData( _generateCollectParams( - accepted.rca, + acceptedRca, agreementId, fuzzy.collectParams.collectionId, 1, fuzzy.collectParams.dataServiceCut ) ); - vm.prank(accepted.rca.dataService); + vm.prank(acceptedRca.dataService); _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), initialData); } // skip to collectable time uint256 collectionSeconds = boundSkip( unboundedCollectionSeconds, - accepted.rca.minSecondsPerCollection, - accepted.rca.maxSecondsPerCollection + acceptedRca.minSecondsPerCollection, + acceptedRca.maxSecondsPerCollection ); skip(collectionSeconds); - uint256 maxTokens = accepted.rca.maxOngoingTokensPerSecond * collectionSeconds; - maxTokens += testInitialCollection ? accepted.rca.maxInitialTokens : 0; + uint256 maxTokens = acceptedRca.maxOngoingTokensPerSecond * collectionSeconds; + maxTokens += testInitialCollection ? acceptedRca.maxInitialTokens : 0; uint256 tokens = bound(unboundedTokens, maxTokens + 1, type(uint256).max); IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( - accepted.rca, + acceptedRca, agreementId, fuzzy.collectParams.collectionId, tokens, fuzzy.collectParams.dataServiceCut ); bytes memory data = _generateCollectData(collectParams); - vm.prank(accepted.rca.dataService); + vm.prank(acceptedRca.dataService); uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); assertEq(collected, maxTokens); } @@ -292,12 +307,15 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { uint256 unboundedCollectionSeconds, uint256 unboundedTokens ) public { - (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( - fuzzy.fuzzyTestAccept - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); (bytes memory data, uint256 collectionSeconds, uint256 tokens) = _generateValidCollection( - accepted.rca, + acceptedRca, fuzzy.collectParams, unboundedCollectionSeconds, unboundedTokens @@ -305,13 +323,13 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { skip(collectionSeconds); _expectCollectCallAndEmit( - accepted.rca, + acceptedRca, agreementId, _paymentType(fuzzy.unboundedPaymentType), fuzzy.collectParams, tokens ); - vm.prank(accepted.rca.dataService); + vm.prank(acceptedRca.dataService); uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); assertEq(collected, tokens); } @@ -333,8 +351,8 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { // Accept the agreement _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, 1); - IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(rca, 1); - bytes16 agreementId = _accept(signedRCA); + (, bytes memory signature) = _recurringCollectorHelper.generateSignedRCA(rca, 1); + bytes16 agreementId = _accept(rca, signature); // Do a first collection to use up initial tokens allowance skip(rca.minSecondsPerCollection); @@ -400,8 +418,8 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { // Accept the agreement _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, 1); - IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(rca, 1); - bytes16 agreementId = _accept(signedRCA); + (, bytes memory signature) = _recurringCollectorHelper.generateSignedRCA(rca, 1); + bytes16 agreementId = _accept(rca, signature); // Do a first collection to use up initial tokens allowance skip(rca.minSecondsPerCollection); @@ -449,22 +467,25 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { assertEq(collected, maxAllowed); } function test_Collect_Revert_WhenZeroTokensBypassesTemporalValidation(FuzzyTestCollect calldata fuzzy) public { - (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( - fuzzy.fuzzyTestAccept - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); // First valid collection to establish lastCollectionAt - skip(accepted.rca.minSecondsPerCollection); + skip(acceptedRca.minSecondsPerCollection); bytes memory firstData = _generateCollectData( _generateCollectParams( - accepted.rca, + acceptedRca, agreementId, fuzzy.collectParams.collectionId, 1, fuzzy.collectParams.dataServiceCut ) ); - vm.prank(accepted.rca.dataService); + vm.prank(acceptedRca.dataService); _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), firstData); // Attempt zero-token collection immediately (before minSecondsPerCollection). @@ -472,7 +493,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { // the temporal validation that guards minSecondsPerCollection. skip(1); IRecurringCollector.CollectParams memory zeroParams = _generateCollectParams( - accepted.rca, + acceptedRca, agreementId, fuzzy.collectParams.collectionId, 0, // zero tokens @@ -485,10 +506,10 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { IRecurringCollector.RecurringCollectorCollectionTooSoon.selector, agreementId, uint32(1), // only 1 second elapsed - accepted.rca.minSecondsPerCollection + acceptedRca.minSecondsPerCollection ) ); - vm.prank(accepted.rca.dataService); + vm.prank(acceptedRca.dataService); _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), zeroData); } /* solhint-enable graph/func-name-mixedcase */ diff --git a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol index 54ebae9a7..0c20ccf7f 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol @@ -54,56 +54,73 @@ contract RecurringCollectorSharedTest is Test, Bounder { function _sensibleAuthorizeAndAccept( FuzzyTestAccept calldata _fuzzyTestAccept - ) internal returns (IRecurringCollector.SignedRCA memory, uint256 key, bytes16 agreementId) { + ) + internal + returns ( + IRecurringCollector.RecurringCollectionAgreement memory, + bytes memory signature, + uint256 key, + bytes16 agreementId + ) + { IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( _fuzzyTestAccept.rca ); key = boundKey(_fuzzyTestAccept.unboundedSignerKey); - IRecurringCollector.SignedRCA memory signedRCA; - (signedRCA, agreementId) = _authorizeAndAccept(rca, key); - return (signedRCA, key, agreementId); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + bytes memory sig, + bytes16 id + ) = _authorizeAndAccept(rca, key); + return (acceptedRca, sig, key, id); } // authorizes signer, signs the RCA, and accepts it function _authorizeAndAccept( IRecurringCollector.RecurringCollectionAgreement memory _rca, uint256 _signerKey - ) internal returns (IRecurringCollector.SignedRCA memory, bytes16 agreementId) { + ) internal returns (IRecurringCollector.RecurringCollectionAgreement memory, bytes memory, bytes16 agreementId) { _recurringCollectorHelper.authorizeSignerWithChecks(_rca.payer, _signerKey); - IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(_rca, _signerKey); + ( + IRecurringCollector.RecurringCollectionAgreement memory rca, + bytes memory signature + ) = _recurringCollectorHelper.generateSignedRCA(_rca, _signerKey); - agreementId = _accept(signedRCA); - return (signedRCA, agreementId); + agreementId = _accept(rca, signature); + return (rca, signature, agreementId); } - function _accept(IRecurringCollector.SignedRCA memory _signedRCA) internal returns (bytes16) { + function _accept( + IRecurringCollector.RecurringCollectionAgreement memory _rca, + bytes memory _signature + ) internal returns (bytes16) { // Set up valid staking provision by default to allow collections to succeed - _setupValidProvision(_signedRCA.rca.serviceProvider, _signedRCA.rca.dataService); + _setupValidProvision(_rca.serviceProvider, _rca.dataService); // Calculate the expected agreement ID for verification bytes16 expectedAgreementId = _recurringCollector.generateAgreementId( - _signedRCA.rca.payer, - _signedRCA.rca.dataService, - _signedRCA.rca.serviceProvider, - _signedRCA.rca.deadline, - _signedRCA.rca.nonce + _rca.payer, + _rca.dataService, + _rca.serviceProvider, + _rca.deadline, + _rca.nonce ); vm.expectEmit(address(_recurringCollector)); emit IRecurringCollector.AgreementAccepted( - _signedRCA.rca.dataService, - _signedRCA.rca.payer, - _signedRCA.rca.serviceProvider, + _rca.dataService, + _rca.payer, + _rca.serviceProvider, expectedAgreementId, uint64(block.timestamp), - _signedRCA.rca.endsAt, - _signedRCA.rca.maxInitialTokens, - _signedRCA.rca.maxOngoingTokensPerSecond, - _signedRCA.rca.minSecondsPerCollection, - _signedRCA.rca.maxSecondsPerCollection + _rca.endsAt, + _rca.maxInitialTokens, + _rca.maxOngoingTokensPerSecond, + _rca.minSecondsPerCollection, + _rca.maxSecondsPerCollection ); - vm.prank(_signedRCA.rca.dataService); - bytes16 actualAgreementId = _recurringCollector.accept(_signedRCA); + vm.prank(_rca.dataService); + bytes16 actualAgreementId = _recurringCollector.accept(_rca, _signature); // Verify the agreement ID matches expectation assertEq(actualAgreementId, expectedAgreementId); diff --git a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol index 70f42af8a..d466f3c49 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol @@ -13,28 +13,25 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { /* solhint-disable graph/func-name-mixedcase */ function test_Update_Revert_WhenUpdateElapsed( - IRecurringCollector.RecurringCollectionAgreement memory rca, - IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + FuzzyTestUpdate calldata fuzzyTestUpdate, uint256 unboundedUpdateSkip ) public { - rca = _recurringCollectorHelper.sensibleRCA(rca); - rcau = _recurringCollectorHelper.sensibleRCAU(rcau); - // Generate deterministic agreement ID - bytes16 agreementId = _recurringCollector.generateAgreementId( - rca.payer, - rca.dataService, - rca.serviceProvider, - rca.deadline, - rca.nonce + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau ); rcau.agreementId = agreementId; boundSkipCeil(unboundedUpdateSkip, type(uint64).max); rcau.deadline = uint64(bound(rcau.deadline, 0, block.timestamp - 1)); - IRecurringCollector.SignedRCAU memory signedRCAU = IRecurringCollector.SignedRCAU({ - rcau: rcau, - signature: "" - }); + + (, bytes memory signature) = _recurringCollectorHelper.generateSignedRCAU(rcau, signerKey); bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorAgreementDeadlineElapsed.selector, @@ -42,8 +39,8 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { rcau.deadline ); vm.expectRevert(expectedErr); - vm.prank(rca.dataService); - _recurringCollector.update(signedRCAU); + vm.prank(acceptedRca.dataService); + _recurringCollector.update(rcau, signature); } function test_Update_Revert_WhenNeverAccepted( @@ -63,10 +60,6 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { rcau.agreementId = agreementId; rcau.deadline = uint64(block.timestamp); - IRecurringCollector.SignedRCAU memory signedRCAU = IRecurringCollector.SignedRCAU({ - rcau: rcau, - signature: "" - }); bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, @@ -75,7 +68,7 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { ); vm.expectRevert(expectedErr); vm.prank(rca.dataService); - _recurringCollector.update(signedRCAU); + _recurringCollector.update(rcau, ""); } function test_Update_Revert_WhenDataServiceNotAuthorized( @@ -83,26 +76,23 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { address notDataService ) public { vm.assume(fuzzyTestUpdate.fuzzyTestAccept.rca.dataService != notDataService); - (, uint256 signerKey, bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); + (, , uint256 signerKey, bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( fuzzyTestUpdate.rcau ); rcau.agreementId = agreementId; - IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAUWithCorrectNonce( - rcau, - signerKey - ); + (, bytes memory signature) = _recurringCollectorHelper.generateSignedRCAUWithCorrectNonce(rcau, signerKey); bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorDataServiceNotAuthorized.selector, - signedRCAU.rcau.agreementId, + rcau.agreementId, notDataService ); vm.expectRevert(expectedErr); vm.prank(notDataService); - _recurringCollector.update(signedRCAU); + _recurringCollector.update(rcau, signature); } function test_Update_Revert_WhenInvalidSigner( @@ -110,10 +100,12 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { uint256 unboundedInvalidSignerKey ) public { ( - IRecurringCollector.SignedRCA memory accepted, - uint256 signerKey, + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + , bytes16 agreementId ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); + uint256 signerKey = boundKey(fuzzyTestUpdate.fuzzyTestAccept.unboundedSignerKey); uint256 invalidSignerKey = boundKey(unboundedInvalidSignerKey); vm.assume(signerKey != invalidSignerKey); @@ -122,19 +114,17 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { ); rcau.agreementId = agreementId; - IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( - rcau, - invalidSignerKey - ); + (, bytes memory signature) = _recurringCollectorHelper.generateSignedRCAU(rcau, invalidSignerKey); vm.expectRevert(IRecurringCollector.RecurringCollectorInvalidSigner.selector); - vm.prank(accepted.rca.dataService); - _recurringCollector.update(signedRCAU); + vm.prank(acceptedRca.dataService); + _recurringCollector.update(rcau, signature); } function test_Update_OK(FuzzyTestUpdate calldata fuzzyTestUpdate) public { ( - IRecurringCollector.SignedRCA memory accepted, + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , uint256 signerKey, bytes16 agreementId ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); @@ -144,16 +134,13 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { rcau.agreementId = agreementId; // Don't use fuzzed nonce - use correct nonce for first update rcau.nonce = 1; - IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( - rcau, - signerKey - ); + (, bytes memory signature) = _recurringCollectorHelper.generateSignedRCAU(rcau, signerKey); vm.expectEmit(address(_recurringCollector)); emit IRecurringCollector.AgreementUpdated( - accepted.rca.dataService, - accepted.rca.payer, - accepted.rca.serviceProvider, + acceptedRca.dataService, + acceptedRca.payer, + acceptedRca.serviceProvider, rcau.agreementId, uint64(block.timestamp), rcau.endsAt, @@ -162,8 +149,8 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { rcau.minSecondsPerCollection, rcau.maxSecondsPerCollection ); - vm.prank(accepted.rca.dataService); - _recurringCollector.update(signedRCAU); + vm.prank(acceptedRca.dataService); + _recurringCollector.update(rcau, signature); IRecurringCollector.AgreementData memory agreement = _recurringCollector.getAgreement(agreementId); assertEq(rcau.endsAt, agreement.endsAt); @@ -176,7 +163,8 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { function test_Update_Revert_WhenInvalidNonce_TooLow(FuzzyTestUpdate calldata fuzzyTestUpdate) public { ( - IRecurringCollector.SignedRCA memory accepted, + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , uint256 signerKey, bytes16 agreementId ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); @@ -186,10 +174,7 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { rcau.agreementId = agreementId; rcau.nonce = 0; // Invalid: should be 1 for first update - IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( - rcau, - signerKey - ); + (, bytes memory signature) = _recurringCollectorHelper.generateSignedRCAU(rcau, signerKey); bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector, @@ -198,13 +183,14 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { 0 // provided ); vm.expectRevert(expectedErr); - vm.prank(accepted.rca.dataService); - _recurringCollector.update(signedRCAU); + vm.prank(acceptedRca.dataService); + _recurringCollector.update(rcau, signature); } function test_Update_Revert_WhenInvalidNonce_TooHigh(FuzzyTestUpdate calldata fuzzyTestUpdate) public { ( - IRecurringCollector.SignedRCA memory accepted, + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , uint256 signerKey, bytes16 agreementId ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); @@ -214,10 +200,7 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { rcau.agreementId = agreementId; rcau.nonce = 5; // Invalid: should be 1 for first update - IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( - rcau, - signerKey - ); + (, bytes memory signature) = _recurringCollectorHelper.generateSignedRCAU(rcau, signerKey); bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector, @@ -226,13 +209,14 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { 5 // provided ); vm.expectRevert(expectedErr); - vm.prank(accepted.rca.dataService); - _recurringCollector.update(signedRCAU); + vm.prank(acceptedRca.dataService); + _recurringCollector.update(rcau, signature); } function test_Update_Revert_WhenReplayAttack(FuzzyTestUpdate calldata fuzzyTestUpdate) public { ( - IRecurringCollector.SignedRCA memory accepted, + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , uint256 signerKey, bytes16 agreementId ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); @@ -243,24 +227,27 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { rcau1.nonce = 1; // First update succeeds - IRecurringCollector.SignedRCAU memory signedRCAU1 = _recurringCollectorHelper.generateSignedRCAU( - rcau1, - signerKey - ); - vm.prank(accepted.rca.dataService); - _recurringCollector.update(signedRCAU1); + (, bytes memory signature1) = _recurringCollectorHelper.generateSignedRCAU(rcau1, signerKey); + vm.prank(acceptedRca.dataService); + _recurringCollector.update(rcau1, signature1); // Second update with different terms and nonce 2 succeeds - IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = rcau1; - rcau2.nonce = 2; - rcau2.maxOngoingTokensPerSecond = rcau1.maxOngoingTokensPerSecond * 2; // Different terms - - IRecurringCollector.SignedRCAU memory signedRCAU2 = _recurringCollectorHelper.generateSignedRCAU( - rcau2, - signerKey - ); - vm.prank(accepted.rca.dataService); - _recurringCollector.update(signedRCAU2); + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = IRecurringCollector + .RecurringCollectionAgreementUpdate({ + agreementId: rcau1.agreementId, + deadline: rcau1.deadline, + endsAt: rcau1.endsAt, + maxInitialTokens: rcau1.maxInitialTokens, + maxOngoingTokensPerSecond: rcau1.maxOngoingTokensPerSecond * 2, // Different terms + minSecondsPerCollection: rcau1.minSecondsPerCollection, + maxSecondsPerCollection: rcau1.maxSecondsPerCollection, + nonce: 2, + metadata: rcau1.metadata + }); + + (, bytes memory signature2) = _recurringCollectorHelper.generateSignedRCAU(rcau2, signerKey); + vm.prank(acceptedRca.dataService); + _recurringCollector.update(rcau2, signature2); // Attempting to replay first update should fail bytes memory expectedErr = abi.encodeWithSelector( @@ -270,13 +257,14 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { 1 // provided (old nonce) ); vm.expectRevert(expectedErr); - vm.prank(accepted.rca.dataService); - _recurringCollector.update(signedRCAU1); + vm.prank(acceptedRca.dataService); + _recurringCollector.update(rcau1, signature1); } function test_Update_OK_NonceIncrementsCorrectly(FuzzyTestUpdate calldata fuzzyTestUpdate) public { ( - IRecurringCollector.SignedRCA memory accepted, + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , uint256 signerKey, bytes16 agreementId ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); @@ -292,28 +280,31 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { rcau1.agreementId = agreementId; rcau1.nonce = 1; - IRecurringCollector.SignedRCAU memory signedRCAU1 = _recurringCollectorHelper.generateSignedRCAU( - rcau1, - signerKey - ); - vm.prank(accepted.rca.dataService); - _recurringCollector.update(signedRCAU1); + (, bytes memory signature1) = _recurringCollectorHelper.generateSignedRCAU(rcau1, signerKey); + vm.prank(acceptedRca.dataService); + _recurringCollector.update(rcau1, signature1); // Verify nonce incremented to 1 IRecurringCollector.AgreementData memory updatedAgreement1 = _recurringCollector.getAgreement(agreementId); assertEq(updatedAgreement1.updateNonce, 1); // Second update with nonce 2 - IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = rcau1; - rcau2.nonce = 2; - rcau2.maxOngoingTokensPerSecond = rcau1.maxOngoingTokensPerSecond * 2; // Different terms - - IRecurringCollector.SignedRCAU memory signedRCAU2 = _recurringCollectorHelper.generateSignedRCAU( - rcau2, - signerKey - ); - vm.prank(accepted.rca.dataService); - _recurringCollector.update(signedRCAU2); + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = IRecurringCollector + .RecurringCollectionAgreementUpdate({ + agreementId: rcau1.agreementId, + deadline: rcau1.deadline, + endsAt: rcau1.endsAt, + maxInitialTokens: rcau1.maxInitialTokens, + maxOngoingTokensPerSecond: rcau1.maxOngoingTokensPerSecond * 2, // Different terms + minSecondsPerCollection: rcau1.minSecondsPerCollection, + maxSecondsPerCollection: rcau1.maxSecondsPerCollection, + nonce: 2, + metadata: rcau1.metadata + }); + + (, bytes memory signature2) = _recurringCollectorHelper.generateSignedRCAU(rcau2, signerKey); + vm.prank(acceptedRca.dataService); + _recurringCollector.update(rcau2, signature2); // Verify nonce incremented to 2 IRecurringCollector.AgreementData memory updatedAgreement2 = _recurringCollector.getAgreement(agreementId); 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..0a8b3220b --- /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.27; + +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.accept(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.update(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); + (, bytes memory signature) = _recurringCollectorHelper.generateSignedRCA(rca, signerKey); + _setupValidProvision(rca.serviceProvider, rca.dataService); + vm.prank(rca.dataService); + bytes16 agreementId = _recurringCollector.accept(rca, signature); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeSimpleRCAU(agreementId, 1); + + vm.expectRevert( + abi.encodeWithSelector(IRecurringCollector.RecurringCollectorApproverNotContract.selector, payer) + ); + vm.prank(rca.dataService); + _recurringCollector.update(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 — approver returns bytes4(0), caller rejects + vm.expectRevert(IRecurringCollector.RecurringCollectorInvalidSigner.selector); + vm.prank(rca.dataService); + _recurringCollector.update(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.update(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.update(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.update(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.update(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.update(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..9decb5bcc --- /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.accept} and {RecurringCollector.update} (with empty authData), + * 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) → approveAgreement 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.accept} with an RCA hash or by + * {RecurringCollector.update} with an RCAU hash to verify authorization (empty authData path). + * @param agreementHash The EIP712 hash of the RCA or RCAU struct + * @return magic `IContractApprover.approveAgreement.selector` if authorized + */ + function approveAgreement(bytes32 agreementHash) external view returns (bytes4); +} diff --git a/packages/interfaces/contracts/horizon/IRecurringCollector.sol b/packages/interfaces/contracts/horizon/IRecurringCollector.sol index e3ca616a3..797694459 100644 --- a/packages/interfaces/contracts/horizon/IRecurringCollector.sol +++ b/packages/interfaces/contracts/horizon/IRecurringCollector.sol @@ -37,16 +37,6 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { InvalidTemporalWindow } - /** - * @notice A representation of a signed Recurring Collection Agreement (RCA) - * @param rca The Recurring Collection Agreement to be signed - * @param signature The signature of the RCA - 65 bytes: r (32 Bytes) || s (32 Bytes) || v (1 Byte) - */ - struct SignedRCA { - RecurringCollectionAgreement rca; - bytes signature; - } - /** * @notice The Recurring Collection Agreement (RCA) * @param deadline The deadline for accepting the RCA @@ -79,16 +69,6 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { bytes metadata; } - /** - * @notice A representation of a signed Recurring Collection Agreement Update (RCAU) - * @param rcau The Recurring Collection Agreement Update to be signed - * @param signature The signature of the RCAU - 65 bytes: r (32 Bytes) || s (32 Bytes) || v (1 Byte) - */ - struct SignedRCAU { - RecurringCollectionAgreementUpdate rcau; - bytes signature; - } - /** * @notice The Recurring Collection Agreement Update (RCAU) * @param agreementId The agreement ID of the RCAU @@ -390,11 +370,25 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { error RecurringCollectorExcessiveSlippage(uint256 requested, uint256 actual, uint256 maxSlippage); /** - * @notice Accept an indexing agreement. - * @param signedRCA The signed Recurring Collection Agreement which is to be accepted. + * @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 a Recurring Collection Agreement. + * @dev Caller must be the data service the RCA was issued to. + * If `signature` is non-empty: checks `rca.deadline >= block.timestamp` and verifies the ECDSA signature. + * If `signature` is empty: the payer must be a contract implementing {IContractApprover.approveAgreement} + * and must return the magic value for the RCA's EIP712 hash. + * @param rca The Recurring Collection Agreement to accept + * @param signature ECDSA signature bytes, or empty for contract-approved agreements * @return agreementId The deterministically generated agreement ID */ - function accept(SignedRCA calldata signedRCA) external returns (bytes16 agreementId); + function accept( + RecurringCollectionAgreement calldata rca, + bytes calldata signature + ) external returns (bytes16 agreementId); /** * @notice Cancel an indexing agreement. @@ -404,10 +398,15 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { function cancel(bytes16 agreementId, CancelAgreementBy by) external; /** - * @notice Update an indexing agreement. - * @param signedRCAU The signed Recurring Collection Agreement Update which is to be applied. + * @notice Update a Recurring Collection Agreement. + * @dev Caller must be the data service for the agreement. + * If `signature` is non-empty: checks `rcau.deadline >= block.timestamp` and verifies the ECDSA signature. + * If `signature` is empty: the payer (stored in the agreement) must be a contract implementing + * {IContractApprover.approveAgreement} and must return the magic value for the RCAU's EIP712 hash. + * @param rcau The Recurring Collection Agreement Update to apply + * @param signature ECDSA signature bytes, or empty for contract-approved updates */ - function update(SignedRCAU calldata signedRCAU) external; + function update(RecurringCollectionAgreementUpdate calldata rcau, bytes calldata signature) external; /** * @notice Computes the hash of a RecurringCollectionAgreement (RCA). @@ -425,17 +424,25 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { /** * @notice Recovers the signer address of a signed RecurringCollectionAgreement (RCA). - * @param signedRCA The SignedRCA containing the RCA and its signature. + * @param rca The RCA whose hash was signed. + * @param signature The ECDSA signature bytes. * @return The address of the signer. */ - function recoverRCASigner(SignedRCA calldata signedRCA) external view returns (address); + function recoverRCASigner( + RecurringCollectionAgreement calldata rca, + bytes calldata signature + ) external view returns (address); /** * @notice Recovers the signer address of a signed RecurringCollectionAgreementUpdate (RCAU). - * @param signedRCAU The SignedRCAU containing the RCAU and its signature. + * @param rcau The RCAU whose hash was signed. + * @param signature The ECDSA signature bytes. * @return The address of the signer. */ - function recoverRCAUSigner(SignedRCAU calldata signedRCAU) external view returns (address); + function recoverRCAUSigner( + RecurringCollectionAgreementUpdate calldata rcau, + bytes calldata signature + ) external view returns (address); /** * @notice Gets an agreement. @@ -444,6 +451,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 From 71caa8a32fd99cd1f7f82e54a1b9a32899ed9581 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:54:59 +0000 Subject: [PATCH 09/11] feat: IDataServiceAgreements interface and SubgraphService integration Extract IDataServiceAgreements from ISubgraphService with cancelIndexingAgreementByPayer and bitmask-dispatched enforce pattern. Add ProvisionManager support for data-service callback verification. --- .../utilities/ProvisionManager.sol | 23 +- .../utilities/ProvisionManager.t.sol | 8 +- .../utilities/ProvisionManagerImpl.t.sol | 10 +- .../data-service/IDataServiceAgreements.sol | 22 ++ .../issuance/allocate/IIssuanceTarget.sol | 3 + .../subgraph-service/ISubgraphService.sol | 29 ++- .../contracts/SubgraphService.sol | 153 ++++++-------- .../contracts/libraries/IndexingAgreement.sol | 80 ++++--- .../scripts/ops/protocol-activity.ts | 2 +- .../disputes/indexingFee/create.t.sol | 199 ++++++++++++++++++ .../subgraphService/collect/query/query.t.sol | 18 +- .../test/unit/subgraphService/getters.t.sol | 5 + .../governance/indexingFeesCut.t.sol | 37 ++++ .../indexing-agreement/accept.t.sol | 174 ++++++++------- .../indexing-agreement/base.t.sol | 10 +- .../indexing-agreement/cancel.t.sol | 56 ++--- .../indexing-agreement/collect.t.sol | 14 +- .../indexing-agreement/integration.t.sol | 17 +- .../indexing-agreement/shared.t.sol | 23 +- .../indexing-agreement/update.t.sol | 89 ++++---- 20 files changed, 635 insertions(+), 337 deletions(-) create mode 100644 packages/interfaces/contracts/data-service/IDataServiceAgreements.sol create mode 100644 packages/subgraph-service/test/unit/disputeManager/disputes/indexingFee/create.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/governance/indexingFeesCut.t.sol diff --git a/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol b/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol index 77f495ed8..202f4693c 100644 --- a/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol +++ b/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.27; -// TODO: Re-enable and fix issues when publishing a new version // solhint-disable gas-indexed-events // solhint-disable gas-strict-inequalities @@ -111,29 +110,15 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa */ error ProvisionManagerProvisionNotFound(address serviceProvider); - // forge-lint: disable-next-item(unwrapped-modifier-logic) /** * @notice Checks if the caller is authorized to manage the provision of a service provider. - * @param serviceProvider The address of the service provider. + * @param _serviceProvider The address of the service provider. */ - modifier onlyAuthorizedForProvision(address serviceProvider) { + function _requireAuthorizedForProvision(address _serviceProvider) internal view { require( - _graphStaking().isAuthorized(serviceProvider, address(this), msg.sender), - ProvisionManagerNotAuthorized(serviceProvider, msg.sender) + _graphStaking().isAuthorized(_serviceProvider, address(this), msg.sender), + ProvisionManagerNotAuthorized(_serviceProvider, msg.sender) ); - _; - } - - // Warning: Virtual modifiers are deprecated and scheduled for removal. - // forge-lint: disable-next-item(unwrapped-modifier-logic) - /** - * @notice Checks if a provision of a service provider is valid according - * to the parameter ranges established. - * @param serviceProvider The address of the service provider. - */ - modifier onlyValidProvision(address serviceProvider) virtual { - _requireValidProvision(serviceProvider); - _; } // forge-lint: disable-next-item(mixed-case-function) diff --git a/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol b/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol index a5c51d5bd..a72c50719 100644 --- a/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol +++ b/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol @@ -27,14 +27,14 @@ contract ProvisionManagerTest is Test { vm.expectRevert( abi.encodeWithSelector(ProvisionManager.ProvisionManagerProvisionNotFound.selector, serviceProvider) ); - _provisionManager.onlyValidProvision_(serviceProvider); + _provisionManager.requireValidProvision_(serviceProvider); IHorizonStakingTypes.Provision memory provision; provision.createdAt = 1; _horizonStakingMock.setProvision(serviceProvider, address(_provisionManager), provision); - _provisionManager.onlyValidProvision_(serviceProvider); + _provisionManager.requireValidProvision_(serviceProvider); } function test_OnlyAuthorizedForProvision(address serviceProvider, address sender) public { @@ -42,11 +42,11 @@ contract ProvisionManagerTest is Test { abi.encodeWithSelector(ProvisionManager.ProvisionManagerNotAuthorized.selector, serviceProvider, sender) ); vm.prank(sender); - _provisionManager.onlyAuthorizedForProvision_(serviceProvider); + _provisionManager.requireAuthorizedForProvision_(serviceProvider); _horizonStakingMock.setIsAuthorized(serviceProvider, address(_provisionManager), sender, true); vm.prank(sender); - _provisionManager.onlyAuthorizedForProvision_(serviceProvider); + _provisionManager.requireAuthorizedForProvision_(serviceProvider); } /* solhint-enable graph/func-name-mixedcase */ diff --git a/packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol b/packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol index 97b8ecce3..110ff27a3 100644 --- a/packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol +++ b/packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol @@ -7,9 +7,11 @@ import { GraphDirectory } from "../../../../contracts/utilities/GraphDirectory.s contract ProvisionManagerImpl is GraphDirectory, ProvisionManager { constructor(address controller) GraphDirectory(controller) {} - function onlyValidProvision_(address serviceProvider) public view onlyValidProvision(serviceProvider) {} + function requireValidProvision_(address serviceProvider) public view { + _requireValidProvision(serviceProvider); + } - function onlyAuthorizedForProvision_( - address serviceProvider - ) public view onlyAuthorizedForProvision(serviceProvider) {} + function requireAuthorizedForProvision_(address serviceProvider) public view { + _requireAuthorizedForProvision(serviceProvider); + } } diff --git a/packages/interfaces/contracts/data-service/IDataServiceAgreements.sol b/packages/interfaces/contracts/data-service/IDataServiceAgreements.sol new file mode 100644 index 000000000..604846a09 --- /dev/null +++ b/packages/interfaces/contracts/data-service/IDataServiceAgreements.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.22; + +import { IDataService } from "./IDataService.sol"; + +/** + * @title Interface for data services that manage indexing agreements. + * @author Edge & Node + * @notice Extension for the {IDataService} contract to support payer-initiated + * cancellation of indexing agreements. Any data service that participates in + * agreement lifecycle management via {ServiceAgreementManager} should implement + * this interface. + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +interface IDataServiceAgreements is IDataService { + /** + * @notice Cancel an indexing agreement by payer / signer. + * @param agreementId The id of the indexing agreement + */ + function cancelIndexingAgreementByPayer(bytes16 agreementId) external; +} 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..1eb2d6373 100644 --- a/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol +++ b/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol @@ -1,8 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.22; +import { IDataServiceAgreements } from "../data-service/IDataServiceAgreements.sol"; 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"; @@ -20,7 +22,7 @@ import { ILegacyAllocation } from "./internal/ILegacyAllocation.sol"; * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ -interface ISubgraphService is IDataServiceFees { +interface ISubgraphService is IDataServiceAgreements, IDataServiceFees { /** * @notice Indexer details * @param url The URL where the indexer can be reached at for queries @@ -267,21 +269,32 @@ interface ISubgraphService is IDataServiceFees { /** * @notice Accept an indexing agreement. + * @dev If `signature` is non-empty it is treated as an ECDSA signature; if empty the payer + * must be a contract implementing {IContractApprover}. * @param allocationId The id of the allocation - * @param signedRCA The signed recurring collector agreement (RCA) that the indexer accepts + * @param rca The recurring collection agreement parameters + * @param signature ECDSA signature bytes, or empty for contract-approved agreements * @return agreementId The ID of the accepted indexing agreement */ function acceptIndexingAgreement( address allocationId, - IRecurringCollector.SignedRCA calldata signedRCA + IRecurringCollector.RecurringCollectionAgreement calldata rca, + bytes calldata signature ) external returns (bytes16); /** * @notice Update an indexing agreement. + * @dev If `signature` is non-empty it is treated as an ECDSA signature; if empty the payer + * must be a contract implementing {IContractApprover}. * @param indexer The address of the indexer - * @param signedRCAU The signed recurring collector agreement update (RCAU) that the indexer accepts + * @param rcau The recurring collector agreement update to apply + * @param signature ECDSA signature bytes, or empty for contract-approved updates */ - function updateIndexingAgreement(address indexer, IRecurringCollector.SignedRCAU calldata signedRCAU) external; + function updateIndexingAgreement( + address indexer, + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau, + bytes calldata signature + ) external; /** * @notice Cancel an indexing agreement by indexer / operator. @@ -290,12 +303,6 @@ interface ISubgraphService is IDataServiceFees { */ function cancelIndexingAgreement(address indexer, bytes16 agreementId) external; - /** - * @notice Cancel an indexing agreement by payer / signer. - * @param agreementId The id of the indexing agreement - */ - function cancelIndexingAgreementByPayer(bytes16 agreementId) external; - /** * @notice Get the indexing agreement for a given agreement ID. * @param agreementId The id of the indexing agreement diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index 1993d2e5e..b0b4b5944 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -6,6 +6,7 @@ import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token import { IGraphTallyCollector } from "@graphprotocol/interfaces/contracts/horizon/IGraphTallyCollector.sol"; import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol"; import { IDataService } from "@graphprotocol/interfaces/contracts/data-service/IDataService.sol"; +import { IDataServiceAgreements } from "@graphprotocol/interfaces/contracts/data-service/IDataServiceAgreements.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; @@ -54,12 +55,21 @@ contract SubgraphService is using TokenUtils for IGraphToken; using IndexingAgreement for IndexingAgreement.StorageManager; + uint256 private constant DEFAULT = 0; + uint256 private constant VALID_PROVISION = 1 << 0; + uint256 private constant REGISTERED = 1 << 1; + /** - * @notice Checks that an indexer is registered - * @param indexer The address of the indexer + * @notice Modifier that enforces service provider requirements. + * @dev Always checks pause state and caller authorization. Additional checks + * (provision validity, indexer registration) are selected via a bitmask. + * Delegates to {_enforceServiceRequirements} which is emitted once in bytecode + * and JUMPed to from each call site, avoiding repeated modifier inlining. + * @param serviceProvider The address of the service provider. + * @param requirements Bitmask of additional requirement flags. */ - modifier onlyRegisteredIndexer(address indexer) { - _checkRegisteredIndexer(indexer); + modifier enforceService(address serviceProvider, uint256 requirements) { + _enforceServiceRequirements(serviceProvider, requirements); _; } @@ -121,10 +131,7 @@ contract SubgraphService is * Use zero address for automatically restaking payments. */ /// @inheritdoc IDataService - function register( - address indexer, - bytes calldata data - ) external override onlyAuthorizedForProvision(indexer) onlyValidProvision(indexer) whenNotPaused { + function register(address indexer, bytes calldata data) external override enforceService(indexer, VALID_PROVISION) { (string memory url, string memory geohash, address paymentsDestination_) = abi.decode( data, (string, string, address) @@ -157,7 +164,7 @@ contract SubgraphService is function acceptProvisionPendingParameters( address indexer, bytes calldata - ) external override onlyAuthorizedForProvision(indexer) whenNotPaused { + ) external override enforceService(indexer, DEFAULT) { _acceptProvisionParameters(indexer); emit ProvisionPendingParametersAccepted(indexer); } @@ -190,14 +197,7 @@ contract SubgraphService is function startService( address indexer, bytes calldata data - ) - external - override - onlyAuthorizedForProvision(indexer) - onlyValidProvision(indexer) - onlyRegisteredIndexer(indexer) - whenNotPaused - { + ) external override enforceService(indexer, VALID_PROVISION | REGISTERED) { (bytes32 subgraphDeploymentId, uint256 tokens, address allocationId, bytes memory allocationProof) = abi.decode( data, (bytes32, uint256, address, bytes) @@ -226,15 +226,9 @@ contract SubgraphService is * - address `allocationId`: The id of the allocation */ /// @inheritdoc IDataService - function stopService( - address indexer, - bytes calldata data - ) external override onlyAuthorizedForProvision(indexer) onlyRegisteredIndexer(indexer) whenNotPaused { + function stopService(address indexer, bytes calldata data) external override enforceService(indexer, REGISTERED) { address allocationId = abi.decode(data, (address)); - require( - _allocations.get(allocationId).indexer == indexer, - SubgraphServiceAllocationNotAuthorized(indexer, allocationId) - ); + _checkAllocationOwnership(indexer, allocationId); _onCloseAllocation(allocationId, false); _closeAllocation(allocationId, false); emit ServiceStopped(indexer, data); @@ -281,15 +275,7 @@ contract SubgraphService is address indexer, IGraphPayments.PaymentTypes paymentType, bytes calldata data - ) - external - override - whenNotPaused - onlyAuthorizedForProvision(indexer) - onlyValidProvision(indexer) - onlyRegisteredIndexer(indexer) - returns (uint256) - { + ) external override enforceService(indexer, VALID_PROVISION | REGISTERED) returns (uint256) { uint256 paymentCollected = 0; if (paymentType == IGraphPayments.PaymentTypes.QueryFee) { @@ -338,17 +324,8 @@ contract SubgraphService is address indexer, address allocationId, uint256 tokens - ) - external - onlyAuthorizedForProvision(indexer) - onlyValidProvision(indexer) - onlyRegisteredIndexer(indexer) - whenNotPaused - { - require( - _allocations.get(allocationId).indexer == indexer, - SubgraphServiceAllocationNotAuthorized(indexer, allocationId) - ); + ) external enforceService(indexer, VALID_PROVISION | REGISTERED) { + _checkAllocationOwnership(indexer, allocationId); _resizeAllocation(allocationId, tokens, _delegationRatio); } @@ -412,27 +389,21 @@ contract SubgraphService is * - Agreement must not have been accepted before * - Allocation must not have an agreement already * - * @dev signedRCA.rca.metadata is an encoding of {IndexingAgreement.AcceptIndexingAgreementMetadata} + * @dev rca.metadata is an encoding of {IndexingAgreement.AcceptIndexingAgreementMetadata} * * Emits {IndexingAgreement.IndexingAgreementAccepted} event * * @param allocationId The id of the allocation - * @param signedRCA The signed Recurring Collection Agreement + * @param rca The Recurring Collection Agreement + * @param signature ECDSA signature bytes, or empty for contract-approved agreements * @return agreementId The ID of the accepted indexing agreement */ function acceptIndexingAgreement( address allocationId, - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCA calldata signedRCA - ) - external - whenNotPaused - onlyAuthorizedForProvision(signedRCA.rca.serviceProvider) - onlyValidProvision(signedRCA.rca.serviceProvider) - onlyRegisteredIndexer(signedRCA.rca.serviceProvider) - returns (bytes16) - { - return IndexingAgreement._getStorageManager().accept(_allocations, allocationId, signedRCA); + IRecurringCollector.RecurringCollectionAgreement calldata rca, + bytes calldata signature + ) external enforceService(rca.serviceProvider, VALID_PROVISION | REGISTERED) returns (bytes16) { + return IndexingAgreement._getStorageManager().accept(_allocations, allocationId, rca, signature); } /** @@ -446,20 +417,15 @@ contract SubgraphService is * - The indexer must be valid * * @param indexer The indexer address - * @param signedRCAU The signed Recurring Collection Agreement Update + * @param rcau The Recurring Collection Agreement Update + * @param signature ECDSA signature bytes, or empty for contract-approved updates */ function updateIndexingAgreement( address indexer, - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCAU calldata signedRCAU - ) - external - whenNotPaused - onlyAuthorizedForProvision(indexer) - onlyValidProvision(indexer) - onlyRegisteredIndexer(indexer) - { - IndexingAgreement._getStorageManager().update(indexer, signedRCAU); + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau, + bytes calldata signature + ) external enforceService(indexer, VALID_PROVISION | REGISTERED) { + IndexingAgreement._getStorageManager().update(indexer, rcau, signature); } /** @@ -480,21 +446,15 @@ contract SubgraphService is function cancelIndexingAgreement( address indexer, bytes16 agreementId - ) - external - whenNotPaused - onlyAuthorizedForProvision(indexer) - onlyValidProvision(indexer) - onlyRegisteredIndexer(indexer) - { + ) external enforceService(indexer, VALID_PROVISION | REGISTERED) { IndexingAgreement._getStorageManager().cancel(indexer, agreementId); } /** - * @inheritdoc ISubgraphService + * @inheritdoc IDataServiceAgreements * @notice Cancel an indexing agreement by payer / signer. * - * See {ISubgraphService.cancelIndexingAgreementByPayer}. + * See {IDataServiceAgreements.cancelIndexingAgreementByPayer}. * * Requirements: * - The caller must be authorized by the payer @@ -614,11 +574,35 @@ contract SubgraphService is } /** - * @notice Checks that an indexer is registered - * @param indexer The address of the indexer + * @notice Enforces service provider requirements. + * @dev Always checks pause state and caller authorization. Additional checks + * (provision validity, indexer registration) are selected via bitmask flags. + * Single dispatch point emitted once in bytecode, JUMPed to from each call site + * via the {enforceService} modifier. + * @param _serviceProvider The address of the service provider. + * @param _checks Bitmask of additional requirement flags (VALID_PROVISION, REGISTERED). + */ + function _enforceServiceRequirements(address _serviceProvider, uint256 _checks) private view { + _requireNotPaused(); + _requireAuthorizedForProvision(_serviceProvider); + if (_checks & VALID_PROVISION != 0) _requireValidProvision(_serviceProvider); + if (_checks & REGISTERED != 0) + require( + bytes(indexers[_serviceProvider].url).length > 0, + SubgraphServiceIndexerNotRegistered(_serviceProvider) + ); + } + + /** + * @notice Checks that the allocation belongs to the given indexer. + * @param _indexer The address of the indexer. + * @param _allocationId The id of the allocation. */ - function _checkRegisteredIndexer(address indexer) private view { - require(bytes(indexers[indexer].url).length > 0, SubgraphServiceIndexerNotRegistered(indexer)); + function _checkAllocationOwnership(address _indexer, address _allocationId) internal view { + require( + _allocations.get(_allocationId).indexer == _indexer, + SubgraphServiceAllocationNotAuthorized(_indexer, _allocationId) + ); } /** @@ -736,10 +720,7 @@ contract SubgraphService is */ function _collectIndexingRewards(address _indexer, bytes calldata _data) private returns (uint256) { (address allocationId, bytes32 poi_, bytes memory poiMetadata_) = abi.decode(_data, (address, bytes32, bytes)); - require( - _allocations.get(allocationId).indexer == _indexer, - SubgraphServiceAllocationNotAuthorized(_indexer, allocationId) - ); + _checkAllocationOwnership(_indexer, allocationId); (uint256 paymentCollected, bool allocationForceClosed) = _presentPoi( allocationId, diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index 19a7eaf4a..94302a9db 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -274,43 +274,38 @@ library IndexingAgreement { * - Agreement must not have been accepted before * - Allocation must not have an agreement already * - * @dev signedRCA.rca.metadata is an encoding of {IndexingAgreement.AcceptIndexingAgreementMetadata} + * @dev rca.metadata is an encoding of {IndexingAgreement.AcceptIndexingAgreementMetadata}. + * If `authData` is non-empty it is treated as an ECDSA signature; if empty the payer + * must be a contract implementing {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 signedRCA The signed Recurring Collection Agreement + * @param rca The Recurring Collection Agreement + * @param authData ECDSA signature bytes, or empty for contract-approved agreements * @return The agreement ID assigned to the accepted indexing agreement */ function accept( StorageManager storage self, mapping(address allocationId => IAllocation.State allocation) storage allocations, address allocationId, - IRecurringCollector.SignedRCA calldata signedRCA + IRecurringCollector.RecurringCollectionAgreement calldata rca, + bytes calldata authData ) external returns (bytes16) { - IAllocation.State memory allocation = _requireValidAllocation( - allocations, - allocationId, - signedRCA.rca.serviceProvider - ); + IAllocation.State memory allocation = _requireValidAllocation(allocations, allocationId, rca.serviceProvider); - require( - signedRCA.rca.dataService == address(this), - IndexingAgreementWrongDataService(address(this), signedRCA.rca.dataService) - ); + require(rca.dataService == address(this), IndexingAgreementWrongDataService(address(this), rca.dataService)); - AcceptIndexingAgreementMetadata memory metadata = IndexingAgreementDecoder.decodeRCAMetadata( - signedRCA.rca.metadata - ); + AcceptIndexingAgreementMetadata memory metadata = IndexingAgreementDecoder.decodeRCAMetadata(rca.metadata); bytes16 agreementId = _directory().recurringCollector().generateAgreementId( - signedRCA.rca.payer, - signedRCA.rca.dataService, - signedRCA.rca.serviceProvider, - signedRCA.rca.deadline, - signedRCA.rca.nonce + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce ); IIndexingAgreement.State storage agreement = self.agreements[agreementId]; @@ -340,11 +335,11 @@ library IndexingAgreement { metadata.version == IIndexingAgreement.IndexingAgreementVersion.V1, IndexingAgreementInvalidVersion(metadata.version) ); - _setTermsV1(self, agreementId, metadata.terms, signedRCA.rca.maxOngoingTokensPerSecond); + _setTermsV1(self, agreementId, metadata.terms, rca.maxOngoingTokensPerSecond); emit IndexingAgreementAccepted( - signedRCA.rca.serviceProvider, - signedRCA.rca.payer, + rca.serviceProvider, + rca.payer, agreementId, allocationId, metadata.subgraphDeploymentId, @@ -352,7 +347,10 @@ library IndexingAgreement { metadata.terms ); - require(_directory().recurringCollector().accept(signedRCA) == agreementId, "internal: agreement ID mismatch"); + require( + _directory().recurringCollector().accept(rca, authData) == agreementId, + "internal: agreement ID mismatch" + ); return agreementId; } /* solhint-enable function-max-lines */ @@ -364,29 +362,31 @@ library IndexingAgreement { * - Agreement must be active * - The indexer must be the service provider of the agreement * - * @dev signedRCA.rcau.metadata is an encoding of {IndexingAgreement.UpdateIndexingAgreementMetadata} + * @dev rcau.metadata is an encoding of {IndexingAgreement.UpdateIndexingAgreementMetadata}. + * If `authData` is non-empty it is treated as an ECDSA signature; if empty the payer + * must be a contract implementing {IContractApprover}. * * Emits {IndexingAgreementUpdated} event * * @param self The indexing agreement storage manager * @param indexer The indexer address - * @param signedRCAU The signed Recurring Collection Agreement Update + * @param rcau The Recurring Collection Agreement Update + * @param authData ECDSA signature bytes, or empty for contract-approved updates */ function update( StorageManager storage self, address indexer, - IRecurringCollector.SignedRCAU calldata signedRCAU + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau, + bytes calldata authData ) external { - IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, signedRCAU.rcau.agreementId); - require(_isActive(wrapper), IndexingAgreementNotActive(signedRCAU.rcau.agreementId)); + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, rcau.agreementId); + require(_isActive(wrapper), IndexingAgreementNotActive(rcau.agreementId)); require( wrapper.collectorAgreement.serviceProvider == indexer, - IndexingAgreementNotAuthorized(signedRCAU.rcau.agreementId, indexer) + IndexingAgreementNotAuthorized(rcau.agreementId, indexer) ); - UpdateIndexingAgreementMetadata memory metadata = IndexingAgreementDecoder.decodeRCAUMetadata( - signedRCAU.rcau.metadata - ); + UpdateIndexingAgreementMetadata memory metadata = IndexingAgreementDecoder.decodeRCAUMetadata(rcau.metadata); require( wrapper.agreement.version == IIndexingAgreement.IndexingAgreementVersion.V1, @@ -396,23 +396,18 @@ library IndexingAgreement { metadata.version == IIndexingAgreement.IndexingAgreementVersion.V1, IndexingAgreementInvalidVersion(metadata.version) ); - _setTermsV1( - self, - signedRCAU.rcau.agreementId, - metadata.terms, - wrapper.collectorAgreement.maxOngoingTokensPerSecond - ); + _setTermsV1(self, rcau.agreementId, metadata.terms, wrapper.collectorAgreement.maxOngoingTokensPerSecond); emit IndexingAgreementUpdated({ indexer: wrapper.collectorAgreement.serviceProvider, payer: wrapper.collectorAgreement.payer, - agreementId: signedRCAU.rcau.agreementId, + agreementId: rcau.agreementId, allocationId: wrapper.agreement.allocationId, version: metadata.version, versionTerms: metadata.terms }); - _directory().recurringCollector().update(signedRCAU); + _directory().recurringCollector().update(rcau, authData); } /** @@ -502,7 +497,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/scripts/ops/protocol-activity.ts b/packages/subgraph-service/scripts/ops/protocol-activity.ts index e53e83ab9..cb62e4250 100644 --- a/packages/subgraph-service/scripts/ops/protocol-activity.ts +++ b/packages/subgraph-service/scripts/ops/protocol-activity.ts @@ -351,7 +351,7 @@ async function main() { } for (const [i, signer] of signers.entries()) { - const escrowAccount = await PaymentsEscrow.escrowAccounts( + const escrowAccount = await PaymentsEscrow.getEscrowAccount( gateway.address, GraphTallyCollector.target, signer.address, diff --git a/packages/subgraph-service/test/unit/disputeManager/disputes/indexingFee/create.t.sol b/packages/subgraph-service/test/unit/disputeManager/disputes/indexingFee/create.t.sol new file mode 100644 index 000000000..f6654d10e --- /dev/null +++ b/packages/subgraph-service/test/unit/disputeManager/disputes/indexingFee/create.t.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IDisputeManager } from "@graphprotocol/interfaces/contracts/subgraph-service/IDisputeManager.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IPaymentsCollector } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "../../../subgraphService/indexing-agreement/shared.t.sol"; + +contract DisputeManagerIndexingFeeCreateDisputeTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * HELPERS + */ + + /// @dev Sets up an indexer with an accepted indexing agreement that has been collected on. + /// Returns the agreement ID and indexer state needed to create a dispute. + function _setupCollectedAgreement( + Seed memory seed, + uint256 unboundedTokensCollected + ) internal returns (bytes16 agreementId, IndexerState memory indexerState) { + Context storage ctx = _newCtx(seed); + indexerState = _withIndexer(ctx); + (, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); + agreementId = acceptedAgreementId; + + // Set payments destination + resetPrank(indexerState.addr); + subgraphService.setPaymentsDestination(indexerState.addr); + + // Mock the collect call to succeed with some tokens + uint256 tokensCollected = bound(unboundedTokensCollected, 1, indexerState.tokens / STAKE_TO_FEES_RATIO); + bytes memory data = abi.encode( + IRecurringCollector.CollectParams({ + agreementId: acceptedAgreementId, + collectionId: bytes32(uint256(uint160(indexerState.allocationId))), + tokens: 0, + dataServiceCut: 0, + receiverDestination: indexerState.addr, + maxSlippage: type(uint256).max + }) + ); + vm.mockCall( + address(recurringCollector), + abi.encodeWithSelector(IPaymentsCollector.collect.selector, IGraphPayments.PaymentTypes.IndexingFee, data), + abi.encode(tokensCollected) + ); + + skip(1); // Make agreement collectable + + // Collect to set lastCollectionAt > 0 + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1( + acceptedAgreementId, + 100, // entities + // forge-lint: disable-next-line(unsafe-typecast) + bytes32("POI1"), + epochManager.currentEpochBlock(), + bytes("") + ) + ); + + // The collect mock prevented the real RecurringCollector from updating lastCollectionAt. + // Mock getAgreement to return lastCollectionAt > 0 so the dispute can be created. + IRecurringCollector.AgreementData memory agreementData = recurringCollector.getAgreement(acceptedAgreementId); + agreementData.lastCollectionAt = uint64(block.timestamp); + vm.mockCall( + address(recurringCollector), + abi.encodeWithSelector(recurringCollector.getAgreement.selector, acceptedAgreementId), + abi.encode(agreementData) + ); + } + + /* + * TESTS + */ + + function test_IndexingFee_Create_Dispute(Seed memory seed, uint256 unboundedTokensCollected) public { + (bytes16 agreementId, IndexerState memory indexerState) = _setupCollectedAgreement( + seed, + unboundedTokensCollected + ); + + // Create dispute as fisherman + resetPrank(users.fisherman); + token.approve(address(disputeManager), disputeManager.disputeDeposit()); + + bytes32 disputeId = disputeManager.createIndexingFeeDisputeV1( + agreementId, + // forge-lint: disable-next-line(unsafe-typecast) + bytes32("disputePOI"), + 200, + block.number + ); + + assertTrue(disputeManager.isDisputeCreated(disputeId)); + + // Verify dispute fields + ( + address indexer, + address fisherman, + uint256 deposit, + , + IDisputeManager.DisputeType disputeType, + IDisputeManager.DisputeStatus status, + , + , + uint256 stakeSnapshot + ) = disputeManager.disputes(disputeId); + + assertEq(indexer, indexerState.addr); + assertEq(fisherman, users.fisherman); + assertEq(deposit, disputeManager.disputeDeposit()); + assertEq(uint8(disputeType), uint8(IDisputeManager.DisputeType.IndexingFeeDispute)); + assertEq(uint8(status), uint8(IDisputeManager.DisputeStatus.Pending)); + assertTrue(stakeSnapshot > 0); + } + + function test_IndexingFee_Create_Dispute_RevertWhen_NotCollected(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); + + // Attempt to create dispute without collecting first (lastCollectionAt == 0) + resetPrank(users.fisherman); + token.approve(address(disputeManager), disputeManager.disputeDeposit()); + + vm.expectRevert( + abi.encodeWithSelector( + IDisputeManager.DisputeManagerIndexingAgreementNotDisputable.selector, + acceptedAgreementId + ) + ); + // forge-lint: disable-next-line(unsafe-typecast) + disputeManager.createIndexingFeeDisputeV1(acceptedAgreementId, bytes32("POI"), 100, block.number); + } + + function test_IndexingFee_Create_Dispute_EmitsEvent( + Seed memory seed, + uint256 unboundedTokensCollected + ) public { + (bytes16 agreementId, IndexerState memory indexerState) = _setupCollectedAgreement( + seed, + unboundedTokensCollected + ); + + resetPrank(users.fisherman); + uint256 deposit = disputeManager.disputeDeposit(); + token.approve(address(disputeManager), deposit); + + // forge-lint: disable-next-line(unsafe-typecast) + bytes32 poi = bytes32("disputePOI"); + uint256 entities = 200; + uint256 blockNumber = block.number; + + bytes32 expectedDisputeId = keccak256( + abi.encodePacked("IndexingFeeDisputeWithAgreement", agreementId, poi, entities, blockNumber) + ); + + vm.expectEmit(address(disputeManager)); + emit IDisputeManager.IndexingFeeDisputeCreated( + expectedDisputeId, + indexerState.addr, + users.fisherman, + deposit, + address(0), // payer from mock agreement (zero-initialized) + agreementId, + poi, + entities, + indexerState.tokens // stakeSnapshot + ); + + bytes32 disputeId = disputeManager.createIndexingFeeDisputeV1(agreementId, poi, entities, blockNumber); + assertEq(disputeId, expectedDisputeId); + } + + function test_IndexingFee_Create_Dispute_RevertWhen_AlreadyCreated( + Seed memory seed, + uint256 unboundedTokensCollected + ) public { + (bytes16 agreementId, ) = _setupCollectedAgreement(seed, unboundedTokensCollected); + + // Create first dispute + resetPrank(users.fisherman); + token.approve(address(disputeManager), disputeManager.disputeDeposit() * 2); + + // forge-lint: disable-next-line(unsafe-typecast) + bytes32 disputeId = disputeManager.createIndexingFeeDisputeV1(agreementId, bytes32("POI"), 100, block.number); + + // Attempt to create a duplicate dispute + vm.expectRevert( + abi.encodeWithSelector(IDisputeManager.DisputeManagerDisputeAlreadyCreated.selector, disputeId) + ); + // forge-lint: disable-next-line(unsafe-typecast) + disputeManager.createIndexingFeeDisputeV1(agreementId, bytes32("POI"), 100, block.number); + } +} diff --git a/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol b/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol index 76fae1307..0cdd570c9 100644 --- a/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol @@ -21,7 +21,7 @@ contract SubgraphServiceRegisterTest is SubgraphServiceTest { * HELPERS */ - function _getSignerProof(uint256 _proofDeadline, uint256 _signer) private returns (bytes memory) { + function _getSignerProof(uint256 _proofDeadline, uint256 _signer) private view returns (bytes memory) { (, address msgSender, ) = vm.readCallers(); bytes32 messageHash = keccak256( abi.encodePacked( @@ -236,7 +236,9 @@ contract SubgraphServiceRegisterTest is SubgraphServiceTest { _deposit(tokensPayment); _authorizeSigner(); - uint256 beforeGatewayBalance = escrow.getBalance(users.gateway, address(graphTallyCollector), users.indexer); + uint256 beforeGatewayBalance = escrow + .getEscrowAccount(users.gateway, address(graphTallyCollector), users.indexer) + .balance; uint256 beforeTokensCollected = graphTallyCollector.tokensCollected( address(subgraphService), bytes32(uint256(uint160(allocationId))), @@ -252,11 +254,9 @@ contract SubgraphServiceRegisterTest is SubgraphServiceTest { bytes memory data = _getQueryFeeEncodedData(users.indexer, uint128(tokensPayment), tokensToCollect); _collect(users.indexer, IGraphPayments.PaymentTypes.QueryFee, data); - uint256 intermediateGatewayBalance = escrow.getBalance( - users.gateway, - address(graphTallyCollector), - users.indexer - ); + uint256 intermediateGatewayBalance = escrow + .getEscrowAccount(users.gateway, address(graphTallyCollector), users.indexer) + .balance; assertEq(intermediateGatewayBalance, beforeGatewayBalance - tokensToCollect); uint256 intermediateTokensCollected = graphTallyCollector.tokensCollected( address(subgraphService), @@ -276,7 +276,9 @@ contract SubgraphServiceRegisterTest is SubgraphServiceTest { _collect(users.indexer, IGraphPayments.PaymentTypes.QueryFee, data2); // Check the indexer received the correct amount of tokens - uint256 afterGatewayBalance = escrow.getBalance(users.gateway, address(graphTallyCollector), users.indexer); + uint256 afterGatewayBalance = escrow + .getEscrowAccount(users.gateway, address(graphTallyCollector), users.indexer) + .balance; assertEq(afterGatewayBalance, beforeGatewayBalance - tokensPayment); uint256 afterTokensCollected = graphTallyCollector.tokensCollected( address(subgraphService), diff --git a/packages/subgraph-service/test/unit/subgraphService/getters.t.sol b/packages/subgraph-service/test/unit/subgraphService/getters.t.sol index 27c9aafbb..5f884cfcb 100644 --- a/packages/subgraph-service/test/unit/subgraphService/getters.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/getters.t.sol @@ -23,6 +23,11 @@ contract SubgraphServiceGettersTest is SubgraphServiceTest { assertEq(result, address(curation)); } + function test_GetRecurringCollector() public view { + address result = address(subgraphService.recurringCollector()); + assertEq(result, address(recurringCollector)); + } + function test_GetAllocationData(uint256 tokens) public useIndexer useAllocation(tokens) { ( bool isOpen, diff --git a/packages/subgraph-service/test/unit/subgraphService/governance/indexingFeesCut.t.sol b/packages/subgraph-service/test/unit/subgraphService/governance/indexingFeesCut.t.sol new file mode 100644 index 000000000..8bd374c01 --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/governance/indexingFeesCut.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; +import { SubgraphServiceTest } from "../SubgraphService.t.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +contract SubgraphServiceGovernanceIndexingFeesCutTest is SubgraphServiceTest { + /* + * TESTS + */ + + function test_Governance_SetIndexingFeesCut(uint256 indexingFeesCut) public useGovernor { + vm.assume(indexingFeesCut <= MAX_PPM); + + vm.expectEmit(address(subgraphService)); + emit ISubgraphService.IndexingFeesCutSet(indexingFeesCut); + subgraphService.setIndexingFeesCut(indexingFeesCut); + + assertEq(subgraphService.indexingFeesCut(), indexingFeesCut); + } + + function test_Governance_SetIndexingFeesCut_RevertWhen_InvalidPPM(uint256 indexingFeesCut) public useGovernor { + vm.assume(indexingFeesCut > MAX_PPM); + + vm.expectRevert( + abi.encodeWithSelector(ISubgraphService.SubgraphServiceInvalidIndexingFeesCut.selector, indexingFeesCut) + ); + subgraphService.setIndexingFeesCut(indexingFeesCut); + } + + function test_Governance_SetIndexingFeesCut_RevertWhen_NotGovernor() public useIndexer { + uint256 indexingFeesCut = 100_000; // 10% + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, users.indexer)); + subgraphService.setIndexingFeesCut(indexingFeesCut); + } +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol index 6b14848ff..4296c8415 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -22,47 +22,47 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenPaused( address allocationId, address operator, - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCA calldata signedRCA + IRecurringCollector.RecurringCollectionAgreement calldata rca, + bytes calldata authData ) public withSafeIndexerOrOperator(operator) { resetPrank(users.pauseGuardian); subgraphService.pause(); resetPrank(operator); vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + subgraphService.acceptIndexingAgreement(allocationId, rca, authData); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenNotAuthorized( address allocationId, address operator, - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCA calldata signedRCA + IRecurringCollector.RecurringCollectionAgreement calldata rca, + bytes calldata authData ) public withSafeIndexerOrOperator(operator) { - vm.assume(operator != signedRCA.rca.serviceProvider); + vm.assume(operator != rca.serviceProvider); resetPrank(operator); bytes memory expectedErr = abi.encodeWithSelector( ProvisionManager.ProvisionManagerNotAuthorized.selector, - signedRCA.rca.serviceProvider, + rca.serviceProvider, operator ); vm.expectRevert(expectedErr); - subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + subgraphService.acceptIndexingAgreement(allocationId, rca, authData); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidProvision( address indexer, uint256 unboundedTokens, address allocationId, - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCA memory signedRCA + IRecurringCollector.RecurringCollectionAgreement memory rca, + bytes memory authData ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); mint(indexer, tokens); resetPrank(indexer); _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); - signedRCA.rca.serviceProvider = indexer; + rca.serviceProvider = indexer; bytes memory expectedErr = abi.encodeWithSelector( ProvisionManager.ProvisionManagerInvalidValue.selector, "tokens", @@ -71,27 +71,27 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg MAXIMUM_PROVISION_TOKENS ); vm.expectRevert(expectedErr); - subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + subgraphService.acceptIndexingAgreement(allocationId, rca, authData); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenIndexerNotRegistered( address indexer, uint256 unboundedTokens, address allocationId, - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCA memory signedRCA + IRecurringCollector.RecurringCollectionAgreement memory rca, + bytes memory authData ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); mint(indexer, tokens); resetPrank(indexer); _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); - signedRCA.rca.serviceProvider = indexer; + rca.serviceProvider = indexer; bytes memory expectedErr = abi.encodeWithSelector( ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, indexer ); vm.expectRevert(expectedErr); - subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + subgraphService.acceptIndexingAgreement(allocationId, rca, authData); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenNotDataService( @@ -102,41 +102,47 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); - acceptable.rca.dataService = incorrectDataService; - IRecurringCollector.SignedRCA memory unacceptable = _recurringCollectorHelper.generateSignedRCA( - acceptable.rca, - ctx.payer.signerPrivateKey + (IRecurringCollector.RecurringCollectionAgreement memory acceptableRca, ) = _generateAcceptableSignedRCA( + ctx, + indexerState.addr ); + acceptableRca.dataService = incorrectDataService; + ( + IRecurringCollector.RecurringCollectionAgreement memory unacceptableRca, + bytes memory signature + ) = _recurringCollectorHelper.generateSignedRCA(acceptableRca, ctx.payer.signerPrivateKey); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreement.IndexingAgreementWrongDataService.selector, address(subgraphService), - unacceptable.rca.dataService + unacceptableRca.dataService ); vm.expectRevert(expectedErr); vm.prank(indexerState.addr); - subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptable); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptableRca, signature); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidMetadata(Seed memory seed) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); - acceptable.rca.metadata = bytes("invalid"); - IRecurringCollector.SignedRCA memory unacceptable = _recurringCollectorHelper.generateSignedRCA( - acceptable.rca, - ctx.payer.signerPrivateKey + (IRecurringCollector.RecurringCollectionAgreement memory acceptableRca, ) = _generateAcceptableSignedRCA( + ctx, + indexerState.addr ); + acceptableRca.metadata = bytes("invalid"); + ( + IRecurringCollector.RecurringCollectionAgreement memory unacceptableRca, + bytes memory signature + ) = _recurringCollectorHelper.generateSignedRCA(acceptableRca, ctx.payer.signerPrivateKey); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, "decodeRCAMetadata", - unacceptable.rca.metadata + unacceptableRca.metadata ); vm.expectRevert(expectedErr); vm.prank(indexerState.addr); - subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptable); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptableRca, signature); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidAllocation( @@ -145,7 +151,10 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg ) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptableRca, + bytes memory signature + ) = _generateAcceptableSignedRCA(ctx, indexerState.addr); bytes memory expectedErr = abi.encodeWithSelector( IAllocation.AllocationDoesNotExist.selector, @@ -153,14 +162,17 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg ); vm.expectRevert(expectedErr); vm.prank(indexerState.addr); - subgraphService.acceptIndexingAgreement(invalidAllocationId, acceptable); + subgraphService.acceptIndexingAgreement(invalidAllocationId, acceptableRca, signature); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAllocationNotAuthorized(Seed memory seed) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerStateA = _withIndexer(ctx); IndexerState memory indexerStateB = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory acceptableA = _generateAcceptableSignedRCA(ctx, indexerStateA.addr); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptableRcaA, + bytes memory signatureA + ) = _generateAcceptableSignedRCA(ctx, indexerStateA.addr); bytes memory expectedErr = abi.encodeWithSelector( ISubgraphService.SubgraphServiceAllocationNotAuthorized.selector, @@ -169,13 +181,16 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg ); vm.expectRevert(expectedErr); vm.prank(indexerStateA.addr); - subgraphService.acceptIndexingAgreement(indexerStateB.allocationId, acceptableA); + subgraphService.acceptIndexingAgreement(indexerStateB.allocationId, acceptableRcaA, signatureA); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAllocationClosed(Seed memory seed) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptableRca, + bytes memory signature + ) = _generateAcceptableSignedRCA(ctx, indexerState.addr); resetPrank(indexerState.addr); subgraphService.stopService(indexerState.addr, abi.encode(indexerState.allocationId)); @@ -185,7 +200,7 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg indexerState.allocationId ); vm.expectRevert(expectedErr); - subgraphService.acceptIndexingAgreement(indexerState.allocationId, acceptable); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, acceptableRca, signature); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenDeploymentIdMismatch( @@ -195,12 +210,15 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); vm.assume(indexerState.subgraphDeploymentId != wrongSubgraphDeploymentId); - IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); - acceptable.rca.metadata = abi.encode(_newAcceptIndexingAgreementMetadataV1(wrongSubgraphDeploymentId)); - IRecurringCollector.SignedRCA memory unacceptable = _recurringCollectorHelper.generateSignedRCA( - acceptable.rca, - ctx.payer.signerPrivateKey + (IRecurringCollector.RecurringCollectionAgreement memory acceptableRca, ) = _generateAcceptableSignedRCA( + ctx, + indexerState.addr ); + acceptableRca.metadata = abi.encode(_newAcceptIndexingAgreementMetadataV1(wrongSubgraphDeploymentId)); + ( + IRecurringCollector.RecurringCollectionAgreement memory unacceptableRca, + bytes memory signature + ) = _recurringCollectorHelper.generateSignedRCA(acceptableRca, ctx.payer.signerPrivateKey); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreement.IndexingAgreementDeploymentIdMismatch.selector, @@ -210,15 +228,21 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg ); vm.expectRevert(expectedErr); vm.prank(indexerState.addr); - subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptable); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptableRca, signature); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAgreementAlreadyAccepted(Seed memory seed) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - (IRecurringCollector.SignedRCA memory accepted, bytes16 agreementId) = _withAcceptedIndexingAgreement( - ctx, - indexerState + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + bytes16 agreementId + ) = _withAcceptedIndexingAgreement(ctx, indexerState); + + // Re-sign for the re-accept attempt (the original signature was consumed) + (, bytes memory signature) = _recurringCollectorHelper.generateSignedRCA( + acceptedRca, + ctx.payer.signerPrivateKey ); bytes memory expectedErr = abi.encodeWithSelector( @@ -227,7 +251,7 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg ); vm.expectRevert(expectedErr); resetPrank(ctx.indexers[0].addr); - subgraphService.acceptIndexingAgreement(ctx.indexers[0].allocationId, accepted); + subgraphService.acceptIndexingAgreement(ctx.indexers[0].allocationId, acceptedRca, signature); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAgreementAlreadyAllocated( @@ -238,8 +262,11 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg IndexerState memory indexerState = _withIndexer(ctx); // First, accept an indexing agreement on the allocation - (IRecurringCollector.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerState); - vm.assume(accepted.rca.nonce != alternativeNonce); + (IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, ) = _withAcceptedIndexingAgreement( + ctx, + indexerState + ); + vm.assume(acceptedRca.nonce != alternativeNonce); // Now try to accept a different agreement on the same allocation // Create a new agreement with different nonce to ensure different agreement ID @@ -248,11 +275,10 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg newRCA.nonce = alternativeNonce; // Different nonce to ensure different agreement ID // Sign the new agreement - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCA memory newSignedRCA = _recurringCollectorHelper.generateSignedRCA( - newRCA, - ctx.payer.signerPrivateKey - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory newSignedRca, + bytes memory newSignature + ) = _recurringCollectorHelper.generateSignedRCA(newRCA, ctx.payer.signerPrivateKey); // Expect the error when trying to accept a second agreement on the same allocation bytes memory expectedErr = abi.encodeWithSelector( @@ -261,23 +287,26 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg ); vm.expectRevert(expectedErr); resetPrank(indexerState.addr); - subgraphService.acceptIndexingAgreement(indexerState.allocationId, newSignedRCA); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, newSignedRca, newSignature); } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidTermsData(Seed memory seed) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + (IRecurringCollector.RecurringCollectionAgreement memory acceptableRca, ) = _generateAcceptableSignedRCA( + ctx, + indexerState.addr + ); // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.RecurringCollectionAgreement memory notAcceptableRCA = acceptable.rca; + IRecurringCollector.RecurringCollectionAgreement memory notAcceptableRCA = acceptableRca; bytes memory invalidTermsData = bytes("invalid terms data"); notAcceptableRCA.metadata = abi.encode( _newAcceptIndexingAgreementMetadataV1Terms(indexerState.subgraphDeploymentId, invalidTermsData) ); - IRecurringCollector.SignedRCA memory notAcceptable = _recurringCollectorHelper.generateSignedRCA( - notAcceptableRCA, - ctx.payer.signerPrivateKey - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory notAcceptableRcaSigned, + bytes memory signature + ) = _recurringCollectorHelper.generateSignedRCA(notAcceptableRCA, ctx.payer.signerPrivateKey); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, @@ -286,30 +315,33 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg ); vm.expectRevert(expectedErr); resetPrank(indexerState.addr); - subgraphService.acceptIndexingAgreement(indexerState.allocationId, notAcceptable); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, notAcceptableRcaSigned, signature); } function test_SubgraphService_AcceptIndexingAgreement(Seed memory seed) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptableRca, + bytes memory signature + ) = _generateAcceptableSignedRCA(ctx, indexerState.addr); IndexingAgreement.AcceptIndexingAgreementMetadata memory metadata = abi.decode( - acceptable.rca.metadata, + acceptableRca.metadata, (IndexingAgreement.AcceptIndexingAgreementMetadata) ); // Generate deterministic agreement ID for event expectation bytes16 expectedAgreementId = recurringCollector.generateAgreementId( - acceptable.rca.payer, - acceptable.rca.dataService, - acceptable.rca.serviceProvider, - acceptable.rca.deadline, - acceptable.rca.nonce + acceptableRca.payer, + acceptableRca.dataService, + acceptableRca.serviceProvider, + acceptableRca.deadline, + acceptableRca.nonce ); vm.expectEmit(address(subgraphService)); emit IndexingAgreement.IndexingAgreementAccepted( - acceptable.rca.serviceProvider, - acceptable.rca.payer, + acceptableRca.serviceProvider, + acceptableRca.payer, expectedAgreementId, indexerState.allocationId, metadata.subgraphDeploymentId, @@ -318,7 +350,7 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg ); resetPrank(indexerState.addr); - subgraphService.acceptIndexingAgreement(indexerState.allocationId, acceptable); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, acceptableRca, signature); } /* solhint-enable graph/func-name-mixedcase */ } diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol index 4a8f020c7..e01d157c0 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol @@ -32,12 +32,12 @@ contract SubgraphServiceIndexingAgreementBaseTest is SubgraphServiceIndexingAgre // Accept an indexing agreement Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - (IRecurringCollector.SignedRCA memory accepted, bytes16 agreementId) = _withAcceptedIndexingAgreement( - ctx, - indexerState - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + bytes16 agreementId + ) = _withAcceptedIndexingAgreement(ctx, indexerState); IIndexingAgreement.AgreementWrapper memory agreement = subgraphService.getIndexingAgreement(agreementId); - _assertEqualAgreement(accepted.rca, agreement); + _assertEqualAgreement(acceptedRca, agreement); } function test_SubgraphService_Revert_WhenUnsafeAddress_WhenProxyAdmin(address indexer, bytes16 agreementId) public { 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 4ca5b56fc..a0d4ed2d1 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,14 +33,16 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg address rando ) public withSafeIndexerOrOperator(rando) { Context storage ctx = _newCtx(seed); - (IRecurringCollector.SignedRCA memory accepted, bytes16 agreementId) = _withAcceptedIndexingAgreement( - ctx, - _withIndexer(ctx) - ); + vm.assume(rando != seed.rca.payer); + vm.assume(rando != ctx.payer.signer); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + bytes16 agreementId + ) = _withAcceptedIndexingAgreement(ctx, _withIndexer(ctx)); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreement.IndexingAgreementNonCancelableBy.selector, - accepted.rca.payer, + acceptedRca.payer, rando ); vm.expectRevert(expectedErr); @@ -70,14 +72,14 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg ) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( - ctx, - indexerState - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + bytes16 acceptedAgreementId + ) = _withAcceptedIndexingAgreement(ctx, indexerState); IRecurringCollector.CancelAgreementBy by = cancelSource ? IRecurringCollector.CancelAgreementBy.ServiceProvider : IRecurringCollector.CancelAgreementBy.Payer; - _cancelAgreement(ctx, acceptedAgreementId, indexerState.addr, accepted.rca.payer, by); + _cancelAgreement(ctx, acceptedAgreementId, indexerState.addr, acceptedRca.payer, by); resetPrank(indexerState.addr); bytes memory expectedErr = abi.encodeWithSelector( @@ -90,16 +92,16 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg function test_SubgraphService_CancelIndexingAgreementByPayer(Seed memory seed) public { Context storage ctx = _newCtx(seed); - (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( - ctx, - _withIndexer(ctx) - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + bytes16 acceptedAgreementId + ) = _withAcceptedIndexingAgreement(ctx, _withIndexer(ctx)); _cancelAgreement( ctx, acceptedAgreementId, - accepted.rca.serviceProvider, - accepted.rca.payer, + acceptedRca.serviceProvider, + acceptedRca.payer, IRecurringCollector.CancelAgreementBy.Payer ); } @@ -193,14 +195,14 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg ) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( - ctx, - indexerState - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca2, + bytes16 acceptedAgreementId + ) = _withAcceptedIndexingAgreement(ctx, indexerState); IRecurringCollector.CancelAgreementBy by = cancelSource ? IRecurringCollector.CancelAgreementBy.ServiceProvider : IRecurringCollector.CancelAgreementBy.Payer; - _cancelAgreement(ctx, acceptedAgreementId, accepted.rca.serviceProvider, accepted.rca.payer, by); + _cancelAgreement(ctx, acceptedAgreementId, acceptedRca2.serviceProvider, acceptedRca2.payer, by); resetPrank(indexerState.addr); bytes memory expectedErr = abi.encodeWithSelector( @@ -213,16 +215,16 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg function test_SubgraphService_CancelIndexingAgreement_OK(Seed memory seed) public { Context storage ctx = _newCtx(seed); - (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( - ctx, - _withIndexer(ctx) - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + bytes16 acceptedAgreementId + ) = _withAcceptedIndexingAgreement(ctx, _withIndexer(ctx)); _cancelAgreement( ctx, acceptedAgreementId, - accepted.rca.serviceProvider, - accepted.rca.payer, + acceptedRca.serviceProvider, + acceptedRca.payer, IRecurringCollector.CancelAgreementBy.ServiceProvider ); } diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol index 447f54f3d..5818a1d63 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol @@ -29,10 +29,10 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA ) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( - ctx, - indexerState - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + bytes16 acceptedAgreementId + ) = _withAcceptedIndexingAgreement(ctx, indexerState); assertEq(subgraphService.feesProvisionTracker(indexerState.addr), 0, "Should be 0 before collect"); @@ -56,7 +56,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA abi.encodeWithSelector(IPaymentsCollector.collect.selector, IGraphPayments.PaymentTypes.IndexingFee, data), abi.encode(tokensCollected) ); - _expectCollectCallAndEmit(data, indexerState, accepted, acceptedAgreementId, tokensCollected, entities, poi); + _expectCollectCallAndEmit(data, indexerState, acceptedRca, acceptedAgreementId, tokensCollected, entities, poi); skip(1); // To make agreement collectable @@ -313,7 +313,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA function _expectCollectCallAndEmit( bytes memory _data, IndexerState memory _indexerState, - IRecurringCollector.SignedRCA memory _accepted, + IRecurringCollector.RecurringCollectionAgreement memory _acceptedRca, bytes16 _acceptedAgreementId, uint256 _tokensCollected, uint256 _entities, @@ -326,7 +326,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA vm.expectEmit(address(subgraphService)); emit IndexingAgreement.IndexingFeesCollectedV1( _indexerState.addr, - _accepted.rca.payer, + _acceptedRca.payer, _acceptedAgreementId, _indexerState.allocationId, _indexerState.subgraphDeploymentId, diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol index 2eb409f03..d0ee9ab28 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.27; import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; @@ -172,10 +173,11 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex subgraphService.setPaymentsDestination(_indexerState.addr); // Accept the Indexing Agreement - bytes16 agreementId = subgraphService.acceptIndexingAgreement( - _indexerState.allocationId, - _recurringCollectorHelper.generateSignedRCA(_rca, _ctx.payer.signerPrivateKey) - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory signedRca, + bytes memory signature + ) = _recurringCollectorHelper.generateSignedRCA(_rca, _ctx.payer.signerPrivateKey); + bytes16 agreementId = subgraphService.acceptIndexingAgreement(_indexerState.allocationId, signedRca, signature); // Skip ahead to collection point skip(_expectedTokens.expectedTotalTokensCollected / terms.tokensPerSecond); @@ -265,10 +267,15 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex function _getState(address _payer, address _indexer) private view returns (TestState memory) { CollectPaymentData memory collect = _collectPaymentData(_indexer); + IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + _payer, + address(recurringCollector), + _indexer + ); return TestState({ - escrowBalance: escrow.getBalance(_payer, address(recurringCollector), _indexer), + escrowBalance: account.balance - account.tokensThawing, indexerBalance: collect.indexerBalance, indexerTokensLocked: collect.lockedTokens }); diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol index 08b8d4ac3..ea371e237 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -170,7 +170,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun function _withAcceptedIndexingAgreement( Context storage _ctx, IndexerState memory _indexerState - ) internal returns (IRecurringCollector.SignedRCA memory, bytes16 agreementId) { + ) internal returns (IRecurringCollector.RecurringCollectionAgreement memory, bytes16 agreementId) { IRecurringCollector.RecurringCollectionAgreement memory rca = _ctx.ctxInternal.seed.rca; IndexingAgreement.AcceptIndexingAgreementMetadata memory metadata = _newAcceptIndexingAgreementMetadataV1( @@ -182,11 +182,10 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun rca = _recurringCollectorHelper.sensibleRCA(rca); - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA( - rca, - _ctx.payer.signerPrivateKey - ); + ( + IRecurringCollector.RecurringCollectionAgreement memory signedRca, + bytes memory signature + ) = _recurringCollectorHelper.generateSignedRCA(rca, _ctx.payer.signerPrivateKey); _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, _ctx.payer.signerPrivateKey); // Generate deterministic agreement ID for event expectation @@ -209,11 +208,15 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun metadata.terms ); _subgraphServiceSafePrank(_indexerState.addr); - bytes16 actualAgreementId = subgraphService.acceptIndexingAgreement(_indexerState.allocationId, signedRCA); + bytes16 actualAgreementId = subgraphService.acceptIndexingAgreement( + _indexerState.allocationId, + signedRca, + signature + ); // Verify the agreement ID matches expectation assertEq(actualAgreementId, agreementId); - return (signedRCA, agreementId); + return (signedRca, agreementId); } function _newCtx(Seed memory _seed) internal returns (Context storage) { @@ -238,7 +241,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun function _generateAcceptableSignedRCA( Context storage _ctx, address _indexerAddress - ) internal returns (IRecurringCollector.SignedRCA memory) { + ) internal returns (IRecurringCollector.RecurringCollectionAgreement memory, bytes memory) { IRecurringCollector.RecurringCollectionAgreement memory rca = _generateAcceptableRecurringCollectionAgreement( _ctx, _indexerAddress @@ -267,7 +270,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun function _generateAcceptableSignedRCAU( Context storage _ctx, IRecurringCollector.RecurringCollectionAgreement memory _rca - ) internal view returns (IRecurringCollector.SignedRCAU memory) { + ) internal view returns (IRecurringCollector.RecurringCollectionAgreementUpdate memory, bytes memory) { IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _generateAcceptableRecurringCollectionAgreementUpdate(_ctx, _rca); // Set correct nonce for first update (should be 1) diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol index d968ba178..b77d91644 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol @@ -19,22 +19,22 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA /* solhint-disable graph/func-name-mixedcase */ function test_SubgraphService_UpdateIndexingAgreementIndexingAgreement_Revert_WhenPaused( address operator, - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCAU calldata signedRCAU + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau, + bytes calldata authData ) public withSafeIndexerOrOperator(operator) { resetPrank(users.pauseGuardian); subgraphService.pause(); resetPrank(operator); vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - subgraphService.updateIndexingAgreement(operator, signedRCAU); + subgraphService.updateIndexingAgreement(operator, rcau, authData); } function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenNotAuthorized( address indexer, address notAuthorized, - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCAU calldata signedRCAU + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau, + bytes calldata authData ) public withSafeIndexerOrOperator(notAuthorized) { vm.assume(notAuthorized != indexer); resetPrank(notAuthorized); @@ -44,14 +44,14 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA notAuthorized ); vm.expectRevert(expectedErr); - subgraphService.updateIndexingAgreement(indexer, signedRCAU); + subgraphService.updateIndexingAgreement(indexer, rcau, authData); } function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenInvalidProvision( address indexer, uint256 unboundedTokens, - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCAU memory signedRCAU + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + bytes memory authData ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); mint(indexer, tokens); @@ -66,14 +66,14 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA MAXIMUM_PROVISION_TOKENS ); vm.expectRevert(expectedErr); - subgraphService.updateIndexingAgreement(indexer, signedRCAU); + subgraphService.updateIndexingAgreement(indexer, rcau, authData); } function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenIndexerNotRegistered( address indexer, uint256 unboundedTokens, - // forge-lint: disable-next-line(mixed-case-variable) - IRecurringCollector.SignedRCAU memory signedRCAU + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + bytes memory authData ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); mint(indexer, tokens); @@ -85,24 +85,24 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA indexer ); vm.expectRevert(expectedErr); - subgraphService.updateIndexingAgreement(indexer, signedRCAU); + subgraphService.updateIndexingAgreement(indexer, rcau, authData); } function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenNotAccepted(Seed memory seed) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCAU memory acceptableUpdate = _generateAcceptableSignedRCAU( - ctx, - _generateAcceptableRecurringCollectionAgreement(ctx, indexerState.addr) - ); + ( + IRecurringCollector.RecurringCollectionAgreementUpdate memory acceptableRcau, + bytes memory authData + ) = _generateAcceptableSignedRCAU(ctx, _generateAcceptableRecurringCollectionAgreement(ctx, indexerState.addr)); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreement.IndexingAgreementNotActive.selector, - acceptableUpdate.rcau.agreementId + acceptableRcau.agreementId ); vm.expectRevert(expectedErr); resetPrank(indexerState.addr); - subgraphService.updateIndexingAgreement(indexerState.addr, acceptableUpdate); + subgraphService.updateIndexingAgreement(indexerState.addr, acceptableRcau, authData); } function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenNotAuthorizedForAgreement( @@ -111,66 +111,81 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA Context storage ctx = _newCtx(seed); IndexerState memory indexerStateA = _withIndexer(ctx); IndexerState memory indexerStateB = _withIndexer(ctx); - (IRecurringCollector.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerStateA); - IRecurringCollector.SignedRCAU memory acceptableUpdate = _generateAcceptableSignedRCAU(ctx, accepted.rca); + (IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, ) = _withAcceptedIndexingAgreement( + ctx, + indexerStateA + ); + ( + IRecurringCollector.RecurringCollectionAgreementUpdate memory acceptableRcau, + bytes memory authData + ) = _generateAcceptableSignedRCAU(ctx, acceptedRca); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreement.IndexingAgreementNotAuthorized.selector, - acceptableUpdate.rcau.agreementId, + acceptableRcau.agreementId, indexerStateB.addr ); vm.expectRevert(expectedErr); resetPrank(indexerStateB.addr); - subgraphService.updateIndexingAgreement(indexerStateB.addr, acceptableUpdate); + subgraphService.updateIndexingAgreement(indexerStateB.addr, acceptableRcau, authData); } function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenInvalidMetadata(Seed memory seed) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - (IRecurringCollector.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerState); + (IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, ) = _withAcceptedIndexingAgreement( + ctx, + indexerState + ); IRecurringCollector.RecurringCollectionAgreementUpdate - memory acceptableUpdate = _generateAcceptableRecurringCollectionAgreementUpdate(ctx, accepted.rca); + memory acceptableUpdate = _generateAcceptableRecurringCollectionAgreementUpdate(ctx, acceptedRca); acceptableUpdate.metadata = bytes("invalid"); // Set correct nonce for first update (should be 1) acceptableUpdate.nonce = 1; - IRecurringCollector.SignedRCAU memory unacceptableUpdate = _recurringCollectorHelper.generateSignedRCAU( - acceptableUpdate, - ctx.payer.signerPrivateKey - ); + ( + IRecurringCollector.RecurringCollectionAgreementUpdate memory unacceptableRcau, + bytes memory authData + ) = _recurringCollectorHelper.generateSignedRCAU(acceptableUpdate, ctx.payer.signerPrivateKey); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, "decodeRCAUMetadata", - unacceptableUpdate.rcau.metadata + unacceptableRcau.metadata ); vm.expectRevert(expectedErr); resetPrank(indexerState.addr); - subgraphService.updateIndexingAgreement(indexerState.addr, unacceptableUpdate); + subgraphService.updateIndexingAgreement(indexerState.addr, unacceptableRcau, authData); } function test_SubgraphService_UpdateIndexingAgreement_OK(Seed memory seed) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - (IRecurringCollector.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerState); - IRecurringCollector.SignedRCAU memory acceptableUpdate = _generateAcceptableSignedRCAU(ctx, accepted.rca); + (IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, ) = _withAcceptedIndexingAgreement( + ctx, + indexerState + ); + ( + IRecurringCollector.RecurringCollectionAgreementUpdate memory acceptableRcau, + bytes memory authData + ) = _generateAcceptableSignedRCAU(ctx, acceptedRca); IndexingAgreement.UpdateIndexingAgreementMetadata memory metadata = abi.decode( - acceptableUpdate.rcau.metadata, + acceptableRcau.metadata, (IndexingAgreement.UpdateIndexingAgreementMetadata) ); vm.expectEmit(address(subgraphService)); emit IndexingAgreement.IndexingAgreementUpdated( - accepted.rca.serviceProvider, - accepted.rca.payer, - acceptableUpdate.rcau.agreementId, + acceptedRca.serviceProvider, + acceptedRca.payer, + acceptableRcau.agreementId, indexerState.allocationId, metadata.version, metadata.terms ); resetPrank(indexerState.addr); - subgraphService.updateIndexingAgreement(indexerState.addr, acceptableUpdate); + subgraphService.updateIndexingAgreement(indexerState.addr, acceptableRcau, authData); } /* solhint-enable graph/func-name-mixedcase */ } From 1f9ae152c5187db5ab9f4fc1994e91a8c13bf71a Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:55:07 +0000 Subject: [PATCH 10/11] feat: RecurringAgreementManager with configurable escrow funding modes Add RecurringAgreementManager (RAM) with full agreement lifecycle: register/remove indexers, create/revoke offers, reconcile escrow, and cancel agreements via IDataServiceAgreements. Includes configurable funding modes (JustInTime/Active/Full) with eligibility oracle, per-agreement collector with 2D escrow storage, contract approver collection callbacks, and comprehensive test suite. --- .../contracts/payments/PaymentsEscrow.sol | 20 +- .../collectors/RecurringCollector.sol | 25 +- .../test/unit/escrow/GraphEscrow.t.sol | 26 +- .../horizon/test/unit/escrow/collect.t.sol | 6 +- .../horizon/test/unit/escrow/deposit.t.sol | 4 +- .../horizon/test/unit/escrow/getters.t.sol | 17 +- .../horizon/test/unit/escrow/isolation.t.sol | 30 +- packages/horizon/test/unit/escrow/thaw.t.sol | 79 +- .../horizon/test/unit/escrow/withdraw.t.sol | 6 +- .../BareContractApprover.t.sol | 24 + .../MockContractApprover.t.sol | 70 +- .../PaymentsEscrowMock.t.sol | 6 +- .../recurring-collector/afterCollection.t.sol | 151 ++ .../recurring-collector/eligibility.t.sol | 190 +++ .../recurring-collector/getMaxNextClaim.t.sol | 114 ++ .../recurring-collector/mixedPath.t.sol | 179 +++ .../PaymentsEscrowShared.t.sol | 8 +- .../data-service/IDataServiceAgreements.sol | 2 +- .../contracts/horizon/IContractApprover.sol | 37 +- .../contracts/horizon/IPaymentsEscrow.sol | 62 +- .../contracts/horizon/IRecurringCollector.sol | 7 + .../agreement/IRecurringAgreementHelper.sol | 31 + .../agreement/IRecurringAgreementManager.sol | 403 +++++ packages/issuance/README.md | 1 + .../agreement/RecurringAgreementHelper.sol | 49 + .../agreement/RecurringAgreementManager.md | 177 +++ .../agreement/RecurringAgreementManager.sol | 721 +++++++++ .../contracts/allocate/DirectAllocation.sol | 7 +- packages/issuance/foundry.toml | 1 + .../agreement-manager/afterCollection.t.sol | 166 ++ .../unit/agreement-manager/approver.t.sol | 150 ++ .../agreement-manager/cancelAgreement.t.sol | 195 +++ .../unit/agreement-manager/edgeCases.t.sol | 1074 +++++++++++++ .../unit/agreement-manager/eligibility.t.sol | 95 ++ .../unit/agreement-manager/fundingModes.t.sol | 1375 +++++++++++++++++ .../test/unit/agreement-manager/fuzz.t.sol | 319 ++++ .../test/unit/agreement-manager/helper.t.sol | 365 +++++ .../mocks/MockEligibilityOracle.sol | 23 + .../mocks/MockGraphToken.sol | 15 + .../mocks/MockPaymentsEscrow.sol | 117 ++ .../mocks/MockRecurringCollector.sol | 97 ++ .../mocks/MockSubgraphService.sol | 27 + .../agreement-manager/multiCollector.t.sol | 211 +++ .../unit/agreement-manager/multiIndexer.t.sol | 449 ++++++ .../unit/agreement-manager/offerUpdate.t.sol | 273 ++++ .../unit/agreement-manager/reconcile.t.sol | 306 ++++ .../unit/agreement-manager/register.t.sol | 234 +++ .../test/unit/agreement-manager/remove.t.sol | 271 ++++ .../unit/agreement-manager/revokeOffer.t.sol | 176 +++ .../test/unit/agreement-manager/shared.t.sol | 263 ++++ .../unit/agreement-manager/updateEscrow.t.sol | 467 ++++++ .../test/unit/allocator/construction.t.sol | 2 +- .../test/unit/allocator/defensiveChecks.t.sol | 2 +- .../test/unit/allocator/distribution.t.sol | 2 +- .../allocator/distributionAccounting.t.sol | 2 +- .../unit/allocator/interfaceIdStability.t.sol | 2 +- .../issuance/test/unit/allocator/shared.t.sol | 2 +- .../unit/allocator/targetManagement.t.sol | 2 +- .../direct-allocation/DirectAllocation.t.sol | 2 +- .../test/unit/eligibility/accessControl.t.sol | 2 +- .../test/unit/eligibility/construction.t.sol | 2 +- .../test/unit/eligibility/eligibility.t.sol | 2 +- .../unit/eligibility/indexerManagement.t.sol | 2 +- .../eligibility/interfaceCompliance.t.sol | 2 +- .../unit/eligibility/operatorFunctions.t.sol | 2 +- .../test/unit/eligibility/shared.t.sol | 2 +- .../test/unit/mocks/MockGraphToken.sol | 2 +- .../scripts/ops/protocol-activity.ts | 2 +- .../disputes/indexingFee/create.t.sol | 10 +- .../subgraphService/collect/query/query.t.sol | 6 +- .../indexing-agreement/integration.t.sol | 2 +- 71 files changed, 9016 insertions(+), 157 deletions(-) create mode 100644 packages/horizon/test/unit/payments/recurring-collector/BareContractApprover.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/afterCollection.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/eligibility.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/getMaxNextClaim.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/mixedPath.t.sol create mode 100644 packages/interfaces/contracts/issuance/agreement/IRecurringAgreementHelper.sol create mode 100644 packages/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol create mode 100644 packages/issuance/contracts/agreement/RecurringAgreementHelper.sol create mode 100644 packages/issuance/contracts/agreement/RecurringAgreementManager.md create mode 100644 packages/issuance/contracts/agreement/RecurringAgreementManager.sol create mode 100644 packages/issuance/test/unit/agreement-manager/afterCollection.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/approver.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/cancelAgreement.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/edgeCases.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/eligibility.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/fundingModes.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/fuzz.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/helper.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/mocks/MockEligibilityOracle.sol create mode 100644 packages/issuance/test/unit/agreement-manager/mocks/MockGraphToken.sol create mode 100644 packages/issuance/test/unit/agreement-manager/mocks/MockPaymentsEscrow.sol create mode 100644 packages/issuance/test/unit/agreement-manager/mocks/MockRecurringCollector.sol create mode 100644 packages/issuance/test/unit/agreement-manager/mocks/MockSubgraphService.sol create mode 100644 packages/issuance/test/unit/agreement-manager/multiCollector.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/multiIndexer.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/offerUpdate.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/reconcile.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/register.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/remove.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/revokeOffer.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/shared.t.sol create mode 100644 packages/issuance/test/unit/agreement-manager/updateEscrow.t.sol diff --git a/packages/horizon/contracts/payments/PaymentsEscrow.sol b/packages/horizon/contracts/payments/PaymentsEscrow.sol index 88d21f75b..eaa02f043 100644 --- a/packages/horizon/contracts/payments/PaymentsEscrow.sol +++ b/packages/horizon/contracts/payments/PaymentsEscrow.sol @@ -159,7 +159,7 @@ contract PaymentsEscrow is Initializable, MulticallUpgradeable, GraphDirectory, } /// @inheritdoc IPaymentsEscrow - function getEscrowAccount( + function escrowAccounts( address payer, address collector, address receiver @@ -167,6 +167,12 @@ contract PaymentsEscrow is Initializable, MulticallUpgradeable, GraphDirectory, return _escrowAccounts[payer][collector][receiver]; } + /// @inheritdoc IPaymentsEscrow + function getBalance(address payer, address collector, address receiver) external view override returns (uint256) { + EscrowAccount storage account = _escrowAccounts[payer][collector][receiver]; + return account.balance > account.tokensThawing ? account.balance - account.tokensThawing : 0; + } + /** * @notice Deposits funds into the escrow for a payer-collector-receiver tuple, where * the payer is the transaction caller. @@ -206,20 +212,24 @@ contract PaymentsEscrow is Initializable, MulticallUpgradeable, GraphDirectory, if (tokensThawing == currentThawing) return tokensThawing; uint256 thawEndTimestamp; + uint256 previousThawEnd = account.thawEndTimestamp; if (tokensThawing < currentThawing) { // Decreasing (or canceling): preserve timer, clear if fully canceled account.tokensThawing = tokensThawing; if (tokensThawing == 0) account.thawEndTimestamp = 0; - else thawEndTimestamp = account.thawEndTimestamp; + else thawEndTimestamp = previousThawEnd; } else { thawEndTimestamp = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; - uint256 currentThawEnd = account.thawEndTimestamp; // Increasing: reset timer (skip if evenIfTimerReset=false and timer would change) - if (!evenIfTimerReset && currentThawEnd != 0 && currentThawEnd != thawEndTimestamp) return currentThawing; + if (!evenIfTimerReset && previousThawEnd != 0 && previousThawEnd != thawEndTimestamp) return currentThawing; account.tokensThawing = tokensThawing; account.thawEndTimestamp = thawEndTimestamp; } - emit Thawing(msg.sender, collector, receiver, tokensThawing, thawEndTimestamp); + if (tokensThawing == 0) { + emit CancelThaw(msg.sender, collector, receiver, currentThawing, previousThawEnd); + } else { + emit Thaw(msg.sender, collector, receiver, tokensThawing, thawEndTimestamp); + } } } diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 2c2b96a9a..5588a03e3 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -9,9 +9,11 @@ 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 { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; 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 { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol"; import { PPMMath } from "../../libraries/PPMMath.sol"; /** @@ -352,7 +354,23 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC } agreement.lastCollectionAt = uint64(block.timestamp); - if (tokensToCollect > 0) { + // Hard eligibility gate for contract payers that opt in via ERC165 + if (0 < tokensToCollect && 0 < agreement.payer.code.length) { + try IERC165(agreement.payer).supportsInterface(type(IRewardsEligibility).interfaceId) returns ( + bool supported + ) { + if (supported) { + require( + IRewardsEligibility(agreement.payer).isEligible(agreement.serviceProvider), + RecurringCollectorCollectionNotEligible(_params.agreementId, agreement.serviceProvider) + ); + } + } catch {} + // Let contract payers top up escrow if short + try IContractApprover(agreement.payer).beforeCollection(_params.agreementId, tokensToCollect) {} catch {} + } + + if (0 < tokensToCollect) { _graphPaymentsEscrow().collect( _paymentType, agreement.payer, @@ -383,6 +401,11 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC _params.dataServiceCut ); + // Notify contract payers so they can reconcile escrow in the same transaction + if (0 < agreement.payer.code.length) { + try IContractApprover(agreement.payer).afterCollection(_params.agreementId, tokensToCollect) {} catch {} + } + return tokensToCollect; } /* solhint-enable function-max-lines */ diff --git a/packages/horizon/test/unit/escrow/GraphEscrow.t.sol b/packages/horizon/test/unit/escrow/GraphEscrow.t.sol index 8673ffee4..cb1afd408 100644 --- a/packages/horizon/test/unit/escrow/GraphEscrow.t.sol +++ b/packages/horizon/test/unit/escrow/GraphEscrow.t.sol @@ -48,7 +48,7 @@ contract GraphEscrowTest is HorizonStakingSharedTest, PaymentsEscrowSharedTest { function _thawEscrow(address collector, address receiver, uint256 amount) internal { (, address msgSender, ) = vm.readCallers(); - IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.getEscrowAccount(msgSender, collector, receiver); + IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.escrowAccounts(msgSender, collector, receiver); // Timer resets when increasing, preserves when decreasing, starts when new uint256 expectedThawEndTimestamp = (accountBefore.thawEndTimestamp == 0 || amount > accountBefore.tokensThawing) @@ -56,25 +56,31 @@ contract GraphEscrowTest is HorizonStakingSharedTest, PaymentsEscrowSharedTest { : accountBefore.thawEndTimestamp; vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.Thawing(msgSender, collector, receiver, amount, expectedThawEndTimestamp); + emit IPaymentsEscrow.Thaw(msgSender, collector, receiver, amount, expectedThawEndTimestamp); escrow.thaw(collector, receiver, amount); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount(msgSender, collector, receiver); + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts(msgSender, collector, receiver); assertEq(account.tokensThawing, amount); assertEq(account.thawEndTimestamp, expectedThawEndTimestamp); } function _cancelThawEscrow(address collector, address receiver) internal { (, address msgSender, ) = vm.readCallers(); - IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.getEscrowAccount(msgSender, collector, receiver); + IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.escrowAccounts(msgSender, collector, receiver); if (accountBefore.tokensThawing != 0) { vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.Thawing(msgSender, collector, receiver, 0, 0); + emit IPaymentsEscrow.CancelThaw( + msgSender, + collector, + receiver, + accountBefore.tokensThawing, + accountBefore.thawEndTimestamp + ); } escrow.cancelThaw(collector, receiver); - IPaymentsEscrow.EscrowAccount memory accountAfter = escrow.getEscrowAccount(msgSender, collector, receiver); + IPaymentsEscrow.EscrowAccount memory accountAfter = escrow.escrowAccounts(msgSender, collector, receiver); assertEq(accountAfter.tokensThawing, 0); assertEq(accountAfter.thawEndTimestamp, 0); } @@ -82,7 +88,7 @@ contract GraphEscrowTest is HorizonStakingSharedTest, PaymentsEscrowSharedTest { function _withdrawEscrow(address collector, address receiver) internal { (, address msgSender, ) = vm.readCallers(); - IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.getEscrowAccount(msgSender, collector, receiver); + IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.escrowAccounts(msgSender, collector, receiver); uint256 tokenBalanceBeforeSender = token.balanceOf(msgSender); uint256 tokenBalanceBeforeEscrow = token.balanceOf(address(escrow)); @@ -92,7 +98,7 @@ contract GraphEscrowTest is HorizonStakingSharedTest, PaymentsEscrowSharedTest { uint256 tokens = escrow.withdraw(collector, receiver); assertEq(tokens, expectedWithdraw); - IPaymentsEscrow.EscrowAccount memory accountAfter = escrow.getEscrowAccount(msgSender, collector, receiver); + IPaymentsEscrow.EscrowAccount memory accountAfter = escrow.escrowAccounts(msgSender, collector, receiver); assertEq(accountAfter.balance, accountBefore.balance - expectedWithdraw); assertEq(accountAfter.tokensThawing, 0); @@ -145,7 +151,7 @@ contract GraphEscrowTest is HorizonStakingSharedTest, PaymentsEscrowSharedTest { receiverExpectedPayment: 0 }); - previousBalances.payerEscrowBalance = escrow.getEscrowAccount(_payer, _collector, _receiver).balance; + previousBalances.payerEscrowBalance = escrow.escrowAccounts(_payer, _collector, _receiver).balance; vm.expectEmit(address(escrow)); emit IPaymentsEscrow.EscrowCollected( @@ -188,7 +194,7 @@ contract GraphEscrowTest is HorizonStakingSharedTest, PaymentsEscrowSharedTest { dataServiceBalance: token.balanceOf(_dataService), payerEscrowBalance: 0 }); - afterBalances.payerEscrowBalance = escrow.getEscrowAccount(_payer, _collector, _receiver).balance; + afterBalances.payerEscrowBalance = escrow.escrowAccounts(_payer, _collector, _receiver).balance; // Check receiver balance after payment assertEq( diff --git a/packages/horizon/test/unit/escrow/collect.t.sol b/packages/horizon/test/unit/escrow/collect.t.sol index b5523fe11..c8d34a0fe 100644 --- a/packages/horizon/test/unit/escrow/collect.t.sol +++ b/packages/horizon/test/unit/escrow/collect.t.sol @@ -154,7 +154,7 @@ contract GraphEscrowCollectTest is GraphEscrowTest { ); // Balance should be zero - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( users.gateway, users.verifier, users.indexer @@ -188,7 +188,7 @@ contract GraphEscrowCollectTest is GraphEscrowTest { ); // tokensThawing and thawEndTimestamp should be reset - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( users.gateway, users.verifier, users.indexer @@ -224,7 +224,7 @@ contract GraphEscrowCollectTest is GraphEscrowTest { users.indexer ); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( users.gateway, users.verifier, users.indexer diff --git a/packages/horizon/test/unit/escrow/deposit.t.sol b/packages/horizon/test/unit/escrow/deposit.t.sol index 5552b9bd6..5dd980e2a 100644 --- a/packages/horizon/test/unit/escrow/deposit.t.sol +++ b/packages/horizon/test/unit/escrow/deposit.t.sol @@ -9,7 +9,7 @@ contract GraphEscrowDepositTest is GraphEscrowTest { */ function testDeposit_Tokens(uint256 amount) public useGateway useDeposit(amount) { - uint256 indexerEscrowBalance = escrow.getEscrowAccount(users.gateway, users.verifier, users.indexer).balance; + uint256 indexerEscrowBalance = escrow.escrowAccounts(users.gateway, users.verifier, users.indexer).balance; assertEq(indexerEscrowBalance, amount); } @@ -29,7 +29,7 @@ contract GraphEscrowDepositTest is GraphEscrowTest { _depositTokens(users.verifier, users.indexer, amount1); _depositTokens(users.verifier, users.indexer, amount2); - uint256 balance = escrow.getEscrowAccount(users.gateway, users.verifier, users.indexer).balance; + uint256 balance = escrow.escrowAccounts(users.gateway, users.verifier, users.indexer).balance; assertEq(balance, amount1 + amount2); } } diff --git a/packages/horizon/test/unit/escrow/getters.t.sol b/packages/horizon/test/unit/escrow/getters.t.sol index 8dc85da38..1b796e5b4 100644 --- a/packages/horizon/test/unit/escrow/getters.t.sol +++ b/packages/horizon/test/unit/escrow/getters.t.sol @@ -11,8 +11,13 @@ contract GraphEscrowGettersTest is GraphEscrowTest { * TESTS */ - function testGetEscrowAccount(uint256 amount) public useGateway useDeposit(amount) { - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + function testGetBalance(uint256 amount) public useGateway useDeposit(amount) { + uint256 balance = escrow.getBalance(users.gateway, users.verifier, users.indexer); + assertEq(balance, amount); + } + + function testEscrowAccounts(uint256 amount) public useGateway useDeposit(amount) { + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( users.gateway, users.verifier, users.indexer @@ -21,7 +26,7 @@ contract GraphEscrowGettersTest is GraphEscrowTest { assertEq(account.tokensThawing, 0); } - function testGetEscrowAccount_WhenThawing( + function testGetBalance_WhenThawing( uint256 amountDeposit, uint256 amountThawing ) public useGateway useDeposit(amountDeposit) { @@ -31,7 +36,7 @@ contract GraphEscrowGettersTest is GraphEscrowTest { // thaw some funds _thawEscrow(users.verifier, users.indexer, amountThawing); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( users.gateway, users.verifier, users.indexer @@ -39,7 +44,7 @@ contract GraphEscrowGettersTest is GraphEscrowTest { assertEq(account.balance - account.tokensThawing, amountDeposit - amountThawing); } - function testGetEscrowAccount_WhenCollectedOverThawing( + function testEscrowAccounts_WhenCollectedOverThawing( uint256 amountDeposit, uint256 amountThawing, uint256 amountCollected @@ -80,7 +85,7 @@ contract GraphEscrowGettersTest is GraphEscrowTest { ); // tokensThawing > balance after collection, so effective available is 0 - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( users.gateway, users.verifier, users.indexer diff --git a/packages/horizon/test/unit/escrow/isolation.t.sol b/packages/horizon/test/unit/escrow/isolation.t.sol index aee9add28..1a1646280 100644 --- a/packages/horizon/test/unit/escrow/isolation.t.sol +++ b/packages/horizon/test/unit/escrow/isolation.t.sol @@ -19,16 +19,8 @@ contract GraphEscrowIsolationTest is GraphEscrowTest { _depositTokens(collector1, users.indexer, amount); _depositTokens(collector2, users.indexer, amount * 2); - IPaymentsEscrow.EscrowAccount memory account1 = escrow.getEscrowAccount( - users.gateway, - collector1, - users.indexer - ); - IPaymentsEscrow.EscrowAccount memory account2 = escrow.getEscrowAccount( - users.gateway, - collector2, - users.indexer - ); + IPaymentsEscrow.EscrowAccount memory account1 = escrow.escrowAccounts(users.gateway, collector1, users.indexer); + IPaymentsEscrow.EscrowAccount memory account2 = escrow.escrowAccounts(users.gateway, collector2, users.indexer); assertEq(account1.balance, amount); assertEq(account2.balance, amount * 2); @@ -43,16 +35,8 @@ contract GraphEscrowIsolationTest is GraphEscrowTest { _depositTokens(users.verifier, receiver1, amount); _depositTokens(users.verifier, receiver2, amount * 2); - IPaymentsEscrow.EscrowAccount memory account1 = escrow.getEscrowAccount( - users.gateway, - users.verifier, - receiver1 - ); - IPaymentsEscrow.EscrowAccount memory account2 = escrow.getEscrowAccount( - users.gateway, - users.verifier, - receiver2 - ); + IPaymentsEscrow.EscrowAccount memory account1 = escrow.escrowAccounts(users.gateway, users.verifier, receiver1); + IPaymentsEscrow.EscrowAccount memory account2 = escrow.escrowAccounts(users.gateway, users.verifier, receiver2); assertEq(account1.balance, amount); assertEq(account2.balance, amount * 2); @@ -68,7 +52,7 @@ contract GraphEscrowIsolationTest is GraphEscrowTest { escrow.thaw(users.verifier, users.indexer, amount / 2); // Second tuple should be unaffected - IPaymentsEscrow.EscrowAccount memory account2 = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account2 = escrow.escrowAccounts( users.gateway, users.verifier, users.delegator @@ -77,7 +61,7 @@ contract GraphEscrowIsolationTest is GraphEscrowTest { assertEq(account2.thawEndTimestamp, 0); // First tuple should have thawing - IPaymentsEscrow.EscrowAccount memory account1 = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account1 = escrow.escrowAccounts( users.gateway, users.verifier, users.indexer @@ -86,7 +70,7 @@ contract GraphEscrowIsolationTest is GraphEscrowTest { } function testIsolation_GetEscrowAccount_NeverUsedAccount() public view { - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( address(0xdead), address(0xbeef), address(0xface) diff --git a/packages/horizon/test/unit/escrow/thaw.t.sol b/packages/horizon/test/unit/escrow/thaw.t.sol index 25a2ab81e..9bcb314cf 100644 --- a/packages/horizon/test/unit/escrow/thaw.t.sol +++ b/packages/horizon/test/unit/escrow/thaw.t.sol @@ -22,7 +22,7 @@ contract GraphEscrowThawTest is GraphEscrowTest { vm.assume(amount > 0); _thawEscrow(users.verifier, users.indexer, amount); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( users.gateway, users.verifier, users.indexer @@ -46,14 +46,10 @@ contract GraphEscrowThawTest is GraphEscrowTest { vm.warp(block.timestamp + 1 hours); vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); + emit IPaymentsEscrow.Thaw(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); escrow.thaw(users.verifier, users.indexer, secondAmountToThaw); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( - msgSender, - users.verifier, - users.indexer - ); + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts(msgSender, users.verifier, users.indexer); assertEq(account.tokensThawing, secondAmountToThaw); assertEq(account.thawEndTimestamp, expectedThawEnd, "Timer should be preserved, not reset"); } @@ -74,14 +70,10 @@ contract GraphEscrowThawTest is GraphEscrowTest { uint256 expectedThawEnd = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); + emit IPaymentsEscrow.Thaw(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); escrow.thaw(users.verifier, users.indexer, secondAmountToThaw); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( - msgSender, - users.verifier, - users.indexer - ); + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts(msgSender, users.verifier, users.indexer); assertEq(account.tokensThawing, secondAmountToThaw); assertEq(account.thawEndTimestamp, expectedThawEnd, "Timer should reset on increase"); } @@ -90,19 +82,21 @@ contract GraphEscrowThawTest is GraphEscrowTest { escrow.thaw(users.verifier, users.indexer, amount); (, address msgSender, ) = vm.readCallers(); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( - msgSender, - users.verifier, - users.indexer - ); + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts(msgSender, users.verifier, users.indexer); assertEq(account.tokensThawing, amount); - // thaw(0) cancels all thawing — event should reflect cleared state + // thaw(0) cancels all thawing — event should reflect previous state vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, 0, 0); + emit IPaymentsEscrow.CancelThaw( + msgSender, + users.verifier, + users.indexer, + account.tokensThawing, + account.thawEndTimestamp + ); escrow.thaw(users.verifier, users.indexer, 0); - account = escrow.getEscrowAccount(msgSender, users.verifier, users.indexer); + account = escrow.escrowAccounts(msgSender, users.verifier, users.indexer); assertEq(account.tokensThawing, 0); assertEq(account.thawEndTimestamp, 0); } @@ -114,11 +108,7 @@ contract GraphEscrowThawTest is GraphEscrowTest { assertEq(tokensThawing, amount, "Should cap at balance"); (, address msgSender, ) = vm.readCallers(); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( - msgSender, - users.verifier, - users.indexer - ); + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts(msgSender, users.verifier, users.indexer); assertEq(account.tokensThawing, amount); } @@ -137,7 +127,7 @@ contract GraphEscrowThawTest is GraphEscrowTest { escrow.thaw(users.verifier, users.indexer, amount); (, address msgSender, ) = vm.readCallers(); - IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory accountBefore = escrow.escrowAccounts( msgSender, users.verifier, users.indexer @@ -147,7 +137,7 @@ contract GraphEscrowThawTest is GraphEscrowTest { uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, amount); assertEq(tokensThawing, amount); - IPaymentsEscrow.EscrowAccount memory accountAfter = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory accountAfter = escrow.escrowAccounts( msgSender, users.verifier, users.indexer @@ -166,7 +156,7 @@ contract GraphEscrowThawTest is GraphEscrowTest { uint256 expectedThawEnd = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, amount, expectedThawEnd); + emit IPaymentsEscrow.Thaw(msgSender, users.verifier, users.indexer, amount, expectedThawEnd); uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, amount, false); assertEq(tokensThawing, amount); } @@ -187,15 +177,11 @@ contract GraphEscrowThawTest is GraphEscrowTest { // Decrease with evenIfTimerReset=false should proceed and preserve timer (, address msgSender, ) = vm.readCallers(); vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); + emit IPaymentsEscrow.Thaw(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, secondAmountToThaw, false); assertEq(tokensThawing, secondAmountToThaw); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( - msgSender, - users.verifier, - users.indexer - ); + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts(msgSender, users.verifier, users.indexer); assertEq(account.thawEndTimestamp, expectedThawEnd, "Timer should be preserved on decrease"); } @@ -219,11 +205,7 @@ contract GraphEscrowThawTest is GraphEscrowTest { // State should be unchanged (, address msgSender, ) = vm.readCallers(); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( - msgSender, - users.verifier, - users.indexer - ); + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts(msgSender, users.verifier, users.indexer); assertEq(account.tokensThawing, firstAmountToThaw); assertEq(account.thawEndTimestamp, originalThawEnd, "Timer should remain unchanged"); } @@ -242,7 +224,7 @@ contract GraphEscrowThawTest is GraphEscrowTest { (, address msgSender, ) = vm.readCallers(); uint256 expectedThawEnd = block.timestamp + WITHDRAW_ESCROW_THAWING_PERIOD; vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); + emit IPaymentsEscrow.Thaw(msgSender, users.verifier, users.indexer, secondAmountToThaw, expectedThawEnd); uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, secondAmountToThaw, false); assertEq(tokensThawing, secondAmountToThaw, "Should proceed when timer unchanged"); } @@ -253,16 +235,19 @@ contract GraphEscrowThawTest is GraphEscrowTest { // Cancel (thaw 0) with evenIfTimerReset=false should still work (decrease path) (, address msgSender, ) = vm.readCallers(); + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts(msgSender, users.verifier, users.indexer); vm.expectEmit(address(escrow)); - emit IPaymentsEscrow.Thawing(msgSender, users.verifier, users.indexer, 0, 0); - uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, 0, false); - assertEq(tokensThawing, 0); - - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + emit IPaymentsEscrow.CancelThaw( msgSender, users.verifier, - users.indexer + users.indexer, + account.tokensThawing, + account.thawEndTimestamp ); + uint256 tokensThawing = escrow.thaw(users.verifier, users.indexer, 0, false); + assertEq(tokensThawing, 0); + + account = escrow.escrowAccounts(msgSender, users.verifier, users.indexer); assertEq(account.tokensThawing, 0); assertEq(account.thawEndTimestamp, 0); } diff --git a/packages/horizon/test/unit/escrow/withdraw.t.sol b/packages/horizon/test/unit/escrow/withdraw.t.sol index d212576b7..52993afb7 100644 --- a/packages/horizon/test/unit/escrow/withdraw.t.sol +++ b/packages/horizon/test/unit/escrow/withdraw.t.sol @@ -34,7 +34,7 @@ contract GraphEscrowWithdrawTest is GraphEscrowTest { assertEq(tokens, 0); // Account unchanged - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( users.gateway, users.verifier, users.indexer @@ -53,7 +53,7 @@ contract GraphEscrowWithdrawTest is GraphEscrowTest { assertEq(tokens, 0, "Should not withdraw when timestamp equals thawEnd"); // Account unchanged - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( users.gateway, users.verifier, users.indexer @@ -101,7 +101,7 @@ contract GraphEscrowWithdrawTest is GraphEscrowTest { // After collect, tokensThawing is capped at remaining balance. // Withdraw succeeds if tokens remain, otherwise is a no-op. resetPrank(users.gateway); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( users.gateway, users.verifier, users.indexer diff --git a/packages/horizon/test/unit/payments/recurring-collector/BareContractApprover.t.sol b/packages/horizon/test/unit/payments/recurring-collector/BareContractApprover.t.sol new file mode 100644 index 000000000..cb199e152 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/BareContractApprover.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IContractApprover } from "@graphprotocol/interfaces/contracts/horizon/IContractApprover.sol"; + +/// @notice Minimal contract payer that implements IContractApprover but NOT IERC165. +/// Calling supportsInterface on this contract will revert (no such function), +/// exercising the catch {} fallthrough in RecurringCollector's eligibility gate. +contract BareContractApprover is IContractApprover { + mapping(bytes32 => bool) public authorizedHashes; + + function authorize(bytes32 agreementHash) external { + authorizedHashes[agreementHash] = true; + } + + function approveAgreement(bytes32 agreementHash) external view override returns (bytes4) { + if (!authorizedHashes[agreementHash]) return bytes4(0); + return IContractApprover.approveAgreement.selector; + } + + function beforeCollection(bytes16, uint256) external override {} + + function afterCollection(bytes16, uint256) external override {} +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/MockContractApprover.t.sol b/packages/horizon/test/unit/payments/recurring-collector/MockContractApprover.t.sol index d9aaa5a41..d6e574b36 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/MockContractApprover.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/MockContractApprover.t.sol @@ -1,16 +1,24 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import { IContractApprover } from "@graphprotocol/interfaces/contracts/horizon/IContractApprover.sol"; +import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.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 { +/// Optionally supports IERC165 + IRewardsEligibility for eligibility gate testing. +contract MockContractApprover is IContractApprover, IERC165, IRewardsEligibility { mapping(bytes32 => bool) public authorizedHashes; bool public shouldRevert; bytes4 public overrideReturnValue; bool public useOverride; + // -- Eligibility configuration -- + bool public eligibilityEnabled; + mapping(address => bool) public eligibleProviders; + bool public defaultEligible; + function authorize(bytes32 agreementHash) external { authorizedHashes[agreementHash] = true; } @@ -36,4 +44,64 @@ contract MockContractApprover is IContractApprover { } return IContractApprover.approveAgreement.selector; } + + bytes16 public lastBeforeCollectionAgreementId; + uint256 public lastBeforeCollectionTokens; + bool public shouldRevertOnBeforeCollection; + + function setShouldRevertOnBeforeCollection(bool _shouldRevert) external { + shouldRevertOnBeforeCollection = _shouldRevert; + } + + function beforeCollection(bytes16 agreementId, uint256 tokensToCollect) external override { + if (shouldRevertOnBeforeCollection) { + revert("MockContractApprover: forced revert on beforeCollection"); + } + lastBeforeCollectionAgreementId = agreementId; + lastBeforeCollectionTokens = tokensToCollect; + } + + bytes16 public lastCollectedAgreementId; + uint256 public lastCollectedTokens; + bool public shouldRevertOnCollected; + + function setShouldRevertOnCollected(bool _shouldRevert) external { + shouldRevertOnCollected = _shouldRevert; + } + + function afterCollection(bytes16 agreementId, uint256 tokensCollected) external override { + if (shouldRevertOnCollected) { + revert("MockContractApprover: forced revert on afterCollection"); + } + lastCollectedAgreementId = agreementId; + lastCollectedTokens = tokensCollected; + } + + // -- ERC165 + IRewardsEligibility -- + + /// @notice Enable ERC165 reporting of IRewardsEligibility support + function setEligibilityEnabled(bool _enabled) external { + eligibilityEnabled = _enabled; + } + + /// @notice Set whether a specific provider is eligible + function setProviderEligible(address provider, bool _eligible) external { + eligibleProviders[provider] = _eligible; + } + + /// @notice Set default eligibility for providers not explicitly configured + function setDefaultEligible(bool _eligible) external { + defaultEligible = _eligible; + } + + function supportsInterface(bytes4 interfaceId) external view override returns (bool) { + if (interfaceId == type(IERC165).interfaceId) return true; + if (interfaceId == type(IRewardsEligibility).interfaceId) return eligibilityEnabled; + return false; + } + + function isEligible(address indexer) external view override returns (bool) { + if (eligibleProviders[indexer]) return true; + return defaultEligible; + } } diff --git a/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol index 3ef52caef..d696a6099 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol @@ -29,7 +29,11 @@ contract PaymentsEscrowMock is IPaymentsEscrow { return 0; } - function getEscrowAccount(address, address, address) external pure returns (EscrowAccount memory) { + function getBalance(address, address, address) external pure returns (uint256) { + return 0; + } + + function escrowAccounts(address, address, address) external pure returns (EscrowAccount memory) { return EscrowAccount(0, 0, 0); } diff --git a/packages/horizon/test/unit/payments/recurring-collector/afterCollection.t.sol b/packages/horizon/test/unit/payments/recurring-collector/afterCollection.t.sol new file mode 100644 index 000000000..04243f89d --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/afterCollection.t.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; +import { MockContractApprover } from "./MockContractApprover.t.sol"; + +/// @notice Tests for IContractApprover.beforeCollection and .afterCollection in RecurringCollector._collect() +contract RecurringCollectorAfterCollectionTest is RecurringCollectorSharedTest { + function _newApprover() internal returns (MockContractApprover) { + return new MockContractApprover(); + } + + function _acceptUnsignedAgreement( + MockContractApprover approver + ) internal returns (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) { + rca = _recurringCollectorHelper.sensibleRCA( + IRecurringCollector.RecurringCollectionAgreement({ + deadline: uint64(block.timestamp + 1 hours), + endsAt: uint64(block.timestamp + 365 days), + payer: address(approver), + dataService: makeAddr("ds"), + serviceProvider: makeAddr("sp"), + maxInitialTokens: 100 ether, + maxOngoingTokensPerSecond: 1 ether, + minSecondsPerCollection: 600, + maxSecondsPerCollection: 3600, + nonce: 1, + metadata: "" + }) + ); + + bytes32 agreementHash = _recurringCollector.hashRCA(rca); + approver.authorize(agreementHash); + _setupValidProvision(rca.serviceProvider, rca.dataService); + + vm.prank(rca.dataService); + agreementId = _recurringCollector.accept(rca, ""); + } + + /* solhint-disable graph/func-name-mixedcase */ + + function test_BeforeCollection_CallbackInvoked() public { + MockContractApprover approver = _newApprover(); + (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement( + approver + ); + + skip(rca.minSecondsPerCollection); + uint256 tokens = 1 ether; + bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0)); + + vm.prank(rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + + // beforeCollection should have been called with the tokens about to be collected + assertEq(approver.lastBeforeCollectionAgreementId(), agreementId); + assertEq(approver.lastBeforeCollectionTokens(), tokens); + } + + function test_BeforeCollection_CollectionSucceedsWhenCallbackReverts() public { + MockContractApprover approver = _newApprover(); + (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement( + approver + ); + + approver.setShouldRevertOnBeforeCollection(true); + + skip(rca.minSecondsPerCollection); + uint256 tokens = 1 ether; + bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0)); + + // Collection should still succeed despite beforeCollection reverting + vm.prank(rca.dataService); + uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + assertEq(collected, tokens); + + // beforeCollection state not updated (it reverted), but afterCollection still runs + assertEq(approver.lastBeforeCollectionAgreementId(), bytes16(0)); + assertEq(approver.lastCollectedAgreementId(), agreementId); + } + + function test_AfterCollection_CallbackInvoked() public { + MockContractApprover approver = _newApprover(); + (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement( + approver + ); + + // Skip past minSecondsPerCollection and collect + skip(rca.minSecondsPerCollection); + uint256 tokens = 1 ether; + bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0)); + + vm.prank(rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + + // Verify callback was invoked with correct parameters + assertEq(approver.lastCollectedAgreementId(), agreementId); + assertEq(approver.lastCollectedTokens(), tokens); + } + + function test_AfterCollection_CollectionSucceedsWhenCallbackReverts() public { + MockContractApprover approver = _newApprover(); + (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement( + approver + ); + + // Configure callback to revert + approver.setShouldRevertOnCollected(true); + + skip(rca.minSecondsPerCollection); + uint256 tokens = 1 ether; + bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0)); + + // Collection should still succeed despite callback reverting + vm.prank(rca.dataService); + uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + assertEq(collected, tokens); + + // Callback state should not have been updated (it reverted) + assertEq(approver.lastCollectedAgreementId(), bytes16(0)); + assertEq(approver.lastCollectedTokens(), 0); + } + + function test_AfterCollection_NotCalledForEOAPayer(FuzzyTestCollect calldata fuzzy) public { + // Use standard ECDSA-signed path (EOA payer, no contract) + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + + (bytes memory data, uint256 collectionSeconds, uint256 tokens) = _generateValidCollection( + acceptedRca, + fuzzy.collectParams, + fuzzy.collectParams.tokens, // reuse as skip seed + fuzzy.collectParams.tokens + ); + + skip(collectionSeconds); + // Should succeed without any callback issues (EOA has no code) + vm.prank(acceptedRca.dataService); + uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + assertEq(collected, tokens); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/eligibility.t.sol b/packages/horizon/test/unit/payments/recurring-collector/eligibility.t.sol new file mode 100644 index 000000000..523ad3d64 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/eligibility.t.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; +import { MockContractApprover } from "./MockContractApprover.t.sol"; +import { BareContractApprover } from "./BareContractApprover.t.sol"; + +/// @notice Tests for the IRewardsEligibility gate in RecurringCollector._collect() +contract RecurringCollectorEligibilityTest is RecurringCollectorSharedTest { + function _newApprover() internal returns (MockContractApprover) { + return new MockContractApprover(); + } + + function _acceptUnsignedAgreement( + MockContractApprover approver + ) internal returns (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) { + rca = _recurringCollectorHelper.sensibleRCA( + IRecurringCollector.RecurringCollectionAgreement({ + deadline: uint64(block.timestamp + 1 hours), + endsAt: uint64(block.timestamp + 365 days), + payer: address(approver), + dataService: makeAddr("ds"), + serviceProvider: makeAddr("sp"), + maxInitialTokens: 100 ether, + maxOngoingTokensPerSecond: 1 ether, + minSecondsPerCollection: 600, + maxSecondsPerCollection: 3600, + nonce: 1, + metadata: "" + }) + ); + + bytes32 agreementHash = _recurringCollector.hashRCA(rca); + approver.authorize(agreementHash); + _setupValidProvision(rca.serviceProvider, rca.dataService); + + vm.prank(rca.dataService); + agreementId = _recurringCollector.accept(rca, ""); + } + + /* solhint-disable graph/func-name-mixedcase */ + + function test_Collect_OK_WhenEligible() public { + MockContractApprover approver = _newApprover(); + (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement( + approver + ); + + // Enable eligibility check and mark provider as eligible + approver.setEligibilityEnabled(true); + approver.setProviderEligible(rca.serviceProvider, true); + + skip(rca.minSecondsPerCollection); + uint256 tokens = 1 ether; + bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0)); + + vm.prank(rca.dataService); + uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + assertEq(collected, tokens); + } + + function test_Collect_Revert_WhenNotEligible() public { + MockContractApprover approver = _newApprover(); + (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement( + approver + ); + + // Enable eligibility check but provider is NOT eligible + approver.setEligibilityEnabled(true); + // defaultEligible is false, and provider not explicitly set + + skip(rca.minSecondsPerCollection); + uint256 tokens = 1 ether; + bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0)); + + vm.expectRevert( + abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorCollectionNotEligible.selector, + agreementId, + rca.serviceProvider + ) + ); + vm.prank(rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_OK_WhenPayerDoesNotSupportInterface() public { + MockContractApprover approver = _newApprover(); + (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement( + approver + ); + + // eligibilityEnabled is false by default — supportsInterface returns false for IRewardsEligibility + // Collection should proceed normally (backward compatible) + + skip(rca.minSecondsPerCollection); + uint256 tokens = 1 ether; + bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0)); + + vm.prank(rca.dataService); + uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + assertEq(collected, tokens); + } + + function test_Collect_OK_WhenEOAPayer(FuzzyTestCollect calldata fuzzy) public { + // Use standard ECDSA-signed path (EOA payer) + ( + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + + (bytes memory data, uint256 collectionSeconds, uint256 tokens) = _generateValidCollection( + acceptedRca, + fuzzy.collectParams, + fuzzy.collectParams.tokens, + fuzzy.collectParams.tokens + ); + + skip(collectionSeconds); + // EOA payer has no code — eligibility check is skipped entirely + vm.prank(acceptedRca.dataService); + uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + assertEq(collected, tokens); + } + + function test_Collect_OK_WhenPayerHasNoERC165() public { + // BareContractApprover implements IContractApprover but NOT IERC165. + // The supportsInterface call will revert, hitting the catch {} branch. + BareContractApprover bare = new BareContractApprover(); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + IRecurringCollector.RecurringCollectionAgreement({ + deadline: uint64(block.timestamp + 1 hours), + endsAt: uint64(block.timestamp + 365 days), + payer: address(bare), + dataService: makeAddr("ds"), + serviceProvider: makeAddr("sp"), + maxInitialTokens: 100 ether, + maxOngoingTokensPerSecond: 1 ether, + minSecondsPerCollection: 600, + maxSecondsPerCollection: 3600, + nonce: 1, + metadata: "" + }) + ); + + bytes32 agreementHash = _recurringCollector.hashRCA(rca); + bare.authorize(agreementHash); + _setupValidProvision(rca.serviceProvider, rca.dataService); + + vm.prank(rca.dataService); + bytes16 agreementId = _recurringCollector.accept(rca, ""); + + skip(rca.minSecondsPerCollection); + uint256 tokens = 1 ether; + bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0)); + + // Collection succeeds — the catch {} swallows the revert from supportsInterface + vm.prank(rca.dataService); + uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + assertEq(collected, tokens); + } + + function test_Collect_OK_ZeroTokensSkipsEligibilityCheck() public { + MockContractApprover approver = _newApprover(); + (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement( + approver + ); + + // Enable eligibility check, provider is NOT eligible + approver.setEligibilityEnabled(true); + // defaultEligible = false + + // Zero-token collection should NOT trigger the eligibility gate + // (the guard is inside `if (0 < tokensToCollect && ...)`) + skip(rca.minSecondsPerCollection); + bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), 0, 0)); + + vm.prank(rca.dataService); + uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + assertEq(collected, 0); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/getMaxNextClaim.t.sol b/packages/horizon/test/unit/payments/recurring-collector/getMaxNextClaim.t.sol new file mode 100644 index 000000000..c809f598c --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/getMaxNextClaim.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorGetMaxNextClaimTest is RecurringCollectorSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + function test_GetMaxNextClaim_NotAccepted() public view { + bytes16 fakeId = bytes16(keccak256("nonexistent")); + assertEq(_recurringCollector.getMaxNextClaim(fakeId), 0); + } + + function test_GetMaxNextClaim_Accepted_NeverCollected(FuzzyTestAccept calldata fuzzy) public { + ( + IRecurringCollector.RecurringCollectionAgreement memory rca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy); + + uint256 maxClaim = _recurringCollector.getMaxNextClaim(agreementId); + + // Never collected: includes maxInitialTokens + // Window = endsAt - acceptedAt, capped at maxSecondsPerCollection + uint256 windowSeconds = rca.endsAt - block.timestamp; + uint256 maxSeconds = windowSeconds < rca.maxSecondsPerCollection ? windowSeconds : rca.maxSecondsPerCollection; + uint256 expected = rca.maxOngoingTokensPerSecond * maxSeconds + rca.maxInitialTokens; + assertEq(maxClaim, expected); + } + + function test_GetMaxNextClaim_Accepted_AfterCollection(FuzzyTestAccept calldata fuzzy) public { + ( + IRecurringCollector.RecurringCollectionAgreement memory rca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy); + + // Perform a first collection + skip(rca.minSecondsPerCollection); + bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, keccak256("col"), 1, 0)); + vm.prank(rca.dataService); + _recurringCollector.collect(_paymentType(0), data); + + uint256 maxClaim = _recurringCollector.getMaxNextClaim(agreementId); + + // After collection: no initial tokens, window from lastCollectionAt + uint256 windowSeconds = rca.endsAt - block.timestamp; + uint256 maxSeconds = windowSeconds < rca.maxSecondsPerCollection ? windowSeconds : rca.maxSecondsPerCollection; + uint256 expected = rca.maxOngoingTokensPerSecond * maxSeconds; + assertEq(maxClaim, expected); + } + + function test_GetMaxNextClaim_CanceledByServiceProvider(FuzzyTestAccept calldata fuzzy) public { + ( + IRecurringCollector.RecurringCollectionAgreement memory rca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy); + + _cancel(rca, agreementId, IRecurringCollector.CancelAgreementBy.ServiceProvider); + + assertEq(_recurringCollector.getMaxNextClaim(agreementId), 0); + } + + function test_GetMaxNextClaim_CanceledByPayer(FuzzyTestAccept calldata fuzzy) public { + ( + IRecurringCollector.RecurringCollectionAgreement memory rca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy); + + _cancel(rca, agreementId, IRecurringCollector.CancelAgreementBy.Payer); + + uint256 maxClaim = _recurringCollector.getMaxNextClaim(agreementId); + + // CanceledByPayer: window frozen at canceledAt + // canceledAt == block.timestamp, acceptedAt == (block.timestamp - 0) + // So window = canceledAt - acceptedAt = 0 (canceled in same block as accepted) + // Since window is 0, maxClaim should be 0 + assertEq(maxClaim, 0); + } + + function test_GetMaxNextClaim_CanceledByPayer_WithWindow(FuzzyTestAccept calldata fuzzy) public { + ( + IRecurringCollector.RecurringCollectionAgreement memory rca, + , + , + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzy); + + // Advance time, then cancel + skip(rca.minSecondsPerCollection + 100); + + _cancel(rca, agreementId, IRecurringCollector.CancelAgreementBy.Payer); + + uint256 maxClaim = _recurringCollector.getMaxNextClaim(agreementId); + + // canceledAt = now, acceptedAt = now - (minSeconds + 100) + // window = canceledAt - acceptedAt = minSeconds + 100, capped at maxSecondsPerCollection + IRecurringCollector.AgreementData memory agreement = _recurringCollector.getAgreement(agreementId); + uint256 windowSeconds = agreement.canceledAt - agreement.acceptedAt; + uint256 maxSeconds = windowSeconds < rca.maxSecondsPerCollection ? windowSeconds : rca.maxSecondsPerCollection; + uint256 expected = rca.maxOngoingTokensPerSecond * maxSeconds + rca.maxInitialTokens; + assertEq(maxClaim, expected); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/mixedPath.t.sol b/packages/horizon/test/unit/payments/recurring-collector/mixedPath.t.sol new file mode 100644 index 000000000..3aac0aa97 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/mixedPath.t.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; +import { MockContractApprover } from "./MockContractApprover.t.sol"; + +/// @notice Tests that ECDSA and contract-approved paths can be mixed for accept and update. +contract RecurringCollectorMixedPathTest is RecurringCollectorSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + /// @notice ECDSA accept, then contract-approved update should fail (payer is EOA) + function test_MixedPath_ECDSAAccept_UnsignedUpdate_RevertsForEOA() public { + 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 ECDSA + (, , bytes16 agreementId) = _authorizeAndAccept(rca, signerKey); + + // Try unsigned update — should revert because payer is an EOA + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _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: 1, + metadata: "" + }) + ); + + vm.expectRevert( + abi.encodeWithSelector(IRecurringCollector.RecurringCollectorApproverNotContract.selector, payer) + ); + vm.prank(rca.dataService); + _recurringCollector.update(rcau, ""); + } + + /// @notice Contract-approved accept, then ECDSA update should fail (no authorized signer) + function test_MixedPath_UnsignedAccept_ECDSAUpdate_RevertsForUnauthorizedSigner() public { + MockContractApprover approver = new MockContractApprover(); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + IRecurringCollector.RecurringCollectionAgreement({ + deadline: uint64(block.timestamp + 1 hours), + endsAt: uint64(block.timestamp + 365 days), + payer: address(approver), + dataService: makeAddr("ds"), + serviceProvider: makeAddr("sp"), + maxInitialTokens: 100 ether, + maxOngoingTokensPerSecond: 1 ether, + minSecondsPerCollection: 600, + maxSecondsPerCollection: 3600, + nonce: 1, + metadata: "" + }) + ); + + // Accept via contract-approved path + bytes32 agreementHash = _recurringCollector.hashRCA(rca); + approver.authorize(agreementHash); + _setupValidProvision(rca.serviceProvider, rca.dataService); + vm.prank(rca.dataService); + bytes16 agreementId = _recurringCollector.accept(rca, ""); + + // Try ECDSA update with an unauthorized signer + uint256 wrongKey = 0xDEAD; + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( + IRecurringCollector.RecurringCollectionAgreementUpdate({ + agreementId: agreementId, + deadline: uint64(block.timestamp + 1 hours), + endsAt: uint64(block.timestamp + 730 days), + maxInitialTokens: 200 ether, + maxOngoingTokensPerSecond: 2 ether, + minSecondsPerCollection: 600, + maxSecondsPerCollection: 7200, + nonce: 1, + metadata: "" + }) + ); + + (, bytes memory sig) = _recurringCollectorHelper.generateSignedRCAU(rcau, wrongKey); + + vm.expectRevert(IRecurringCollector.RecurringCollectorInvalidSigner.selector); + vm.prank(rca.dataService); + _recurringCollector.update(rcau, sig); + } + + /// @notice Contract-approved accept, then contract-approved update works + function test_MixedPath_UnsignedAccept_UnsignedUpdate_OK() public { + MockContractApprover approver = new MockContractApprover(); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + IRecurringCollector.RecurringCollectionAgreement({ + deadline: uint64(block.timestamp + 1 hours), + endsAt: uint64(block.timestamp + 365 days), + payer: address(approver), + dataService: makeAddr("ds"), + serviceProvider: makeAddr("sp"), + maxInitialTokens: 100 ether, + maxOngoingTokensPerSecond: 1 ether, + minSecondsPerCollection: 600, + maxSecondsPerCollection: 3600, + nonce: 1, + metadata: "" + }) + ); + + // Accept via contract-approved path + bytes32 agreementHash = _recurringCollector.hashRCA(rca); + approver.authorize(agreementHash); + _setupValidProvision(rca.serviceProvider, rca.dataService); + vm.prank(rca.dataService); + bytes16 agreementId = _recurringCollector.accept(rca, ""); + + // Update via contract-approved path (use sensibleRCAU to stay in valid ranges) + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( + IRecurringCollector.RecurringCollectionAgreementUpdate({ + agreementId: agreementId, + deadline: 0, + endsAt: uint64(block.timestamp + 730 days), + maxInitialTokens: 50 ether, + maxOngoingTokensPerSecond: 0.5 ether, + minSecondsPerCollection: 600, + maxSecondsPerCollection: 7200, + nonce: 1, + metadata: "" + }) + ); + + bytes32 updateHash = _recurringCollector.hashRCAU(rcau); + approver.authorize(updateHash); + + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.AgreementUpdated( + rca.dataService, + address(approver), + rca.serviceProvider, + agreementId, + uint64(block.timestamp), + rcau.endsAt, + rcau.maxInitialTokens, + rcau.maxOngoingTokensPerSecond, + rcau.minSecondsPerCollection, + rcau.maxSecondsPerCollection + ); + + vm.prank(rca.dataService); + _recurringCollector.update(rcau, ""); + + // Verify updated terms + IRecurringCollector.AgreementData memory agreement = _recurringCollector.getAgreement(agreementId); + assertEq(agreement.maxOngoingTokensPerSecond, rcau.maxOngoingTokensPerSecond); + assertEq(agreement.maxSecondsPerCollection, rcau.maxSecondsPerCollection); + assertEq(agreement.updateNonce, 1); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol b/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol index bdf7e2f3b..48fe1656f 100644 --- a/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol +++ b/packages/horizon/test/unit/shared/payments-escrow/PaymentsEscrowShared.t.sol @@ -21,26 +21,26 @@ abstract contract PaymentsEscrowSharedTest is GraphBaseTest { function _depositTokens(address _collector, address _receiver, uint256 _tokens) internal { (, address msgSender, ) = vm.readCallers(); - uint256 escrowBalanceBefore = escrow.getEscrowAccount(msgSender, _collector, _receiver).balance; + uint256 escrowBalanceBefore = escrow.escrowAccounts(msgSender, _collector, _receiver).balance; token.approve(address(escrow), _tokens); vm.expectEmit(address(escrow)); emit IPaymentsEscrow.Deposit(msgSender, _collector, _receiver, _tokens); escrow.deposit(_collector, _receiver, _tokens); - uint256 escrowBalanceAfter = escrow.getEscrowAccount(msgSender, _collector, _receiver).balance; + uint256 escrowBalanceAfter = escrow.escrowAccounts(msgSender, _collector, _receiver).balance; assertEq(escrowBalanceAfter - _tokens, escrowBalanceBefore); } function _depositToTokens(address _payer, address _collector, address _receiver, uint256 _tokens) internal { - uint256 escrowBalanceBefore = escrow.getEscrowAccount(_payer, _collector, _receiver).balance; + uint256 escrowBalanceBefore = escrow.escrowAccounts(_payer, _collector, _receiver).balance; token.approve(address(escrow), _tokens); vm.expectEmit(address(escrow)); emit IPaymentsEscrow.Deposit(_payer, _collector, _receiver, _tokens); escrow.depositTo(_payer, _collector, _receiver, _tokens); - uint256 escrowBalanceAfter = escrow.getEscrowAccount(_payer, _collector, _receiver).balance; + uint256 escrowBalanceAfter = escrow.escrowAccounts(_payer, _collector, _receiver).balance; assertEq(escrowBalanceAfter - _tokens, escrowBalanceBefore); } } diff --git a/packages/interfaces/contracts/data-service/IDataServiceAgreements.sol b/packages/interfaces/contracts/data-service/IDataServiceAgreements.sol index 604846a09..92406d3b5 100644 --- a/packages/interfaces/contracts/data-service/IDataServiceAgreements.sol +++ b/packages/interfaces/contracts/data-service/IDataServiceAgreements.sol @@ -8,7 +8,7 @@ import { IDataService } from "./IDataService.sol"; * @author Edge & Node * @notice Extension for the {IDataService} contract to support payer-initiated * cancellation of indexing agreements. Any data service that participates in - * agreement lifecycle management via {ServiceAgreementManager} should implement + * agreement lifecycle management via {RecurringAgreementManager} should implement * this interface. * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. diff --git a/packages/interfaces/contracts/horizon/IContractApprover.sol b/packages/interfaces/contracts/horizon/IContractApprover.sol index 9decb5bcc..73144ca5b 100644 --- a/packages/interfaces/contracts/horizon/IContractApprover.sol +++ b/packages/interfaces/contracts/horizon/IContractApprover.sol @@ -2,16 +2,19 @@ pragma solidity ^0.8.22; /** - * @title Interface for contracts that can act as authorized agreement approvers + * @title Interface for contract payer callbacks from RecurringCollector * @author Edge & Node - * @notice Enables contracts to authorize RCA agreements and updates on-chain via - * {RecurringCollector.accept} and {RecurringCollector.update} (with empty authData), - * replacing ECDSA signatures with a callback. + * @notice Callbacks that RecurringCollector invokes on contract payers (payers with + * deployed code, as opposed to EOA payers that use ECDSA signatures). * - * 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. + * Three callbacks: + * - {approveAgreement}: gate — called during accept/update to verify authorization. + * Uses the magic-value pattern (return selector on success). Called with RCA hash + * on accept, RCAU hash on update; namespaces don't collide (different EIP712 type hashes). + * - {beforeCollection}: called before PaymentsEscrow.collect() so the payer can top up + * escrow if needed. Only acts when the escrow balance is short for the collection. + * - {afterCollection}: called after collection so the payer can reconcile escrow state. + * Both collection callbacks are wrapped in try/catch — reverts do not block collection. * * No per-payer authorization step is needed — the contract's code is the authorization. * The trust chain is: governance grants operator role → operator registers @@ -29,4 +32,22 @@ interface IContractApprover { * @return magic `IContractApprover.approveAgreement.selector` if authorized */ function approveAgreement(bytes32 agreementHash) external view returns (bytes4); + + /** + * @notice Called by RecurringCollector before PaymentsEscrow.collect() + * @dev Allows contract payers to top up escrow if the balance is insufficient + * for the upcoming collection. Wrapped in try/catch — reverts do not block collection. + * @param agreementId The agreement being collected + * @param tokensToCollect Amount of tokens about to be collected + */ + function beforeCollection(bytes16 agreementId, uint256 tokensToCollect) external; + + /** + * @notice Called by RecurringCollector after a successful collection + * @dev Allows contract payers to reconcile escrow state in the same transaction + * as the collection. Wrapped in try/catch — reverts do not block collection. + * @param agreementId The collected agreement + * @param tokensCollected Amount of tokens collected + */ + function afterCollection(bytes16 agreementId, uint256 tokensCollected) external; } diff --git a/packages/interfaces/contracts/horizon/IPaymentsEscrow.sol b/packages/interfaces/contracts/horizon/IPaymentsEscrow.sol index aa904984d..89698fcd8 100644 --- a/packages/interfaces/contracts/horizon/IPaymentsEscrow.sol +++ b/packages/interfaces/contracts/horizon/IPaymentsEscrow.sol @@ -40,15 +40,14 @@ interface IPaymentsEscrow { event Deposit(address indexed payer, address indexed collector, address indexed receiver, uint256 tokens); /** - * @notice Emitted when the thawing state changes for a payer-collector-receiver tuple. - * Covers starting, increasing, reducing, and canceling a thaw. + * @notice Emitted when a payer cancels an escrow thawing * @param payer The address of the payer * @param collector The address of the collector * @param receiver The address of the receiver - * @param tokensThawing The amount of tokens thawing after the change - * @param thawEndTimestamp The thaw end timestamp after the change (zero if no longer thawing) + * @param tokensThawing The amount of tokens that were being thawed + * @param thawEndTimestamp The timestamp at which the thawing period was ending */ - event Thawing( + event CancelThaw( address indexed payer, address indexed collector, address indexed receiver, @@ -56,6 +55,22 @@ interface IPaymentsEscrow { uint256 thawEndTimestamp ); + /** + * @notice Emitted when a payer thaws funds from the escrow for a payer-collector-receiver tuple + * @param payer The address of the payer + * @param collector The address of the collector + * @param receiver The address of the receiver + * @param tokens The amount of tokens being thawed + * @param thawEndTimestamp The timestamp at which the thawing period ends + */ + event Thaw( + address indexed payer, + address indexed collector, + address indexed receiver, + uint256 tokens, + uint256 thawEndTimestamp + ); + /** * @notice Emitted when a payer withdraws funds from the escrow for a payer-collector-receiver tuple * @param payer The address of the payer @@ -97,6 +112,23 @@ interface IPaymentsEscrow { */ error PaymentsEscrowInsufficientBalance(uint256 balance, uint256 minBalance); + /** + * @notice Thrown when a thawing is expected to be in progress but it is not + */ + error PaymentsEscrowNotThawing(); + + /** + * @notice Thrown when a thawing is still in progress + * @param currentTimestamp The current timestamp + * @param thawEndTimestamp The timestamp at which the thawing period ends + */ + error PaymentsEscrowStillThawing(uint256 currentTimestamp, uint256 thawEndTimestamp); + + /** + * @notice Thrown when operating a zero token amount is not allowed. + */ + error PaymentsEscrowInvalidZeroTokens(); + /** * @notice Thrown when setting the thawing period to a value greater than the maximum * @param thawingPeriod The thawing period @@ -161,7 +193,8 @@ interface IPaymentsEscrow { * @param receiver The address of the receiver * @param tokens The desired amount of tokens to thaw * @return tokensThawing The resulting amount of tokens thawing after the operation - * @dev Emits a {Thawing} event if the thawing state changes. + * @dev Emits a {Thaw} event when the thaw amount increases. + * Emits a {CancelThaw} event when thawing is fully canceled. */ function thaw(address collector, address receiver, uint256 tokens) external returns (uint256 tokensThawing); @@ -175,7 +208,6 @@ interface IPaymentsEscrow { * @param tokens The desired amount of tokens to thaw * @param evenIfTimerReset If true, always proceed. If false, skip increases that would reset the timer. * @return tokensThawing The resulting amount of tokens thawing after the operation - * @dev Emits a {Thawing} event if the thawing state changes. */ function thaw( address collector, @@ -190,7 +222,7 @@ interface IPaymentsEscrow { * @param collector The address of the collector * @param receiver The address of the receiver * @return tokensThawing The resulting amount of tokens thawing (always 0) - * @dev Emits a {Thawing} event if any tokens were thawing. + * @dev Emits a {CancelThaw} event if any tokens were thawing. */ function cancelThaw(address collector, address receiver) external returns (uint256 tokensThawing); @@ -231,13 +263,23 @@ interface IPaymentsEscrow { ) external; /** - * @notice Get the full escrow account for a payer-collector-receiver tuple + * @notice Get the balance of a payer-collector-receiver tuple + * This function will return 0 if the current balance is less than the amount of funds being thawed. + * @param payer The address of the payer + * @param collector The address of the collector + * @param receiver The address of the receiver + * @return The balance of the payer-collector-receiver tuple + */ + function getBalance(address payer, address collector, address receiver) external view returns (uint256); + + /** + * @notice Escrow account details for a payer-collector-receiver tuple * @param payer The address of the payer * @param collector The address of the collector * @param receiver The address of the receiver * @return The escrow account details */ - function getEscrowAccount( + function escrowAccounts( address payer, address collector, address receiver diff --git a/packages/interfaces/contracts/horizon/IRecurringCollector.sol b/packages/interfaces/contracts/horizon/IRecurringCollector.sol index 797694459..aafe7a9d1 100644 --- a/packages/interfaces/contracts/horizon/IRecurringCollector.sol +++ b/packages/interfaces/contracts/horizon/IRecurringCollector.sol @@ -369,6 +369,13 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { */ error RecurringCollectorExcessiveSlippage(uint256 requested, uint256 actual, uint256 maxSlippage); + /** + * @notice Thrown when a contract payer's eligibility oracle denies the service provider + * @param agreementId The agreement ID + * @param serviceProvider The service provider that is not eligible + */ + error RecurringCollectorCollectionNotEligible(bytes16 agreementId, address serviceProvider); + /** * @notice Thrown when the contract approver is not a contract * @param approver The address that is not a contract diff --git a/packages/interfaces/contracts/issuance/agreement/IRecurringAgreementHelper.sol b/packages/interfaces/contracts/issuance/agreement/IRecurringAgreementHelper.sol new file mode 100644 index 000000000..5506e5e87 --- /dev/null +++ b/packages/interfaces/contracts/issuance/agreement/IRecurringAgreementHelper.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.22; + +/** + * @title Interface for the {RecurringAgreementHelper} contract + * @author Edge & Node + * @notice Stateless convenience contract that provides batch reconciliation + * functions for {RecurringAgreementManager}. Loops over agreements and delegates + * each reconciliation to the manager's single-agreement `reconcileAgreement`. + * + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +interface IRecurringAgreementHelper { + /** + * @notice Reconcile all agreements for a provider (convenience function). + * @dev Permissionless. Iterates all tracked agreements — O(n) gas, + * may hit gas limits with many agreements. Prefer reconcileAgreement on the + * manager for individual updates, or reconcileBatch for controlled batching. + * @param provider The provider to reconcile + */ + function reconcile(address provider) external; + + /** + * @notice Reconcile a batch of agreements (caller-controlled batching). + * @dev Permissionless. Allows callers to control gas usage by choosing which + * agreements to reconcile in a single transaction. Skips non-existent agreements. + * @param agreementIds The agreement IDs to reconcile + */ + function reconcileBatch(bytes16[] calldata agreementIds) external; +} diff --git a/packages/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol b/packages/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol new file mode 100644 index 000000000..ea68d108b --- /dev/null +++ b/packages/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.22; + +import { IRecurringCollector } from "../../horizon/IRecurringCollector.sol"; + +/** + * @title Interface for the {RecurringAgreementManager} 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 provider and ensures PaymentsEscrow is always funded to cover those maximums. + * + * One escrow per (RecurringAgreementManager, collector, provider) covering all RCAs for + * that (collector, provider) pair 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 IRecurringAgreementManager { + // -- Enums -- + + /** + * @notice Escrow funding level — controls how aggressively escrow is pre-funded. + * Ordered low-to-high. The configured level is the maximum aspiration; the system + * automatically degrades when funds are insufficient. `beforeCollection` (JIT top-up) + * is always active regardless of setting. + * + * @dev JustInTime=0 (thaw everything, pure JIT), OnDemand=1 (no deposits, hold at + * required level), Full=2 (fund sum of all maxNextClaim — current default). + */ + enum FundingBasis { + JustInTime, + OnDemand, + Full + } + + // -- Structs -- + + /** + * @notice Tracked state for a managed agreement + * @dev An agreement is considered tracked when `provider != address(0)`. + * @param provider The service provider for this agreement + * @param deadline The RCA deadline for acceptance (used to detect expired offers) + * @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 + * @param collector The RecurringCollector contract for this agreement + */ + struct AgreementInfo { + address provider; + uint64 deadline; + address dataService; + uint32 pendingUpdateNonce; + uint256 maxNextClaim; + uint256 pendingUpdateMaxNextClaim; + bytes32 agreementHash; + bytes32 pendingUpdateHash; + address collector; + } + + // -- Events -- + // solhint-disable gas-indexed-events + + /** + * @notice Emitted when an agreement is offered for escrow management + * @param agreementId The deterministic agreement ID + * @param provider The service provider for this agreement + * @param maxNextClaim The calculated maximum next claim amount + */ + event AgreementOffered(bytes16 indexed agreementId, address indexed provider, uint256 maxNextClaim); + + /** + * @notice Emitted when an agreement offer is revoked before acceptance + * @param agreementId The agreement ID + * @param provider The provider whose required escrow was reduced + */ + event OfferRevoked(bytes16 indexed agreementId, address indexed provider); + + /** + * @notice Emitted when an agreement is canceled via the data service + * @param agreementId The agreement ID + * @param provider The provider for this agreement + */ + event AgreementCanceled(bytes16 indexed agreementId, address indexed provider); + + /** + * @notice Emitted when an agreement is removed from escrow management + * @param agreementId The agreement ID being removed + * @param provider The provider whose required escrow was reduced + */ + event AgreementRemoved(bytes16 indexed agreementId, address indexed provider); + + /** + * @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 a provider + * @param provider The provider whose escrow was funded + * @param collector The collector address for the escrow account + * @param deposited The amount deposited + */ + event EscrowFunded(address indexed provider, address indexed collector, uint256 deposited); + + /** + * @notice Emitted when thawed escrow tokens are withdrawn + * @param provider The provider whose escrow was withdrawn + * @param collector The collector address for the escrow account + * @param tokens The amount of tokens withdrawn + */ + event EscrowWithdrawn(address indexed provider, address indexed collector, uint256 tokens); + + /** + * @notice Emitted when the funding basis is changed + * @param oldBasis The previous funding basis + * @param newBasis The new funding basis + */ + event FundingBasisChanged(FundingBasis oldBasis, FundingBasis newBasis); + + /** + * @notice Emitted when JIT mode is enforced due to insufficient funds during collection + * @param configuredBasis The governance-configured funding basis (not modified) + */ + event EnforcedJit(FundingBasis configuredBasis); + + /** + * @notice Emitted when enforced JIT recovers after RAM accumulates sufficient funds + * @param configuredBasis The governance-configured funding basis now effective again + */ + event EnforcedJitRecovered(FundingBasis configuredBasis); + + /** + * @notice Emitted when the payment eligibility oracle is changed + * @param oldOracle The previous oracle address (address(0) means none) + * @param newOracle The new oracle address (address(0) means disabled) + */ + event PaymentEligibilityOracleSet(address indexed oldOracle, address indexed newOracle); + + // solhint-enable gas-indexed-events + + // -- Errors -- + + /** + * @notice Thrown when trying to offer an agreement that is already offered + * @param agreementId The agreement ID + */ + error AgreementAlreadyOffered(bytes16 agreementId); + + /** + * @notice Thrown when trying to operate on an agreement that is not offered + * @param agreementId The agreement ID + */ + error AgreementNotOffered(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 PayerMustBeManager(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 AgreementStillClaimable(bytes16 agreementId, uint256 maxNextClaim); + + /** + * @notice Thrown when trying to revoke an agreement that is already accepted + * @param agreementId The agreement ID + */ + error AgreementAlreadyAccepted(bytes16 agreementId); + + /** + * @notice Thrown when trying to cancel an agreement that has not been accepted yet + * @param agreementId The agreement ID + */ + error AgreementNotAccepted(bytes16 agreementId); + + /** + * @notice Thrown when the data service address has no deployed code + * @param dataService The address that was expected to be a contract + */ + error InvalidDataService(address dataService); + + /// @notice Thrown when the RCA service provider is the zero address + error ServiceProviderZeroAddress(); + + /// @notice Thrown when the RCA data service is the zero address + error DataServiceZeroAddress(); + + /// @notice Thrown when a collection callback is called by an address other than the agreement's collector + error OnlyAgreementCollector(); + + /// @notice Thrown when the collector address is the zero address + error CollectorZeroAddress(); + + // -- Core Functions -- + + /** + * @notice Offer an RCA for escrow management. Must be called before + * the data service accepts the agreement (with empty authData). + * @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 + * @param collector The RecurringCollector contract to use for this agreement + * @return agreementId The deterministic agreement ID + */ + function offerAgreement( + IRecurringCollector.RecurringCollectionAgreement calldata rca, + address collector + ) external returns (bytes16 agreementId); + + /** + * @notice Offer a pending agreement update for escrow management. Must be called + * before the data service applies the update (with empty authData). + * @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 Update escrow state for a provider: withdraw completed thaws, fund any deficit, + * and thaw excess balance. + * @dev Permissionless. Three-phase operation: + * - Phase 1: If a previous thaw has completed, withdraws tokens back to this contract + * - Phase 2a (deficit): If balance < required, cancels any thaw and deposits to cover + * - Phase 2b (excess): If balance > required, starts a thaw for the excess (only when + * no thaw is already in progress) or partially cancels an existing thaw if too much + * is being thawed + * Works regardless of whether the provider has active agreements. + * @param collector The collector contract address + * @param provider The provider to update escrow for + */ + function updateEscrow(address collector, address provider) external; + + /** + * @notice Set the escrow funding basis (maximum aspiration level). + * @dev Requires GOVERNOR_ROLE. The system automatically degrades below the configured + * level when funds are insufficient. Changing the basis does not immediately rebalance + * escrow — call {updateEscrow} or {reconcile} per provider to apply. + * @param basis The new funding basis + */ + function setFundingBasis(FundingBasis basis) external; + + /** + * @notice Set the payment eligibility oracle. + * @dev Requires GOVERNOR_ROLE. When set, {isEligible} delegates to this oracle. + * When set to address(0), all providers are considered eligible (passthrough). + * @param oracle The address of the eligibility oracle (or address(0) to disable) + */ + function setPaymentEligibilityOracle(address oracle) external; + + // -- View Functions -- + + /** + * @notice Get the total required escrow for a (collector, provider) pair + * @param collector The collector contract address + * @param provider The provider address + * @return The sum of max next claims for all managed agreements for this (collector, provider) + */ + function getRequiredEscrow(address collector, address provider) external view returns (uint256); + + /** + * @notice Get the current escrow deficit for a (collector, provider) pair + * @dev Returns 0 if escrow is fully funded or over-funded. + * @param collector The collector contract address + * @param provider The provider address + * @return The deficit amount (required - current balance), or 0 if no deficit + */ + function getDeficit(address collector, address provider) 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 a provider + * @param provider The provider address + * @return The count of tracked agreements + */ + function getProviderAgreementCount(address provider) external view returns (uint256); + + /** + * @notice Get all managed agreement IDs for a provider + * @dev Returns the full set of tracked agreement IDs. May be expensive for providers + * with many agreements — prefer {getProviderAgreementCount} for on-chain use. + * @param provider The provider address + * @return The array of agreement IDs + */ + function getProviderAgreements(address provider) external view returns (bytes16[] memory); + + /** + * @notice Get the current funding basis setting + * @return The configured funding basis + */ + function getFundingBasis() external view returns (FundingBasis); + + /** + * @notice Get the total required escrow across all providers + * @dev Populated lazily through normal operations. May be stale if agreements were + * offered before this feature was deployed — run reconciliation to populate. + * @return The sum of requiredEscrow across all providers + */ + function getTotalRequired() external view returns (uint256); + + /** + * @notice Get the total unfunded escrow across all providers + * @dev Maintained incrementally: sum of max(0, requiredEscrow[p] - funded[p]) + * for each provider p. Correctly accounts for per-provider deficits without + * allowing over-funded providers to mask under-funded ones. + * @return The total unfunded amount + */ + function getTotalUnfunded() external view returns (uint256); + + /** + * @notice Get the total number of tracked agreements across all providers + * @dev Populated lazily through normal operations. + * @return The total agreement count + */ + function getTotalAgreementCount() external view returns (uint256); + + /** + * @notice Check whether JIT mode is currently enforced + * @dev When enforced, the system operates in JIT-only mode regardless of the configured + * funding basis. The configured basis is preserved and takes effect again when + * enforced JIT recovers (totalUnfunded <= available) or governance calls {setFundingBasis}. + * @return True if JIT mode is enforced + */ + function isEnforcedJit() external view returns (bool); +} diff --git a/packages/issuance/README.md b/packages/issuance/README.md index 0209e2d97..c6def2743 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) +- **[RecurringAgreementManager](contracts/agreement/RecurringAgreementManager.md)** - Funds PaymentsEscrow deposits for RCAs using issuance tokens, tracking max-next-claim per agreement per indexer ## Development diff --git a/packages/issuance/contracts/agreement/RecurringAgreementHelper.sol b/packages/issuance/contracts/agreement/RecurringAgreementHelper.sol new file mode 100644 index 000000000..08b6d2eb2 --- /dev/null +++ b/packages/issuance/contracts/agreement/RecurringAgreementHelper.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.27; + +import { IRecurringAgreementHelper } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementHelper.sol"; +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; + +/** + * @title RecurringAgreementHelper + * @author Edge & Node + * @notice Stateless convenience contract that provides batch reconciliation + * functions for {RecurringAgreementManager}. Each call delegates to the + * manager's single-agreement `reconcileAgreement`. + * + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +contract RecurringAgreementHelper is IRecurringAgreementHelper { + /// @notice The RecurringAgreementManager contract + IRecurringAgreementManager public immutable MANAGER; + + /// @notice Thrown when the manager address is the zero address + error ManagerZeroAddress(); + + /** + * @notice Constructor for the RecurringAgreementHelper contract + * @param manager Address of the RecurringAgreementManager contract + */ + constructor(address manager) { + require(manager != address(0), ManagerZeroAddress()); + MANAGER = IRecurringAgreementManager(manager); + } + + /// @inheritdoc IRecurringAgreementHelper + function reconcile(address provider) external { + bytes16[] memory agreementIds = MANAGER.getProviderAgreements(provider); + for (uint256 i = 0; i < agreementIds.length; ++i) { + MANAGER.reconcileAgreement(agreementIds[i]); + } + } + + /// @inheritdoc IRecurringAgreementHelper + function reconcileBatch(bytes16[] calldata agreementIds) external { + for (uint256 i = 0; i < agreementIds.length; ++i) { + if (MANAGER.getAgreementInfo(agreementIds[i]).provider == address(0)) continue; + MANAGER.reconcileAgreement(agreementIds[i]); + } + } +} diff --git a/packages/issuance/contracts/agreement/RecurringAgreementManager.md b/packages/issuance/contracts/agreement/RecurringAgreementManager.md new file mode 100644 index 000000000..f35f70429 --- /dev/null +++ b/packages/issuance/contracts/agreement/RecurringAgreementManager.md @@ -0,0 +1,177 @@ +# RecurringAgreementManager + +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. RecurringAgreementManager automates this for protocol-funded agreements by receiving minted GRT from IssuanceAllocator and maintaining escrow balances sufficient to cover worst-case collection amounts. + +It implements three interfaces: + +- **`IIssuanceTarget`** — receives minted GRT from IssuanceAllocator +- **`IContractApprover`** — authorizes RCA acceptance and updates via callback (replaces ECDSA signature) +- **`IRecurringAgreementManager`** — core escrow management functions + +## Escrow Structure + +One escrow account per (RecurringAgreementManager, RecurringCollector, service provider) tuple covers **all** managed RCAs for that service provider. Multiple agreements for the same provider share a single escrow balance: + +``` +sum(maxNextClaim + pendingUpdateMaxNextClaim for all active agreements for that provider) <= PaymentsEscrow.escrowAccounts[RecurringAgreementManager][RecurringCollector][provider] +``` + +Funding never reverts — it deposits `min(deficit, availableBalance)`. The `getDeficit` view exposes any shortfall for monitoring. + +## Hash Authorization + +The `authorizedHashes` mapping stores `hash → agreementId` rather than `hash → bool`. Hashes are automatically invalidated when agreements are deleted, preventing reuse without explicit cleanup. + +## Max Next Claim + +For accepted agreements, delegated to `RecurringCollector.getMaxNextClaim(agreementId)` as the single source of truth. For pre-accepted offers, a conservative estimate calculated at offer time: + +``` +maxNextClaim = maxOngoingTokensPerSecond * maxSecondsPerCollection + maxInitialTokens +``` + +| Agreement State | maxNextClaim | +| --------------------------- | -------------------------------------------------------------- | +| NotAccepted (pre-offered) | 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 + +### Offer → Accept (two-step) + +1. **Operator** calls `offerAgreement(rca)` — stores hash, calculates conservative maxNextClaim, funds escrow +2. **Service provider operator** calls `SubgraphService.acceptUnsignedIndexingAgreement(allocationId, rca)` — SubgraphService → RecurringCollector → `approveAgreement(hash)` callback to RecurringAgreementManager + +During the pending update window, both current and pending maxNextClaim are funded simultaneously (conservative). + +### Collect → Reconcile + +Collection flows through `SubgraphService → RecurringCollector → PaymentsEscrow`. RecurringCollector then calls `IContractApprover.afterCollection` on the payer, which triggers automatic reconciliation and escrow top-up in the same transaction. Manual reconcile is still available as a fallback. + +The manager exposes `reconcileAgreement` (gas-predictable, per-agreement). Batch convenience functions `reconcileBatch` (caller-selected list) and `reconcile(provider)` (iterates all agreements) are in the stateless `RecurringAgreementHelper` contract, which delegates each reconciliation back to the manager. + +### Revoke / Cancel / Remove + +- **`revokeOffer`** — withdraws an un-accepted offer +- **`cancelAgreement`** — for accepted agreements, routes cancellation through the data service then reconciles; idempotent for already-canceled agreements +- **`removeAgreement`** (permissionless) — cleans up agreements with maxNextClaim = 0 + +| 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 | + +## Escrow Funding Modes + +The configured `FundingBasis` controls how aggressively escrow is pre-funded. The setting is a **maximum aspiration** — the system automatically degrades when funds are insufficient. `beforeCollection` (JIT top-up) is always active regardless of setting, providing a safety net for any gap. + +### Levels + +``` +enum FundingBasis { JustInTime, OnDemand, Full } +``` + +Ordered low-to-high: + +| Level | Deposit target | Thaw ceiling | Behavior | +| -------------- | --------------------------------------- | ------------ | ----------------------------------------------- | +| Full (2) | `requiredEscrow[provider]` (sum of all) | `required` | Current default. Funds worst-case for all RCAs. | +| OnDemand (1) | 0 | `required` | No deposits, holds at required level. | +| JustInTime (0) | 0 | 0 | Thaws everything, pure JIT. | + +**Stability guarantee**: `depositTarget <= thawCeiling` at every level. Deposit-then-immediate-reconcile at the same level never triggers a thaw. + +### Two-Target Model + +`_updateEscrow` uses two numbers instead of a single `required`: + +- **depositTarget**: deposit if effective balance is below this +- **thawCeiling**: thaw if balance is above this + +The split ensures smooth transitions between levels. When degradation occurs, the deposit target drops to 0 but the thaw ceiling holds at `required`, preventing oscillation. + +### Automatic Degradation + +The setting is a ceiling, not a mandate. When funds are insufficient, Full degrades to OnDemand: + +| Configured | Can afford? | Effective deposit | Effective thaw ceiling | +| ---------- | -------------- | ----------------- | ---------------------- | +| Full | Yes | `required` | `required` | +| Full | No (→OnDemand) | 0 | `required` | +| OnDemand | Always | 0 | `required` | +| JustInTime | Always | 0 | 0 | + +Degradation trigger: + +- **Full → OnDemand**: `totalUnfunded > available` (SAM's balance can't close the system-wide gap) + +Key properties: + +- Degradation never reaches JustInTime automatically — only explicit governor setting or enforced JIT thaws to zero +- JIT deposit is all-or-nothing: if `deficit < available` the full deficit is deposited, otherwise nothing is deposited + +### Reconciliation + +Per-agreement reconciliation (`reconcileAgreement`) re-reads agreement state from RecurringCollector and updates `requiredEscrow`. Provider-level escrow rebalancing is O(1) via `updateEscrow(provider)`. Batch helpers `reconcileBatch` and `reconcile(provider)` live in the separate `RecurringAgreementHelper` contract — they are stateless wrappers that call `reconcileAgreement` in a loop. + +### Global Tracking + +| Storage field | Type | Updated at | +| --------------------------- | ------- | ------------------------------------------------------------------------------- | +| `fundingBasis` | enum | `setFundingBasis()` (also clears enforced JIT) | +| `totalRequiredAll` | uint256 | Every `requiredEscrow[provider]` mutation | +| `totalUnfunded` | uint256 | Every `requiredEscrow[provider]` or `lastKnownFunded[provider]` mutation | +| `totalAgreementCount` | uint256 | `offerAgreement` (+1), `revokeOffer` (-1), `removeAgreement` (-1) | +| `lastKnownFunded[provider]` | mapping | End of `_updateEscrow` via snapshot diff | +| `enforcedJit` | bool | `beforeCollection` (trip), `_updateEscrow` (recover), `setFundingBasis` (clear) | + +**`totalUnfunded`** is maintained incrementally as `Σ max(0, requiredEscrow[p] - lastKnownFunded[p])` per provider. This correctly handles over-funded providers: a provider with excess escrow cannot mask another provider's deficit. At each of 6 mutation points (offer, offerUpdate, revoke, remove, reconcile, updateFundedSnapshot), the provider's unfunded contribution is recomputed before and after the mutation. + +Globals start at 0 and populate lazily through normal operations. This is safe because Full mode's per-provider logic uses `requiredEscrow[provider]` directly (unaffected by globals), and degradation with `totalUnfunded=0` means no degradation triggers (stays Full). Governor should run a reconciliation pass across providers before switching away from Full mode to populate globals. + +### Enforced JIT + +If `beforeCollection` can't fully fund a collection (`deficit >= available`), it deposits nothing and enforces JIT mode. While active, `_fundingTargets` returns `(0, 0)` — JIT-only behavior — regardless of the configured `fundingBasis`. The configured basis is preserved and takes effect again on recovery. + +**Trigger**: In `beforeCollection`, if `deficit >= available` (all-or-nothing: no partial deposits) and not already enforced: + +- Set `enforcedJit = true` +- Emit `EnforcedJit($.fundingBasis)` (configured basis unchanged) + +**Recovery**: In `_updateEscrow` (runs after every reconcile, collection, etc.), if enforced and `totalUnfunded <= GRAPH_TOKEN.balanceOf(this)`: + +- Clear `enforcedJit` +- Emit `EnforcedJitRecovered($.fundingBasis)` + +Recovery uses `totalUnfunded` — the sum of per-(collector, provider) deficits — rather than total required. This correctly accounts for already-funded escrow. During JIT mode, thaws complete and tokens return to RAM, naturally building toward recovery. + +**Governor override**: `setFundingBasis` always clears enforced JIT, regardless of recovery conditions. + +### Upgrade Safety + +Default storage value 0 maps to `JustInTime`, so `reinitializer(2)` sets `fundingBasis = Full` to preserve current behavior. The `initializeV2()` function handles this. `enforcedJit` defaults to `false` (0), which is correct — no enforcement on upgrade. + +## Roles + +- **GOVERNOR_ROLE**: Sets the issuance allocator reference, sets funding basis +- **OPERATOR_ROLE**: Offers agreements/updates, revokes offers, cancels agreements +- **PAUSE_ROLE**: Pauses contract (reconcile/remove remain available) +- **Permissionless**: `reconcileAgreement`, `removeAgreement`, `updateEscrow` +- **RecurringAgreementHelper** (permissionless): `reconcile(provider)`, `reconcileBatch(ids[])` + +## Deployment + +Prerequisites: GraphToken, PaymentsEscrow, RecurringCollector, IssuanceAllocator deployed. + +1. Deploy RecurringAgreementManager implementation (graphToken, paymentsEscrow, recurringCollector) +2. Deploy TransparentUpgradeableProxy with implementation and initialization data +3. Initialize with governor address +4. Grant `OPERATOR_ROLE` to the operator account +5. Configure IssuanceAllocator to allocate tokens to RecurringAgreementManager diff --git a/packages/issuance/contracts/agreement/RecurringAgreementManager.sol b/packages/issuance/contracts/agreement/RecurringAgreementManager.sol new file mode 100644 index 000000000..7bca00afd --- /dev/null +++ b/packages/issuance/contracts/agreement/RecurringAgreementManager.sol @@ -0,0 +1,721 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.27; + +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 { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IDataServiceAgreements } from "@graphprotocol/interfaces/contracts/data-service/IDataServiceAgreements.sol"; +import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.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 RecurringAgreementManager + * @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, collector, provider) covers all managed + * RCAs for that (collector, provider) pair. Each agreement stores its own collector + * address. 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 RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContractApprover, IRecurringAgreementManager { + using EnumerableSet for EnumerableSet.Bytes32Set; + + // -- Immutables -- + + /// @notice The PaymentsEscrow contract + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + IPaymentsEscrow public immutable PAYMENTS_ESCROW; + + // -- Storage (ERC-7201) -- + + /// @custom:storage-location erc7201:graphprotocol.issuance.storage.RecurringAgreementManager + struct RecurringAgreementManagerStorage { + /// @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 (collector, provider) pair + mapping(address collector => mapping(address provider => uint256)) requiredEscrow; + /// @notice Set of agreement IDs per service provider (stored as bytes32 for EnumerableSet) + mapping(address provider => EnumerableSet.Bytes32Set) providerAgreementIds; + /// @notice Governance-configured funding level (not modified by enforced JIT) + FundingBasis fundingBasis; + /// @notice Sum of requiredEscrow across all (collector, provider) pairs + uint256 totalRequiredAll; + /// @notice Total unfunded escrow: sum of max(0, requiredEscrow[c][p] - lastKnownFunded[c][p]) + uint256 totalUnfunded; + /// @notice Total number of tracked agreements across all providers + uint256 totalAgreementCount; + /// @notice Last known escrow balance per (collector, provider) pair (for snapshot diff) + mapping(address collector => mapping(address provider => uint256)) lastKnownFunded; + /// @notice Whether JIT mode is enforced (beforeCollection couldn't fund) + bool enforcedJit; + /// @notice Optional oracle for checking payment eligibility of service providers + IRewardsEligibility paymentEligibilityOracle; + } + + // solhint-disable-next-line gas-named-return-values + // keccak256(abi.encode(uint256(keccak256("graphprotocol.issuance.storage.RecurringAgreementManager")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant RECURRING_AGREEMENT_MANAGER_STORAGE_LOCATION = + 0x13814b254ec9c757012be47b3445539ef5e5e946eb9d2ef31ea6d4423bf88b00; + + // -- Constructor -- + + /** + * @notice Constructor for the RecurringAgreementManager contract + * @param graphToken Address of the Graph Token contract + * @param paymentsEscrow Address of the PaymentsEscrow contract + * @custom:oz-upgrades-unsafe-allow constructor + */ + constructor(address graphToken, address paymentsEscrow) BaseUpgradeable(graphToken) { + PAYMENTS_ESCROW = IPaymentsEscrow(paymentsEscrow); + } + + // -- Initialization -- + + /** + * @notice Initialize the RecurringAgreementManager contract + * @param governor Address that will have the GOVERNOR_ROLE + */ + function initialize(address governor) external virtual initializer { + __BaseUpgradeable_init(governor); + _getStorage().fundingBasis = FundingBasis.Full; + } + + /** + * @notice Reinitialize for upgrade: set default funding basis to Full + */ + function initializeV2() external reinitializer(2) { + _getStorage().fundingBasis = FundingBasis.Full; + } + + // -- ERC165 -- + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return + interfaceId == type(IIssuanceTarget).interfaceId || + interfaceId == type(IContractApprover).interfaceId || + interfaceId == type(IRecurringAgreementManager).interfaceId || + interfaceId == type(IRewardsEligibility).interfaceId || + super.supportsInterface(interfaceId); + } + + // -- IIssuanceTarget -- + + /// @inheritdoc IIssuanceTarget + function beforeIssuanceAllocationChange() external virtual override {} + + /// @inheritdoc IIssuanceTarget + /// @dev No-op: RecurringAgreementManager receives tokens via transfer, does not need the allocator address. + function setIssuanceAllocator(address /* issuanceAllocator */) external virtual override onlyRole(GOVERNOR_ROLE) {} + + // -- IContractApprover -- + + /// @inheritdoc IContractApprover + function approveAgreement(bytes32 agreementHash) external view override returns (bytes4) { + RecurringAgreementManagerStorage storage $ = _getStorage(); + bytes16 agreementId = $.authorizedHashes[agreementHash]; + + if (agreementId == bytes16(0) || $.agreements[agreementId].provider == address(0)) return bytes4(0); + + return IContractApprover.approveAgreement.selector; + } + + /// @inheritdoc IContractApprover + function beforeCollection(bytes16 agreementId, uint256 tokensToCollect) external override { + RecurringAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage agreement = $.agreements[agreementId]; + address provider = agreement.provider; + if (provider == address(0)) return; + address collector = msg.sender; + require(collector == agreement.collector, OnlyAgreementCollector()); + + // Only deposit if escrow is short for this collection + IPaymentsEscrow.EscrowAccount memory account = PAYMENTS_ESCROW.escrowAccounts( + address(this), + collector, + provider + ); + if (tokensToCollect < account.balance) return; + + uint256 deficit = tokensToCollect - account.balance; + if (deficit < GRAPH_TOKEN.balanceOf(address(this))) { + GRAPH_TOKEN.approve(address(PAYMENTS_ESCROW), deficit); + PAYMENTS_ESCROW.deposit(collector, provider, deficit); + } else if (!$.enforcedJit) { + $.enforcedJit = true; + emit EnforcedJit($.fundingBasis); + } + } + + /// @inheritdoc IContractApprover + function afterCollection(bytes16 agreementId, uint256 /* tokensCollected */) external override { + RecurringAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage info = $.agreements[agreementId]; + if (info.provider == address(0)) return; + require(msg.sender == info.collector, OnlyAgreementCollector()); + + _reconcileAgreement($, agreementId); + _updateEscrow($, info.collector, info.provider); + } + + // -- IRecurringAgreementManager: Core Functions -- + + /// @inheritdoc IRecurringAgreementManager + function offerAgreement( + IRecurringCollector.RecurringCollectionAgreement calldata rca, + address collector + ) external onlyRole(OPERATOR_ROLE) whenNotPaused returns (bytes16 agreementId) { + require(rca.payer == address(this), PayerMustBeManager(rca.payer, address(this))); + require(rca.serviceProvider != address(0), ServiceProviderZeroAddress()); + require(rca.dataService != address(0), DataServiceZeroAddress()); + require(collector != address(0), CollectorZeroAddress()); + + RecurringAgreementManagerStorage storage $ = _getStorage(); + + agreementId = IRecurringCollector(collector).generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + + require($.agreements[agreementId].provider == address(0), AgreementAlreadyOffered(agreementId)); + + // 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 = IRecurringCollector(collector).hashRCA(rca); + $.authorizedHashes[agreementHash] = agreementId; + + // Store agreement tracking data (maxNextClaim set to 0; _setAgreementRequired handles accounting) + $.agreements[agreementId] = AgreementInfo({ + provider: rca.serviceProvider, + deadline: rca.deadline, + dataService: rca.dataService, + pendingUpdateNonce: 0, + maxNextClaim: 0, + pendingUpdateMaxNextClaim: 0, + agreementHash: agreementHash, + pendingUpdateHash: bytes32(0), + collector: collector + }); + $.providerAgreementIds[rca.serviceProvider].add(bytes32(agreementId)); + _setAgreementRequired($, agreementId, maxNextClaim, false); + $.totalAgreementCount += 1; + + // Update escrow: fund deficit (partial-cancel thaw if needed), thaw excess + _updateEscrow($, collector, rca.serviceProvider); + + emit AgreementOffered(agreementId, rca.serviceProvider, maxNextClaim); + } + + /// @inheritdoc IRecurringAgreementManager + function offerAgreementUpdate( + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau + ) external onlyRole(OPERATOR_ROLE) whenNotPaused returns (bytes16 agreementId) { + agreementId = rcau.agreementId; + RecurringAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage agreement = $.agreements[agreementId]; + require(agreement.provider != address(0), AgreementNotOffered(agreementId)); + + // Calculate pending max next claim from RCAU parameters (conservative: includes initial + ongoing) + uint256 pendingMaxNextClaim = rcau.maxOngoingTokensPerSecond * rcau.maxSecondsPerCollection + + rcau.maxInitialTokens; + + // Clean up old pending hash if replacing + if (agreement.pendingUpdateHash != bytes32(0)) { + delete $.authorizedHashes[agreement.pendingUpdateHash]; + } + + // Authorize the RCAU hash for the IContractApprover callback + bytes32 updateHash = IRecurringCollector(agreement.collector).hashRCAU(rcau); + $.authorizedHashes[updateHash] = agreementId; + + // Update pending tracking — _setAgreementRequired handles escrow accounting + _setAgreementRequired($, agreementId, pendingMaxNextClaim, true); + agreement.pendingUpdateNonce = rcau.nonce; + agreement.pendingUpdateHash = updateHash; + + // Update escrow: fund deficit (partial-cancel thaw if needed), thaw excess + _updateEscrow($, agreement.collector, agreement.provider); + + emit AgreementUpdateOffered(agreementId, pendingMaxNextClaim, rcau.nonce); + } + + /// @inheritdoc IRecurringAgreementManager + function revokeOffer(bytes16 agreementId) external onlyRole(OPERATOR_ROLE) whenNotPaused { + RecurringAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage info = $.agreements[agreementId]; + require(info.provider != address(0), AgreementNotOffered(agreementId)); + + // Only revoke un-accepted agreements — accepted ones must be canceled via cancelAgreement + IRecurringCollector.AgreementData memory agreement = IRecurringCollector(info.collector).getAgreement( + agreementId + ); + require( + agreement.state == IRecurringCollector.AgreementState.NotAccepted, + AgreementAlreadyAccepted(agreementId) + ); + + address provider = info.provider; + address collector = info.collector; + + // Clean up authorized hashes + delete $.authorizedHashes[info.agreementHash]; + if (info.pendingUpdateHash != bytes32(0)) { + delete $.authorizedHashes[info.pendingUpdateHash]; + } + + // Zero out escrow requirements before deleting + _setAgreementRequired($, agreementId, 0, false); + _setAgreementRequired($, agreementId, 0, true); + $.totalAgreementCount -= 1; + $.providerAgreementIds[provider].remove(bytes32(agreementId)); + delete $.agreements[agreementId]; + + emit OfferRevoked(agreementId, provider); + _updateEscrow($, collector, provider); + } + + /// @inheritdoc IRecurringAgreementManager + function cancelAgreement(bytes16 agreementId) external onlyRole(OPERATOR_ROLE) whenNotPaused { + RecurringAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage info = $.agreements[agreementId]; + require(info.provider != address(0), AgreementNotOffered(agreementId)); + + IRecurringCollector.AgreementData memory agreement = IRecurringCollector(info.collector).getAgreement( + agreementId + ); + + // Not accepted — use revokeOffer instead + require(agreement.state != IRecurringCollector.AgreementState.NotAccepted, AgreementNotAccepted(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, InvalidDataService(ds)); + IDataServiceAgreements(ds).cancelIndexingAgreementByPayer(agreementId); + emit AgreementCanceled(agreementId, info.provider); + } + // else: already canceled (CanceledByPayer or CanceledByServiceProvider) — skip cancel call, just reconcile + + // Reconcile to update escrow requirements after cancellation + _reconcileAgreement($, agreementId); + _updateEscrow($, info.collector, info.provider); + } + + /// @inheritdoc IRecurringAgreementManager + function removeAgreement(bytes16 agreementId) external { + RecurringAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage info = $.agreements[agreementId]; + require(info.provider != address(0), AgreementNotOffered(agreementId)); + + // Re-read from the agreement's collector to get current state + IRecurringCollector rc = IRecurringCollector(info.collector); + IRecurringCollector.AgreementData memory agreement = rc.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 = rc.getMaxNextClaim(agreementId); + } + require(currentMaxClaim == 0, AgreementStillClaimable(agreementId, currentMaxClaim)); + + address provider = info.provider; + address collector = info.collector; + + // Clean up authorized hashes + delete $.authorizedHashes[info.agreementHash]; + if (info.pendingUpdateHash != bytes32(0)) { + delete $.authorizedHashes[info.pendingUpdateHash]; + } + + // Zero out escrow requirements before deleting + _setAgreementRequired($, agreementId, 0, false); + _setAgreementRequired($, agreementId, 0, true); + $.totalAgreementCount -= 1; + $.providerAgreementIds[provider].remove(bytes32(agreementId)); + delete $.agreements[agreementId]; + + emit AgreementRemoved(agreementId, provider); + _updateEscrow($, collector, provider); + } + + /// @inheritdoc IRecurringAgreementManager + function reconcileAgreement(bytes16 agreementId) external { + RecurringAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage info = $.agreements[agreementId]; + require(info.provider != address(0), AgreementNotOffered(agreementId)); + + _reconcileAgreement($, agreementId); + _updateEscrow($, info.collector, info.provider); + } + + /// @inheritdoc IRecurringAgreementManager + function updateEscrow(address collector, address provider) external { + _updateEscrow(_getStorage(), collector, provider); + } + + /// @inheritdoc IRecurringAgreementManager + function setFundingBasis(FundingBasis basis) external onlyRole(GOVERNOR_ROLE) { + RecurringAgreementManagerStorage storage $ = _getStorage(); + FundingBasis oldBasis = $.fundingBasis; + $.fundingBasis = basis; + $.enforcedJit = false; + emit FundingBasisChanged(oldBasis, basis); + } + + /// @inheritdoc IRecurringAgreementManager + function setPaymentEligibilityOracle(address oracle) external onlyRole(GOVERNOR_ROLE) { + RecurringAgreementManagerStorage storage $ = _getStorage(); + address oldOracle = address($.paymentEligibilityOracle); + $.paymentEligibilityOracle = IRewardsEligibility(oracle); + emit PaymentEligibilityOracleSet(oldOracle, oracle); + } + + // -- IRewardsEligibility -- + + /** + * @notice Check if a service provider is eligible for payment collection. + * @dev When no oracle is configured (address(0)), all providers are eligible. + * When an oracle is set, delegates to the oracle's isEligible check. + * @param serviceProvider The address of the service provider + * @return True if the service provider is eligible + */ + function isEligible(address serviceProvider) external view returns (bool) { + IRewardsEligibility oracle = _getStorage().paymentEligibilityOracle; + if (address(oracle) == address(0)) return true; + return oracle.isEligible(serviceProvider); + } + + // -- IRecurringAgreementManager: View Functions -- + + /// @inheritdoc IRecurringAgreementManager + function getRequiredEscrow(address collector, address provider) external view returns (uint256) { + return _getStorage().requiredEscrow[collector][provider]; + } + + /// @inheritdoc IRecurringAgreementManager + function getDeficit(address collector, address provider) external view returns (uint256) { + RecurringAgreementManagerStorage storage $ = _getStorage(); + uint256 required = $.requiredEscrow[collector][provider]; + IPaymentsEscrow.EscrowAccount memory account = PAYMENTS_ESCROW.escrowAccounts( + address(this), + collector, + provider + ); + uint256 currentBalance = account.balance - account.tokensThawing; + if (currentBalance < required) { + return required - currentBalance; + } + return 0; + } + + /// @inheritdoc IRecurringAgreementManager + function getAgreementMaxNextClaim(bytes16 agreementId) external view returns (uint256) { + return _getStorage().agreements[agreementId].maxNextClaim; + } + + /// @inheritdoc IRecurringAgreementManager + function getAgreementInfo(bytes16 agreementId) external view returns (AgreementInfo memory) { + return _getStorage().agreements[agreementId]; + } + + /// @inheritdoc IRecurringAgreementManager + function getProviderAgreementCount(address provider) external view returns (uint256) { + return _getStorage().providerAgreementIds[provider].length(); + } + + /// @inheritdoc IRecurringAgreementManager + function getProviderAgreements(address provider) external view returns (bytes16[] memory) { + RecurringAgreementManagerStorage storage $ = _getStorage(); + EnumerableSet.Bytes32Set storage ids = $.providerAgreementIds[provider]; + 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; + } + + /// @inheritdoc IRecurringAgreementManager + function getFundingBasis() external view returns (FundingBasis) { + return _getStorage().fundingBasis; + } + + /// @inheritdoc IRecurringAgreementManager + function getTotalRequired() external view returns (uint256) { + return _getStorage().totalRequiredAll; + } + + /// @inheritdoc IRecurringAgreementManager + function getTotalUnfunded() external view returns (uint256) { + return _getStorage().totalUnfunded; + } + + /// @inheritdoc IRecurringAgreementManager + function getTotalAgreementCount() external view returns (uint256) { + return _getStorage().totalAgreementCount; + } + + /// @inheritdoc IRecurringAgreementManager + function isEnforcedJit() external view returns (bool) { + return _getStorage().enforcedJit; + } + + // -- 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(RecurringAgreementManagerStorage storage $, bytes16 agreementId) private { + AgreementInfo storage info = $.agreements[agreementId]; + + IRecurringCollector rc = IRecurringCollector(info.collector); + IRecurringCollector.AgreementData memory agreement = rc.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) { + _setAgreementRequired($, agreementId, 0, true); + delete $.authorizedHashes[info.pendingUpdateHash]; + info.pendingUpdateNonce = 0; + info.pendingUpdateHash = bytes32(0); + } + + uint256 oldMaxClaim = info.maxNextClaim; + uint256 newMaxClaim = rc.getMaxNextClaim(agreementId); + + if (oldMaxClaim != newMaxClaim) { + _setAgreementRequired($, agreementId, newMaxClaim, false); + emit AgreementReconciled(agreementId, oldMaxClaim, newMaxClaim); + } + } + + /** + * @notice Atomically set one escrow obligation slot of an agreement and cascade to provider/global totals. + * @dev This and {_setFundedSnapshot} are the only two functions that mutate totalUnfunded. + * @param agreementId The agreement to update + * @param newValue The new obligation value + * @param pending If true, updates pendingUpdateMaxNextClaim; otherwise updates maxNextClaim + */ + // solhint-disable-next-line use-natspec + function _setAgreementRequired( + RecurringAgreementManagerStorage storage $, + bytes16 agreementId, + uint256 newValue, + bool pending + ) private { + AgreementInfo storage info = $.agreements[agreementId]; + address collector = info.collector; + address provider = info.provider; + uint256 oldUnfunded = _providerUnfunded($, collector, provider); + + uint256 oldValue; + if (pending) { + oldValue = info.pendingUpdateMaxNextClaim; + info.pendingUpdateMaxNextClaim = newValue; + } else { + oldValue = info.maxNextClaim; + info.maxNextClaim = newValue; + } + + $.requiredEscrow[collector][provider] = $.requiredEscrow[collector][provider] - oldValue + newValue; + $.totalRequiredAll = $.totalRequiredAll - oldValue + newValue; + $.totalUnfunded = $.totalUnfunded - oldUnfunded + _providerUnfunded($, collector, provider); + } + + /** + * @notice Compute deposit target and thaw ceiling based on funding basis. + * @dev Funding ladder: + * + * | Level | Deposit target | Thaw ceiling | + * |------------|---------------|-------------| + * | Full | required | required | + * | OnDemand | 0 | required | + * | JustInTime | 0 | 0 | + * + * When enforcedJit, behaves as JustInTime regardless of configured basis. + * Full degrades to OnDemand when totalUnfunded > available. + * + * @param required The requiredEscrow for this (collector, provider) pair + * @return depositTarget The target for deposits (deposit if balance is below) + * @return thawCeiling The ceiling for thaws (thaw if balance is above) + */ + // solhint-disable-next-line use-natspec + function _fundingTargets( + RecurringAgreementManagerStorage storage $, + uint256 required + ) private view returns (uint256 depositTarget, uint256 thawCeiling) { + FundingBasis basis = $.enforcedJit ? FundingBasis.JustInTime : $.fundingBasis; + + depositTarget = (basis == FundingBasis.Full && $.totalUnfunded <= GRAPH_TOKEN.balanceOf(address(this))) + ? required + : 0; + thawCeiling = basis == FundingBasis.JustInTime ? 0 : required; + } + + /** + * @notice Compute a (collector, provider) pair's unfunded escrow: max(0, required - funded). + * @param collector The collector contract + * @param provider The service provider + * @return The unfunded amount for this (collector, provider) + */ + // solhint-disable-next-line use-natspec + function _providerUnfunded( + RecurringAgreementManagerStorage storage $, + address collector, + address provider + ) private view returns (uint256) { + uint256 required = $.requiredEscrow[collector][provider]; + uint256 funded = $.lastKnownFunded[collector][provider]; + if (required <= funded) return 0; + return required - funded; + } + + /** + * @notice Update escrow state for a (collector, provider) pair: withdraw completed thaws, + * fund any deficit, and thaw excess balance. + * @dev Sequential state normalization using two targets from {_fundingTargets}: + * - depositTarget: deposit if balance is below this + * - thawCeiling: thaw if balance is above this + * + * Phases: + * 1. Withdraw completed thaw (skip when escrow is short — cancelThaw avoids round-trip) + * 2. Reduce thaw if effective balance (balance - thawing) is below thawCeiling + * 3. Not thawing: start thaw for excess above thawCeiling or deposit for deficit below depositTarget + * + * Uses per-call approve (not infinite allowance). Safe because PaymentsEscrow + * is a trusted protocol contract that transfers exactly the approved amount. + * + * Updates funded snapshot at the end for global tracking. + * + * @param collector The collector contract address + * @param provider The service provider to update escrow for + */ + // solhint-disable-next-line use-natspec + function _updateEscrow(RecurringAgreementManagerStorage storage $, address collector, address provider) private { + // Enforced JIT recovery: clear when RAM can afford totalUnfunded + if ($.enforcedJit) { + uint256 available = GRAPH_TOKEN.balanceOf(address(this)); + if ($.totalUnfunded <= available) { + $.enforcedJit = false; + emit EnforcedJitRecovered($.fundingBasis); + } + } + + IPaymentsEscrow.EscrowAccount memory account = PAYMENTS_ESCROW.escrowAccounts( + address(this), + collector, + provider + ); + uint256 required = $.requiredEscrow[collector][provider]; + (uint256 depositTarget, uint256 thawCeiling) = _fundingTargets($, required); + + // Withdraw completed thaw (skip when escrow is short — step 2 cancels thaw instead) + bool thawReady = 0 < account.thawEndTimestamp && account.thawEndTimestamp < block.timestamp; + if (thawReady && depositTarget < account.balance) { + uint256 withdrawn = PAYMENTS_ESCROW.withdraw(collector, provider); + emit EscrowWithdrawn(provider, collector, withdrawn); + account = PAYMENTS_ESCROW.escrowAccounts(address(this), collector, provider); + } + + // Reduce thaw if effective balance is below thawCeiling; else thawing at acceptable level + if (0 < account.tokensThawing) + if (account.balance - account.tokensThawing < thawCeiling) { + uint256 target = account.balance < thawCeiling ? 0 : account.balance - thawCeiling; + PAYMENTS_ESCROW.thaw(collector, provider, target, false); + // solhint-disable-next-line gas-strict-inequalities + if (depositTarget <= account.balance) { + _setFundedSnapshot($, collector, provider); + return; + } + // thaw cancelled; fall through to deposit below + } else { + _setFundedSnapshot($, collector, provider); + return; + } + + // Not thawing: thaw excess or deposit deficit + if (thawCeiling < account.balance) { + uint256 excess = account.balance - thawCeiling; + PAYMENTS_ESCROW.thaw(collector, provider, excess, false); + } else if (account.balance < depositTarget) { + uint256 deficit = depositTarget - account.balance; + 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(collector, provider, toDeposit); + emit EscrowFunded(provider, collector, toDeposit); + } + } + + _setFundedSnapshot($, collector, provider); + } + + /** + * @notice Atomically sync the funded snapshot for a (collector, provider) pair after escrow mutations. + * @dev This and {_setAgreementRequired} are the only two functions that mutate totalUnfunded. + * @param collector The collector address + * @param provider The service provider + */ + // solhint-disable-next-line use-natspec + function _setFundedSnapshot( + RecurringAgreementManagerStorage storage $, + address collector, + address provider + ) private { + uint256 oldUnfunded = _providerUnfunded($, collector, provider); + uint256 currentFunded = PAYMENTS_ESCROW.escrowAccounts(address(this), collector, provider).balance; + $.lastKnownFunded[collector][provider] = currentFunded; + uint256 newUnfunded = _providerUnfunded($, collector, provider); + $.totalUnfunded = $.totalUnfunded - oldUnfunded + newUnfunded; + } + + /** + * @notice Get the ERC-7201 namespaced storage + */ + // solhint-disable-next-line use-natspec + function _getStorage() private pure returns (RecurringAgreementManagerStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := RECURRING_AGREEMENT_MANAGER_STORAGE_LOCATION + } + } +} diff --git a/packages/issuance/contracts/allocate/DirectAllocation.sol b/packages/issuance/contracts/allocate/DirectAllocation.sol index 799755256..26144a951 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 -- /** @@ -89,9 +86,7 @@ contract DirectAllocation is BaseUpgradeable, IIssuanceTarget, ISendTokens { * before an allocation change. We simply receive tokens from the IssuanceAllocator. * @inheritdoc IIssuanceTarget */ - function beforeIssuanceAllocationChange() external virtual override { - emit BeforeIssuanceAllocationChange(); - } + function beforeIssuanceAllocationChange() external virtual override {} /** * @dev No-op for DirectAllocation; issuanceAllocator is not stored. diff --git a/packages/issuance/foundry.toml b/packages/issuance/foundry.toml index cfd6d9c04..ea60843f6 100644 --- a/packages/issuance/foundry.toml +++ b/packages/issuance/foundry.toml @@ -3,6 +3,7 @@ src = 'contracts' out = 'forge-artifacts' libs = ["node_modules"] auto_detect_remappings = false +test = 'test' remappings = [ "@openzeppelin/=node_modules/@openzeppelin/", "@graphprotocol/=node_modules/@graphprotocol/", diff --git a/packages/issuance/test/unit/agreement-manager/afterCollection.t.sol b/packages/issuance/test/unit/agreement-manager/afterCollection.t.sol new file mode 100644 index 000000000..6f650c520 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/afterCollection.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerCollectionCallbackTest is RecurringAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + // -- beforeCollection -- + + function test_BeforeCollection_TopsUpWhenEscrowShort() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Simulate: escrow was partially drained (e.g. by a previous collection) + // The mock escrow has the full balance from offerAgreement, so we need to + // set up a scenario where balance < tokensToCollect. + // We'll just call beforeCollection with a large tokensToCollect. + uint256 escrowBalance = paymentsEscrow + .escrowAccounts(address(agreementManager), address(recurringCollector), indexer) + .balance; + + // Mint more tokens so SAM has available balance to deposit + token.mint(address(agreementManager), 1000 ether); + + // Request more than current escrow balance + uint256 tokensToCollect = escrowBalance + 500 ether; + + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, tokensToCollect); + + // Escrow should now have enough + uint256 newBalance = paymentsEscrow + .escrowAccounts(address(agreementManager), address(recurringCollector), indexer) + .balance; + assertEq(newBalance, tokensToCollect); + } + + function test_BeforeCollection_NoOpWhenEscrowSufficient() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + uint256 escrowBefore = paymentsEscrow + .escrowAccounts(address(agreementManager), address(recurringCollector), indexer) + .balance; + + // Request less than current escrow — should be a no-op + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1 ether); + + uint256 escrowAfter = paymentsEscrow + .escrowAccounts(address(agreementManager), address(recurringCollector), indexer) + .balance; + assertEq(escrowAfter, escrowBefore); + } + + function test_BeforeCollection_Revert_WhenCallerNotRecurringCollector() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + vm.expectRevert(IRecurringAgreementManager.OnlyAgreementCollector.selector); + agreementManager.beforeCollection(agreementId, 100 ether); + } + + function test_BeforeCollection_IgnoresUnknownAgreement() public { + bytes16 unknownId = bytes16(keccak256("unknown")); + + // Should not revert + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(unknownId, 100 ether); + } + + // -- afterCollection -- + + function test_AfterCollection_ReconcileAndFundEscrow() 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); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 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); + + vm.warp(lastCollectionAt); + + // Call afterCollection as RecurringCollector (simulates post-collect callback) + vm.prank(address(recurringCollector)); + agreementManager.afterCollection(agreementId, 500 ether); + + // After first collection, maxInitialTokens no longer applies + // New max = 1e18 * 3600 = 3600e18 + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 3600 ether); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 3600 ether); + } + + function test_AfterCollection_Revert_WhenCallerNotRecurringCollector() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + vm.expectRevert(IRecurringAgreementManager.OnlyAgreementCollector.selector); + agreementManager.afterCollection(agreementId, 100 ether); + } + + function test_AfterCollection_IgnoresUnknownAgreement() public { + bytes16 unknownId = bytes16(keccak256("unknown")); + + // Should not revert — just silently return + vm.prank(address(recurringCollector)); + agreementManager.afterCollection(unknownId, 100 ether); + } + + function test_AfterCollection_CanceledByServiceProvider() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + _setAgreementCanceledBySP(agreementId, rca); + + vm.prank(address(recurringCollector)); + agreementManager.afterCollection(agreementId, 0); + + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + } + + /* solhint-enable graph/func-name-mixedcase */ +} 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..a6d4baaa9 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/approver.t.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IContractApprover } from "@graphprotocol/interfaces/contracts/horizon/IContractApprover.sol"; +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; +import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerApproverTest is RecurringAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + // -- IContractApprover Tests -- + + function test_ApproveAgreement_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.approveAgreement(agreementHash); + assertEq(result, IContractApprover.approveAgreement.selector); + } + + function test_ApproveAgreement_ReturnsZero_WhenNotAuthorized() public { + bytes32 fakeHash = keccak256("fake agreement"); + assertEq(agreementManager.approveAgreement(fakeHash), bytes4(0)); + } + + function test_ApproveAgreement_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.approveAgreement(hash1), IContractApprover.approveAgreement.selector); + + // rca2 hash should NOT be authorized + bytes32 hash2 = recurringCollector.hashRCA(rca2); + assertEq(agreementManager.approveAgreement(hash2), bytes4(0)); + } + + // -- 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_IRecurringAgreementManager() public view { + assertTrue(agreementManager.supportsInterface(type(IRecurringAgreementManager).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(address(recurringCollector), 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, address(recurringCollector)); + + // With 1 agreement, Full degrades to OnDemand (no deposit). Deficit is the full required. + assertEq(agreementManager.getDeficit(address(recurringCollector), indexer), maxClaim); + } + + function test_GetRequiredEscrow_ZeroForUnknownIndexer() public { + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), makeAddr("unknown")), 0); + } + + function test_GetAgreementMaxNextClaim_ZeroForUnknown() public view { + assertEq(agreementManager.getAgreementMaxNextClaim(bytes16(keccak256("unknown"))), 0); + } + + function test_GetIndexerAgreementCount_ZeroForUnknown() public { + assertEq(agreementManager.getProviderAgreementCount(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..64622875a --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/cancelAgreement.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.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 { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerCancelAgreementTest is RecurringAgreementManagerSharedTest { + /* 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 IRecurringAgreementManager.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(address(recurringCollector), 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(address(recurringCollector), 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(address(recurringCollector), 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(IRecurringAgreementManager.AgreementNotAccepted.selector, agreementId)); + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + } + + function test_CancelAgreement_Revert_WhenNotOffered() public { + bytes16 fakeId = bytes16(keccak256("fake")); + + vm.expectRevert(abi.encodeWithSelector(IRecurringAgreementManager.AgreementNotOffered.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 IRecurringAgreementManager.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..deb85ad9f --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/edgeCases.t.sol @@ -0,0 +1,1074 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import { IContractApprover } from "@graphprotocol/interfaces/contracts/horizon/IContractApprover.sol"; +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +/// @notice Edge case and boundary condition tests for RecurringAgreementManager. +contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + // ==================== supportsInterface Fallback ==================== + + function test_SupportsInterface_UnknownInterfaceReturnsFalse() public view { + // Use a random interfaceId that doesn't match any supported interface + // This exercises the super.supportsInterface() fallback (line 100) + assertFalse(agreementManager.supportsInterface(bytes4(0xdeadbeef))); + } + + function test_SupportsInterface_ERC165() public view { + // ERC165 itself (0x01ffc9a7) is supported via super.supportsInterface() + assertTrue(agreementManager.supportsInterface(type(IERC165).interfaceId)); + } + + // ==================== Cancel with Invalid Data Service ==================== + + function test_CancelAgreement_Revert_WhenDataServiceHasNoCode() public { + // Use an EOA as dataService so ds.code.length == 0 (line 255) + address eoa = makeAddr("eoa-data-service"); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca.dataService = eoa; + + token.mint(address(agreementManager), 1_000_000 ether); + vm.prank(operator); + bytes16 agreementId = agreementManager.offerAgreement(rca, address(recurringCollector)); + + // Set as Accepted so it takes the cancel-via-dataService path + recurringCollector.setAgreement( + agreementId, + IRecurringCollector.AgreementData({ + dataService: eoa, + payer: address(agreementManager), + serviceProvider: indexer, + acceptedAt: uint64(block.timestamp), + 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 + }) + ); + + vm.expectRevert(abi.encodeWithSelector(IRecurringAgreementManager.InvalidDataService.selector, eoa)); + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + } + + // ==================== 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.approveAgreement(rcaHash), IContractApprover.approveAgreement.selector); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + // Hash is cleaned up (not just stale — actually deleted) + assertEq(agreementManager.approveAgreement(rcaHash), bytes4(0)); + } + + 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.approveAgreement(updateHash), IContractApprover.approveAgreement.selector); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + // Both hashes cleaned up + assertEq(agreementManager.approveAgreement(updateHash), bytes4(0)); + } + + 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 + assertEq(agreementManager.approveAgreement(rcaHash), bytes4(0)); + } + + 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 + assertEq(agreementManager.approveAgreement(updateHash), bytes4(0)); + } + + 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.approveAgreement(updateHash), IContractApprover.approveAgreement.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 + assertEq(agreementManager.approveAgreement(updateHash), bytes4(0)); + } + + 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.approveAgreement(hash1), IContractApprover.approveAgreement.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 + assertEq(agreementManager.approveAgreement(hash1), bytes4(0)); + + // Second update hash should be authorized + bytes32 hash2 = recurringCollector.hashRCAU(rcau2); + assertEq(agreementManager.approveAgreement(hash2), IContractApprover.approveAgreement.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); + + IRecurringAgreementManager.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(address(recurringCollector), 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(address(recurringCollector), 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(address(recurringCollector), indexer), 0); + assertEq(agreementManager.getProviderAgreementCount(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(IRecurringAgreementManager.AgreementStillClaimable.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.getProviderAgreementCount(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(address(recurringCollector), 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, address(recurringCollector)); + + 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(address(recurringCollector), indexer), maxClaim); + + // Escrow has zero balance + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + 0 + ); + + // Full deficit + assertEq(agreementManager.getDeficit(address(recurringCollector), 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 + agreementHelper.reconcileBatch(ids); + + // All reconciled to 0 + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), 0); + } + + function test_ReconcileBatch_EmptyArray() public { + // Empty batch should succeed with no effect + bytes16[] memory ids = new bytes16[](0); + agreementHelper.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")); + + agreementHelper.reconcileBatch(ids); + } + + // ==================== UpdateEscrow Edge Cases ==================== + + function test_UpdateEscrow_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 updateEscrow: initiates thaw + agreementManager.updateEscrow(address(recurringCollector), indexer); + + // Warp past mock's thawing period (1 day) + vm.warp(block.timestamp + 1 days + 1); + + // Second updateEscrow: withdraws thawed tokens, then no more to thaw + agreementManager.updateEscrow(address(recurringCollector), indexer); + + // Third updateEscrow: should be a no-op (nothing to thaw or withdraw) + agreementManager.updateEscrow(address(recurringCollector), 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.approveAgreement(zeroHash), IContractApprover.approveAgreement.selector); + // requiredEscrow should be unchanged (original + 0) + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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 + assertEq(agreementManager.approveAgreement(zeroHash), bytes4(0)); + + // New hash should be authorized + bytes32 newHash = recurringCollector.hashRCAU(rcau2); + assertEq(agreementManager.approveAgreement(newHash), IContractApprover.approveAgreement.selector); + + uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; + assertEq( + agreementManager.getRequiredEscrow(address(recurringCollector), 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.approveAgreement(zeroHash), IContractApprover.approveAgreement.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 + assertEq(agreementManager.approveAgreement(zeroHash), bytes4(0)); + + // Pending fields should be cleared + IRecurringAgreementManager.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(address(recurringCollector), indexer), maxClaim); + assertEq(agreementManager.getProviderAgreementCount(indexer), 1); + + // 2. SP cancels and remove + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.getProviderAgreementCount(indexer), 0); + + // 3. Re-offer the same agreement (same parameters, same agreementId) + bytes16 reofferedId = _offerAgreement(rca); + assertEq(reofferedId, agreementId); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim); + assertEq(agreementManager.getProviderAgreementCount(indexer), 1); + + // 4. Verify the re-offered agreement is fully functional + IRecurringAgreementManager.AgreementInfo memory info = agreementManager.getAgreementInfo(reofferedId); + assertTrue(info.provider != address(0)); + assertEq(info.provider, indexer); + assertEq(info.maxNextClaim, maxClaim); + + // Hash is authorized again + bytes32 rcaHash = recurringCollector.hashRCA(rca); + assertEq(agreementManager.approveAgreement(rcaHash), IContractApprover.approveAgreement.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(address(recurringCollector), indexer), maxClaim2); + assertEq(agreementManager.getProviderAgreementCount(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(IRecurringAgreementManager.ServiceProviderZeroAddress.selector); + vm.prank(operator); + agreementManager.offerAgreement(rca, address(recurringCollector)); + } + + 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(IRecurringAgreementManager.DataServiceZeroAddress.selector); + vm.prank(operator); + agreementManager.offerAgreement(rca, address(recurringCollector)); + } + + // ==================== getProviderAgreements ==================== + + function test_GetIndexerAgreements_Empty() public { + bytes16[] memory ids = agreementManager.getProviderAgreements(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.getProviderAgreements(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.getProviderAgreements(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.getProviderAgreements(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.getProviderAgreements(indexer); + bytes16[] memory indexer2Ids = agreementManager.getProviderAgreements(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 IRecurringAgreementManager.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(address(recurringCollector), 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(address(recurringCollector), 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(address(recurringCollector), 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); + + assertEq(agreementManager.approveAgreement(hash1), bytes4(0)); + assertEq(agreementManager.approveAgreement(hash2), bytes4(0)); + assertEq(agreementManager.approveAgreement(hash3), IContractApprover.approveAgreement.selector); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/eligibility.t.sol b/packages/issuance/test/unit/agreement-manager/eligibility.t.sol new file mode 100644 index 000000000..87688a870 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/eligibility.t.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol"; +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; + +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; +import { MockEligibilityOracle } from "./mocks/MockEligibilityOracle.sol"; + +/// @notice Tests for payment eligibility oracle in RecurringAgreementManager +contract RecurringAgreementManagerEligibilityTest is RecurringAgreementManagerSharedTest { + MockEligibilityOracle internal oracle; + + function setUp() public override { + super.setUp(); + oracle = new MockEligibilityOracle(); + vm.label(address(oracle), "EligibilityOracle"); + } + + /* solhint-disable graph/func-name-mixedcase */ + + // -- setPaymentEligibilityOracle tests -- + + function test_SetPaymentEligibilityOracle() public { + vm.expectEmit(address(agreementManager)); + emit IRecurringAgreementManager.PaymentEligibilityOracleSet(address(0), address(oracle)); + + vm.prank(governor); + agreementManager.setPaymentEligibilityOracle(address(oracle)); + } + + function test_SetPaymentEligibilityOracle_DisableWithZeroAddress() public { + // First set an oracle + vm.prank(governor); + agreementManager.setPaymentEligibilityOracle(address(oracle)); + + // Then disable it + vm.expectEmit(address(agreementManager)); + emit IRecurringAgreementManager.PaymentEligibilityOracleSet(address(oracle), address(0)); + + vm.prank(governor); + agreementManager.setPaymentEligibilityOracle(address(0)); + } + + function test_SetPaymentEligibilityOracle_Revert_WhenNotGovernor() public { + vm.expectRevert(); + vm.prank(operator); + agreementManager.setPaymentEligibilityOracle(address(oracle)); + } + + // -- isEligible passthrough tests -- + + function test_IsEligible_TrueWhenNoOracle() public view { + // No oracle set — all providers are eligible + assertTrue(agreementManager.isEligible(indexer)); + } + + function test_IsEligible_DelegatesToOracle_WhenEligible() public { + oracle.setEligible(indexer, true); + + vm.prank(governor); + agreementManager.setPaymentEligibilityOracle(address(oracle)); + + assertTrue(agreementManager.isEligible(indexer)); + } + + function test_IsEligible_DelegatesToOracle_WhenNotEligible() public { + // indexer not set as eligible, default is false + + vm.prank(governor); + agreementManager.setPaymentEligibilityOracle(address(oracle)); + + assertFalse(agreementManager.isEligible(indexer)); + } + + function test_IsEligible_TrueAfterOracleDisabled() public { + // Set oracle that denies indexer + vm.prank(governor); + agreementManager.setPaymentEligibilityOracle(address(oracle)); + assertFalse(agreementManager.isEligible(indexer)); + + // Disable oracle + vm.prank(governor); + agreementManager.setPaymentEligibilityOracle(address(0)); + assertTrue(agreementManager.isEligible(indexer)); + } + + // -- ERC165 tests -- + + function test_SupportsInterface_IRewardsEligibility() public view { + assertTrue(agreementManager.supportsInterface(type(IRewardsEligibility).interfaceId)); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/fundingModes.t.sol b/packages/issuance/test/unit/agreement-manager/fundingModes.t.sol new file mode 100644 index 000000000..8a9c91b31 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/fundingModes.t.sol @@ -0,0 +1,1375 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; + +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + address internal indexer2; + + function setUp() public virtual override { + super.setUp(); + indexer2 = makeAddr("indexer2"); + } + + // -- Helper -- + + 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; + } + + // ==================== setFundingBasis ==================== + + function test_SetFundingBasis_DefaultIsFull() public view { + assertEq(uint256(agreementManager.getFundingBasis()), uint256(IRecurringAgreementManager.FundingBasis.Full)); + } + + function test_SetFundingBasis_GovernorCanSet() public { + vm.prank(governor); + vm.expectEmit(address(agreementManager)); + emit IRecurringAgreementManager.FundingBasisChanged( + IRecurringAgreementManager.FundingBasis.Full, + IRecurringAgreementManager.FundingBasis.OnDemand + ); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + assertEq( + uint256(agreementManager.getFundingBasis()), + uint256(IRecurringAgreementManager.FundingBasis.OnDemand) + ); + } + + function test_SetFundingBasis_Revert_WhenNotGovernor() public { + vm.prank(operator); + vm.expectRevert(); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + } + + // ==================== Global Tracking ==================== + + function test_GlobalTracking_TotalRequired() 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 + ); + + _offerAgreement(rca1); + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + assertEq(agreementManager.getTotalRequired(), maxClaim1); + assertEq(agreementManager.getTotalAgreementCount(), 1); + + _offerAgreement(rca2); + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getTotalRequired(), maxClaim1 + maxClaim2); + assertEq(agreementManager.getTotalAgreementCount(), 2); + } + + function test_GlobalTracking_TotalUnfunded() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + _offerAgreement(rca); + + // In Full mode, escrow is fully funded — totalUnfunded should be 0 + assertEq(agreementManager.getTotalUnfunded(), 0, "Fully funded: totalUnfunded = 0"); + } + + function test_GlobalTracking_TotalUnfunded_WhenPartiallyFunded() public { + // Offer in JIT mode (no deposits) — totalUnfunded = totalRequired + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + assertEq(agreementManager.getTotalUnfunded(), maxClaim, "JIT: totalUnfunded = totalRequired"); + } + + function test_GlobalTracking_RevokeDecrementsCountAndRequired() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + assertEq(agreementManager.getTotalRequired(), maxClaim); + assertEq(agreementManager.getTotalAgreementCount(), 1); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + assertEq(agreementManager.getTotalRequired(), 0); + assertEq(agreementManager.getTotalAgreementCount(), 0); + } + + function test_GlobalTracking_RemoveDecrementsCountAndRequired() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + bytes16 agreementId = _offerAgreement(rca); + assertEq(agreementManager.getTotalAgreementCount(), 1); + + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + + assertEq(agreementManager.getTotalRequired(), 0); + assertEq(agreementManager.getTotalAgreementCount(), 0); + } + + function test_GlobalTracking_ReconcileUpdatesRequired() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + assertEq(agreementManager.getTotalRequired(), maxClaim); + + // SP cancels — reconcile sets maxNextClaim to 0 + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.reconcileAgreement(agreementId); + + assertEq(agreementManager.getTotalRequired(), 0); + // Count unchanged (not removed yet) + assertEq(agreementManager.getTotalAgreementCount(), 1); + } + + function test_GlobalTracking_TotalUnfunded_MultiProvider() 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 + ); + + _offerAgreement(rca1); + _offerAgreement(rca2); + + // In Full mode, both are fully funded — totalUnfunded should be 0 + assertEq(agreementManager.getTotalUnfunded(), 0, "Both funded: totalUnfunded = 0"); + } + + function test_GlobalTracking_TotalUnfunded_OverfundedProviderDoesNotMaskDeficit() public { + // Regression test: over-funded provider must NOT mask another provider's deficit. + // Offer rca1 for indexer (gets fully funded) + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + _offerAgreement(rca1); + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + + // Drain SAM so indexer2's agreement can't be funded + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + // Offer rca2 for indexer2 (can't be funded) + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer2, + 200 ether, + 2 ether, + 7200, + 2 + ); + vm.prank(operator); + agreementManager.offerAgreement(rca2, address(recurringCollector)); + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + + // indexer is fully funded (unfunded = 0), indexer2 has full deficit (unfunded = maxClaim2) + // totalUnfunded must be maxClaim2, NOT 0 (the old buggy totalRequired - totalInEscrow approach + // would compute totalRequired = maxClaim1 + maxClaim2, totalInEscrow = maxClaim1, + // deficit = maxClaim2 — which happens to be correct here, but would be wrong if indexer + // were over-funded and the excess masked indexer2's deficit) + assertEq(agreementManager.getTotalUnfunded(), maxClaim2, "Unfunded = indexer2's full deficit"); + + // Verify per-provider escrow state + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + maxClaim1, + "indexer: fully funded" + ); + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer2).balance, + 0, + "indexer2: unfunded" + ); + } + + // ==================== Full Mode (default — existing behavior) ==================== + + function test_FullMode_DepositsFullRequired() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + maxClaim + ); + } + + function test_FullMode_ThawsExcess() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + bytes16 agreementId = _offerAgreement(rca); + + // SP cancels, remove (triggers thaw of all excess) + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.balance - account.tokensThawing, 0, "Full mode: all excess should be thawing"); + } + + // ==================== JustInTime Mode ==================== + + function test_JustInTime_ThawsEverything() public { + // Start in Full mode, offer agreement (gets funded) + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + // Switch to JustInTime + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + + // Update escrow — should thaw everything + agreementManager.updateEscrow(address(recurringCollector), indexer); + + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.tokensThawing, maxClaim, "JustInTime: all balance should be thawing"); + } + + function test_JustInTime_NoProactiveDeposit() public { + // Switch to JustInTime before offering + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + _offerAgreement(rca); + + // No deposit should have been made + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.balance, 0, "JustInTime: no proactive deposit"); + } + + function test_JustInTime_JITStillWorks() public { + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Escrow is 0, but beforeCollection should top up + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 500 ether); + + uint256 newBalance = paymentsEscrow + .escrowAccounts(address(agreementManager), address(recurringCollector), indexer) + .balance; + assertEq(newBalance, 500 ether, "JustInTime: JIT should deposit requested amount"); + } + + // ==================== OnDemand Mode ==================== + + function test_OnDemand_NoProactiveDeposit() public { + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + _offerAgreement(rca); + + // No deposit — same as JustInTime for deposits + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.balance, 0, "OnDemand: no proactive deposit"); + } + + function test_OnDemand_HoldsAtRequiredLevel() public { + // Fund with Full mode first, then switch to OnDemand + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + // OnDemand thaw ceiling = required — no thaw expected (balance == thawCeiling) + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + agreementManager.updateEscrow(address(recurringCollector), indexer); + + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.tokensThawing, 0, "OnDemand: no thaw (balance == required == thawCeiling)"); + assertEq(account.balance, maxClaim, "OnDemand: balance held at required level"); + } + + function test_OnDemand_DoesNotThawBelowRequired_VsJustInTime() public { + // Fund 6 agreements at Full level, compare OnDemand vs JustInTime + for (uint256 i = 1; i <= 6; i++) { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + i + ); + _offerAgreement(rca); + } + + uint256 maxClaimEach = 1 ether * 3600 + 100 ether; + uint256 totalRequired = maxClaimEach * 6; + + // JustInTime would thaw everything + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + agreementManager.updateEscrow(address(recurringCollector), indexer); + + IPaymentsEscrow.EscrowAccount memory jitAccount = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(jitAccount.tokensThawing, totalRequired, "JustInTime: thaws everything"); + + // Switch to OnDemand — should cancel thaw (thaw ceiling = required) + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + agreementManager.updateEscrow(address(recurringCollector), indexer); + + IPaymentsEscrow.EscrowAccount memory odAccount = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + // OnDemand holds at required level — should reduce/cancel thaw + assertTrue(odAccount.tokensThawing < jitAccount.tokensThawing, "OnDemand thaws less than JustInTime"); + } + + function test_OnDemand_JITStillWorks() public { + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + + // No deposit, but JIT works + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 500 ether); + + uint256 newBalance = paymentsEscrow + .escrowAccounts(address(agreementManager), address(recurringCollector), indexer) + .balance; + assertEq(newBalance, 500 ether, "OnDemand: JIT should work"); + } + + // ==================== Degradation: Full -> OnDemand ==================== + + function test_Degradation_FullToOnDemand_WhenInsufficientBalance() public { + // Offer agreement for indexer1 that consumes most available funds + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + _offerAgreement(rca1); + + // Offer 6 agreements for indexer2, each with large maxClaim + // SAM won't have enough for all of them at Full level + for (uint256 i = 1; i <= 6; i++) { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer2, + 100_000 ether, + 100 ether, + 7200, + i + 10 + ); + token.mint(address(agreementManager), 100_000 ether); + vm.prank(operator); + agreementManager.offerAgreement(rca, address(recurringCollector)); + } + + // totalRequired should be larger than totalUnfunded (degradation occurred: Full -> OnDemand) + assertTrue(0 < agreementManager.getTotalUnfunded(), "Degradation: some unfunded deficit exists"); + } + + function test_Degradation_NeverReachesJustInTime() public { + // Even with severe underfunding, degradation stops at OnDemand (thaw ceiling = required) + // and never reaches JustInTime (thaw ceiling = 0) + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + // Balance should still be at maxClaim (thaw ceiling = required) + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.balance, maxClaim, "Balance preserved - degradation doesn't go to JustInTime"); + assertEq(account.tokensThawing, 0, "No thaw - not at JustInTime"); + } + + // ==================== Mode Switch Doesn't Break State ==================== + + function test_ModeSwitch_PreservesAgreements() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + // Switch through all modes — agreement data preserved + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), maxClaim); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim); + + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), maxClaim); + assertEq(agreementManager.getProviderAgreementCount(indexer), 1); + } + + function test_ModeSwitch_UpdateEscrowAppliesNewMode() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + + _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + maxClaim + ); + + // Switch to JustInTime and update escrow + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + agreementManager.updateEscrow(address(recurringCollector), indexer); + + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.tokensThawing, maxClaim, "JustInTime should thaw all"); + } + + // ==================== JIT (beforeCollection) Works in All Modes ==================== + + function test_JIT_WorksInFullMode() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + + token.mint(address(agreementManager), 10000 ether); + + uint256 escrowBalance = paymentsEscrow + .escrowAccounts(address(agreementManager), address(recurringCollector), indexer) + .balance; + + uint256 tokensToCollect = escrowBalance + 500 ether; + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, tokensToCollect); + + uint256 newBalance = paymentsEscrow + .escrowAccounts(address(agreementManager), address(recurringCollector), indexer) + .balance; + assertEq(newBalance, tokensToCollect, "JIT top-up should cover collection in Full mode"); + } + + // ==================== afterCollection Reconciles in All Modes ==================== + + function test_AfterCollection_ReconcileInOnDemandMode() public { + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + + uint64 acceptedAt = uint64(block.timestamp); + uint64 lastCollectionAt = uint64(block.timestamp + 1 hours); + _setAgreementCollected(agreementId, rca, acceptedAt, lastCollectionAt); + vm.warp(lastCollectionAt); + + vm.prank(address(recurringCollector)); + agreementManager.afterCollection(agreementId, 500 ether); + + uint256 newMaxClaim = agreementManager.getAgreementMaxNextClaim(agreementId); + assertEq(newMaxClaim, 1 ether * 3600, "maxNextClaim = ongoing only after first collection"); + } + + // ==================== PendingUpdate with totalRequired tracking ==================== + + function test_GlobalTracking_PendingUpdate() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + assertEq(agreementManager.getTotalRequired(), maxClaim); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days), + 1 + ); + _offerAgreementUpdate(rcau); + + uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getTotalRequired(), maxClaim + pendingMaxClaim); + } + + function test_GlobalTracking_ReplacePendingUpdate() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days), + 1 + ); + _offerAgreementUpdate(rcau1); + + uint256 pendingMaxClaim1 = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getTotalRequired(), maxClaim + pendingMaxClaim1); + + 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; + assertEq(agreementManager.getTotalRequired(), maxClaim + pendingMaxClaim2); + } + + // ==================== Upward Transitions ==================== + + function test_Transition_JustInTimeToFull() public { + // Start in JIT (no deposits), switch to Full (deposits required) + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + // Verify no deposit in JIT mode + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + 0, + "JIT: no deposit" + ); + + // Switch to Full + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.Full); + agreementManager.updateEscrow(address(recurringCollector), indexer); + + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + maxClaim, + "Full: deposits required" + ); + } + + function test_Transition_OnDemandToFull() public { + // Fund at Full, switch to OnDemand (holds at required), switch back to Full + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + // Switch to OnDemand — holds at required (no thaw for 1 agreement) + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + agreementManager.updateEscrow(address(recurringCollector), indexer); + + IPaymentsEscrow.EscrowAccount memory odAccount = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(odAccount.balance, maxClaim, "OnDemand: balance held at required"); + + // Switch back to Full — no change needed (already at required) + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.Full); + agreementManager.updateEscrow(address(recurringCollector), indexer); + + IPaymentsEscrow.EscrowAccount memory fullAccount = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(fullAccount.balance, maxClaim, "Full: at required"); + } + + // ==================== Thaw-In-Progress Transitions ==================== + + function test_Transition_FullToJustInTime_WhileThawActive() public { + // Create agreements, cancel one to start a thaw, then switch to JIT + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 2 + ); + + bytes16 id1 = _offerAgreement(rca1); + _offerAgreement(rca2); + + uint256 maxClaimEach = 1 ether * 3600 + 100 ether; + + // Cancel and remove rca1 — this triggers a thaw for excess + _setAgreementCanceledBySP(id1, rca1); + agreementManager.removeAgreement(id1); + + IPaymentsEscrow.EscrowAccount memory beforeSwitch = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertTrue(0 < beforeSwitch.tokensThawing, "Thaw in progress before switch"); + assertEq(beforeSwitch.tokensThawing, maxClaimEach, "Thawing excess from removed agreement"); + + // Switch to JustInTime while thaw is active — existing thaw continues, + // remaining balance thaws after current thaw completes and is withdrawn + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + agreementManager.updateEscrow(address(recurringCollector), indexer); + + IPaymentsEscrow.EscrowAccount memory midCycle = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + // Existing thaw continues (effective >= thawCeiling=0) + assertEq(midCycle.tokensThawing, maxClaimEach, "Existing thaw continues"); + + // Complete first thaw, withdraw, then second cycle thaws the rest + vm.warp(block.timestamp + 2 days); + agreementManager.updateEscrow(address(recurringCollector), indexer); + + IPaymentsEscrow.EscrowAccount memory afterWithdraw = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + // After withdrawal, remaining balance starts thawing + assertEq(afterWithdraw.tokensThawing, afterWithdraw.balance, "JIT: all remaining balance thawing"); + } + + // ==================== Enforced JIT ==================== + + function test_EnforcedJit_TripsOnPartialBeforeCollection() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + + // Drain SAM's token balance so beforeCollection can't fully fund + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + // Request collection exceeding escrow balance + vm.expectEmit(address(agreementManager)); + emit IRecurringAgreementManager.EnforcedJit(IRecurringAgreementManager.FundingBasis.Full); + + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + + // Verify state + assertTrue(agreementManager.isEnforcedJit(), "Enforced JIT should be tripped"); + assertEq( + uint256(agreementManager.getFundingBasis()), + uint256(IRecurringAgreementManager.FundingBasis.Full), + "Basis unchanged (enforced JIT overrides behavior, not fundingBasis)" + ); + } + + function test_EnforcedJit_PreservesBasisOnTrip() public { + // Set OnDemand, trip — fundingBasis should NOT change + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + + // Drain SAM + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + vm.expectEmit(address(agreementManager)); + emit IRecurringAgreementManager.EnforcedJit(IRecurringAgreementManager.FundingBasis.OnDemand); + + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + + // Basis stays OnDemand (not switched to JIT) + assertEq( + uint256(agreementManager.getFundingBasis()), + uint256(IRecurringAgreementManager.FundingBasis.OnDemand), + "Basis unchanged during trip" + ); + assertTrue(agreementManager.isEnforcedJit()); + } + + function test_EnforcedJit_DoesNotTripWhenFullyCovered() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + // Ensure SAM has plenty of tokens + token.mint(address(agreementManager), 1_000_000 ether); + + // Request less than escrow balance — no trip + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, maxClaim); + + assertFalse(agreementManager.isEnforcedJit(), "No trip when fully covered"); + } + + function test_EnforcedJit_DoesNotTripWhenAlreadyActive() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + + // Drain SAM + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + // First trip + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + assertTrue(agreementManager.isEnforcedJit()); + + // Second partial collection — should NOT emit event again + vm.recordLogs(); + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + + // Check no EnforcedJit event was emitted + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 tripSig = keccak256("EnforcedJit(uint8)"); + bool found = false; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == tripSig) found = true; + } + assertFalse(found, "No second trip event"); + } + + function test_EnforcedJit_TripsEvenWhenAlreadyJustInTime() public { + // Governor explicitly sets JIT + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + + // Drain SAM so beforeCollection can't cover + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + + assertTrue(agreementManager.isEnforcedJit(), "Trips even in JIT mode"); + } + + function test_EnforcedJit_JitStillWorksWhileActive() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + + // Drain SAM to trip the breaker + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + assertTrue(agreementManager.isEnforcedJit()); + + // Now fund SAM and do a JIT top-up while enforced JIT is active + token.mint(address(agreementManager), 500 ether); + + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 500 ether); + + uint256 escrowBalance = paymentsEscrow + .escrowAccounts(address(agreementManager), address(recurringCollector), indexer) + .balance; + uint256 maxClaim = 1 ether * 3600 + 100 ether; + assertTrue(maxClaim <= escrowBalance, "JIT still works during enforced JIT"); + } + + function test_EnforcedJit_RecoveryOnUpdateEscrow() public { + // Offer rca1 (fully funded), drain SAM, offer rca2 (creates unfunded deficit) + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca1); + + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 2 + ); + vm.prank(operator); + agreementManager.offerAgreement(rca2, address(recurringCollector)); + + // Trip enforced JIT + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + assertTrue(agreementManager.isEnforcedJit()); + + // Mint enough to cover totalUnfunded — triggers recovery + uint256 totalUnfunded = agreementManager.getTotalUnfunded(); + assertTrue(0 < totalUnfunded, "Deficit exists"); + token.mint(address(agreementManager), totalUnfunded); + + vm.expectEmit(address(agreementManager)); + emit IRecurringAgreementManager.EnforcedJitRecovered(IRecurringAgreementManager.FundingBasis.Full); + + agreementManager.updateEscrow(address(recurringCollector), indexer); + + assertFalse(agreementManager.isEnforcedJit(), "Enforced JIT recovered"); + assertEq( + uint256(agreementManager.getFundingBasis()), + uint256(IRecurringAgreementManager.FundingBasis.Full), + "Basis still Full" + ); + } + + function test_EnforcedJit_NoRecoveryWhenPartiallyFunded() public { + // Offer rca1 (fully funded), drain, offer rca2 (unfunded — creates deficit) + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca1); + + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 2 + ); + vm.prank(operator); + agreementManager.offerAgreement(rca2, address(recurringCollector)); + + // Trip + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + assertTrue(agreementManager.isEnforcedJit()); + + uint256 totalUnfunded = agreementManager.getTotalUnfunded(); + assertTrue(0 < totalUnfunded, "totalUnfunded > 0"); + + // Mint less than totalUnfunded — no recovery + token.mint(address(agreementManager), totalUnfunded / 2); + + agreementManager.updateEscrow(address(recurringCollector), indexer); + + assertTrue(agreementManager.isEnforcedJit(), "Still tripped (insufficient balance)"); + assertEq( + uint256(agreementManager.getFundingBasis()), + uint256(IRecurringAgreementManager.FundingBasis.Full), + "Basis unchanged" + ); + } + + function test_EnforcedJit_FundingBasisPreservedDuringTrip() public { + // Set OnDemand, trip, recover — fundingBasis stays OnDemand throughout + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + + // Drain and trip + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + assertTrue(agreementManager.isEnforcedJit()); + + assertEq( + uint256(agreementManager.getFundingBasis()), + uint256(IRecurringAgreementManager.FundingBasis.OnDemand), + "Basis preserved during trip" + ); + + // Recovery + token.mint(address(agreementManager), agreementManager.getTotalRequired()); + + vm.expectEmit(address(agreementManager)); + emit IRecurringAgreementManager.EnforcedJitRecovered(IRecurringAgreementManager.FundingBasis.OnDemand); + + agreementManager.updateEscrow(address(recurringCollector), indexer); + assertFalse(agreementManager.isEnforcedJit()); + assertEq( + uint256(agreementManager.getFundingBasis()), + uint256(IRecurringAgreementManager.FundingBasis.OnDemand), + "Basis still OnDemand after recovery" + ); + } + + function test_EnforcedJit_SetFundingBasisClearsBreaker() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca); + + // Drain and trip + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + assertTrue(agreementManager.isEnforcedJit()); + + // Governor manually sets basis — clears enforced JIT + vm.prank(governor); + agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + + assertFalse(agreementManager.isEnforcedJit(), "Governor cleared breaker"); + assertEq( + uint256(agreementManager.getFundingBasis()), + uint256(IRecurringAgreementManager.FundingBasis.OnDemand), + "Governor's chosen basis" + ); + } + + function test_EnforcedJit_MultipleTripRecoverCycles() public { + // Offer rca1 (funded), drain SAM, offer rca2 (unfunded — creates deficit) + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 agreementId = _offerAgreement(rca1); + + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 2 + ); + vm.prank(operator); + agreementManager.offerAgreement(rca2, address(recurringCollector)); + + uint256 unfunded = agreementManager.getTotalUnfunded(); + assertTrue(0 < unfunded, "Has unfunded deficit"); + + // --- Cycle 1: Trip --- + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + assertTrue(agreementManager.isEnforcedJit()); + + // --- Cycle 1: Recover --- + token.mint(address(agreementManager), unfunded); + agreementManager.updateEscrow(address(recurringCollector), indexer); + assertFalse(agreementManager.isEnforcedJit()); + assertEq(uint256(agreementManager.getFundingBasis()), uint256(IRecurringAgreementManager.FundingBasis.Full)); + + // After recovery, updateEscrow deposited into escrow. Drain again and create new deficit. + samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + IRecurringCollector.RecurringCollectionAgreement memory rca3 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 3 + ); + vm.prank(operator); + agreementManager.offerAgreement(rca3, address(recurringCollector)); + + unfunded = agreementManager.getTotalUnfunded(); + assertTrue(0 < unfunded, "New unfunded deficit"); + + // --- Cycle 2: Trip --- + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId, 1_000_000 ether); + assertTrue(agreementManager.isEnforcedJit()); + + // --- Cycle 2: Recover --- + token.mint(address(agreementManager), unfunded); + agreementManager.updateEscrow(address(recurringCollector), indexer); + assertFalse(agreementManager.isEnforcedJit()); + assertEq(uint256(agreementManager.getFundingBasis()), uint256(IRecurringAgreementManager.FundingBasis.Full)); + } + + function test_EnforcedJit_MultiProvider() public { + // Offer rca1 (funded), drain SAM, offer rca2 (creates deficit → totalUnfunded > 0) + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + bytes16 id1 = _offerAgreement(rca1); + + // Drain SAM so rca2 can't be funded + uint256 samBalance = token.balanceOf(address(agreementManager)); + if (0 < samBalance) { + vm.prank(address(agreementManager)); + token.transfer(address(1), samBalance); + } + + // Offer rca2 directly (no mint) — escrow stays unfunded, creates deficit + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer2, + 100 ether, + 1 ether, + 3600, + 2 + ); + vm.prank(operator); + agreementManager.offerAgreement(rca2, address(recurringCollector)); + assertTrue(0 < agreementManager.getTotalUnfunded(), "should have unfunded escrow"); + + // Trip via indexer's agreement + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(id1, 1_000_000 ether); + assertTrue(agreementManager.isEnforcedJit()); + + // Both providers should see JIT behavior (thaw everything) + agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(address(recurringCollector), indexer2); + + IPaymentsEscrow.EscrowAccount memory acc1 = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + IPaymentsEscrow.EscrowAccount memory acc2 = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer2 + ); + + // Both providers should be thawing (JIT mode via enforced JIT) + assertEq(acc1.tokensThawing, acc1.balance, "indexer: JIT thaws all"); + assertEq(acc2.tokensThawing, acc2.balance, "indexer2: JIT thaws all"); + } + + /* 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..7964e773d --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/fuzz.t.sol @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTest { + /* 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(address(recurringCollector), 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, address(recurringCollector)); + + uint256 maxNextClaim = agreementManager.getAgreementMaxNextClaim(agreementId); + uint256 escrowBalance = paymentsEscrow + .escrowAccounts(address(agreementManager), address(recurringCollector), indexer) + .balance; + + // In Full mode (default): + // If totalUnfunded <= available: Full deposits required. + // If totalUnfunded > available: degrades to OnDemand (deposit target = 0). + // JIT beforeCollection is the safety net for underfunded escrow. + if (maxNextClaim <= availableTokens) { + assertEq(escrowBalance, maxNextClaim); + } else { + // Degraded to OnDemand: no deposit + assertEq(escrowBalance, 0); + } + } + + 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(address(recurringCollector), indexer); + + _offerAgreement(rca2); + uint256 required2 = agreementManager.getRequiredEscrow(address(recurringCollector), 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(address(recurringCollector), indexer); + assertTrue(0 < requiredBefore || (maxInitial == 0 && maxOngoing == 0)); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.getProviderAgreementCount(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(address(recurringCollector), indexer), 0); + assertEq(agreementManager.getProviderAgreementCount(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(address(recurringCollector), 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(address(recurringCollector), 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(address(recurringCollector), 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(address(recurringCollector), 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( + IRecurringAgreementManager.AgreementStillClaimable.selector, + agreementId, + storedMaxClaim + ) + ); + agreementManager.removeAgreement(agreementId); + + // Warp past deadline + vm.warp(rca.deadline + extraTime); + + // After deadline: should succeed + agreementManager.removeAgreement(agreementId); + assertEq(agreementManager.getProviderAgreementCount(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, address(recurringCollector)); + + uint256 required = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + uint256 balance = account.balance - account.tokensThawing; + uint256 deficit = agreementManager.getDeficit(address(recurringCollector), 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/helper.t.sol b/packages/issuance/test/unit/agreement-manager/helper.t.sol new file mode 100644 index 000000000..a84d66f48 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/helper.t.sol @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringAgreementHelper } from "../../../contracts/agreement/RecurringAgreementHelper.sol"; +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementHelperTest is RecurringAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + // -- Constructor tests -- + + function test_Constructor_SetsManager() public view { + assertEq(address(agreementHelper.MANAGER()), address(agreementManager)); + } + + function test_Constructor_Revert_ZeroAddress() public { + vm.expectRevert(RecurringAgreementHelper.ManagerZeroAddress.selector); + new RecurringAgreementHelper(address(0)); + } + + // -- reconcile(provider) tests -- + + 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); + + agreementHelper.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(address(recurringCollector), indexer), 14400 ether); + } + + function test_Reconcile_EmptyProvider() public { + // reconcile for a provider with no agreements — should be a no-op + address unknown = makeAddr("unknown"); + agreementHelper.reconcile(unknown); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), unknown), 0); + } + + function test_Reconcile_IdempotentWhenUnchanged() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Set as accepted + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + + // First reconcile + agreementHelper.reconcile(indexer); + uint256 escrowAfterFirst = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 maxClaimAfterFirst = agreementManager.getAgreementMaxNextClaim(agreementId); + + // Second reconcile should produce identical results (idempotent) + vm.recordLogs(); + agreementHelper.reconcile(indexer); + + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), escrowAfterFirst); + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), maxClaimAfterFirst); + + // No reconcile event on the second call since nothing changed + 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 on idempotent call"); + } + } + + function test_Reconcile_MultipleAgreements_MixedStates() public { + // Three agreements for the same indexer, each in a different state + 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; + + IRecurringCollector.RecurringCollectionAgreement memory rca3 = _makeRCA( + 0, + 3 ether, + 60, + 1800, + uint64(block.timestamp + 365 days) + ); + rca3.nonce = 3; + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + bytes16 id3 = _offerAgreement(rca3); + + // id1: Canceled by SP -> maxClaim = 0 + _setAgreementCanceledBySP(id1, rca1); + + // id2: Accepted, collected -> no initial tokens + uint64 lastCollectionAt = uint64(block.timestamp + 1 hours); + _setAgreementCollected(id2, rca2, uint64(block.timestamp), lastCollectionAt); + + // id3: Not yet accepted -> keep pre-offer estimate + // (default mock returns NotAccepted) + + vm.warp(lastCollectionAt); + token.mint(address(agreementManager), 1_000_000 ether); + + agreementHelper.reconcile(indexer); + + assertEq(agreementManager.getAgreementMaxNextClaim(id1), 0); + assertEq(agreementManager.getAgreementMaxNextClaim(id2), 14400 ether); // 2e18 * 7200 + // id3 unchanged: 3e18 * 1800 = 5400e18 (pre-offer estimate) + assertEq(agreementManager.getAgreementMaxNextClaim(id3), 5400 ether); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 14400 ether + 5400 ether); + } + + // -- reconcileBatch tests -- + + 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(address(recurringCollector), 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; + agreementHelper.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(address(recurringCollector), 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; + agreementHelper.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); + agreementHelper.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(address(recurringCollector), indexer), maxClaim1); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + + // Cancel both by SP + _setAgreementCanceledBySP(id1, rca1); + _setAgreementCanceledBySP(id2, rca2); + + bytes16[] memory ids = new bytes16[](2); + ids[0] = id1; + ids[1] = id2; + agreementHelper.reconcileBatch(ids); + + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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); + agreementHelper.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(address(recurringCollector), 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; + agreementHelper.reconcileBatch(ids); + + // Pending should be cleared; required escrow should be based on new terms + uint256 newMaxClaim = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), newMaxClaim); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/mocks/MockEligibilityOracle.sol b/packages/issuance/test/unit/agreement-manager/mocks/MockEligibilityOracle.sol new file mode 100644 index 000000000..c1577d8b7 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/mocks/MockEligibilityOracle.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol"; + +/// @notice Simple mock eligibility oracle for testing SAM passthrough +contract MockEligibilityOracle is IRewardsEligibility { + mapping(address => bool) public eligible; + bool public defaultEligible; + + function setEligible(address indexer, bool _eligible) external { + eligible[indexer] = _eligible; + } + + function setDefaultEligible(bool _default) external { + defaultEligible = _default; + } + + function isEligible(address indexer) external view override returns (bool) { + if (eligible[indexer]) return true; + return defaultEligible; + } +} 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..dd07fab6e --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/mocks/MockGraphToken.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +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..5b4260f63 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/mocks/MockPaymentsEscrow.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +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 RecurringAgreementManager testing. +/// Tracks deposits per (payer, collector, receiver) and transfers tokens on deposit. +/// Supports thaw/withdraw lifecycle for updateEscrow() 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 thaw(address collector, address receiver, uint256 tokens) external returns (uint256) { + return _thaw(collector, receiver, tokens, true); + } + + function thaw( + address collector, + address receiver, + uint256 tokens, + bool evenIfTimerReset + ) external returns (uint256) { + return _thaw(collector, receiver, tokens, evenIfTimerReset); + } + + function cancelThaw(address collector, address receiver) external returns (uint256) { + return _thaw(collector, receiver, 0, true); + } + + function _thaw( + address collector, + address receiver, + uint256 tokens, + bool evenIfTimerReset + ) private returns (uint256 tokensThawing) { + Account storage account = accounts[msg.sender][collector][receiver]; + tokensThawing = tokens < account.balance ? tokens : account.balance; + if (tokensThawing == account.tokensThawing) { + return tokensThawing; + } + uint256 newThawEndTimestamp = block.timestamp + THAWING_PERIOD; + if (tokensThawing < account.tokensThawing) { + account.tokensThawing = tokensThawing; + if (tokensThawing == 0) account.thawEndTimestamp = 0; + } else { + if (!evenIfTimerReset && account.thawEndTimestamp != 0 && account.thawEndTimestamp != newThawEndTimestamp) + return account.tokensThawing; + account.tokensThawing = tokensThawing; + account.thawEndTimestamp = newThawEndTimestamp; + } + } + + function withdraw(address collector, address receiver) external returns (uint256 tokens) { + Account storage account = accounts[msg.sender][collector][receiver]; + if (account.thawEndTimestamp == 0 || block.timestamp <= account.thawEndTimestamp) { + return 0; + } + tokens = account.tokensThawing; + account.balance -= tokens; + account.tokensThawing = 0; + account.thawEndTimestamp = 0; + token.transfer(msg.sender, tokens); + } + + function escrowAccounts( + address payer, + address collector, + address receiver + ) external view returns (IPaymentsEscrow.EscrowAccount memory) { + Account storage account = accounts[payer][collector][receiver]; + return + IPaymentsEscrow.EscrowAccount({ + balance: account.balance, + tokensThawing: account.tokensThawing, + thawEndTimestamp: account.thawEndTimestamp + }); + } + + 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; + } + + // -- Stubs (not used by RecurringAgreementManager) -- + + 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..36275f404 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/mocks/MockRecurringCollector.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +/// @notice Minimal mock of RecurringCollector for RecurringAgreementManager 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..c74bf72cb --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/mocks/MockSubgraphService.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/// @notice Minimal mock of SubgraphService for RecurringAgreementManager 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/multiCollector.t.sol b/packages/issuance/test/unit/agreement-manager/multiCollector.t.sol new file mode 100644 index 000000000..b56790e76 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/multiCollector.t.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; +import { MockRecurringCollector } from "./mocks/MockRecurringCollector.sol"; + +contract RecurringAgreementManagerMultiCollectorTest is RecurringAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + MockRecurringCollector internal collector2; + + function setUp() public override { + super.setUp(); + collector2 = new MockRecurringCollector(); + vm.label(address(collector2), "RecurringCollector2"); + } + + // -- Helpers -- + + function _makeRCAForCollector( + MockRecurringCollector collector, + uint256 maxInitialTokens, + uint256 maxOngoingTokensPerSecond, + uint32 maxSecondsPerCollection, + uint64 endsAt, + uint256 nonce + ) internal view returns (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) { + rca = IRecurringCollector.RecurringCollectionAgreement({ + deadline: uint64(block.timestamp + 1 hours), + endsAt: endsAt, + payer: address(agreementManager), + dataService: dataService, + serviceProvider: indexer, + maxInitialTokens: maxInitialTokens, + maxOngoingTokensPerSecond: maxOngoingTokensPerSecond, + minSecondsPerCollection: 60, + maxSecondsPerCollection: maxSecondsPerCollection, + nonce: nonce, + metadata: "" + }); + agreementId = collector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + } + + // -- Tests -- + + function test_MultiCollector_RequiredEscrowIsolation() public { + // Offer agreement via collector1 (the default recurringCollector) + (IRecurringCollector.RecurringCollectionAgreement memory rca1, ) = _makeRCAForCollector( + recurringCollector, + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days), + 1 + ); + token.mint(address(agreementManager), 1_000_000 ether); + vm.prank(operator); + agreementManager.offerAgreement(rca1, address(recurringCollector)); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + + // Offer agreement via collector2 with different terms + (IRecurringCollector.RecurringCollectionAgreement memory rca2, ) = _makeRCAForCollector( + collector2, + 200 ether, + 2 ether, + 7200, + uint64(block.timestamp + 365 days), + 2 + ); + vm.prank(operator); + agreementManager.offerAgreement(rca2, address(collector2)); + + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + + // Required escrow is independent per collector + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1); + assertEq(agreementManager.getRequiredEscrow(address(collector2), indexer), maxClaim2); + } + + function test_MultiCollector_BeforeCollectionOnlyOwnAgreements() public { + // Offer agreement via collector1 + (IRecurringCollector.RecurringCollectionAgreement memory rca1, bytes16 agreementId1) = _makeRCAForCollector( + recurringCollector, + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days), + 1 + ); + token.mint(address(agreementManager), 1_000_000 ether); + vm.prank(operator); + agreementManager.offerAgreement(rca1, address(recurringCollector)); + + // collector2 cannot call beforeCollection on collector1's agreement + vm.prank(address(collector2)); + vm.expectRevert(IRecurringAgreementManager.OnlyAgreementCollector.selector); + agreementManager.beforeCollection(agreementId1, 100 ether); + + // collector1 can call beforeCollection on its own agreement + vm.prank(address(recurringCollector)); + agreementManager.beforeCollection(agreementId1, 100 ether); + } + + function test_MultiCollector_AfterCollectionOnlyOwnAgreements() public { + // Offer agreement via collector1 + (IRecurringCollector.RecurringCollectionAgreement memory rca1, bytes16 agreementId1) = _makeRCAForCollector( + recurringCollector, + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days), + 1 + ); + token.mint(address(agreementManager), 1_000_000 ether); + vm.prank(operator); + agreementManager.offerAgreement(rca1, address(recurringCollector)); + + // collector2 cannot call afterCollection on collector1's agreement + vm.prank(address(collector2)); + vm.expectRevert(IRecurringAgreementManager.OnlyAgreementCollector.selector); + agreementManager.afterCollection(agreementId1, 100 ether); + } + + function test_MultiCollector_SeparateEscrowAccounts() public { + // Offer via collector1 + (IRecurringCollector.RecurringCollectionAgreement memory rca1, ) = _makeRCAForCollector( + recurringCollector, + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days), + 1 + ); + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + token.mint(address(agreementManager), maxClaim1); + vm.prank(operator); + agreementManager.offerAgreement(rca1, address(recurringCollector)); + + // Offer via collector2 + (IRecurringCollector.RecurringCollectionAgreement memory rca2, ) = _makeRCAForCollector( + collector2, + 200 ether, + 2 ether, + 7200, + uint64(block.timestamp + 365 days), + 2 + ); + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + token.mint(address(agreementManager), maxClaim2); + vm.prank(operator); + agreementManager.offerAgreement(rca2, address(collector2)); + + // Escrow accounts are separate per (collector, provider) + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + maxClaim1 + ); + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(collector2), indexer).balance, + maxClaim2 + ); + } + + function test_MultiCollector_RevokeOnlyAffectsOwnCollectorEscrow() public { + // Offer via both collectors + (IRecurringCollector.RecurringCollectionAgreement memory rca1, bytes16 agreementId1) = _makeRCAForCollector( + recurringCollector, + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days), + 1 + ); + token.mint(address(agreementManager), 1_000_000 ether); + vm.prank(operator); + agreementManager.offerAgreement(rca1, address(recurringCollector)); + + (IRecurringCollector.RecurringCollectionAgreement memory rca2, ) = _makeRCAForCollector( + collector2, + 200 ether, + 2 ether, + 7200, + uint64(block.timestamp + 365 days), + 2 + ); + vm.prank(operator); + agreementManager.offerAgreement(rca2, address(collector2)); + + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + + // Revoke collector1's agreement + vm.prank(operator); + agreementManager.revokeOffer(agreementId1); + + // Collector1 escrow cleared, collector2 unaffected + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.getRequiredEscrow(address(collector2), indexer), maxClaim2); + } + + /* solhint-enable graph/func-name-mixedcase */ +} 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..4f0405856 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/multiIndexer.t.sol @@ -0,0 +1,449 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerSharedTest { + /* 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(address(recurringCollector), indexer), maxClaim1); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer3), maxClaim3); + + // Each has exactly 1 agreement + assertEq(agreementManager.getProviderAgreementCount(indexer), 1); + assertEq(agreementManager.getProviderAgreementCount(indexer2), 1); + assertEq(agreementManager.getProviderAgreementCount(indexer3), 1); + + // Each has independent escrow balance + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + maxClaim1 + ); + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer2).balance, + maxClaim2 + ); + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer3).balance, + 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(address(recurringCollector), indexer), 0); + assertEq(agreementManager.getProviderAgreementCount(indexer), 0); + + // Indexer2 unaffected + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + assertEq(agreementManager.getProviderAgreementCount(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(address(recurringCollector), indexer), 0); + + // Indexer2 unaffected + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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(address(recurringCollector), indexer), 0); + + // Indexer2 completely unaffected (still pre-offered estimate) + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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.getProviderAgreementCount(indexer), 2); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1a + maxClaim1b); + assertEq(agreementManager.getProviderAgreementCount(indexer2), 1); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + + // Remove one of indexer's agreements + _setAgreementCanceledBySP(id1a, rca1a); + agreementManager.removeAgreement(id1a); + + assertEq(agreementManager.getProviderAgreementCount(indexer), 1); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1b); + + // Indexer2 still unaffected + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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.getProviderAgreementCount(indexer), 1); + assertEq(agreementManager.getProviderAgreementCount(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); + + // Update escrow for indexer1 — should thaw excess + agreementManager.updateEscrow(address(recurringCollector), indexer); + + // Indexer1 escrow thawing (excess = maxClaim1, required = 0) + IPaymentsEscrow.EscrowAccount memory acct1 = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(acct1.balance - acct1.tokensThawing, 0); + + // Indexer2 escrow completely unaffected + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer2).balance, + maxClaim2 + ); + + // updateEscrow on indexer2 is a no-op (balance == required, no excess) + agreementManager.updateEscrow(address(recurringCollector), 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(address(recurringCollector), indexer), maxClaim1); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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(address(recurringCollector), indexer) < maxClaim1); + + // Indexer2 unaffected + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + + // 5. Cancel indexer2 by SP + _setAgreementCanceledBySP(id2, rca2); + agreementManager.reconcileAgreement(id2); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), 0); + + // 6. Remove indexer2's agreement + agreementManager.removeAgreement(id2); + assertEq(agreementManager.getProviderAgreementCount(indexer2), 0); + + // 7. Update escrow for indexer2 (thaw excess) + agreementManager.updateEscrow(address(recurringCollector), indexer2); + IPaymentsEscrow.EscrowAccount memory acct2 = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer2 + ); + assertEq(acct2.balance - acct2.tokensThawing, 0); + + // 8. Indexer1 still active + assertEq(agreementManager.getProviderAgreementCount(indexer), 1); + assertTrue(0 < agreementManager.getRequiredEscrow(address(recurringCollector), 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); + + IRecurringAgreementManager.AgreementInfo memory info1 = agreementManager.getAgreementInfo(id1); + IRecurringAgreementManager.AgreementInfo memory info2 = agreementManager.getAgreementInfo(id2); + + assertEq(info1.provider, indexer); + assertEq(info2.provider, indexer2); + assertTrue(info1.provider != address(0)); + assertTrue(info2.provider != address(0)); + 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..57fb9537e --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/offerUpdate.t.sol @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.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 { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerOfferUpdateTest is RecurringAgreementManagerSharedTest { + /* 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(address(recurringCollector), 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.approveAgreement(updateHash); + assertEq(result, agreementManager.approveAgreement.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, address(recurringCollector)); + + // 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 + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + 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(address(recurringCollector), 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(address(recurringCollector), 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 IRecurringAgreementManager.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(IRecurringAgreementManager.AgreementNotOffered.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..96b3f4ea5 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/reconcile.t.sol @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; + +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerReconcileTest is RecurringAgreementManagerSharedTest { + /* 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(address(recurringCollector), 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(address(recurringCollector), 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 IRecurringAgreementManager.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_ReconcileAgreement_Revert_WhenNotOffered() public { + bytes16 fakeId = bytes16(keccak256("fake")); + vm.expectRevert(abi.encodeWithSelector(IRecurringAgreementManager.AgreementNotOffered.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(address(recurringCollector), 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(address(recurringCollector), 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(address(recurringCollector), indexer), + originalMaxClaim + pendingMaxClaim + ); + } + + /* 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..fad478a9a --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/register.t.sol @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.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 { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerOfferTest is RecurringAgreementManagerSharedTest { + /* 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(address(recurringCollector), indexer), expectedMaxClaim); + assertEq(agreementManager.getProviderAgreementCount(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, address(recurringCollector)); + + // Verify escrow was funded + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + 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, address(recurringCollector)); + + // Since available < required, Full degrades to OnDemand (deposit target = 0). + // No proactive deposit; JIT beforeCollection is the safety net. + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + 0 + ); + // Deficit is full required since no deposit was made + assertEq(agreementManager.getDeficit(address(recurringCollector), indexer), expectedMaxClaim); + } + + 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 IRecurringAgreementManager.AgreementOffered(expectedId, indexer, expectedMaxClaim); + + vm.prank(operator); + agreementManager.offerAgreement(rca, address(recurringCollector)); + } + + 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.approveAgreement(agreementHash); + assertEq(result, agreementManager.approveAgreement.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.getProviderAgreementCount(indexer), 2); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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( + IRecurringAgreementManager.PayerMustBeManager.selector, + address(0xdead), + address(agreementManager) + ) + ); + vm.prank(operator); + agreementManager.offerAgreement(rca, address(recurringCollector)); + } + + 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(IRecurringAgreementManager.AgreementAlreadyOffered.selector, agreementId) + ); + vm.prank(operator); + agreementManager.offerAgreement(rca, address(recurringCollector)); + } + + 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, address(recurringCollector)); + } + + 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, address(recurringCollector)); + } + + /* 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..9b19441b0 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/remove.t.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerRemoveTest is RecurringAgreementManagerSharedTest { + /* 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.getProviderAgreementCount(indexer), 1); + + // SP cancels - immediately removable + _setAgreementCanceledBySP(agreementId, rca); + + vm.expectEmit(address(agreementManager)); + emit IRecurringAgreementManager.AgreementRemoved(agreementId, indexer); + + agreementManager.removeAgreement(agreementId); + + assertEq(agreementManager.getProviderAgreementCount(indexer), 0); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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.getProviderAgreementCount(indexer), 0); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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.getProviderAgreementCount(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(address(recurringCollector), 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(address(recurringCollector), indexer), maxClaim2); + assertEq(agreementManager.getProviderAgreementCount(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(IRecurringAgreementManager.AgreementNotOffered.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(IRecurringAgreementManager.AgreementStillClaimable.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.getProviderAgreementCount(indexer), 0); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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( + IRecurringAgreementManager.AgreementStillClaimable.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(IRecurringAgreementManager.AgreementStillClaimable.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.getProviderAgreementCount(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(address(recurringCollector), 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(address(recurringCollector), indexer), 0); + assertEq(agreementManager.getProviderAgreementCount(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..dd2d083d2 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/revokeOffer.t.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.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 { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerRevokeOfferTest is RecurringAgreementManagerSharedTest { + /* 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.getProviderAgreementCount(indexer), 1); + + uint256 maxClaim = 1 ether * 3600 + 100 ether; + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + assertEq(agreementManager.getProviderAgreementCount(indexer), 0); + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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.approveAgreement(rcaHash); // should not revert + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + // Hash should be rejected after revoke (agreement no longer exists) + assertEq(agreementManager.approveAgreement(rcaHash), bytes4(0)); + } + + 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(address(recurringCollector), indexer), + originalMaxClaim + pendingMaxClaim + ); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + // Both original and pending should be cleared + assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), 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 IRecurringAgreementManager.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(IRecurringAgreementManager.AgreementAlreadyAccepted.selector, agreementId) + ); + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + } + + function test_RevokeOffer_Revert_WhenNotOffered() public { + bytes16 fakeId = bytes16(keccak256("fake")); + vm.expectRevert(abi.encodeWithSelector(IRecurringAgreementManager.AgreementNotOffered.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..fc0b0f2fa --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/shared.t.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +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 { RecurringAgreementManager } from "../../../contracts/agreement/RecurringAgreementManager.sol"; +import { RecurringAgreementHelper } from "../../../contracts/agreement/RecurringAgreementHelper.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 RecurringAgreementManager tests. +contract RecurringAgreementManagerSharedTest is Test { + // -- Contracts -- + MockGraphToken internal token; + MockPaymentsEscrow internal paymentsEscrow; + MockRecurringCollector internal recurringCollector; + MockSubgraphService internal mockSubgraphService; + RecurringAgreementManager internal agreementManager; + RecurringAgreementHelper internal agreementHelper; + + // -- 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 RecurringAgreementManager behind proxy + RecurringAgreementManager impl = new RecurringAgreementManager(address(token), address(paymentsEscrow)); + bytes memory initData = abi.encodeCall(RecurringAgreementManager.initialize, (governor)); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(impl), + address(this), // proxy admin + initData + ); + agreementManager = RecurringAgreementManager(address(proxy)); + + // Deploy RecurringAgreementHelper pointing at the manager + agreementHelper = new RecurringAgreementHelper(address(agreementManager)); + + // 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), "RecurringAgreementManager"); + vm.label(address(agreementHelper), "RecurringAgreementHelper"); + vm.label(address(mockSubgraphService), "SubgraphService"); + } + + // -- Helpers -- + + /// @notice Create a standard RCA with RecurringAgreementManager 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 RecurringAgreementManager with enough tokens + token.mint(address(agreementManager), 1_000_000 ether); + + vm.prank(operator); + return agreementManager.offerAgreement(rca, address(recurringCollector)); + } + + /// @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/issuance/test/unit/agreement-manager/updateEscrow.t.sol b/packages/issuance/test/unit/agreement-manager/updateEscrow.t.sol new file mode 100644 index 000000000..1f0f67af2 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/updateEscrow.t.sol @@ -0,0 +1,467 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; +import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringAgreementManagerSharedTest } from "./shared.t.sol"; + +contract RecurringAgreementManagerUpdateEscrowTest is RecurringAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + // ==================== Basic Thaw / Withdraw ==================== + + function test_UpdateEscrow_ThawsExcessWhenNoAgreements() 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 + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + maxClaim + ); + + // SP cancels — removeAgreement triggers escrow update, thawing the full balance + _setAgreementCanceledBySP(agreementId, rca); + + agreementManager.removeAgreement(agreementId); + + assertEq(agreementManager.getProviderAgreementCount(indexer), 0); + + // balance should now be fully thawing + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.balance - account.tokensThawing, 0); + } + + function test_UpdateEscrow_WithdrawsCompletedThaw() 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 (triggers thaw) + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + + // Fast forward past thawing period (1 day in mock) + vm.warp(block.timestamp + 1 days + 1); + + uint256 agreementManagerBalanceBefore = token.balanceOf(address(agreementManager)); + + // updateEscrow: withdraw + vm.expectEmit(address(agreementManager)); + emit IRecurringAgreementManager.EscrowWithdrawn(indexer, address(recurringCollector), maxClaim); + + agreementManager.updateEscrow(address(recurringCollector), indexer); + + // Tokens should be back in RecurringAgreementManager + uint256 agreementManagerBalanceAfter = token.balanceOf(address(agreementManager)); + assertEq(agreementManagerBalanceAfter - agreementManagerBalanceBefore, maxClaim); + } + + function test_UpdateEscrow_NoopWhenNoBalance() public { + // No agreements, no balance — should succeed silently + agreementManager.updateEscrow(address(recurringCollector), indexer); + } + + function test_UpdateEscrow_NoopWhenStillThawing() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // SP cancels and remove (triggers thaw) + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + + // Subsequent call before thaw complete: no-op (thaw in progress, amount is correct) + agreementManager.updateEscrow(address(recurringCollector), indexer); + + // Balance should still be fully thawing + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.balance - account.tokensThawing, 0); + } + + function test_UpdateEscrow_Permissionless() public { + // Anyone can call updateEscrow + address anyone = makeAddr("anyone"); + vm.prank(anyone); + agreementManager.updateEscrow(address(recurringCollector), indexer); + } + + // ==================== Excess Thawing With Active Agreements ==================== + + function test_UpdateEscrow_ThawsExcessWithActiveAgreements() public { + // Offer agreement, accept, then reconcile down — excess should be thawed + (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; + + // Accept and simulate a collection (reduces maxNextClaim) + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + uint64 collectionTime = uint64(block.timestamp + 1800); + _setAgreementCollected(agreementId, rca, uint64(block.timestamp), collectionTime); + vm.warp(collectionTime); + + // Reconcile — should reduce required escrow + agreementManager.reconcileAgreement(agreementId); + uint256 newRequired = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + assertTrue(newRequired < maxClaim, "Required should have decreased"); + + // Escrow balance is still maxClaim — excess exists + // The reconcileAgreement call already invoked _updateEscrow which thawed the excess + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + uint256 expectedExcess = maxClaim - newRequired; + assertEq(account.tokensThawing, expectedExcess, "Excess should be thawing"); + + // Liquid balance should equal required + uint256 liquid = account.balance - account.tokensThawing; + assertEq(liquid, newRequired, "Liquid balance should equal required"); + } + + // ==================== Partial Cancel ==================== + + function test_OfferAgreement_PartialCancelPreservesThawTimer() public { + // Setup: two agreements, reconcile one down to create excess, thaw it + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + bytes16 id1 = _offerAgreement(rca1); + _offerAgreement(rca2); + + uint256 maxClaimEach = 1 ether * 3600 + 100 ether; + + // SP cancels agreement 1, reconcile to 0, then remove (triggers thaw of excess) + _setAgreementCanceledBySP(id1, rca1); + agreementManager.reconcileAgreement(id1); + agreementManager.removeAgreement(id1); + + // Verify excess is thawing + IPaymentsEscrow.EscrowAccount memory accountBefore = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(accountBefore.tokensThawing, maxClaimEach, "Excess should be thawing"); + uint256 thawEndBefore = accountBefore.thawEndTimestamp; + assertTrue(0 < thawEndBefore, "Thaw should be in progress"); + + // Now offer a small new agreement — should partial-cancel, NOT restart timer + IRecurringCollector.RecurringCollectionAgreement memory rca3 = _makeRCA( + 10 ether, + 0.1 ether, + 60, + 1800, + uint64(block.timestamp + 180 days) + ); + rca3.nonce = 3; + _offerAgreement(rca3); + + uint256 maxClaim3 = 0.1 ether * 1800 + 10 ether; + + // Check that thaw was partially canceled (not fully canceled) + IPaymentsEscrow.EscrowAccount memory accountAfter = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + + // New required = maxClaimEach + maxClaim3 + // Excess = 2*maxClaimEach - (maxClaimEach + maxClaim3) = maxClaimEach - maxClaim3 + uint256 expectedThawing = maxClaimEach - maxClaim3; + assertEq(accountAfter.tokensThawing, expectedThawing, "Thaw should be partially canceled"); + + // Timer should be preserved (not reset) + assertEq(accountAfter.thawEndTimestamp, thawEndBefore, "Thaw timer should be preserved"); + + // Liquid balance should cover new required + uint256 newRequired = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 liquid = accountAfter.balance - accountAfter.tokensThawing; + assertEq(liquid, newRequired, "Liquid should cover required"); + } + + function test_UpdateEscrow_FullCancelWhenDeficit() public { + // Setup: agreement funded, then increase required beyond balance + (IRecurringCollector.RecurringCollectionAgreement memory rca1, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 id1 = _offerAgreement(rca1); + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + + // SP cancels, reconcile to 0, remove (triggers thaw of all excess) + _setAgreementCanceledBySP(id1, rca1); + agreementManager.reconcileAgreement(id1); + agreementManager.removeAgreement(id1); + + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.tokensThawing, maxClaim1, "All should be thawing"); + + // Now offer a new agreement larger than what's in escrow + // This will make balance < required, so all thawing should be canceled + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 500 ether, + 5 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + _offerAgreement(rca2); + + // Thaw should have been fully canceled + account = paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer); + assertEq(account.tokensThawing, 0, "Thaw should be fully canceled for deficit"); + } + + function test_UpdateEscrow_SkipsThawIncreaseToPreserveTimer() public { + // Setup: two agreements, thaw excess from removing first + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + bytes16 id1 = _offerAgreement(rca1); + _offerAgreement(rca2); + uint256 maxClaimEach = 1 ether * 3600 + 100 ether; + + // Remove agreement 1 to create excess (triggers thaw) + _setAgreementCanceledBySP(id1, rca1); + agreementManager.reconcileAgreement(id1); + agreementManager.removeAgreement(id1); + + IPaymentsEscrow.EscrowAccount memory accountBefore = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(accountBefore.tokensThawing, maxClaimEach); + uint256 thawEndBefore = accountBefore.thawEndTimestamp; + + // Advance time halfway through thawing + vm.warp(block.timestamp + 12 hours); + + // Remove agreement 2 — excess grows to 2*maxClaimEach + // Uses evenIfTimerReset=false internally, so thaw increase is skipped + bytes16 id2 = bytes16( + recurringCollector.generateAgreementId( + rca2.payer, + rca2.dataService, + rca2.serviceProvider, + rca2.deadline, + rca2.nonce + ) + ); + _setAgreementCanceledBySP(id2, rca2); + agreementManager.reconcileAgreement(id2); + agreementManager.removeAgreement(id2); + + IPaymentsEscrow.EscrowAccount memory accountAfter = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + + // Timer preserved — thaw increase was skipped to avoid resetting it + assertEq(accountAfter.thawEndTimestamp, thawEndBefore, "Thaw timer should be preserved"); + // Thaw amount stays at original (increase skipped) + assertEq(accountAfter.tokensThawing, maxClaimEach, "Thaw should stay at original amount"); + } + + // ==================== Cross-Indexer Isolation ==================== + + function test_UpdateEscrow_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); + _offerAgreement(rca2); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + + // Remove indexer1's agreement (triggers thaw) + _setAgreementCanceledBySP(id1, rca1); + agreementManager.removeAgreement(id1); + + IPaymentsEscrow.EscrowAccount memory acct1 = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(acct1.balance - acct1.tokensThawing, 0); + + // Indexer2 escrow should be unaffected + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer2).balance, + maxClaim2 + ); + + // updateEscrow on indexer2 should be a no-op (balance == required) + agreementManager.updateEscrow(address(recurringCollector), indexer2); + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer2).balance, + maxClaim2 + ); + } + + // ==================== NoopWhenBalanced ==================== + + function test_UpdateEscrow_NoopWhenBalanced() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + // Balance should exactly match required — no excess, no deficit + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + maxClaim + ); + + // updateEscrow should be a no-op + agreementManager.updateEscrow(address(recurringCollector), indexer); + + // Nothing changed + assertEq( + paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, + maxClaim + ); + + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.tokensThawing, 0, "No thawing should occur"); + } + + // ==================== Automatic Thaw on Reconcile ==================== + + function test_Reconcile_AutomaticallyThawsExcess() public { + // Reconcile calls _updateEscrow, which should thaw excess automatically + (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; + + // Accept and simulate a collection + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + uint64 collectionTime = uint64(block.timestamp + 1800); + _setAgreementCollected(agreementId, rca, uint64(block.timestamp), collectionTime); + vm.warp(collectionTime); + + // Reconcile — triggers _updateEscrow internally + agreementManager.reconcileAgreement(agreementId); + + // Excess should already be thawing + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + uint256 newRequired = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 expectedExcess = maxClaim - newRequired; + assertEq(account.tokensThawing, expectedExcess, "Excess should auto-thaw after reconcile"); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/allocator/construction.t.sol b/packages/issuance/test/unit/allocator/construction.t.sol index 7df34bc42..149dd31ac 100644 --- a/packages/issuance/test/unit/allocator/construction.t.sol +++ b/packages/issuance/test/unit/allocator/construction.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; diff --git a/packages/issuance/test/unit/allocator/defensiveChecks.t.sol b/packages/issuance/test/unit/allocator/defensiveChecks.t.sol index 2ba79fc21..de23e47ad 100644 --- a/packages/issuance/test/unit/allocator/defensiveChecks.t.sol +++ b/packages/issuance/test/unit/allocator/defensiveChecks.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { Test } from "forge-std/Test.sol"; diff --git a/packages/issuance/test/unit/allocator/distribution.t.sol b/packages/issuance/test/unit/allocator/distribution.t.sol index 466f013d5..fb94737de 100644 --- a/packages/issuance/test/unit/allocator/distribution.t.sol +++ b/packages/issuance/test/unit/allocator/distribution.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; diff --git a/packages/issuance/test/unit/allocator/distributionAccounting.t.sol b/packages/issuance/test/unit/allocator/distributionAccounting.t.sol index 30638a0e4..ae40b10f7 100644 --- a/packages/issuance/test/unit/allocator/distributionAccounting.t.sol +++ b/packages/issuance/test/unit/allocator/distributionAccounting.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; import { Allocation } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol"; diff --git a/packages/issuance/test/unit/allocator/interfaceIdStability.t.sol b/packages/issuance/test/unit/allocator/interfaceIdStability.t.sol index b7b8a4d42..463416bbd 100644 --- a/packages/issuance/test/unit/allocator/interfaceIdStability.t.sol +++ b/packages/issuance/test/unit/allocator/interfaceIdStability.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { Test } from "forge-std/Test.sol"; diff --git a/packages/issuance/test/unit/allocator/shared.t.sol b/packages/issuance/test/unit/allocator/shared.t.sol index e1cc41100..9846bfdde 100644 --- a/packages/issuance/test/unit/allocator/shared.t.sol +++ b/packages/issuance/test/unit/allocator/shared.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { Test } from "forge-std/Test.sol"; diff --git a/packages/issuance/test/unit/allocator/targetManagement.t.sol b/packages/issuance/test/unit/allocator/targetManagement.t.sol index bf1229c93..111621715 100644 --- a/packages/issuance/test/unit/allocator/targetManagement.t.sol +++ b/packages/issuance/test/unit/allocator/targetManagement.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; import { diff --git a/packages/issuance/test/unit/direct-allocation/DirectAllocation.t.sol b/packages/issuance/test/unit/direct-allocation/DirectAllocation.t.sol index dab61dc44..dc7e539fd 100644 --- a/packages/issuance/test/unit/direct-allocation/DirectAllocation.t.sol +++ b/packages/issuance/test/unit/direct-allocation/DirectAllocation.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { Test } from "forge-std/Test.sol"; diff --git a/packages/issuance/test/unit/eligibility/accessControl.t.sol b/packages/issuance/test/unit/eligibility/accessControl.t.sol index f1e9d15db..3f0a3dd56 100644 --- a/packages/issuance/test/unit/eligibility/accessControl.t.sol +++ b/packages/issuance/test/unit/eligibility/accessControl.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { RewardsEligibilityOracleSharedTest } from "./shared.t.sol"; diff --git a/packages/issuance/test/unit/eligibility/construction.t.sol b/packages/issuance/test/unit/eligibility/construction.t.sol index f623baee2..068741f31 100644 --- a/packages/issuance/test/unit/eligibility/construction.t.sol +++ b/packages/issuance/test/unit/eligibility/construction.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; diff --git a/packages/issuance/test/unit/eligibility/eligibility.t.sol b/packages/issuance/test/unit/eligibility/eligibility.t.sol index 5ceb13fbe..aaa74e0c6 100644 --- a/packages/issuance/test/unit/eligibility/eligibility.t.sol +++ b/packages/issuance/test/unit/eligibility/eligibility.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { RewardsEligibilityOracleSharedTest } from "./shared.t.sol"; diff --git a/packages/issuance/test/unit/eligibility/indexerManagement.t.sol b/packages/issuance/test/unit/eligibility/indexerManagement.t.sol index 1411d97c9..bffb14e60 100644 --- a/packages/issuance/test/unit/eligibility/indexerManagement.t.sol +++ b/packages/issuance/test/unit/eligibility/indexerManagement.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IRewardsEligibilityEvents } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityEvents.sol"; diff --git a/packages/issuance/test/unit/eligibility/interfaceCompliance.t.sol b/packages/issuance/test/unit/eligibility/interfaceCompliance.t.sol index 45668b582..2156b2711 100644 --- a/packages/issuance/test/unit/eligibility/interfaceCompliance.t.sol +++ b/packages/issuance/test/unit/eligibility/interfaceCompliance.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; diff --git a/packages/issuance/test/unit/eligibility/operatorFunctions.t.sol b/packages/issuance/test/unit/eligibility/operatorFunctions.t.sol index 07a3eedad..3d7fa4a1d 100644 --- a/packages/issuance/test/unit/eligibility/operatorFunctions.t.sol +++ b/packages/issuance/test/unit/eligibility/operatorFunctions.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { Vm } from "forge-std/Vm.sol"; diff --git a/packages/issuance/test/unit/eligibility/shared.t.sol b/packages/issuance/test/unit/eligibility/shared.t.sol index 5c564d857..6cd442063 100644 --- a/packages/issuance/test/unit/eligibility/shared.t.sol +++ b/packages/issuance/test/unit/eligibility/shared.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.33; +pragma solidity ^0.8.27; import { Test } from "forge-std/Test.sol"; diff --git a/packages/issuance/test/unit/mocks/MockGraphToken.sol b/packages/issuance/test/unit/mocks/MockGraphToken.sol index f4478cd7a..dd07fab6e 100644 --- a/packages/issuance/test/unit/mocks/MockGraphToken.sol +++ b/packages/issuance/test/unit/mocks/MockGraphToken.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.33; +pragma solidity ^0.8.27; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; diff --git a/packages/subgraph-service/scripts/ops/protocol-activity.ts b/packages/subgraph-service/scripts/ops/protocol-activity.ts index cb62e4250..e53e83ab9 100644 --- a/packages/subgraph-service/scripts/ops/protocol-activity.ts +++ b/packages/subgraph-service/scripts/ops/protocol-activity.ts @@ -351,7 +351,7 @@ async function main() { } for (const [i, signer] of signers.entries()) { - const escrowAccount = await PaymentsEscrow.getEscrowAccount( + const escrowAccount = await PaymentsEscrow.escrowAccounts( gateway.address, GraphTallyCollector.target, signer.address, diff --git a/packages/subgraph-service/test/unit/disputeManager/disputes/indexingFee/create.t.sol b/packages/subgraph-service/test/unit/disputeManager/disputes/indexingFee/create.t.sol index f6654d10e..73ca400bf 100644 --- a/packages/subgraph-service/test/unit/disputeManager/disputes/indexingFee/create.t.sol +++ b/packages/subgraph-service/test/unit/disputeManager/disputes/indexingFee/create.t.sol @@ -137,15 +137,15 @@ contract DisputeManagerIndexingFeeCreateDisputeTest is SubgraphServiceIndexingAg disputeManager.createIndexingFeeDisputeV1(acceptedAgreementId, bytes32("POI"), 100, block.number); } - function test_IndexingFee_Create_Dispute_EmitsEvent( - Seed memory seed, - uint256 unboundedTokensCollected - ) public { + function test_IndexingFee_Create_Dispute_EmitsEvent(Seed memory seed, uint256 unboundedTokensCollected) public { (bytes16 agreementId, IndexerState memory indexerState) = _setupCollectedAgreement( seed, unboundedTokensCollected ); + // Read the payer from the (mocked) agreement data + IRecurringCollector.AgreementData memory agreementData = recurringCollector.getAgreement(agreementId); + resetPrank(users.fisherman); uint256 deposit = disputeManager.disputeDeposit(); token.approve(address(disputeManager), deposit); @@ -165,7 +165,7 @@ contract DisputeManagerIndexingFeeCreateDisputeTest is SubgraphServiceIndexingAg indexerState.addr, users.fisherman, deposit, - address(0), // payer from mock agreement (zero-initialized) + agreementData.payer, agreementId, poi, entities, diff --git a/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol b/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol index 0cdd570c9..6aee78810 100644 --- a/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol @@ -237,7 +237,7 @@ contract SubgraphServiceRegisterTest is SubgraphServiceTest { _authorizeSigner(); uint256 beforeGatewayBalance = escrow - .getEscrowAccount(users.gateway, address(graphTallyCollector), users.indexer) + .escrowAccounts(users.gateway, address(graphTallyCollector), users.indexer) .balance; uint256 beforeTokensCollected = graphTallyCollector.tokensCollected( address(subgraphService), @@ -255,7 +255,7 @@ contract SubgraphServiceRegisterTest is SubgraphServiceTest { _collect(users.indexer, IGraphPayments.PaymentTypes.QueryFee, data); uint256 intermediateGatewayBalance = escrow - .getEscrowAccount(users.gateway, address(graphTallyCollector), users.indexer) + .escrowAccounts(users.gateway, address(graphTallyCollector), users.indexer) .balance; assertEq(intermediateGatewayBalance, beforeGatewayBalance - tokensToCollect); uint256 intermediateTokensCollected = graphTallyCollector.tokensCollected( @@ -277,7 +277,7 @@ contract SubgraphServiceRegisterTest is SubgraphServiceTest { // Check the indexer received the correct amount of tokens uint256 afterGatewayBalance = escrow - .getEscrowAccount(users.gateway, address(graphTallyCollector), users.indexer) + .escrowAccounts(users.gateway, address(graphTallyCollector), users.indexer) .balance; assertEq(afterGatewayBalance, beforeGatewayBalance - tokensPayment); uint256 afterTokensCollected = graphTallyCollector.tokensCollected( diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol index d0ee9ab28..d8e4e7c34 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol @@ -267,7 +267,7 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex function _getState(address _payer, address _indexer) private view returns (TestState memory) { CollectPaymentData memory collect = _collectPaymentData(_indexer); - IPaymentsEscrow.EscrowAccount memory account = escrow.getEscrowAccount( + IPaymentsEscrow.EscrowAccount memory account = escrow.escrowAccounts( _payer, address(recurringCollector), _indexer From 1013fb02522147053081d736323f7cd81f74936d Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:56:06 +0000 Subject: [PATCH 11/11] refactor: typed interfaces, escrow-centric naming, and code quality --- .../data-service/IDataServiceAgreements.sol | 11 +- .../agreement/IRecurringAgreementManager.sol | 161 +++-- .../agreement/RecurringAgreementManager.md | 101 +-- .../agreement/RecurringAgreementManager.sol | 682 ++++++++++-------- .../contracts/allocate/DirectAllocation.sol | 5 +- .../contracts/allocate/IssuanceAllocator.sol | 5 +- .../contracts/common/BaseUpgradeable.sol | 8 +- .../eligibility/RewardsEligibilityOracle.sol | 5 +- .../allocate/IssuanceAllocatorTestHarness.sol | 5 +- packages/issuance/foundry.toml | 2 +- .../agreement-manager/afterCollection.t.sol | 6 +- .../unit/agreement-manager/approver.t.sol | 22 +- .../agreement-manager/cancelAgreement.t.sol | 10 +- .../unit/agreement-manager/edgeCases.t.sol | 102 ++- .../unit/agreement-manager/eligibility.t.sol | 35 +- .../unit/agreement-manager/fundingModes.t.sol | 314 ++++---- .../test/unit/agreement-manager/fuzz.t.sol | 49 +- .../test/unit/agreement-manager/helper.t.sol | 29 +- .../mocks/MockPaymentsEscrow.sol | 17 +- .../agreement-manager/multiCollector.t.sol | 33 +- .../unit/agreement-manager/multiIndexer.t.sol | 48 +- .../unit/agreement-manager/offerUpdate.t.sol | 23 +- .../unit/agreement-manager/reconcile.t.sol | 21 +- .../unit/agreement-manager/register.t.sol | 44 +- .../test/unit/agreement-manager/remove.t.sol | 24 +- .../unit/agreement-manager/revokeOffer.t.sol | 11 +- .../test/unit/agreement-manager/shared.t.sol | 23 +- .../unit/agreement-manager/updateEscrow.t.sol | 272 ++++++- .../test/unit/allocator/construction.t.sol | 5 +- .../test/unit/allocator/defensiveChecks.t.sol | 3 +- .../issuance/test/unit/allocator/shared.t.sol | 3 +- .../direct-allocation/DirectAllocation.t.sol | 9 +- .../test/unit/eligibility/construction.t.sol | 5 +- .../test/unit/eligibility/shared.t.sol | 3 +- 34 files changed, 1271 insertions(+), 825 deletions(-) diff --git a/packages/interfaces/contracts/data-service/IDataServiceAgreements.sol b/packages/interfaces/contracts/data-service/IDataServiceAgreements.sol index 92406d3b5..ea5b0dd54 100644 --- a/packages/interfaces/contracts/data-service/IDataServiceAgreements.sol +++ b/packages/interfaces/contracts/data-service/IDataServiceAgreements.sol @@ -1,19 +1,16 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.22; -import { IDataService } from "./IDataService.sol"; - /** * @title Interface for data services that manage indexing agreements. * @author Edge & Node - * @notice Extension for the {IDataService} contract to support payer-initiated - * cancellation of indexing agreements. Any data service that participates in - * agreement lifecycle management via {RecurringAgreementManager} should implement - * this interface. + * @notice Interface to support payer-initiated cancellation of indexing agreements. + * Any data service that participates in agreement lifecycle management via + * {RecurringAgreementManager} should implement this interface. * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ -interface IDataServiceAgreements is IDataService { +interface IDataServiceAgreements { /** * @notice Cancel an indexing agreement by payer / signer. * @param agreementId The id of the indexing agreement diff --git a/packages/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol b/packages/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol index ea68d108b..d0350824f 100644 --- a/packages/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol +++ b/packages/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol @@ -1,14 +1,17 @@ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity ^0.8.22; +import { IDataServiceAgreements } from "../../data-service/IDataServiceAgreements.sol"; +import { IPaymentsEscrow } from "../../horizon/IPaymentsEscrow.sol"; import { IRecurringCollector } from "../../horizon/IRecurringCollector.sol"; +import { IRewardsEligibility } from "../eligibility/IRewardsEligibility.sol"; /** * @title Interface for the {RecurringAgreementManager} contract * @author Edge & Node - * @notice Manages escrow funding for RCAs (Recurring Collection Agreements) using + * @notice Manages escrow for RCAs (Recurring Collection Agreements) using * issuance-allocated tokens. Tracks the maximum possible next claim for each managed - * RCA per provider and ensures PaymentsEscrow is always funded to cover those maximums. + * RCA per provider and ensures PaymentsEscrow is always deposited to cover those maximums. * * One escrow per (RecurringAgreementManager, collector, provider) covering all RCAs for * that (collector, provider) pair managed by this contract. @@ -20,15 +23,15 @@ interface IRecurringAgreementManager { // -- Enums -- /** - * @notice Escrow funding level — controls how aggressively escrow is pre-funded. + * @notice Escrow level — controls how aggressively escrow is pre-deposited. * Ordered low-to-high. The configured level is the maximum aspiration; the system - * automatically degrades when funds are insufficient. `beforeCollection` (JIT top-up) + * automatically degrades when balance is insufficient. `beforeCollection` (JIT top-up) * is always active regardless of setting. * * @dev JustInTime=0 (thaw everything, pure JIT), OnDemand=1 (no deposits, hold at - * required level), Full=2 (fund sum of all maxNextClaim — current default). + * sumMaxNextClaim level), Full=2 (deposit sum of all maxNextClaim — current default). */ - enum FundingBasis { + enum EscrowBasis { JustInTime, OnDemand, Full @@ -41,7 +44,7 @@ interface IRecurringAgreementManager { * @dev An agreement is considered tracked when `provider != address(0)`. * @param provider The service provider for this agreement * @param deadline The RCA deadline for acceptance (used to detect expired offers) - * @param dataService The data service address for this agreement + * @param dataService The data service contract 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 @@ -52,13 +55,13 @@ interface IRecurringAgreementManager { struct AgreementInfo { address provider; uint64 deadline; - address dataService; + IDataServiceAgreements dataService; uint32 pendingUpdateNonce; uint256 maxNextClaim; uint256 pendingUpdateMaxNextClaim; bytes32 agreementHash; bytes32 pendingUpdateHash; - address collector; + IRecurringCollector collector; } // -- Events -- @@ -75,7 +78,7 @@ interface IRecurringAgreementManager { /** * @notice Emitted when an agreement offer is revoked before acceptance * @param agreementId The agreement ID - * @param provider The provider whose required escrow was reduced + * @param provider The provider whose sumMaxNextClaim was reduced */ event OfferRevoked(bytes16 indexed agreementId, address indexed provider); @@ -89,7 +92,7 @@ interface IRecurringAgreementManager { /** * @notice Emitted when an agreement is removed from escrow management * @param agreementId The agreement ID being removed - * @param provider The provider whose required escrow was reduced + * @param provider The provider whose sumMaxNextClaim was reduced */ event AgreementRemoved(bytes16 indexed agreementId, address indexed provider); @@ -110,8 +113,8 @@ interface IRecurringAgreementManager { event AgreementUpdateOffered(bytes16 indexed agreementId, uint256 pendingMaxNextClaim, uint32 updateNonce); /** - * @notice Emitted when escrow is funded for a provider - * @param provider The provider whose escrow was funded + * @notice Emitted when escrow is deposited for a provider + * @param provider The provider whose escrow was deposited into * @param collector The collector address for the escrow account * @param deposited The amount deposited */ @@ -126,30 +129,30 @@ interface IRecurringAgreementManager { event EscrowWithdrawn(address indexed provider, address indexed collector, uint256 tokens); /** - * @notice Emitted when the funding basis is changed - * @param oldBasis The previous funding basis - * @param newBasis The new funding basis + * @notice Emitted when the escrow basis is changed + * @param oldBasis The previous escrow basis + * @param newBasis The new escrow basis */ - event FundingBasisChanged(FundingBasis oldBasis, FundingBasis newBasis); + event EscrowBasisChanged(EscrowBasis oldBasis, EscrowBasis newBasis); /** - * @notice Emitted when JIT mode is enforced due to insufficient funds during collection - * @param configuredBasis The governance-configured funding basis (not modified) + * @notice Emitted when JIT mode is enforced due to insufficient balance during collection + * @param configuredBasis The governance-configured escrow basis (not modified) */ - event EnforcedJit(FundingBasis configuredBasis); + event EnforcedJit(EscrowBasis configuredBasis); /** - * @notice Emitted when enforced JIT recovers after RAM accumulates sufficient funds - * @param configuredBasis The governance-configured funding basis now effective again + * @notice Emitted when enforced JIT recovers after RAM accumulates sufficient balance + * @param configuredBasis The governance-configured escrow basis now effective again */ - event EnforcedJitRecovered(FundingBasis configuredBasis); + event EnforcedJitRecovered(EscrowBasis configuredBasis); /** * @notice Emitted when the payment eligibility oracle is changed - * @param oldOracle The previous oracle address (address(0) means none) - * @param newOracle The new oracle address (address(0) means disabled) + * @param oldOracle The previous oracle (IRewardsEligibility(address(0)) means none) + * @param newOracle The new oracle (IRewardsEligibility(address(0)) means disabled) */ - event PaymentEligibilityOracleSet(address indexed oldOracle, address indexed newOracle); + event PaymentEligibilityOracleSet(IRewardsEligibility indexed oldOracle, IRewardsEligibility indexed newOracle); // solhint-enable gas-indexed-events @@ -202,14 +205,20 @@ interface IRecurringAgreementManager { /// @notice Thrown when the RCA service provider is the zero address error ServiceProviderZeroAddress(); - /// @notice Thrown when the RCA data service is the zero address - error DataServiceZeroAddress(); + /** + * @notice Thrown when the data service address does not have DATA_SERVICE_ROLE + * @param dataService The unauthorized data service address + */ + error UnauthorizedDataService(address dataService); /// @notice Thrown when a collection callback is called by an address other than the agreement's collector error OnlyAgreementCollector(); - /// @notice Thrown when the collector address is the zero address - error CollectorZeroAddress(); + /** + * @notice Thrown when the collector address does not have COLLECTOR_ROLE + * @param collector The unauthorized collector address + */ + error UnauthorizedCollector(address collector); // -- Core Functions -- @@ -217,21 +226,21 @@ interface IRecurringAgreementManager { * @notice Offer an RCA for escrow management. Must be called before * the data service accepts the agreement (with empty authData). * @dev Calculates max next claim from RCA parameters, stores the authorized hash - * for the {IContractApprover} callback, and funds the escrow. + * for the {IContractApprover} callback, and deposits into escrow. * @param rca The Recurring Collection Agreement parameters * @param collector The RecurringCollector contract to use for this agreement * @return agreementId The deterministic agreement ID */ function offerAgreement( IRecurringCollector.RecurringCollectionAgreement calldata rca, - address collector + IRecurringCollector collector ) external returns (bytes16 agreementId); /** * @notice Offer a pending agreement update for escrow management. Must be called * before the data service applies the update (with empty authData). * @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 + * adds the pending update's max next claim to sumMaxNextClaim. 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 @@ -254,8 +263,8 @@ interface IRecurringAgreementManager { * @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 + * - Accepted: cancels via the data service, then reconciles and updates escrow + * - Already canceled: idempotent — reconciles and updates escrow without re-canceling * After cancellation, call {removeAgreement} once the collection window closes. * @param agreementId The agreement ID to cancel */ @@ -282,55 +291,57 @@ interface IRecurringAgreementManager { function reconcileAgreement(bytes16 agreementId) external; /** - * @notice Update escrow state for a provider: withdraw completed thaws, fund any deficit, + * @notice Update escrow state for a provider: withdraw completed thaws, deposit any deficit, * and thaw excess balance. * @dev Permissionless. Three-phase operation: * - Phase 1: If a previous thaw has completed, withdraws tokens back to this contract - * - Phase 2a (deficit): If balance < required, cancels any thaw and deposits to cover - * - Phase 2b (excess): If balance > required, starts a thaw for the excess (only when + * - Phase 2a (deficit): If balance < min, cancels any thaw and deposits to cover + * - Phase 2b (excess): If balance > max, starts a thaw for the excess (only when * no thaw is already in progress) or partially cancels an existing thaw if too much * is being thawed * Works regardless of whether the provider has active agreements. * @param collector The collector contract address * @param provider The provider to update escrow for */ - function updateEscrow(address collector, address provider) external; + function updateEscrow(IRecurringCollector collector, address provider) external; /** - * @notice Set the escrow funding basis (maximum aspiration level). + * @notice Set the escrow basis (maximum aspiration level). * @dev Requires GOVERNOR_ROLE. The system automatically degrades below the configured - * level when funds are insufficient. Changing the basis does not immediately rebalance + * level when balance is insufficient. Changing the basis does not immediately rebalance * escrow — call {updateEscrow} or {reconcile} per provider to apply. - * @param basis The new funding basis + * @param basis The new escrow basis */ - function setFundingBasis(FundingBasis basis) external; + function setEscrowBasis(EscrowBasis basis) external; /** * @notice Set the payment eligibility oracle. * @dev Requires GOVERNOR_ROLE. When set, {isEligible} delegates to this oracle. - * When set to address(0), all providers are considered eligible (passthrough). - * @param oracle The address of the eligibility oracle (or address(0) to disable) + * When set to IRewardsEligibility(address(0)), all providers are considered eligible (passthrough). + * @param oracle The eligibility oracle (or IRewardsEligibility(address(0)) to disable) */ - function setPaymentEligibilityOracle(address oracle) external; + function setPaymentEligibilityOracle(IRewardsEligibility oracle) external; // -- View Functions -- /** - * @notice Get the total required escrow for a (collector, provider) pair - * @param collector The collector contract address + * @notice Get the sum of maxNextClaim for all managed agreements for a (collector, provider) pair + * @param collector The collector contract * @param provider The provider address - * @return The sum of max next claims for all managed agreements for this (collector, provider) + * @return The sum of max next claims */ - function getRequiredEscrow(address collector, address provider) external view returns (uint256); + function sumMaxNextClaim(IRecurringCollector collector, address provider) external view returns (uint256); /** - * @notice Get the current escrow deficit for a (collector, provider) pair - * @dev Returns 0 if escrow is fully funded or over-funded. - * @param collector The collector contract address + * @notice Get the escrow account for a (collector, provider) pair + * @param collector The collector contract * @param provider The provider address - * @return The deficit amount (required - current balance), or 0 if no deficit + * @return The escrow account data */ - function getDeficit(address collector, address provider) external view returns (uint256); + function getEscrowAccount( + IRecurringCollector collector, + address provider + ) external view returns (IPaymentsEscrow.EscrowAccount memory); /** * @notice Get the max next claim for a specific agreement @@ -356,34 +367,48 @@ interface IRecurringAgreementManager { /** * @notice Get all managed agreement IDs for a provider * @dev Returns the full set of tracked agreement IDs. May be expensive for providers - * with many agreements — prefer {getProviderAgreementCount} for on-chain use. + * with many agreements — prefer the paginated overload or {getProviderAgreementCount} + * for on-chain use. * @param provider The provider address * @return The array of agreement IDs */ function getProviderAgreements(address provider) external view returns (bytes16[] memory); /** - * @notice Get the current funding basis setting - * @return The configured funding basis + * @notice Get a paginated slice of managed agreement IDs for a provider + * @param provider The provider address + * @param offset The index to start from + * @param count Maximum number of IDs to return (clamped to available) + * @return The array of agreement IDs + */ + function getProviderAgreements( + address provider, + uint256 offset, + uint256 count + ) external view returns (bytes16[] memory); + + /** + * @notice Get the current escrow basis setting + * @return The configured escrow basis */ - function getFundingBasis() external view returns (FundingBasis); + function getEscrowBasis() external view returns (EscrowBasis); /** - * @notice Get the total required escrow across all providers + * @notice Get the sum of maxNextClaim across all (collector, provider) pairs * @dev Populated lazily through normal operations. May be stale if agreements were * offered before this feature was deployed — run reconciliation to populate. - * @return The sum of requiredEscrow across all providers + * @return The global sum of max next claims */ - function getTotalRequired() external view returns (uint256); + function sumMaxNextClaimAll() external view returns (uint256); /** - * @notice Get the total unfunded escrow across all providers - * @dev Maintained incrementally: sum of max(0, requiredEscrow[p] - funded[p]) + * @notice Get the total undeposited escrow across all providers + * @dev Maintained incrementally: sum of max(0, sumMaxNextClaim[p] - deposited[p]) * for each provider p. Correctly accounts for per-provider deficits without - * allowing over-funded providers to mask under-funded ones. + * allowing over-deposited providers to mask under-deposited ones. * @return The total unfunded amount */ - function getTotalUnfunded() external view returns (uint256); + function getTotalEscrowDeficit() external view returns (uint256); /** * @notice Get the total number of tracked agreements across all providers @@ -395,8 +420,8 @@ interface IRecurringAgreementManager { /** * @notice Check whether JIT mode is currently enforced * @dev When enforced, the system operates in JIT-only mode regardless of the configured - * funding basis. The configured basis is preserved and takes effect again when - * enforced JIT recovers (totalUnfunded <= available) or governance calls {setFundingBasis}. + * escrow basis. The configured basis is preserved and takes effect again when + * enforced JIT recovers (totalEscrowDeficit <= available) or governance calls {setEscrowBasis}. * @return True if JIT mode is enforced */ function isEnforcedJit() external view returns (bool); diff --git a/packages/issuance/contracts/agreement/RecurringAgreementManager.md b/packages/issuance/contracts/agreement/RecurringAgreementManager.md index f35f70429..2369847ad 100644 --- a/packages/issuance/contracts/agreement/RecurringAgreementManager.md +++ b/packages/issuance/contracts/agreement/RecurringAgreementManager.md @@ -1,6 +1,6 @@ # RecurringAgreementManager -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. RecurringAgreementManager automates this for protocol-funded agreements by receiving minted GRT from IssuanceAllocator and maintaining escrow balances sufficient to cover worst-case collection amounts. +RCA-based payments require escrow pre-deposits — the payer must deposit enough tokens to cover the maximum that could be collected in the next collection window. RecurringAgreementManager automates this for protocol-escrowed agreements by receiving minted GRT from IssuanceAllocator and maintaining escrow balances sufficient to cover worst-case collection amounts. It implements three interfaces: @@ -16,7 +16,7 @@ One escrow account per (RecurringAgreementManager, RecurringCollector, service p sum(maxNextClaim + pendingUpdateMaxNextClaim for all active agreements for that provider) <= PaymentsEscrow.escrowAccounts[RecurringAgreementManager][RecurringCollector][provider] ``` -Funding never reverts — it deposits `min(deficit, availableBalance)`. The `getDeficit` view exposes any shortfall for monitoring. +Deposits never revert — `_escrowMinMax` degrades the mode when balance is insufficient, ensuring the deposit amount is always affordable. The `getEscrowAccount` view exposes the underlying escrow account for monitoring. ## Hash Authorization @@ -44,10 +44,10 @@ maxNextClaim = maxOngoingTokensPerSecond * maxSecondsPerCollection + maxInitialT ### Offer → Accept (two-step) -1. **Operator** calls `offerAgreement(rca)` — stores hash, calculates conservative maxNextClaim, funds escrow +1. **Operator** calls `offerAgreement(rca)` — stores hash, calculates conservative maxNextClaim, deposits into escrow 2. **Service provider operator** calls `SubgraphService.acceptUnsignedIndexingAgreement(allocationId, rca)` — SubgraphService → RecurringCollector → `approveAgreement(hash)` callback to RecurringAgreementManager -During the pending update window, both current and pending maxNextClaim are funded simultaneously (conservative). +During the pending update window, both current and pending maxNextClaim are escrowed simultaneously (conservative). ### Collect → Reconcile @@ -68,99 +68,110 @@ The manager exposes `reconcileAgreement` (gas-predictable, per-agreement). Batch | Accepted past endsAt | After final collection window expires | | NotAccepted (expired) | After `rca.deadline` passes | -## Escrow Funding Modes +## Escrow Modes -The configured `FundingBasis` controls how aggressively escrow is pre-funded. The setting is a **maximum aspiration** — the system automatically degrades when funds are insufficient. `beforeCollection` (JIT top-up) is always active regardless of setting, providing a safety net for any gap. +The configured `EscrowBasis` controls how aggressively escrow is pre-deposited. The setting is a **maximum aspiration** — the system automatically degrades when balance is insufficient. `beforeCollection` (JIT top-up) is always active regardless of setting, providing a safety net for any gap. ### Levels ``` -enum FundingBasis { JustInTime, OnDemand, Full } +enum EscrowBasis { JustInTime, OnDemand, Full } ``` Ordered low-to-high: -| Level | Deposit target | Thaw ceiling | Behavior | -| -------------- | --------------------------------------- | ------------ | ----------------------------------------------- | -| Full (2) | `requiredEscrow[provider]` (sum of all) | `required` | Current default. Funds worst-case for all RCAs. | -| OnDemand (1) | 0 | `required` | No deposits, holds at required level. | -| JustInTime (0) | 0 | 0 | Thaws everything, pure JIT. | +| Level | min (deposit floor) | max (thaw ceiling) | Behavior | +| -------------- | ---------------------------------------- | ------------------ | -------------------------------------------------- | +| Full (2) | `sumMaxNextClaim[provider]` (sum of all) | `sumMaxNextClaim` | Current default. Deposits worst-case for all RCAs. | +| OnDemand (1) | 0 | `sumMaxNextClaim` | No deposits, holds at sumMaxNextClaim level. | +| JustInTime (0) | 0 | 0 | Thaws everything, pure JIT. | -**Stability guarantee**: `depositTarget <= thawCeiling` at every level. Deposit-then-immediate-reconcile at the same level never triggers a thaw. +**Stability guarantee**: `min <= max` at every level. Deposit-then-immediate-reconcile at the same level never triggers a thaw. -### Two-Target Model +### Min/Max Model -`_updateEscrow` uses two numbers instead of a single `required`: +`_updateEscrow` uses two numbers from `_escrowMinMax` instead of a single `sumMaxNextClaim`: -- **depositTarget**: deposit if effective balance is below this -- **thawCeiling**: thaw if balance is above this +- **min**: deposit floor — deposit if effective balance is below this +- **max**: thaw ceiling — thaw effective balance above this (never resetting an active thaw timer) -The split ensures smooth transitions between levels. When degradation occurs, the deposit target drops to 0 but the thaw ceiling holds at `required`, preventing oscillation. +The split ensures smooth transitions between levels. When degradation occurs, min drops to 0 but max holds at `sumMaxNextClaim`, preventing oscillation. ### Automatic Degradation -The setting is a ceiling, not a mandate. When funds are insufficient, Full degrades to OnDemand: +The setting is a ceiling, not a mandate. When balance is insufficient, Full degrades to OnDemand: -| Configured | Can afford? | Effective deposit | Effective thaw ceiling | -| ---------- | -------------- | ----------------- | ---------------------- | -| Full | Yes | `required` | `required` | -| Full | No (→OnDemand) | 0 | `required` | -| OnDemand | Always | 0 | `required` | -| JustInTime | Always | 0 | 0 | +| Configured | Can afford? | Effective min | Effective max | +| ---------- | -------------- | ----------------- | ----------------- | +| Full | Yes | `sumMaxNextClaim` | `sumMaxNextClaim` | +| Full | No (→OnDemand) | 0 | `sumMaxNextClaim` | +| OnDemand | Always | 0 | `sumMaxNextClaim` | +| JustInTime | Always | 0 | 0 | Degradation trigger: -- **Full → OnDemand**: `totalUnfunded > available` (SAM's balance can't close the system-wide gap) +- **Full → OnDemand**: `totalEscrowDeficit >= available` (RAM's balance can't close the system-wide gap) Key properties: - Degradation never reaches JustInTime automatically — only explicit governor setting or enforced JIT thaws to zero - JIT deposit is all-or-nothing: if `deficit < available` the full deficit is deposited, otherwise nothing is deposited +### `_updateEscrow` Flow + +`_updateEscrow(collector, provider)` normalizes escrow state in four steps using (min, max) from `_escrowMinMax`: + +1. **Adjust thaw target** — cancel/reduce unrealised thawing to keep effective balance >= min, or increase thawing toward max. Never resets the thaw timer (`evenIfTimerReset=false`). +2. **Withdraw completed thaw** — realised thawing is always withdrawn, even if within [min, max]. +3. **Thaw excess** — if no thaw is active (possibly after a withdraw), start a new thaw for balance above max. +4. **Deposit deficit** — if no thaw is active, deposit to reach min. + +Steps 3 and 4 are mutually exclusive (min <= max). The thaw timer is never reset: step 1 uses `evenIfTimerReset=false`, and steps 3/4 only run when `tokensThawing == 0`. + ### Reconciliation -Per-agreement reconciliation (`reconcileAgreement`) re-reads agreement state from RecurringCollector and updates `requiredEscrow`. Provider-level escrow rebalancing is O(1) via `updateEscrow(provider)`. Batch helpers `reconcileBatch` and `reconcile(provider)` live in the separate `RecurringAgreementHelper` contract — they are stateless wrappers that call `reconcileAgreement` in a loop. +Per-agreement reconciliation (`reconcileAgreement`) re-reads agreement state from RecurringCollector and updates `sumMaxNextClaim`. Provider-level escrow rebalancing is O(1) via `updateEscrow(provider)`. Batch helpers `reconcileBatch` and `reconcile(provider)` live in the separate `RecurringAgreementHelper` contract — they are stateless wrappers that call `reconcileAgreement` in a loop. ### Global Tracking -| Storage field | Type | Updated at | -| --------------------------- | ------- | ------------------------------------------------------------------------------- | -| `fundingBasis` | enum | `setFundingBasis()` (also clears enforced JIT) | -| `totalRequiredAll` | uint256 | Every `requiredEscrow[provider]` mutation | -| `totalUnfunded` | uint256 | Every `requiredEscrow[provider]` or `lastKnownFunded[provider]` mutation | -| `totalAgreementCount` | uint256 | `offerAgreement` (+1), `revokeOffer` (-1), `removeAgreement` (-1) | -| `lastKnownFunded[provider]` | mapping | End of `_updateEscrow` via snapshot diff | -| `enforcedJit` | bool | `beforeCollection` (trip), `_updateEscrow` (recover), `setFundingBasis` (clear) | +| Storage field | Type | Updated at | +| ---------------------- | ------- | ------------------------------------------------------------------------------ | +| `escrowBasis` | enum | `setEscrowBasis()` (also clears enforced JIT) | +| `sumMaxNextClaimAll` | uint256 | Every `sumMaxNextClaim[provider]` mutation | +| `totalEscrowDeficit` | uint256 | Every `sumMaxNextClaim[provider]` or `escrowSnap[provider]` mutation | +| `totalAgreementCount` | uint256 | `offerAgreement` (+1), `revokeOffer` (-1), `removeAgreement` (-1) | +| `escrowSnap[provider]` | mapping | End of `_updateEscrow` via snapshot diff | +| `enforcedJit` | bool | `beforeCollection` (trip), `_updateEscrow` (recover), `setEscrowBasis` (clear) | -**`totalUnfunded`** is maintained incrementally as `Σ max(0, requiredEscrow[p] - lastKnownFunded[p])` per provider. This correctly handles over-funded providers: a provider with excess escrow cannot mask another provider's deficit. At each of 6 mutation points (offer, offerUpdate, revoke, remove, reconcile, updateFundedSnapshot), the provider's unfunded contribution is recomputed before and after the mutation. +**`totalEscrowDeficit`** is maintained incrementally as `Σ max(0, sumMaxNextClaim[p] - escrowSnap[p])` per provider. This correctly handles over-deposited providers: a provider with excess escrow cannot mask another provider's deficit. At each of 6 mutation points (offer, offerUpdate, revoke, remove, reconcile, setEscrowSnap), the provider's deficit is recomputed before and after the mutation. -Globals start at 0 and populate lazily through normal operations. This is safe because Full mode's per-provider logic uses `requiredEscrow[provider]` directly (unaffected by globals), and degradation with `totalUnfunded=0` means no degradation triggers (stays Full). Governor should run a reconciliation pass across providers before switching away from Full mode to populate globals. +Globals start at 0 and populate lazily through normal operations. This is safe because Full mode's per-provider logic uses `sumMaxNextClaim[provider]` directly (unaffected by globals), and degradation with `totalEscrowDeficit=0` means no degradation triggers (stays Full). Governor should run a reconciliation pass across providers before switching away from Full mode to populate globals. ### Enforced JIT -If `beforeCollection` can't fully fund a collection (`deficit >= available`), it deposits nothing and enforces JIT mode. While active, `_fundingTargets` returns `(0, 0)` — JIT-only behavior — regardless of the configured `fundingBasis`. The configured basis is preserved and takes effect again on recovery. +If `beforeCollection` can't fully deposit for a collection (`deficit >= available`), it deposits nothing and enforces JIT mode. While active, `_escrowMinMax` returns `(0, 0)` — JIT-only behavior — regardless of the configured `escrowBasis`. The configured basis is preserved and takes effect again on recovery. **Trigger**: In `beforeCollection`, if `deficit >= available` (all-or-nothing: no partial deposits) and not already enforced: - Set `enforcedJit = true` -- Emit `EnforcedJit($.fundingBasis)` (configured basis unchanged) +- Emit `EnforcedJit($.escrowBasis)` (configured basis unchanged) -**Recovery**: In `_updateEscrow` (runs after every reconcile, collection, etc.), if enforced and `totalUnfunded <= GRAPH_TOKEN.balanceOf(this)`: +**Recovery**: In `_updateEscrow` (runs after every reconcile, collection, etc.), if enforced and `totalEscrowDeficit <= GRAPH_TOKEN.balanceOf(this)`: - Clear `enforcedJit` -- Emit `EnforcedJitRecovered($.fundingBasis)` +- Emit `EnforcedJitRecovered($.escrowBasis)` -Recovery uses `totalUnfunded` — the sum of per-(collector, provider) deficits — rather than total required. This correctly accounts for already-funded escrow. During JIT mode, thaws complete and tokens return to RAM, naturally building toward recovery. +Recovery uses `totalEscrowDeficit` — the sum of per-(collector, provider) deficits — rather than total sumMaxNextClaim. This correctly accounts for already-deposited escrow. During JIT mode, thaws complete and tokens return to RAM, naturally building toward recovery. -**Governor override**: `setFundingBasis` always clears enforced JIT, regardless of recovery conditions. +**Governor override**: `setEscrowBasis` always clears enforced JIT, regardless of recovery conditions. ### Upgrade Safety -Default storage value 0 maps to `JustInTime`, so `reinitializer(2)` sets `fundingBasis = Full` to preserve current behavior. The `initializeV2()` function handles this. `enforcedJit` defaults to `false` (0), which is correct — no enforcement on upgrade. +Default storage value 0 maps to `JustInTime`, so `reinitializer(2)` sets `escrowBasis = Full` to preserve current behavior. The `initializeV2()` function handles this. `enforcedJit` defaults to `false` (0), which is correct — no enforcement on upgrade. ## Roles -- **GOVERNOR_ROLE**: Sets the issuance allocator reference, sets funding basis +- **GOVERNOR_ROLE**: Sets the issuance allocator reference, sets escrow basis - **OPERATOR_ROLE**: Offers agreements/updates, revokes offers, cancels agreements - **PAUSE_ROLE**: Pauses contract (reconcile/remove remain available) - **Permissionless**: `reconcileAgreement`, `removeAgreement`, `updateEscrow` diff --git a/packages/issuance/contracts/agreement/RecurringAgreementManager.sol b/packages/issuance/contracts/agreement/RecurringAgreementManager.sol index 7bca00afd..d7db8e674 100644 --- a/packages/issuance/contracts/agreement/RecurringAgreementManager.sol +++ b/packages/issuance/contracts/agreement/RecurringAgreementManager.sol @@ -13,6 +13,7 @@ import { IDataServiceAgreements } from "@graphprotocol/interfaces/contracts/data import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol"; import { BaseUpgradeable } from "../common/BaseUpgradeable.sol"; +import { IGraphToken } from "../common/IGraphToken.sol"; // solhint-disable-next-line no-unused-import import { ERC165Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; // Used by @inheritdoc @@ -20,12 +21,12 @@ import { ERC165Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/int /** * @title RecurringAgreementManager * @author Edge & Node - * @notice Manages escrow funding for RCAs (Recurring Collection Agreements) using + * @notice Manages escrow 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 + * 3. Tracks max-next-claim per agreement, deposits into PaymentsEscrow to cover maximums * * One escrow per (this contract, collector, provider) covers all managed * RCAs for that (collector, provider) pair. Each agreement stores its own collector @@ -34,9 +35,31 @@ import { ERC165Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/int * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ -contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContractApprover, IRecurringAgreementManager { +contract RecurringAgreementManager is + BaseUpgradeable, + IIssuanceTarget, + IContractApprover, + IRecurringAgreementManager, + IRewardsEligibility +{ using EnumerableSet for EnumerableSet.Bytes32Set; + // -- Role Constants -- + + /** + * @notice Role identifier for approved data service contracts + * @dev Addresses with this role can be used as data services in offered agreements. + * Admin: GOVERNOR_ROLE + */ + bytes32 public constant DATA_SERVICE_ROLE = keccak256("DATA_SERVICE_ROLE"); + + /** + * @notice Role identifier for approved collector contracts + * @dev Addresses with this role can be used as collectors in offered agreements. + * Admin: GOVERNOR_ROLE + */ + bytes32 public constant COLLECTOR_ROLE = keccak256("COLLECTOR_ROLE"); + // -- Immutables -- /// @notice The PaymentsEscrow contract @@ -52,26 +75,25 @@ contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContrac /// @notice Per-agreement tracking data mapping(bytes16 agreementId => AgreementInfo) agreements; /// @notice Sum of maxNextClaim for all agreements per (collector, provider) pair - mapping(address collector => mapping(address provider => uint256)) requiredEscrow; + mapping(address collector => mapping(address provider => uint256)) sumMaxNextClaim; /// @notice Set of agreement IDs per service provider (stored as bytes32 for EnumerableSet) mapping(address provider => EnumerableSet.Bytes32Set) providerAgreementIds; - /// @notice Governance-configured funding level (not modified by enforced JIT) - FundingBasis fundingBasis; - /// @notice Sum of requiredEscrow across all (collector, provider) pairs - uint256 totalRequiredAll; - /// @notice Total unfunded escrow: sum of max(0, requiredEscrow[c][p] - lastKnownFunded[c][p]) - uint256 totalUnfunded; + /// @notice Governance-configured escrow level (not modified by enforced JIT) + EscrowBasis escrowBasis; + /// @notice Sum of sumMaxNextClaim across all (collector, provider) pairs + uint256 sumMaxNextClaimAll; + /// @notice Total unfunded escrow: sum of max(0, sumMaxNextClaim[c][p] - escrowSnap[c][p]) + uint256 totalEscrowDeficit; /// @notice Total number of tracked agreements across all providers uint256 totalAgreementCount; /// @notice Last known escrow balance per (collector, provider) pair (for snapshot diff) - mapping(address collector => mapping(address provider => uint256)) lastKnownFunded; - /// @notice Whether JIT mode is enforced (beforeCollection couldn't fund) + mapping(address collector => mapping(address provider => uint256)) escrowSnap; + /// @notice Whether JIT mode is enforced (beforeCollection couldn't deposit) bool enforcedJit; /// @notice Optional oracle for checking payment eligibility of service providers IRewardsEligibility paymentEligibilityOracle; } - // solhint-disable-next-line gas-named-return-values // keccak256(abi.encode(uint256(keccak256("graphprotocol.issuance.storage.RecurringAgreementManager")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant RECURRING_AGREEMENT_MANAGER_STORAGE_LOCATION = 0x13814b254ec9c757012be47b3445539ef5e5e946eb9d2ef31ea6d4423bf88b00; @@ -80,12 +102,12 @@ contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContrac /** * @notice Constructor for the RecurringAgreementManager contract - * @param graphToken Address of the Graph Token contract - * @param paymentsEscrow Address of the PaymentsEscrow contract + * @param graphToken The Graph Token contract + * @param paymentsEscrow The PaymentsEscrow contract * @custom:oz-upgrades-unsafe-allow constructor */ - constructor(address graphToken, address paymentsEscrow) BaseUpgradeable(graphToken) { - PAYMENTS_ESCROW = IPaymentsEscrow(paymentsEscrow); + constructor(IGraphToken graphToken, IPaymentsEscrow paymentsEscrow) BaseUpgradeable(graphToken) { + PAYMENTS_ESCROW = paymentsEscrow; } // -- Initialization -- @@ -96,14 +118,9 @@ contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContrac */ function initialize(address governor) external virtual initializer { __BaseUpgradeable_init(governor); - _getStorage().fundingBasis = FundingBasis.Full; - } - - /** - * @notice Reinitialize for upgrade: set default funding basis to Full - */ - function initializeV2() external reinitializer(2) { - _getStorage().fundingBasis = FundingBasis.Full; + _setRoleAdmin(DATA_SERVICE_ROLE, GOVERNOR_ROLE); + _setRoleAdmin(COLLECTOR_ROLE, GOVERNOR_ROLE); + _getStorage().escrowBasis = EscrowBasis.Full; } // -- ERC165 -- @@ -145,13 +162,12 @@ contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContrac AgreementInfo storage agreement = $.agreements[agreementId]; address provider = agreement.provider; if (provider == address(0)) return; - address collector = msg.sender; - require(collector == agreement.collector, OnlyAgreementCollector()); + _requireCollector(agreement); // Only deposit if escrow is short for this collection IPaymentsEscrow.EscrowAccount memory account = PAYMENTS_ESCROW.escrowAccounts( address(this), - collector, + msg.sender, provider ); if (tokensToCollect < account.balance) return; @@ -159,22 +175,21 @@ contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContrac uint256 deficit = tokensToCollect - account.balance; if (deficit < GRAPH_TOKEN.balanceOf(address(this))) { GRAPH_TOKEN.approve(address(PAYMENTS_ESCROW), deficit); - PAYMENTS_ESCROW.deposit(collector, provider, deficit); + PAYMENTS_ESCROW.deposit(msg.sender, provider, deficit); } else if (!$.enforcedJit) { $.enforcedJit = true; - emit EnforcedJit($.fundingBasis); + emit EnforcedJit($.escrowBasis); } } /// @inheritdoc IContractApprover function afterCollection(bytes16 agreementId, uint256 /* tokensCollected */) external override { RecurringAgreementManagerStorage storage $ = _getStorage(); - AgreementInfo storage info = $.agreements[agreementId]; - if (info.provider == address(0)) return; - require(msg.sender == info.collector, OnlyAgreementCollector()); + AgreementInfo storage agreement = $.agreements[agreementId]; + if (agreement.provider == address(0)) return; + _requireCollector(agreement); - _reconcileAgreement($, agreementId); - _updateEscrow($, info.collector, info.provider); + _reconcileAndUpdateEscrow($, agreementId); } // -- IRecurringAgreementManager: Core Functions -- @@ -182,37 +197,33 @@ contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContrac /// @inheritdoc IRecurringAgreementManager function offerAgreement( IRecurringCollector.RecurringCollectionAgreement calldata rca, - address collector + IRecurringCollector collector ) external onlyRole(OPERATOR_ROLE) whenNotPaused returns (bytes16 agreementId) { require(rca.payer == address(this), PayerMustBeManager(rca.payer, address(this))); require(rca.serviceProvider != address(0), ServiceProviderZeroAddress()); - require(rca.dataService != address(0), DataServiceZeroAddress()); - require(collector != address(0), CollectorZeroAddress()); + require(hasRole(DATA_SERVICE_ROLE, rca.dataService), UnauthorizedDataService(rca.dataService)); + require(hasRole(COLLECTOR_ROLE, address(collector)), UnauthorizedCollector(address(collector))); RecurringAgreementManagerStorage storage $ = _getStorage(); - agreementId = IRecurringCollector(collector).generateAgreementId( + agreementId = collector.generateAgreementId( rca.payer, rca.dataService, rca.serviceProvider, rca.deadline, rca.nonce ); - require($.agreements[agreementId].provider == address(0), AgreementAlreadyOffered(agreementId)); - // 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 = IRecurringCollector(collector).hashRCA(rca); + bytes32 agreementHash = collector.hashRCA(rca); $.authorizedHashes[agreementHash] = agreementId; - // Store agreement tracking data (maxNextClaim set to 0; _setAgreementRequired handles accounting) + // Store agreement tracking data (maxNextClaim set to 0; _setAgreementMaxNextClaim handles accounting) $.agreements[agreementId] = AgreementInfo({ provider: rca.serviceProvider, deadline: rca.deadline, - dataService: rca.dataService, + dataService: IDataServiceAgreements(rca.dataService), pendingUpdateNonce: 0, maxNextClaim: 0, pendingUpdateMaxNextClaim: 0, @@ -221,10 +232,14 @@ contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContrac collector: collector }); $.providerAgreementIds[rca.serviceProvider].add(bytes32(agreementId)); - _setAgreementRequired($, agreementId, maxNextClaim, false); - $.totalAgreementCount += 1; + ++$.totalAgreementCount; - // Update escrow: fund deficit (partial-cancel thaw if needed), thaw excess + uint256 maxNextClaim = _computeMaxFirstClaim( + rca.maxOngoingTokensPerSecond, + rca.maxSecondsPerCollection, + rca.maxInitialTokens + ); + _setAgreementMaxNextClaim($, agreementId, maxNextClaim, false); _updateEscrow($, collector, rca.serviceProvider); emit AgreementOffered(agreementId, rca.serviceProvider, maxNextClaim); @@ -239,25 +254,21 @@ contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContrac AgreementInfo storage agreement = $.agreements[agreementId]; require(agreement.provider != address(0), AgreementNotOffered(agreementId)); - // Calculate pending max next claim from RCAU parameters (conservative: includes initial + ongoing) - uint256 pendingMaxNextClaim = rcau.maxOngoingTokensPerSecond * rcau.maxSecondsPerCollection + - rcau.maxInitialTokens; - // Clean up old pending hash if replacing - if (agreement.pendingUpdateHash != bytes32(0)) { - delete $.authorizedHashes[agreement.pendingUpdateHash]; - } + if (agreement.pendingUpdateHash != bytes32(0)) delete $.authorizedHashes[agreement.pendingUpdateHash]; // Authorize the RCAU hash for the IContractApprover callback - bytes32 updateHash = IRecurringCollector(agreement.collector).hashRCAU(rcau); + bytes32 updateHash = agreement.collector.hashRCAU(rcau); $.authorizedHashes[updateHash] = agreementId; - - // Update pending tracking — _setAgreementRequired handles escrow accounting - _setAgreementRequired($, agreementId, pendingMaxNextClaim, true); agreement.pendingUpdateNonce = rcau.nonce; agreement.pendingUpdateHash = updateHash; - // Update escrow: fund deficit (partial-cancel thaw if needed), thaw excess + uint256 pendingMaxNextClaim = _computeMaxFirstClaim( + rcau.maxOngoingTokensPerSecond, + rcau.maxSecondsPerCollection, + rcau.maxInitialTokens + ); + _setAgreementMaxNextClaim($, agreementId, pendingMaxNextClaim, true); _updateEscrow($, agreement.collector, agreement.provider); emit AgreementUpdateOffered(agreementId, pendingMaxNextClaim, rcau.nonce); @@ -266,177 +277,119 @@ contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContrac /// @inheritdoc IRecurringAgreementManager function revokeOffer(bytes16 agreementId) external onlyRole(OPERATOR_ROLE) whenNotPaused { RecurringAgreementManagerStorage storage $ = _getStorage(); - AgreementInfo storage info = $.agreements[agreementId]; - require(info.provider != address(0), AgreementNotOffered(agreementId)); + AgreementInfo storage agreement = $.agreements[agreementId]; + require(agreement.provider != address(0), AgreementNotOffered(agreementId)); // Only revoke un-accepted agreements — accepted ones must be canceled via cancelAgreement - IRecurringCollector.AgreementData memory agreement = IRecurringCollector(info.collector).getAgreement( - agreementId - ); - require( - agreement.state == IRecurringCollector.AgreementState.NotAccepted, - AgreementAlreadyAccepted(agreementId) - ); - - address provider = info.provider; - address collector = info.collector; - - // Clean up authorized hashes - delete $.authorizedHashes[info.agreementHash]; - if (info.pendingUpdateHash != bytes32(0)) { - delete $.authorizedHashes[info.pendingUpdateHash]; - } - - // Zero out escrow requirements before deleting - _setAgreementRequired($, agreementId, 0, false); - _setAgreementRequired($, agreementId, 0, true); - $.totalAgreementCount -= 1; - $.providerAgreementIds[provider].remove(bytes32(agreementId)); - delete $.agreements[agreementId]; + IRecurringCollector.AgreementData memory rca = agreement.collector.getAgreement(agreementId); + require(rca.state == IRecurringCollector.AgreementState.NotAccepted, AgreementAlreadyAccepted(agreementId)); + address provider = _deleteAgreement($, agreementId, agreement); emit OfferRevoked(agreementId, provider); - _updateEscrow($, collector, provider); } /// @inheritdoc IRecurringAgreementManager function cancelAgreement(bytes16 agreementId) external onlyRole(OPERATOR_ROLE) whenNotPaused { RecurringAgreementManagerStorage storage $ = _getStorage(); - AgreementInfo storage info = $.agreements[agreementId]; - require(info.provider != address(0), AgreementNotOffered(agreementId)); + AgreementInfo storage agreement = $.agreements[agreementId]; + if (agreement.provider == address(0)) return; - IRecurringCollector.AgreementData memory agreement = IRecurringCollector(info.collector).getAgreement( - agreementId - ); + IRecurringCollector.AgreementData memory rca = agreement.collector.getAgreement(agreementId); // Not accepted — use revokeOffer instead - require(agreement.state != IRecurringCollector.AgreementState.NotAccepted, AgreementNotAccepted(agreementId)); + require(rca.state != IRecurringCollector.AgreementState.NotAccepted, AgreementNotAccepted(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, InvalidDataService(ds)); - IDataServiceAgreements(ds).cancelIndexingAgreementByPayer(agreementId); - emit AgreementCanceled(agreementId, info.provider); + if (rca.state == IRecurringCollector.AgreementState.Accepted) { + IDataServiceAgreements ds = agreement.dataService; + require(address(ds).code.length != 0, InvalidDataService(address(ds))); + ds.cancelIndexingAgreementByPayer(agreementId); + emit AgreementCanceled(agreementId, agreement.provider); } // else: already canceled (CanceledByPayer or CanceledByServiceProvider) — skip cancel call, just reconcile - // Reconcile to update escrow requirements after cancellation - _reconcileAgreement($, agreementId); - _updateEscrow($, info.collector, info.provider); + _reconcileAndUpdateEscrow($, agreementId); } /// @inheritdoc IRecurringAgreementManager function removeAgreement(bytes16 agreementId) external { RecurringAgreementManagerStorage storage $ = _getStorage(); - AgreementInfo storage info = $.agreements[agreementId]; - require(info.provider != address(0), AgreementNotOffered(agreementId)); + AgreementInfo storage agreement = $.agreements[agreementId]; + if (agreement.provider == address(0)) return; // Re-read from the agreement's collector to get current state - IRecurringCollector rc = IRecurringCollector(info.collector); - IRecurringCollector.AgreementData memory agreement = rc.getAgreement(agreementId); + IRecurringCollector.AgreementData memory rca = agreement.collector.getAgreement(agreementId); // Calculate current max next claim - must be 0 to remove uint256 currentMaxClaim; - if (agreement.state == IRecurringCollector.AgreementState.NotAccepted) { + if (rca.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; - } + if (block.timestamp <= agreement.deadline) currentMaxClaim = agreement.maxNextClaim; // else: deadline passed, currentMaxClaim stays 0 (expired offer) - } else { - currentMaxClaim = rc.getMaxNextClaim(agreementId); - } - require(currentMaxClaim == 0, AgreementStillClaimable(agreementId, currentMaxClaim)); + } else currentMaxClaim = agreement.collector.getMaxNextClaim(agreementId); - address provider = info.provider; - address collector = info.collector; - - // Clean up authorized hashes - delete $.authorizedHashes[info.agreementHash]; - if (info.pendingUpdateHash != bytes32(0)) { - delete $.authorizedHashes[info.pendingUpdateHash]; - } - - // Zero out escrow requirements before deleting - _setAgreementRequired($, agreementId, 0, false); - _setAgreementRequired($, agreementId, 0, true); - $.totalAgreementCount -= 1; - $.providerAgreementIds[provider].remove(bytes32(agreementId)); - delete $.agreements[agreementId]; + require(currentMaxClaim == 0, AgreementStillClaimable(agreementId, currentMaxClaim)); + address provider = _deleteAgreement($, agreementId, agreement); emit AgreementRemoved(agreementId, provider); - _updateEscrow($, collector, provider); } /// @inheritdoc IRecurringAgreementManager function reconcileAgreement(bytes16 agreementId) external { RecurringAgreementManagerStorage storage $ = _getStorage(); - AgreementInfo storage info = $.agreements[agreementId]; - require(info.provider != address(0), AgreementNotOffered(agreementId)); + AgreementInfo storage agreement = $.agreements[agreementId]; + if (agreement.provider == address(0)) return; - _reconcileAgreement($, agreementId); - _updateEscrow($, info.collector, info.provider); + _reconcileAndUpdateEscrow($, agreementId); } /// @inheritdoc IRecurringAgreementManager - function updateEscrow(address collector, address provider) external { + function updateEscrow(IRecurringCollector collector, address provider) external { _updateEscrow(_getStorage(), collector, provider); } /// @inheritdoc IRecurringAgreementManager - function setFundingBasis(FundingBasis basis) external onlyRole(GOVERNOR_ROLE) { + function setEscrowBasis(EscrowBasis basis) external onlyRole(GOVERNOR_ROLE) { RecurringAgreementManagerStorage storage $ = _getStorage(); - FundingBasis oldBasis = $.fundingBasis; - $.fundingBasis = basis; - $.enforcedJit = false; - emit FundingBasisChanged(oldBasis, basis); + if ($.escrowBasis == basis) return; + EscrowBasis oldBasis = $.escrowBasis; + $.escrowBasis = basis; + emit EscrowBasisChanged(oldBasis, basis); } /// @inheritdoc IRecurringAgreementManager - function setPaymentEligibilityOracle(address oracle) external onlyRole(GOVERNOR_ROLE) { + function setPaymentEligibilityOracle(IRewardsEligibility oracle) external onlyRole(GOVERNOR_ROLE) { RecurringAgreementManagerStorage storage $ = _getStorage(); - address oldOracle = address($.paymentEligibilityOracle); - $.paymentEligibilityOracle = IRewardsEligibility(oracle); + if (address($.paymentEligibilityOracle) == address(oracle)) return; + IRewardsEligibility oldOracle = $.paymentEligibilityOracle; + $.paymentEligibilityOracle = oracle; emit PaymentEligibilityOracleSet(oldOracle, oracle); } // -- IRewardsEligibility -- - /** - * @notice Check if a service provider is eligible for payment collection. - * @dev When no oracle is configured (address(0)), all providers are eligible. - * When an oracle is set, delegates to the oracle's isEligible check. - * @param serviceProvider The address of the service provider - * @return True if the service provider is eligible - */ - function isEligible(address serviceProvider) external view returns (bool) { + /// @inheritdoc IRewardsEligibility + /// @dev When no oracle is configured (address(0)), all providers are eligible. + /// When an oracle is set, delegates to the oracle's isEligible check. + function isEligible(address serviceProvider) external view override returns (bool eligible) { IRewardsEligibility oracle = _getStorage().paymentEligibilityOracle; - if (address(oracle) == address(0)) return true; - return oracle.isEligible(serviceProvider); + eligible = (address(oracle) == address(0)) || oracle.isEligible(serviceProvider); } // -- IRecurringAgreementManager: View Functions -- /// @inheritdoc IRecurringAgreementManager - function getRequiredEscrow(address collector, address provider) external view returns (uint256) { - return _getStorage().requiredEscrow[collector][provider]; + function sumMaxNextClaim(IRecurringCollector collector, address provider) external view returns (uint256) { + return _getStorage().sumMaxNextClaim[address(collector)][provider]; } /// @inheritdoc IRecurringAgreementManager - function getDeficit(address collector, address provider) external view returns (uint256) { - RecurringAgreementManagerStorage storage $ = _getStorage(); - uint256 required = $.requiredEscrow[collector][provider]; - IPaymentsEscrow.EscrowAccount memory account = PAYMENTS_ESCROW.escrowAccounts( - address(this), - collector, - provider - ); - uint256 currentBalance = account.balance - account.tokensThawing; - if (currentBalance < required) { - return required - currentBalance; - } - return 0; + function getEscrowAccount( + IRecurringCollector collector, + address provider + ) external view returns (IPaymentsEscrow.EscrowAccount memory) { + return PAYMENTS_ESCROW.escrowAccounts(address(this), address(collector), provider); } /// @inheritdoc IRecurringAgreementManager @@ -456,29 +409,31 @@ contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContrac /// @inheritdoc IRecurringAgreementManager function getProviderAgreements(address provider) external view returns (bytes16[] memory) { - RecurringAgreementManagerStorage storage $ = _getStorage(); - EnumerableSet.Bytes32Set storage ids = $.providerAgreementIds[provider]; - 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; + return _getProviderAgreements(provider, 0, type(uint256).max); + } + + /// @inheritdoc IRecurringAgreementManager + function getProviderAgreements( + address provider, + uint256 offset, + uint256 count + ) external view returns (bytes16[] memory) { + return _getProviderAgreements(provider, offset, count); } /// @inheritdoc IRecurringAgreementManager - function getFundingBasis() external view returns (FundingBasis) { - return _getStorage().fundingBasis; + function getEscrowBasis() external view returns (EscrowBasis) { + return _getStorage().escrowBasis; } /// @inheritdoc IRecurringAgreementManager - function getTotalRequired() external view returns (uint256) { - return _getStorage().totalRequiredAll; + function sumMaxNextClaimAll() external view returns (uint256) { + return _getStorage().sumMaxNextClaimAll; } /// @inheritdoc IRecurringAgreementManager - function getTotalUnfunded() external view returns (uint256) { - return _getStorage().totalUnfunded; + function getTotalEscrowDeficit() external view returns (uint256) { + return _getStorage().totalEscrowDeficit; } /// @inheritdoc IRecurringAgreementManager @@ -493,219 +448,326 @@ contract RecurringAgreementManager is BaseUpgradeable, IIssuanceTarget, IContrac // -- Internal Functions -- + /** + * @notice Require that msg.sender is the agreement's collector. + * @param agreement The agreement info to check against + */ + function _requireCollector(AgreementInfo storage agreement) private view { + require(msg.sender == address(agreement.collector), OnlyAgreementCollector()); + } + + /** + * @notice Compute maximum first claim from agreement rate parameters. + * @param maxOngoingTokensPerSecond Maximum ongoing tokens per second + * @param maxSecondsPerCollection Maximum seconds per collection period + * @param maxInitialTokens Maximum initial tokens + * @return Maximum possible claim amount + */ + function _computeMaxFirstClaim( + uint256 maxOngoingTokensPerSecond, + uint256 maxSecondsPerCollection, + uint256 maxInitialTokens + ) private pure returns (uint256) { + return maxOngoingTokensPerSecond * maxSecondsPerCollection + maxInitialTokens; + } + + function _getProviderAgreements( + address provider, + uint256 offset, + uint256 count + ) private view returns (bytes16[] memory) { + RecurringAgreementManagerStorage storage $ = _getStorage(); + EnumerableSet.Bytes32Set storage ids = $.providerAgreementIds[provider]; + uint256 total = ids.length(); + // solhint-disable-next-line gas-strict-inequalities + if (total <= offset) return new bytes16[](0); + + uint256 remaining = total - offset; + if (remaining < count) count = remaining; + + bytes16[] memory result = new bytes16[](count); + for (uint256 i = 0; i < count; ++i) result[i] = bytes16(ids.at(offset + i)); + + return result; + } + + /** + * @notice Reconcile an agreement and update escrow for its (collector, provider) pair. + * @param agreementId The agreement ID to reconcile + */ + // solhint-disable-next-line use-natspec + function _reconcileAndUpdateEscrow(RecurringAgreementManagerStorage storage $, bytes16 agreementId) private { + _reconcileAgreement($, agreementId); + AgreementInfo storage info = $.agreements[agreementId]; + _updateEscrow($, info.collector, info.provider); + } + /** * @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(RecurringAgreementManagerStorage storage $, bytes16 agreementId) private { - AgreementInfo storage info = $.agreements[agreementId]; + AgreementInfo storage agreement = $.agreements[agreementId]; - IRecurringCollector rc = IRecurringCollector(info.collector); - IRecurringCollector.AgreementData memory agreement = rc.getAgreement(agreementId); + IRecurringCollector rc = agreement.collector; + IRecurringCollector.AgreementData memory rca = rc.getAgreement(agreementId); // If not yet accepted in RC, keep the pre-offer estimate - if (agreement.state == IRecurringCollector.AgreementState.NotAccepted) { - return; - } + if (rca.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) { - _setAgreementRequired($, agreementId, 0, true); - delete $.authorizedHashes[info.pendingUpdateHash]; - info.pendingUpdateNonce = 0; - info.pendingUpdateHash = bytes32(0); + if (agreement.pendingUpdateHash != bytes32(0) && agreement.pendingUpdateNonce <= rca.updateNonce) { + _setAgreementMaxNextClaim($, agreementId, 0, true); + delete $.authorizedHashes[agreement.pendingUpdateHash]; + agreement.pendingUpdateNonce = 0; + agreement.pendingUpdateHash = bytes32(0); } - uint256 oldMaxClaim = info.maxNextClaim; + uint256 oldMaxClaim = agreement.maxNextClaim; uint256 newMaxClaim = rc.getMaxNextClaim(agreementId); if (oldMaxClaim != newMaxClaim) { - _setAgreementRequired($, agreementId, newMaxClaim, false); + _setAgreementMaxNextClaim($, agreementId, newMaxClaim, false); emit AgreementReconciled(agreementId, oldMaxClaim, newMaxClaim); } } + /** + * @notice Delete an agreement: clean up hashes, zero escrow obligations, remove from provider set, and update escrow. + * @param agreementId The agreement ID to delete + * @param agreement Storage pointer to the agreement info + * @return provider The provider address (captured before deletion) + */ + // solhint-disable-next-line use-natspec + function _deleteAgreement( + RecurringAgreementManagerStorage storage $, + bytes16 agreementId, + AgreementInfo storage agreement + ) private returns (address provider) { + provider = agreement.provider; + IRecurringCollector collector = agreement.collector; + + // Clean up authorized hashes + delete $.authorizedHashes[agreement.agreementHash]; + if (agreement.pendingUpdateHash != bytes32(0)) delete $.authorizedHashes[agreement.pendingUpdateHash]; + + // Zero out escrow requirements before deleting + _setAgreementMaxNextClaim($, agreementId, 0, false); + _setAgreementMaxNextClaim($, agreementId, 0, true); + --$.totalAgreementCount; + $.providerAgreementIds[provider].remove(bytes32(agreementId)); + delete $.agreements[agreementId]; + + _updateEscrow($, collector, provider); + } + /** * @notice Atomically set one escrow obligation slot of an agreement and cascade to provider/global totals. - * @dev This and {_setFundedSnapshot} are the only two functions that mutate totalUnfunded. + * @dev This and {_setEscrowSnap} are the only two functions that mutate totalEscrowDeficit. * @param agreementId The agreement to update * @param newValue The new obligation value * @param pending If true, updates pendingUpdateMaxNextClaim; otherwise updates maxNextClaim */ // solhint-disable-next-line use-natspec - function _setAgreementRequired( + function _setAgreementMaxNextClaim( RecurringAgreementManagerStorage storage $, bytes16 agreementId, uint256 newValue, bool pending ) private { - AgreementInfo storage info = $.agreements[agreementId]; - address collector = info.collector; - address provider = info.provider; - uint256 oldUnfunded = _providerUnfunded($, collector, provider); - - uint256 oldValue; - if (pending) { - oldValue = info.pendingUpdateMaxNextClaim; - info.pendingUpdateMaxNextClaim = newValue; - } else { - oldValue = info.maxNextClaim; - info.maxNextClaim = newValue; - } + AgreementInfo storage agreement = $.agreements[agreementId]; + + uint256 oldValue = pending ? agreement.pendingUpdateMaxNextClaim : agreement.maxNextClaim; + if (oldValue == newValue) return; - $.requiredEscrow[collector][provider] = $.requiredEscrow[collector][provider] - oldValue + newValue; - $.totalRequiredAll = $.totalRequiredAll - oldValue + newValue; - $.totalUnfunded = $.totalUnfunded - oldUnfunded + _providerUnfunded($, collector, provider); + IRecurringCollector collector = agreement.collector; + address provider = agreement.provider; + address c = address(collector); + uint256 oldDeficit = _providerEscrowDeficit($, collector, provider); + + if (pending) agreement.pendingUpdateMaxNextClaim = newValue; + else agreement.maxNextClaim = newValue; + + $.sumMaxNextClaim[c][provider] = $.sumMaxNextClaim[c][provider] - oldValue + newValue; + $.sumMaxNextClaimAll = $.sumMaxNextClaimAll - oldValue + newValue; + $.totalEscrowDeficit = $.totalEscrowDeficit - oldDeficit + _providerEscrowDeficit($, collector, provider); } /** - * @notice Compute deposit target and thaw ceiling based on funding basis. - * @dev Funding ladder: + * @notice Compute escrow levels (min, max) based on escrow basis. + * @dev Escrow ladder: * - * | Level | Deposit target | Thaw ceiling | - * |------------|---------------|-------------| - * | Full | required | required | - * | OnDemand | 0 | required | - * | JustInTime | 0 | 0 | + * | Level | min (deposit floor) | max (thaw ceiling) | + * |------------|--------------------|--------------------| + * | Full | sumMaxNext | sumMaxNext | + * | OnDemand | 0 | sumMaxNext | + * | JustInTime | 0 | 0 | * * When enforcedJit, behaves as JustInTime regardless of configured basis. - * Full degrades to OnDemand when totalUnfunded > available. + * Full degrades to OnDemand when totalEscrowDeficit >= available. * - * @param required The requiredEscrow for this (collector, provider) pair - * @return depositTarget The target for deposits (deposit if balance is below) - * @return thawCeiling The ceiling for thaws (thaw if balance is above) + * @param collector The collector contract address + * @param provider The service provider + * @return min Deposit floor — deposit if balance is below this + * @return max Thaw ceiling — thaw if balance is above this */ // solhint-disable-next-line use-natspec - function _fundingTargets( + function _escrowMinMax( RecurringAgreementManagerStorage storage $, - uint256 required - ) private view returns (uint256 depositTarget, uint256 thawCeiling) { - FundingBasis basis = $.enforcedJit ? FundingBasis.JustInTime : $.fundingBasis; + IRecurringCollector collector, + address provider + ) private view returns (uint256 min, uint256 max) { + EscrowBasis basis = $.enforcedJit ? EscrowBasis.JustInTime : $.escrowBasis; - depositTarget = (basis == FundingBasis.Full && $.totalUnfunded <= GRAPH_TOKEN.balanceOf(address(this))) - ? required - : 0; - thawCeiling = basis == FundingBasis.JustInTime ? 0 : required; + max = basis == EscrowBasis.JustInTime ? 0 : $.sumMaxNextClaim[address(collector)][provider]; + min = (basis == EscrowBasis.Full && $.totalEscrowDeficit < GRAPH_TOKEN.balanceOf(address(this))) ? max : 0; } /** - * @notice Compute a (collector, provider) pair's unfunded escrow: max(0, required - funded). + * @notice Compute a (collector, provider) pair's escrow deficit: max(0, sumMaxNext - snapshot). * @param collector The collector contract * @param provider The service provider - * @return The unfunded amount for this (collector, provider) + * @return deficit The amount not in escrow for this (collector, provider) */ // solhint-disable-next-line use-natspec - function _providerUnfunded( + function _providerEscrowDeficit( RecurringAgreementManagerStorage storage $, - address collector, + IRecurringCollector collector, address provider - ) private view returns (uint256) { - uint256 required = $.requiredEscrow[collector][provider]; - uint256 funded = $.lastKnownFunded[collector][provider]; - if (required <= funded) return 0; - return required - funded; + ) private view returns (uint256 deficit) { + address c = address(collector); + uint256 sumMaxNext = $.sumMaxNextClaim[c][provider]; + uint256 snapshot = $.escrowSnap[c][provider]; + + deficit = (snapshot < sumMaxNext) ? sumMaxNext - snapshot : 0; } /** - * @notice Update escrow state for a (collector, provider) pair: withdraw completed thaws, - * fund any deficit, and thaw excess balance. - * @dev Sequential state normalization using two targets from {_fundingTargets}: - * - depositTarget: deposit if balance is below this - * - thawCeiling: thaw if balance is above this + * @notice Update escrow state for a (collector, provider) pair: adjust thaw targets, + * withdraw completed thaws, thaw excess, or deposit deficit. + * @dev Sequential state normalization using (min, max) from {_escrowMinMax}: + * - min: deposit floor — deposit if effective balance (balance - tokensThawing) is below this + * - max: thaw ceiling — thaw effective balance above this, unless it would reset the thaw timer * - * Phases: - * 1. Withdraw completed thaw (skip when escrow is short — cancelThaw avoids round-trip) - * 2. Reduce thaw if effective balance (balance - thawing) is below thawCeiling - * 3. Not thawing: start thaw for excess above thawCeiling or deposit for deficit below depositTarget + * Steps: + * 1. Adjust thaw target — cancel/reduce unrealised thawing to keep effective balance >= min, + * or increase thawing to bring effective balance toward max (without resetting timer). + * 2. Withdraw completed thaw — realised thawing is always withdrawn, even if within [min, max]. + * 3. Thaw excess — if no thaw is active (possibly after a withdraw), start a new thaw for + * any balance above max. + * 4. Deposit deficit — if no thaw is active, deposit to reach min. + * + * Steps 3 and 4 are mutually exclusive (min <= max). Only one runs per call. + * The thaw timer is never reset: step 1 passes evenIfTimerReset=false, and steps 3/4 + * only run when tokensThawing == 0. * * Uses per-call approve (not infinite allowance). Safe because PaymentsEscrow * is a trusted protocol contract that transfers exactly the approved amount. * - * Updates funded snapshot at the end for global tracking. + * Updates escrow snapshot at the end for global tracking. * * @param collector The collector contract address * @param provider The service provider to update escrow for */ // solhint-disable-next-line use-natspec - function _updateEscrow(RecurringAgreementManagerStorage storage $, address collector, address provider) private { - // Enforced JIT recovery: clear when RAM can afford totalUnfunded - if ($.enforcedJit) { - uint256 available = GRAPH_TOKEN.balanceOf(address(this)); - if ($.totalUnfunded <= available) { - $.enforcedJit = false; - emit EnforcedJitRecovered($.fundingBasis); - } + function _updateEscrow( + RecurringAgreementManagerStorage storage $, + IRecurringCollector collector, + address provider + ) private { + // solhint-disable-next-line gas-strict-inequalities + if ($.enforcedJit && $.totalEscrowDeficit <= GRAPH_TOKEN.balanceOf(address(this))) { + $.enforcedJit = false; + emit EnforcedJitRecovered($.escrowBasis); } - IPaymentsEscrow.EscrowAccount memory account = PAYMENTS_ESCROW.escrowAccounts( - address(this), - collector, - provider - ); - uint256 required = $.requiredEscrow[collector][provider]; - (uint256 depositTarget, uint256 thawCeiling) = _fundingTargets($, required); - - // Withdraw completed thaw (skip when escrow is short — step 2 cancels thaw instead) - bool thawReady = 0 < account.thawEndTimestamp && account.thawEndTimestamp < block.timestamp; - if (thawReady && depositTarget < account.balance) { - uint256 withdrawn = PAYMENTS_ESCROW.withdraw(collector, provider); - emit EscrowWithdrawn(provider, collector, withdrawn); - account = PAYMENTS_ESCROW.escrowAccounts(address(this), collector, provider); + address c = address(collector); + IPaymentsEscrow.EscrowAccount memory account = PAYMENTS_ESCROW.escrowAccounts(address(this), c, provider); + (uint256 min, uint256 max) = _escrowMinMax($, collector, provider); + + uint256 escrowed = account.balance - account.tokensThawing; + // Objectives in order of priority: + // We want to end with escrowed of at least min, and seek to thaw down to no more than max. + // 1. Do not reset thaw timer if a thaw is in progress. + // (This is to avoid thrash of restarting thaws resulting in never withdrawing excess.) + // 2. Make minimal adjustment to thawing tokens to get as close to min/max as possible. + // (First cancel unrealised thawing before depositing.) + uint256 thawTarget = (escrowed < min) + ? (min < account.balance ? account.balance - min : 0) + : (max < escrowed ? account.balance - max : account.tokensThawing); + if (thawTarget != account.tokensThawing) { + PAYMENTS_ESCROW.thaw(c, provider, thawTarget, false); + account = PAYMENTS_ESCROW.escrowAccounts(address(this), c, provider); } - // Reduce thaw if effective balance is below thawCeiling; else thawing at acceptable level - if (0 < account.tokensThawing) - if (account.balance - account.tokensThawing < thawCeiling) { - uint256 target = account.balance < thawCeiling ? 0 : account.balance - thawCeiling; - PAYMENTS_ESCROW.thaw(collector, provider, target, false); - // solhint-disable-next-line gas-strict-inequalities - if (depositTarget <= account.balance) { - _setFundedSnapshot($, collector, provider); - return; - } - // thaw cancelled; fall through to deposit below - } else { - _setFundedSnapshot($, collector, provider); - return; - } + _withdrawAndRebalance(c, provider, account, min, max); + _setEscrowSnap($, collector, provider); + } - // Not thawing: thaw excess or deposit deficit - if (thawCeiling < account.balance) { - uint256 excess = account.balance - thawCeiling; - PAYMENTS_ESCROW.thaw(collector, provider, excess, false); - } else if (account.balance < depositTarget) { - uint256 deficit = depositTarget - account.balance; - 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(collector, provider, toDeposit); - emit EscrowFunded(provider, collector, toDeposit); - } + /** + * @notice Withdraw completed thaws and rebalance: thaw excess above max or deposit deficit below min. + * @dev Realised thawing is always withdrawn, even if within [min, max]. + * Then if no thaw is active: thaw any balance above max, or deposit to reach min. + * These last two steps are mutually exclusive (min <= max). Only one runs per call. + * @param c Collector address + * @param provider Service provider address + * @param account Current escrow account state + * @param min Deposit floor + * @param max Thaw ceiling + */ + function _withdrawAndRebalance( + address c, + address provider, + IPaymentsEscrow.EscrowAccount memory account, + uint256 min, + uint256 max + ) private { + // Withdraw any remaining thawed tokens (realised thawing is withdrawn even if within [min, max]) + // solhint-disable-next-line gas-strict-inequalities + if (0 < account.tokensThawing && account.thawEndTimestamp <= block.timestamp) { + emit EscrowWithdrawn(provider, c, PAYMENTS_ESCROW.withdraw(c, provider)); + account = PAYMENTS_ESCROW.escrowAccounts(address(this), c, provider); } - _setFundedSnapshot($, collector, provider); + if (account.tokensThawing == 0) { + if (max < account.balance) + // Thaw excess above max (might have withdrawn allowing a new thaw to start) + PAYMENTS_ESCROW.thaw(c, provider, account.balance - max, false); + else { + // Deposit any deficit below min (deposit exactly the missing amount, no more) + uint256 deposit = (min < account.balance) ? 0 : min - account.balance; + if (0 < deposit) { + GRAPH_TOKEN.approve(address(PAYMENTS_ESCROW), deposit); + PAYMENTS_ESCROW.deposit(c, provider, deposit); + emit EscrowFunded(provider, c, deposit); + } + } + } } /** - * @notice Atomically sync the funded snapshot for a (collector, provider) pair after escrow mutations. - * @dev This and {_setAgreementRequired} are the only two functions that mutate totalUnfunded. + * @notice Atomically sync the escrow snapshot for a (collector, provider) pair after escrow mutations. + * @dev This and {_setAgreementMaxNextClaim} are the only two functions that mutate totalEscrowDeficit. * @param collector The collector address * @param provider The service provider */ // solhint-disable-next-line use-natspec - function _setFundedSnapshot( + function _setEscrowSnap( RecurringAgreementManagerStorage storage $, - address collector, + IRecurringCollector collector, address provider ) private { - uint256 oldUnfunded = _providerUnfunded($, collector, provider); - uint256 currentFunded = PAYMENTS_ESCROW.escrowAccounts(address(this), collector, provider).balance; - $.lastKnownFunded[collector][provider] = currentFunded; - uint256 newUnfunded = _providerUnfunded($, collector, provider); - $.totalUnfunded = $.totalUnfunded - oldUnfunded + newUnfunded; + address c = address(collector); + uint256 oldEscrow = $.escrowSnap[c][provider]; + uint256 newEscrow = PAYMENTS_ESCROW.escrowAccounts(address(this), c, provider).balance; + if (oldEscrow == newEscrow) return; + + uint256 oldDeficit = _providerEscrowDeficit($, collector, provider); + $.escrowSnap[c][provider] = newEscrow; + uint256 newDeficit = _providerEscrowDeficit($, collector, provider); + $.totalEscrowDeficit = $.totalEscrowDeficit - oldDeficit + newDeficit; } /** diff --git a/packages/issuance/contracts/allocate/DirectAllocation.sol b/packages/issuance/contracts/allocate/DirectAllocation.sol index 26144a951..91f153b5e 100644 --- a/packages/issuance/contracts/allocate/DirectAllocation.sol +++ b/packages/issuance/contracts/allocate/DirectAllocation.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.27; import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; import { ISendTokens } from "@graphprotocol/interfaces/contracts/issuance/allocate/ISendTokens.sol"; import { BaseUpgradeable } from "../common/BaseUpgradeable.sol"; +import { IGraphToken } from "../common/IGraphToken.sol"; // solhint-disable-next-line no-unused-import import { ERC165Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; // Used by @inheritdoc @@ -44,10 +45,10 @@ contract DirectAllocation is BaseUpgradeable, IIssuanceTarget, ISendTokens { * @notice Constructor for the DirectAllocation contract * @dev This contract is upgradeable, but we use the constructor to pass the Graph Token address * to the base contract. - * @param graphToken Address of the Graph Token contract + * @param graphToken The Graph Token contract * @custom:oz-upgrades-unsafe-allow constructor */ - constructor(address graphToken) BaseUpgradeable(graphToken) {} + constructor(IGraphToken graphToken) BaseUpgradeable(graphToken) {} // -- Initialization -- diff --git a/packages/issuance/contracts/allocate/IssuanceAllocator.sol b/packages/issuance/contracts/allocate/IssuanceAllocator.sol index 83456daf6..76ecf8792 100644 --- a/packages/issuance/contracts/allocate/IssuanceAllocator.sol +++ b/packages/issuance/contracts/allocate/IssuanceAllocator.sol @@ -15,6 +15,7 @@ import { IIssuanceAllocationStatus } from "@graphprotocol/interfaces/contracts/i import { IIssuanceAllocationData } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationData.sol"; import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; import { BaseUpgradeable } from "../common/BaseUpgradeable.sol"; +import { IGraphToken } from "../common/IGraphToken.sol"; import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; @@ -324,10 +325,10 @@ contract IssuanceAllocator is * @notice Constructor for the IssuanceAllocator contract * @dev This contract is upgradeable, but we use the constructor to pass the Graph Token address * to the base contract. - * @param _graphToken Address of the Graph Token contract + * @param _graphToken The Graph Token contract * @custom:oz-upgrades-unsafe-allow constructor */ - constructor(address _graphToken) BaseUpgradeable(_graphToken) {} + constructor(IGraphToken _graphToken) BaseUpgradeable(_graphToken) {} // -- Initialization -- diff --git a/packages/issuance/contracts/common/BaseUpgradeable.sol b/packages/issuance/contracts/common/BaseUpgradeable.sol index 2141a8e20..28a8f8966 100644 --- a/packages/issuance/contracts/common/BaseUpgradeable.sol +++ b/packages/issuance/contracts/common/BaseUpgradeable.sol @@ -87,12 +87,12 @@ abstract contract BaseUpgradeable is * @notice Constructor for the BaseUpgradeable contract * @dev This contract is upgradeable, but we use the constructor to set immutable variables * and disable initializers to prevent the implementation contract from being initialized. - * @param graphToken Address of the Graph Token contract + * @param graphToken The Graph Token contract * @custom:oz-upgrades-unsafe-allow constructor */ - constructor(address graphToken) { - require(graphToken != address(0), GraphTokenCannotBeZeroAddress()); - GRAPH_TOKEN = IGraphToken(graphToken); + constructor(IGraphToken graphToken) { + require(address(graphToken) != address(0), GraphTokenCannotBeZeroAddress()); + GRAPH_TOKEN = graphToken; _disableInitializers(); } diff --git a/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol b/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol index 06ed29e8d..d64cb25da 100644 --- a/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol +++ b/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol @@ -7,6 +7,7 @@ import { IRewardsEligibilityAdministration } from "@graphprotocol/interfaces/con import { IRewardsEligibilityReporting } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityReporting.sol"; import { IRewardsEligibilityStatus } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityStatus.sol"; import { BaseUpgradeable } from "../common/BaseUpgradeable.sol"; +import { IGraphToken } from "../common/IGraphToken.sol"; /** * @title RewardsEligibilityOracle @@ -91,10 +92,10 @@ contract RewardsEligibilityOracle is * @notice Constructor for the RewardsEligibilityOracle contract * @dev This contract is upgradeable, but we use the constructor to pass the Graph Token address * to the base contract. - * @param graphToken Address of the Graph Token contract + * @param graphToken The Graph Token contract * @custom:oz-upgrades-unsafe-allow constructor */ - constructor(address graphToken) BaseUpgradeable(graphToken) {} + constructor(IGraphToken graphToken) BaseUpgradeable(graphToken) {} // -- Initialization -- diff --git a/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol b/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol index f9b037682..e4aeb5fab 100644 --- a/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol +++ b/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.27; import { IssuanceAllocator } from "../../allocate/IssuanceAllocator.sol"; +import { IGraphToken } from "../../common/IGraphToken.sol"; /** * @title IssuanceAllocatorTestHarness @@ -13,10 +14,10 @@ import { IssuanceAllocator } from "../../allocate/IssuanceAllocator.sol"; contract IssuanceAllocatorTestHarness is IssuanceAllocator { /** * @notice Constructor for the test harness - * @param _graphToken Address of the Graph Token contract + * @param _graphToken The Graph Token contract * @custom:oz-upgrades-unsafe-allow constructor */ - constructor(address _graphToken) IssuanceAllocator(_graphToken) {} + constructor(IGraphToken _graphToken) IssuanceAllocator(_graphToken) {} /** * @notice Exposes _distributePendingProportionally for testing diff --git a/packages/issuance/foundry.toml b/packages/issuance/foundry.toml index ea60843f6..9251965b5 100644 --- a/packages/issuance/foundry.toml +++ b/packages/issuance/foundry.toml @@ -18,7 +18,7 @@ solc_version = '0.8.34' evm_version = 'cancun' # Exclude test files from coverage reports -no_match_coverage = "(^test/|/mocks/)" +no_match_coverage = "(^test/|^contracts/test/|/mocks/)" [lint] exclude_lints = ["mixed-case-function", "mixed-case-variable"] diff --git a/packages/issuance/test/unit/agreement-manager/afterCollection.t.sol b/packages/issuance/test/unit/agreement-manager/afterCollection.t.sol index 6f650c520..9424cecd3 100644 --- a/packages/issuance/test/unit/agreement-manager/afterCollection.t.sol +++ b/packages/issuance/test/unit/agreement-manager/afterCollection.t.sol @@ -103,7 +103,7 @@ contract RecurringAgreementManagerCollectionCallbackTest is RecurringAgreementMa ); bytes16 agreementId = _offerAgreement(rca); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 3700 ether); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 3700 ether); // Simulate: agreement accepted and first collection happened uint64 acceptedAt = uint64(block.timestamp); @@ -119,7 +119,7 @@ contract RecurringAgreementManagerCollectionCallbackTest is RecurringAgreementMa // After first collection, maxInitialTokens no longer applies // New max = 1e18 * 3600 = 3600e18 assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 3600 ether); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 3600 ether); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 3600 ether); } function test_AfterCollection_Revert_WhenCallerNotRecurringCollector() public { @@ -159,7 +159,7 @@ contract RecurringAgreementManagerCollectionCallbackTest is RecurringAgreementMa agreementManager.afterCollection(agreementId, 0); assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); } /* solhint-enable graph/func-name-mixedcase */ diff --git a/packages/issuance/test/unit/agreement-manager/approver.t.sol b/packages/issuance/test/unit/agreement-manager/approver.t.sol index a6d4baaa9..050a2306e 100644 --- a/packages/issuance/test/unit/agreement-manager/approver.t.sol +++ b/packages/issuance/test/unit/agreement-manager/approver.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; import { IContractApprover } from "@graphprotocol/interfaces/contracts/horizon/IContractApprover.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; import { IRecurringAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/agreement/IRecurringAgreementManager.sol"; import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; @@ -111,10 +112,11 @@ contract RecurringAgreementManagerApproverTest is RecurringAgreementManagerShare _offerAgreement(rca); // Fully funded (offerAgreement mints enough tokens) - assertEq(agreementManager.getDeficit(address(recurringCollector), indexer), 0); + IPaymentsEscrow.EscrowAccount memory account = agreementManager.getEscrowAccount(_collector(), indexer); + assertEq(account.balance - account.tokensThawing, agreementManager.sumMaxNextClaim(_collector(), indexer)); } - function test_GetDeficit_ReturnsDeficitWhenUnderfunded() public { + function test_GetEscrowAccount_MatchesUnderlying() public { IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( 100 ether, 1 ether, @@ -123,19 +125,25 @@ contract RecurringAgreementManagerApproverTest is RecurringAgreementManagerShare 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, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); - // With 1 agreement, Full degrades to OnDemand (no deposit). Deficit is the full required. - assertEq(agreementManager.getDeficit(address(recurringCollector), indexer), maxClaim); + IPaymentsEscrow.EscrowAccount memory expected = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + IPaymentsEscrow.EscrowAccount memory actual = agreementManager.getEscrowAccount(_collector(), indexer); + assertEq(actual.balance, expected.balance); + assertEq(actual.tokensThawing, expected.tokensThawing); + assertEq(actual.thawEndTimestamp, expected.thawEndTimestamp); } function test_GetRequiredEscrow_ZeroForUnknownIndexer() public { - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), makeAddr("unknown")), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), makeAddr("unknown")), 0); } function test_GetAgreementMaxNextClaim_ZeroForUnknown() public view { diff --git a/packages/issuance/test/unit/agreement-manager/cancelAgreement.t.sol b/packages/issuance/test/unit/agreement-manager/cancelAgreement.t.sol index 64622875a..829f1c397 100644 --- a/packages/issuance/test/unit/agreement-manager/cancelAgreement.t.sol +++ b/packages/issuance/test/unit/agreement-manager/cancelAgreement.t.sol @@ -45,7 +45,7 @@ contract RecurringAgreementManagerCancelAgreementTest is RecurringAgreementManag bytes16 agreementId = _offerAgreement(rca); - uint256 originalRequired = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 originalRequired = agreementManager.sumMaxNextClaim(_collector(), indexer); uint256 maxClaim = 1 ether * 3600 + 100 ether; assertEq(originalRequired, maxClaim); @@ -56,7 +56,7 @@ contract RecurringAgreementManagerCancelAgreementTest is RecurringAgreementManag agreementManager.cancelAgreement(agreementId); // After cancelAgreement (which now reconciles), required escrow should decrease - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); } function test_CancelAgreement_Idempotent_CanceledByPayer() public { @@ -101,7 +101,7 @@ contract RecurringAgreementManagerCancelAgreementTest is RecurringAgreementManag assertEq(mockSubgraphService.cancelCallCount(agreementId), 0); // Required escrow should drop to 0 (CanceledBySP has maxNextClaim=0) - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); } function test_CancelAgreement_Revert_WhenNotAccepted() public { @@ -120,10 +120,10 @@ contract RecurringAgreementManagerCancelAgreementTest is RecurringAgreementManag agreementManager.cancelAgreement(agreementId); } - function test_CancelAgreement_Revert_WhenNotOffered() public { + function test_CancelAgreement_Noop_WhenNotOffered() public { bytes16 fakeId = bytes16(keccak256("fake")); - vm.expectRevert(abi.encodeWithSelector(IRecurringAgreementManager.AgreementNotOffered.selector, fakeId)); + // Silently returns when agreement not found (idempotent) vm.prank(operator); agreementManager.cancelAgreement(fakeId); } diff --git a/packages/issuance/test/unit/agreement-manager/edgeCases.t.sol b/packages/issuance/test/unit/agreement-manager/edgeCases.t.sol index deb85ad9f..260802f43 100644 --- a/packages/issuance/test/unit/agreement-manager/edgeCases.t.sol +++ b/packages/issuance/test/unit/agreement-manager/edgeCases.t.sol @@ -43,9 +43,13 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar ); rca.dataService = eoa; + // Grant DATA_SERVICE_ROLE so the offer goes through + vm.prank(governor); + agreementManager.grantRole(DATA_SERVICE_ROLE, eoa); + token.mint(address(agreementManager), 1_000_000 ether); vm.prank(operator); - bytes16 agreementId = agreementManager.offerAgreement(rca, address(recurringCollector)); + bytes16 agreementId = agreementManager.offerAgreement(rca, _collector()); // Set as Accepted so it takes the cancel-via-dataService path recurringCollector.setAgreement( @@ -328,7 +332,7 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar // maxNextClaim = 1e18 * 3600 + 0 = 3600e18 uint256 expectedMaxClaim = 1 ether * 3600; assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), expectedMaxClaim); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), expectedMaxClaim); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), expectedMaxClaim); } function test_Offer_ZeroOngoingTokensPerSecond() public { @@ -344,7 +348,7 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar // maxNextClaim = 0 * 3600 + 100e18 = 100e18 assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 100 ether); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 100 ether); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 100 ether); } function test_Offer_AllZeroValues() public { @@ -360,7 +364,7 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar // maxNextClaim = 0 * 0 + 0 = 0 assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); assertEq(agreementManager.getProviderAgreementCount(indexer), 1); } @@ -449,7 +453,7 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar // getMaxNextClaim returns 0 when collectionEnd <= collectionStart assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); } // ==================== Cancel Edge Cases ==================== @@ -489,13 +493,13 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar // Don't fund the contract — zero token balance vm.prank(operator); - bytes16 agreementId = agreementManager.offerAgreement(rca, address(recurringCollector)); + bytes16 agreementId = agreementManager.offerAgreement(rca, _collector()); 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(address(recurringCollector), indexer), maxClaim); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim); // Escrow has zero balance assertEq( @@ -503,8 +507,8 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar 0 ); - // Full deficit - assertEq(agreementManager.getDeficit(address(recurringCollector), indexer), maxClaim); + // Escrow balance is 0 + assertEq(agreementManager.getEscrowAccount(_collector(), indexer).balance, 0); } // ==================== ReconcileBatch Edge Cases ==================== @@ -561,8 +565,8 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar agreementHelper.reconcileBatch(ids); // All reconciled to 0 - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), 0); } function test_ReconcileBatch_EmptyArray() public { @@ -598,16 +602,16 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar agreementManager.removeAgreement(agreementId); // First updateEscrow: initiates thaw - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); // Warp past mock's thawing period (1 day) vm.warp(block.timestamp + 1 days + 1); // Second updateEscrow: withdraws thawed tokens, then no more to thaw - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); // Third updateEscrow: should be a no-op (nothing to thaw or withdraw) - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); } // ==================== Multiple Pending Update Replacements ==================== @@ -641,8 +645,8 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar bytes32 zeroHash = recurringCollector.hashRCAU(rcau1); // Zero-value hash should still be authorized assertEq(agreementManager.approveAgreement(zeroHash), IContractApprover.approveAgreement.selector); - // requiredEscrow should be unchanged (original + 0) - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), originalMaxClaim); + // sumMaxNextClaim should be unchanged (original + 0) + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim); // Replace with a non-zero update — the old zero-value hash must be cleaned up IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = _makeRCAU( @@ -664,10 +668,7 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar assertEq(agreementManager.approveAgreement(newHash), IContractApprover.approveAgreement.selector); uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; - assertEq( - agreementManager.getRequiredEscrow(address(recurringCollector), indexer), - originalMaxClaim + pendingMaxClaim - ); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + pendingMaxClaim); } function test_Reconcile_ZeroValuePendingUpdate_ClearedWhenApplied() public { @@ -742,19 +743,19 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar // 1. Offer bytes16 agreementId = _offerAgreement(rca); uint256 maxClaim = 1 ether * 3600 + 100 ether; - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim); assertEq(agreementManager.getProviderAgreementCount(indexer), 1); // 2. SP cancels and remove _setAgreementCanceledBySP(agreementId, rca); agreementManager.removeAgreement(agreementId); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); assertEq(agreementManager.getProviderAgreementCount(indexer), 0); // 3. Re-offer the same agreement (same parameters, same agreementId) bytes16 reofferedId = _offerAgreement(rca); assertEq(reofferedId, agreementId); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim); assertEq(agreementManager.getProviderAgreementCount(indexer), 1); // 4. Verify the re-offered agreement is fully functional @@ -798,7 +799,7 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar assertTrue(id1 != id2); uint256 maxClaim2 = 2 ether * 7200 + 200 ether; - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim2); assertEq(agreementManager.getProviderAgreementCount(indexer), 1); } @@ -817,7 +818,7 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar token.mint(address(agreementManager), 1_000_000 ether); vm.expectRevert(IRecurringAgreementManager.ServiceProviderZeroAddress.selector); vm.prank(operator); - agreementManager.offerAgreement(rca, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); } function test_Offer_Revert_ZeroDataService() public { @@ -831,9 +832,11 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar rca.dataService = address(0); token.mint(address(agreementManager), 1_000_000 ether); - vm.expectRevert(IRecurringAgreementManager.DataServiceZeroAddress.selector); + vm.expectRevert( + abi.encodeWithSelector(IRecurringAgreementManager.UnauthorizedDataService.selector, address(0)) + ); vm.prank(operator); - agreementManager.offerAgreement(rca, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); } // ==================== getProviderAgreements ==================== @@ -953,6 +956,45 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar assertEq(indexer2Ids[0], id2); } + function test_GetIndexerAgreements_Paginated() 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); + + // Full range returns both + bytes16[] memory all = agreementManager.getProviderAgreements(indexer, 0, 10); + assertEq(all.length, 2); + assertEq(all[0], id1); + assertEq(all[1], id2); + + // Offset skips first + bytes16[] memory fromOne = agreementManager.getProviderAgreements(indexer, 1, 10); + assertEq(fromOne.length, 1); + assertEq(fromOne[0], id2); + + // Count limits result + bytes16[] memory firstOnly = agreementManager.getProviderAgreements(indexer, 0, 1); + assertEq(firstOnly.length, 1); + assertEq(firstOnly[0], id1); + } + // ==================== Cancel Event Behavior ==================== function test_CancelAgreement_NoEvent_WhenAlreadyCanceled() public { @@ -1030,7 +1072,7 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar ); _offerAgreementUpdate(rcau1); uint256 pending1 = 2 ether * 7200 + 200 ether; - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), originalMaxClaim + pending1); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + pending1); // Update 2 replaces 1 IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = _makeRCAU( @@ -1044,7 +1086,7 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar ); _offerAgreementUpdate(rcau2); uint256 pending2 = 0.5 ether * 1800 + 50 ether; - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), originalMaxClaim + pending2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + pending2); // Update 3 replaces 2 IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau3 = _makeRCAU( @@ -1058,7 +1100,7 @@ contract RecurringAgreementManagerEdgeCasesTest is RecurringAgreementManagerShar ); _offerAgreementUpdate(rcau3); uint256 pending3 = 3 ether * 3600 + 300 ether; - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), originalMaxClaim + pending3); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + pending3); // Only hash for update 3 should be authorized bytes32 hash1 = recurringCollector.hashRCAU(rcau1); diff --git a/packages/issuance/test/unit/agreement-manager/eligibility.t.sol b/packages/issuance/test/unit/agreement-manager/eligibility.t.sol index 87688a870..23ddd9445 100644 --- a/packages/issuance/test/unit/agreement-manager/eligibility.t.sol +++ b/packages/issuance/test/unit/agreement-manager/eligibility.t.sol @@ -10,6 +10,7 @@ import { MockEligibilityOracle } from "./mocks/MockEligibilityOracle.sol"; /// @notice Tests for payment eligibility oracle in RecurringAgreementManager contract RecurringAgreementManagerEligibilityTest is RecurringAgreementManagerSharedTest { MockEligibilityOracle internal oracle; + IRewardsEligibility internal constant NO_ORACLE = IRewardsEligibility(address(0)); function setUp() public override { super.setUp(); @@ -23,29 +24,43 @@ contract RecurringAgreementManagerEligibilityTest is RecurringAgreementManagerSh function test_SetPaymentEligibilityOracle() public { vm.expectEmit(address(agreementManager)); - emit IRecurringAgreementManager.PaymentEligibilityOracleSet(address(0), address(oracle)); + emit IRecurringAgreementManager.PaymentEligibilityOracleSet(NO_ORACLE, oracle); vm.prank(governor); - agreementManager.setPaymentEligibilityOracle(address(oracle)); + agreementManager.setPaymentEligibilityOracle(oracle); } function test_SetPaymentEligibilityOracle_DisableWithZeroAddress() public { // First set an oracle vm.prank(governor); - agreementManager.setPaymentEligibilityOracle(address(oracle)); + agreementManager.setPaymentEligibilityOracle(oracle); // Then disable it vm.expectEmit(address(agreementManager)); - emit IRecurringAgreementManager.PaymentEligibilityOracleSet(address(oracle), address(0)); + emit IRecurringAgreementManager.PaymentEligibilityOracleSet(oracle, NO_ORACLE); vm.prank(governor); - agreementManager.setPaymentEligibilityOracle(address(0)); + agreementManager.setPaymentEligibilityOracle(NO_ORACLE); + } + + function test_SetPaymentEligibilityOracle_NoopWhenSameOracle() public { + // Set oracle + vm.prank(governor); + agreementManager.setPaymentEligibilityOracle(oracle); + + // Set same oracle again — early return, no event + vm.prank(governor); + agreementManager.setPaymentEligibilityOracle(oracle); + + // Oracle still works (confirms state unchanged) + oracle.setEligible(indexer, true); + assertTrue(agreementManager.isEligible(indexer)); } function test_SetPaymentEligibilityOracle_Revert_WhenNotGovernor() public { vm.expectRevert(); vm.prank(operator); - agreementManager.setPaymentEligibilityOracle(address(oracle)); + agreementManager.setPaymentEligibilityOracle(oracle); } // -- isEligible passthrough tests -- @@ -59,7 +74,7 @@ contract RecurringAgreementManagerEligibilityTest is RecurringAgreementManagerSh oracle.setEligible(indexer, true); vm.prank(governor); - agreementManager.setPaymentEligibilityOracle(address(oracle)); + agreementManager.setPaymentEligibilityOracle(oracle); assertTrue(agreementManager.isEligible(indexer)); } @@ -68,7 +83,7 @@ contract RecurringAgreementManagerEligibilityTest is RecurringAgreementManagerSh // indexer not set as eligible, default is false vm.prank(governor); - agreementManager.setPaymentEligibilityOracle(address(oracle)); + agreementManager.setPaymentEligibilityOracle(oracle); assertFalse(agreementManager.isEligible(indexer)); } @@ -76,12 +91,12 @@ contract RecurringAgreementManagerEligibilityTest is RecurringAgreementManagerSh function test_IsEligible_TrueAfterOracleDisabled() public { // Set oracle that denies indexer vm.prank(governor); - agreementManager.setPaymentEligibilityOracle(address(oracle)); + agreementManager.setPaymentEligibilityOracle(oracle); assertFalse(agreementManager.isEligible(indexer)); // Disable oracle vm.prank(governor); - agreementManager.setPaymentEligibilityOracle(address(0)); + agreementManager.setPaymentEligibilityOracle(NO_ORACLE); assertTrue(agreementManager.isEligible(indexer)); } diff --git a/packages/issuance/test/unit/agreement-manager/fundingModes.t.sol b/packages/issuance/test/unit/agreement-manager/fundingModes.t.sol index 8a9c91b31..6aab53457 100644 --- a/packages/issuance/test/unit/agreement-manager/fundingModes.t.sol +++ b/packages/issuance/test/unit/agreement-manager/fundingModes.t.sol @@ -40,30 +40,27 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS return rca; } - // ==================== setFundingBasis ==================== + // ==================== setEscrowBasis ==================== - function test_SetFundingBasis_DefaultIsFull() public view { - assertEq(uint256(agreementManager.getFundingBasis()), uint256(IRecurringAgreementManager.FundingBasis.Full)); + function test_SetEscrowBasis_DefaultIsFull() public view { + assertEq(uint256(agreementManager.getEscrowBasis()), uint256(IRecurringAgreementManager.EscrowBasis.Full)); } - function test_SetFundingBasis_GovernorCanSet() public { + function test_SetEscrowBasis_GovernorCanSet() public { vm.prank(governor); vm.expectEmit(address(agreementManager)); - emit IRecurringAgreementManager.FundingBasisChanged( - IRecurringAgreementManager.FundingBasis.Full, - IRecurringAgreementManager.FundingBasis.OnDemand - ); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); - assertEq( - uint256(agreementManager.getFundingBasis()), - uint256(IRecurringAgreementManager.FundingBasis.OnDemand) + emit IRecurringAgreementManager.EscrowBasisChanged( + IRecurringAgreementManager.EscrowBasis.Full, + IRecurringAgreementManager.EscrowBasis.OnDemand ); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); + assertEq(uint256(agreementManager.getEscrowBasis()), uint256(IRecurringAgreementManager.EscrowBasis.OnDemand)); } - function test_SetFundingBasis_Revert_WhenNotGovernor() public { + function test_SetEscrowBasis_Revert_WhenNotGovernor() public { vm.prank(operator); vm.expectRevert(); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); } // ==================== Global Tracking ==================== @@ -86,16 +83,16 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS _offerAgreement(rca1); uint256 maxClaim1 = 1 ether * 3600 + 100 ether; - assertEq(agreementManager.getTotalRequired(), maxClaim1); + assertEq(agreementManager.sumMaxNextClaimAll(), maxClaim1); assertEq(agreementManager.getTotalAgreementCount(), 1); _offerAgreement(rca2); uint256 maxClaim2 = 2 ether * 7200 + 200 ether; - assertEq(agreementManager.getTotalRequired(), maxClaim1 + maxClaim2); + assertEq(agreementManager.sumMaxNextClaimAll(), maxClaim1 + maxClaim2); assertEq(agreementManager.getTotalAgreementCount(), 2); } - function test_GlobalTracking_TotalUnfunded() public { + function test_GlobalTracking_TotalUndeposited() public { IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, 100 ether, @@ -106,14 +103,14 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS _offerAgreement(rca); - // In Full mode, escrow is fully funded — totalUnfunded should be 0 - assertEq(agreementManager.getTotalUnfunded(), 0, "Fully funded: totalUnfunded = 0"); + // In Full mode, escrow is fully deposited — totalEscrowDeficit should be 0 + assertEq(agreementManager.getTotalEscrowDeficit(), 0, "Fully escrowed: totalEscrowDeficit = 0"); } - function test_GlobalTracking_TotalUnfunded_WhenPartiallyFunded() public { - // Offer in JIT mode (no deposits) — totalUnfunded = totalRequired + function test_GlobalTracking_TotalUndeposited_WhenPartiallyFunded() public { + // Offer in JIT mode (no deposits) — totalEscrowDeficit = sumMaxNextClaim vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.JustInTime); IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, @@ -126,7 +123,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS _offerAgreement(rca); uint256 maxClaim = 1 ether * 3600 + 100 ether; - assertEq(agreementManager.getTotalUnfunded(), maxClaim, "JIT: totalUnfunded = totalRequired"); + assertEq(agreementManager.getTotalEscrowDeficit(), maxClaim, "JIT: totalEscrowDeficit = sumMaxNextClaim"); } function test_GlobalTracking_RevokeDecrementsCountAndRequired() public { @@ -140,13 +137,13 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS bytes16 agreementId = _offerAgreement(rca); uint256 maxClaim = 1 ether * 3600 + 100 ether; - assertEq(agreementManager.getTotalRequired(), maxClaim); + assertEq(agreementManager.sumMaxNextClaimAll(), maxClaim); assertEq(agreementManager.getTotalAgreementCount(), 1); vm.prank(operator); agreementManager.revokeOffer(agreementId); - assertEq(agreementManager.getTotalRequired(), 0); + assertEq(agreementManager.sumMaxNextClaimAll(), 0); assertEq(agreementManager.getTotalAgreementCount(), 0); } @@ -165,7 +162,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS _setAgreementCanceledBySP(agreementId, rca); agreementManager.removeAgreement(agreementId); - assertEq(agreementManager.getTotalRequired(), 0); + assertEq(agreementManager.sumMaxNextClaimAll(), 0); assertEq(agreementManager.getTotalAgreementCount(), 0); } @@ -180,18 +177,18 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS bytes16 agreementId = _offerAgreement(rca); uint256 maxClaim = 1 ether * 3600 + 100 ether; - assertEq(agreementManager.getTotalRequired(), maxClaim); + assertEq(agreementManager.sumMaxNextClaimAll(), maxClaim); // SP cancels — reconcile sets maxNextClaim to 0 _setAgreementCanceledBySP(agreementId, rca); agreementManager.reconcileAgreement(agreementId); - assertEq(agreementManager.getTotalRequired(), 0); + assertEq(agreementManager.sumMaxNextClaimAll(), 0); // Count unchanged (not removed yet) assertEq(agreementManager.getTotalAgreementCount(), 1); } - function test_GlobalTracking_TotalUnfunded_MultiProvider() public { + function test_GlobalTracking_TotalUndeposited_MultiProvider() public { IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( indexer, 100 ether, @@ -210,13 +207,13 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS _offerAgreement(rca1); _offerAgreement(rca2); - // In Full mode, both are fully funded — totalUnfunded should be 0 - assertEq(agreementManager.getTotalUnfunded(), 0, "Both funded: totalUnfunded = 0"); + // In Full mode, both are fully deposited — totalEscrowDeficit should be 0 + assertEq(agreementManager.getTotalEscrowDeficit(), 0, "Both deposited: totalEscrowDeficit = 0"); } - function test_GlobalTracking_TotalUnfunded_OverfundedProviderDoesNotMaskDeficit() public { - // Regression test: over-funded provider must NOT mask another provider's deficit. - // Offer rca1 for indexer (gets fully funded) + function test_GlobalTracking_TotalUndeposited_OverdepositedProviderDoesNotMaskDeficit() public { + // Regression test: over-deposited provider must NOT mask another provider's deficit. + // Offer rca1 for indexer (gets fully deposited) IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( indexer, 100 ether, @@ -227,14 +224,14 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS _offerAgreement(rca1); uint256 maxClaim1 = 1 ether * 3600 + 100 ether; - // Drain SAM so indexer2's agreement can't be funded + // Drain SAM so indexer2's agreement can't be deposited uint256 samBalance = token.balanceOf(address(agreementManager)); if (0 < samBalance) { vm.prank(address(agreementManager)); token.transfer(address(1), samBalance); } - // Offer rca2 for indexer2 (can't be funded) + // Offer rca2 for indexer2 (can't be deposited) IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( indexer2, 200 ether, @@ -243,26 +240,26 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS 2 ); vm.prank(operator); - agreementManager.offerAgreement(rca2, address(recurringCollector)); + agreementManager.offerAgreement(rca2, _collector()); uint256 maxClaim2 = 2 ether * 7200 + 200 ether; - // indexer is fully funded (unfunded = 0), indexer2 has full deficit (unfunded = maxClaim2) - // totalUnfunded must be maxClaim2, NOT 0 (the old buggy totalRequired - totalInEscrow approach - // would compute totalRequired = maxClaim1 + maxClaim2, totalInEscrow = maxClaim1, + // indexer is fully deposited (undeposited = 0), indexer2 has full deficit (undeposited = maxClaim2) + // totalEscrowDeficit must be maxClaim2, NOT 0 (the old buggy sumMaxNextClaim - totalInEscrow approach + // would compute sumMaxNextClaim = maxClaim1 + maxClaim2, totalInEscrow = maxClaim1, // deficit = maxClaim2 — which happens to be correct here, but would be wrong if indexer - // were over-funded and the excess masked indexer2's deficit) - assertEq(agreementManager.getTotalUnfunded(), maxClaim2, "Unfunded = indexer2's full deficit"); + // were over-deposited and the excess masked indexer2's deficit) + assertEq(agreementManager.getTotalEscrowDeficit(), maxClaim2, "Undeposited = indexer2's full deficit"); // Verify per-provider escrow state assertEq( paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, maxClaim1, - "indexer: fully funded" + "indexer: fully deposited" ); assertEq( paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer2).balance, 0, - "indexer2: unfunded" + "indexer2: undeposited" ); } @@ -312,7 +309,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS // ==================== JustInTime Mode ==================== function test_JustInTime_ThawsEverything() public { - // Start in Full mode, offer agreement (gets funded) + // Start in Full mode, offer agreement (gets deposited) IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, 100 ether, @@ -326,10 +323,10 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS // Switch to JustInTime vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.JustInTime); // Update escrow — should thaw everything - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( address(agreementManager), @@ -342,7 +339,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS function test_JustInTime_NoProactiveDeposit() public { // Switch to JustInTime before offering vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.JustInTime); IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, @@ -365,7 +362,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS function test_JustInTime_JITStillWorks() public { vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.JustInTime); IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, @@ -391,7 +388,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS function test_OnDemand_NoProactiveDeposit() public { vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, @@ -427,8 +424,8 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS // OnDemand thaw ceiling = required — no thaw expected (balance == thawCeiling) vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); + agreementManager.updateEscrow(_collector(), indexer); IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( address(agreementManager), @@ -439,8 +436,8 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS assertEq(account.balance, maxClaim, "OnDemand: balance held at required level"); } - function test_OnDemand_DoesNotThawBelowRequired_VsJustInTime() public { - // Fund 6 agreements at Full level, compare OnDemand vs JustInTime + function test_OnDemand_PreservesThawFromJIT() public { + // Fund 6 agreements at Full level, then switch JIT -> OnDemand for (uint256 i = 1; i <= 6; i++) { IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, @@ -453,37 +450,37 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS } uint256 maxClaimEach = 1 ether * 3600 + 100 ether; - uint256 totalRequired = maxClaimEach * 6; + uint256 sumMaxNextClaim = maxClaimEach * 6; // JustInTime would thaw everything vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.JustInTime); + agreementManager.updateEscrow(_collector(), indexer); IPaymentsEscrow.EscrowAccount memory jitAccount = paymentsEscrow.escrowAccounts( address(agreementManager), address(recurringCollector), indexer ); - assertEq(jitAccount.tokensThawing, totalRequired, "JustInTime: thaws everything"); + assertEq(jitAccount.tokensThawing, sumMaxNextClaim, "JustInTime: thaws everything"); - // Switch to OnDemand — should cancel thaw (thaw ceiling = required) + // Switch to OnDemand — min=0, liquid=0 >= min, so thaw is left alone vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); + agreementManager.updateEscrow(_collector(), indexer); IPaymentsEscrow.EscrowAccount memory odAccount = paymentsEscrow.escrowAccounts( address(agreementManager), address(recurringCollector), indexer ); - // OnDemand holds at required level — should reduce/cancel thaw - assertTrue(odAccount.tokensThawing < jitAccount.tokensThawing, "OnDemand thaws less than JustInTime"); + // OnDemand: min=0, liquid(0) >= min(0) — existing thaw preserved, no unnecessary cancellation + assertEq(odAccount.tokensThawing, jitAccount.tokensThawing, "OnDemand preserves thaw when liquid >= min"); } function test_OnDemand_JITStillWorks() public { vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, @@ -529,11 +526,11 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS ); token.mint(address(agreementManager), 100_000 ether); vm.prank(operator); - agreementManager.offerAgreement(rca, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); } - // totalRequired should be larger than totalUnfunded (degradation occurred: Full -> OnDemand) - assertTrue(0 < agreementManager.getTotalUnfunded(), "Degradation: some unfunded deficit exists"); + // sumMaxNextClaim should be larger than totalEscrowDeficit (degradation occurred: Full -> OnDemand) + assertTrue(0 < agreementManager.getTotalEscrowDeficit(), "Degradation: some undeposited deficit exists"); } function test_Degradation_NeverReachesJustInTime() public { @@ -575,12 +572,12 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS // Switch through all modes — agreement data preserved vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), maxClaim); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim); vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.JustInTime); assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), maxClaim); assertEq(agreementManager.getProviderAgreementCount(indexer), 1); } @@ -604,8 +601,8 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS // Switch to JustInTime and update escrow vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.JustInTime); + agreementManager.updateEscrow(_collector(), indexer); IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( address(agreementManager), @@ -647,7 +644,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS function test_AfterCollection_ReconcileInOnDemandMode() public { vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, @@ -670,7 +667,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS assertEq(newMaxClaim, 1 ether * 3600, "maxNextClaim = ongoing only after first collection"); } - // ==================== PendingUpdate with totalRequired tracking ==================== + // ==================== PendingUpdate with sumMaxNextClaim tracking ==================== function test_GlobalTracking_PendingUpdate() public { IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( @@ -683,7 +680,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS bytes16 agreementId = _offerAgreement(rca); uint256 maxClaim = 1 ether * 3600 + 100 ether; - assertEq(agreementManager.getTotalRequired(), maxClaim); + assertEq(agreementManager.sumMaxNextClaimAll(), maxClaim); IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( agreementId, @@ -697,7 +694,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS _offerAgreementUpdate(rcau); uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; - assertEq(agreementManager.getTotalRequired(), maxClaim + pendingMaxClaim); + assertEq(agreementManager.sumMaxNextClaimAll(), maxClaim + pendingMaxClaim); } function test_GlobalTracking_ReplacePendingUpdate() public { @@ -723,7 +720,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS _offerAgreementUpdate(rcau1); uint256 pendingMaxClaim1 = 2 ether * 7200 + 200 ether; - assertEq(agreementManager.getTotalRequired(), maxClaim + pendingMaxClaim1); + assertEq(agreementManager.sumMaxNextClaimAll(), maxClaim + pendingMaxClaim1); IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = _makeRCAU( agreementId, @@ -737,7 +734,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS _offerAgreementUpdate(rcau2); uint256 pendingMaxClaim2 = 0.5 ether * 1800 + 50 ether; - assertEq(agreementManager.getTotalRequired(), maxClaim + pendingMaxClaim2); + assertEq(agreementManager.sumMaxNextClaimAll(), maxClaim + pendingMaxClaim2); } // ==================== Upward Transitions ==================== @@ -745,7 +742,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS function test_Transition_JustInTimeToFull() public { // Start in JIT (no deposits), switch to Full (deposits required) vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.JustInTime); IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, @@ -766,8 +763,8 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS // Switch to Full vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.Full); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.Full); + agreementManager.updateEscrow(_collector(), indexer); assertEq( paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, @@ -790,8 +787,8 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS // Switch to OnDemand — holds at required (no thaw for 1 agreement) vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); + agreementManager.updateEscrow(_collector(), indexer); IPaymentsEscrow.EscrowAccount memory odAccount = paymentsEscrow.escrowAccounts( address(agreementManager), @@ -802,8 +799,8 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS // Switch back to Full — no change needed (already at required) vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.Full); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.Full); + agreementManager.updateEscrow(_collector(), indexer); IPaymentsEscrow.EscrowAccount memory fullAccount = paymentsEscrow.escrowAccounts( address(agreementManager), @@ -852,28 +849,29 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS // Switch to JustInTime while thaw is active — existing thaw continues, // remaining balance thaws after current thaw completes and is withdrawn vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.JustInTime); + agreementManager.updateEscrow(_collector(), indexer); IPaymentsEscrow.EscrowAccount memory midCycle = paymentsEscrow.escrowAccounts( address(agreementManager), address(recurringCollector), indexer ); - // Existing thaw continues (effective >= thawCeiling=0) - assertEq(midCycle.tokensThawing, maxClaimEach, "Existing thaw continues"); + // Same-block increase is fine (no timer reset) — thaws everything + assertEq(midCycle.tokensThawing, 2 * maxClaimEach, "Same-block: thaw increased to full balance"); - // Complete first thaw, withdraw, then second cycle thaws the rest + // Complete thaw, withdraw all vm.warp(block.timestamp + 2 days); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); IPaymentsEscrow.EscrowAccount memory afterWithdraw = paymentsEscrow.escrowAccounts( address(agreementManager), address(recurringCollector), indexer ); - // After withdrawal, remaining balance starts thawing - assertEq(afterWithdraw.tokensThawing, afterWithdraw.balance, "JIT: all remaining balance thawing"); + // Everything withdrawn in one cycle + assertEq(afterWithdraw.balance, 0, "JIT: all withdrawn"); + assertEq(afterWithdraw.tokensThawing, 0, "JIT: nothing left to thaw"); } // ==================== Enforced JIT ==================== @@ -897,7 +895,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS // Request collection exceeding escrow balance vm.expectEmit(address(agreementManager)); - emit IRecurringAgreementManager.EnforcedJit(IRecurringAgreementManager.FundingBasis.Full); + emit IRecurringAgreementManager.EnforcedJit(IRecurringAgreementManager.EscrowBasis.Full); vm.prank(address(recurringCollector)); agreementManager.beforeCollection(agreementId, 1_000_000 ether); @@ -905,16 +903,16 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS // Verify state assertTrue(agreementManager.isEnforcedJit(), "Enforced JIT should be tripped"); assertEq( - uint256(agreementManager.getFundingBasis()), - uint256(IRecurringAgreementManager.FundingBasis.Full), - "Basis unchanged (enforced JIT overrides behavior, not fundingBasis)" + uint256(agreementManager.getEscrowBasis()), + uint256(IRecurringAgreementManager.EscrowBasis.Full), + "Basis unchanged (enforced JIT overrides behavior, not escrowBasis)" ); } function test_EnforcedJit_PreservesBasisOnTrip() public { - // Set OnDemand, trip — fundingBasis should NOT change + // Set OnDemand, trip — escrowBasis should NOT change vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, @@ -933,15 +931,15 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS } vm.expectEmit(address(agreementManager)); - emit IRecurringAgreementManager.EnforcedJit(IRecurringAgreementManager.FundingBasis.OnDemand); + emit IRecurringAgreementManager.EnforcedJit(IRecurringAgreementManager.EscrowBasis.OnDemand); vm.prank(address(recurringCollector)); agreementManager.beforeCollection(agreementId, 1_000_000 ether); // Basis stays OnDemand (not switched to JIT) assertEq( - uint256(agreementManager.getFundingBasis()), - uint256(IRecurringAgreementManager.FundingBasis.OnDemand), + uint256(agreementManager.getEscrowBasis()), + uint256(IRecurringAgreementManager.EscrowBasis.OnDemand), "Basis unchanged during trip" ); assertTrue(agreementManager.isEnforcedJit()); @@ -1008,7 +1006,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS function test_EnforcedJit_TripsEvenWhenAlreadyJustInTime() public { // Governor explicitly sets JIT vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.JustInTime); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.JustInTime); IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, @@ -1067,7 +1065,7 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS } function test_EnforcedJit_RecoveryOnUpdateEscrow() public { - // Offer rca1 (fully funded), drain SAM, offer rca2 (creates unfunded deficit) + // Offer rca1 (fully deposited), drain SAM, offer rca2 (creates undeposited deficit) IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( indexer, 100 ether, @@ -1091,33 +1089,33 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS 2 ); vm.prank(operator); - agreementManager.offerAgreement(rca2, address(recurringCollector)); + agreementManager.offerAgreement(rca2, _collector()); // Trip enforced JIT vm.prank(address(recurringCollector)); agreementManager.beforeCollection(agreementId, 1_000_000 ether); assertTrue(agreementManager.isEnforcedJit()); - // Mint enough to cover totalUnfunded — triggers recovery - uint256 totalUnfunded = agreementManager.getTotalUnfunded(); - assertTrue(0 < totalUnfunded, "Deficit exists"); - token.mint(address(agreementManager), totalUnfunded); + // Mint enough to cover totalEscrowDeficit — triggers recovery + uint256 totalEscrowDeficit = agreementManager.getTotalEscrowDeficit(); + assertTrue(0 < totalEscrowDeficit, "Deficit exists"); + token.mint(address(agreementManager), totalEscrowDeficit); vm.expectEmit(address(agreementManager)); - emit IRecurringAgreementManager.EnforcedJitRecovered(IRecurringAgreementManager.FundingBasis.Full); + emit IRecurringAgreementManager.EnforcedJitRecovered(IRecurringAgreementManager.EscrowBasis.Full); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); assertFalse(agreementManager.isEnforcedJit(), "Enforced JIT recovered"); assertEq( - uint256(agreementManager.getFundingBasis()), - uint256(IRecurringAgreementManager.FundingBasis.Full), + uint256(agreementManager.getEscrowBasis()), + uint256(IRecurringAgreementManager.EscrowBasis.Full), "Basis still Full" ); } function test_EnforcedJit_NoRecoveryWhenPartiallyFunded() public { - // Offer rca1 (fully funded), drain, offer rca2 (unfunded — creates deficit) + // Offer rca1 (fully deposited), drain, offer rca2 (undeposited — creates deficit) IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( indexer, 100 ether, @@ -1141,33 +1139,33 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS 2 ); vm.prank(operator); - agreementManager.offerAgreement(rca2, address(recurringCollector)); + agreementManager.offerAgreement(rca2, _collector()); // Trip vm.prank(address(recurringCollector)); agreementManager.beforeCollection(agreementId, 1_000_000 ether); assertTrue(agreementManager.isEnforcedJit()); - uint256 totalUnfunded = agreementManager.getTotalUnfunded(); - assertTrue(0 < totalUnfunded, "totalUnfunded > 0"); + uint256 totalEscrowDeficit = agreementManager.getTotalEscrowDeficit(); + assertTrue(0 < totalEscrowDeficit, "totalEscrowDeficit > 0"); - // Mint less than totalUnfunded — no recovery - token.mint(address(agreementManager), totalUnfunded / 2); + // Mint less than totalEscrowDeficit — no recovery + token.mint(address(agreementManager), totalEscrowDeficit / 2); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); assertTrue(agreementManager.isEnforcedJit(), "Still tripped (insufficient balance)"); assertEq( - uint256(agreementManager.getFundingBasis()), - uint256(IRecurringAgreementManager.FundingBasis.Full), + uint256(agreementManager.getEscrowBasis()), + uint256(IRecurringAgreementManager.EscrowBasis.Full), "Basis unchanged" ); } - function test_EnforcedJit_FundingBasisPreservedDuringTrip() public { - // Set OnDemand, trip, recover — fundingBasis stays OnDemand throughout + function test_EnforcedJit_EscrowBasisPreservedDuringTrip() public { + // Set OnDemand, trip, recover — escrowBasis stays OnDemand throughout vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, @@ -1190,27 +1188,27 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS assertTrue(agreementManager.isEnforcedJit()); assertEq( - uint256(agreementManager.getFundingBasis()), - uint256(IRecurringAgreementManager.FundingBasis.OnDemand), + uint256(agreementManager.getEscrowBasis()), + uint256(IRecurringAgreementManager.EscrowBasis.OnDemand), "Basis preserved during trip" ); // Recovery - token.mint(address(agreementManager), agreementManager.getTotalRequired()); + token.mint(address(agreementManager), agreementManager.sumMaxNextClaimAll()); vm.expectEmit(address(agreementManager)); - emit IRecurringAgreementManager.EnforcedJitRecovered(IRecurringAgreementManager.FundingBasis.OnDemand); + emit IRecurringAgreementManager.EnforcedJitRecovered(IRecurringAgreementManager.EscrowBasis.OnDemand); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); assertFalse(agreementManager.isEnforcedJit()); assertEq( - uint256(agreementManager.getFundingBasis()), - uint256(IRecurringAgreementManager.FundingBasis.OnDemand), + uint256(agreementManager.getEscrowBasis()), + uint256(IRecurringAgreementManager.EscrowBasis.OnDemand), "Basis still OnDemand after recovery" ); } - function test_EnforcedJit_SetFundingBasisClearsBreaker() public { + function test_EnforcedJit_SetEscrowBasisClearsBreaker() public { IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCAForIndexer( indexer, 100 ether, @@ -1231,20 +1229,20 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS agreementManager.beforeCollection(agreementId, 1_000_000 ether); assertTrue(agreementManager.isEnforcedJit()); - // Governor manually sets basis — clears enforced JIT + // Governor sets basis — enforcedJit remains (cleared by recovery, not by setBasis) vm.prank(governor); - agreementManager.setFundingBasis(IRecurringAgreementManager.FundingBasis.OnDemand); + agreementManager.setEscrowBasis(IRecurringAgreementManager.EscrowBasis.OnDemand); - assertFalse(agreementManager.isEnforcedJit(), "Governor cleared breaker"); + assertTrue(agreementManager.isEnforcedJit(), "Breaker persists until recovery"); assertEq( - uint256(agreementManager.getFundingBasis()), - uint256(IRecurringAgreementManager.FundingBasis.OnDemand), + uint256(agreementManager.getEscrowBasis()), + uint256(IRecurringAgreementManager.EscrowBasis.OnDemand), "Governor's chosen basis" ); } function test_EnforcedJit_MultipleTripRecoverCycles() public { - // Offer rca1 (funded), drain SAM, offer rca2 (unfunded — creates deficit) + // Offer rca1 (deposited), drain SAM, offer rca2 (undeposited — creates deficit) IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( indexer, 100 ether, @@ -1268,10 +1266,10 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS 2 ); vm.prank(operator); - agreementManager.offerAgreement(rca2, address(recurringCollector)); + agreementManager.offerAgreement(rca2, _collector()); - uint256 unfunded = agreementManager.getTotalUnfunded(); - assertTrue(0 < unfunded, "Has unfunded deficit"); + uint256 undeposited = agreementManager.getTotalEscrowDeficit(); + assertTrue(0 < undeposited, "Has undeposited deficit"); // --- Cycle 1: Trip --- vm.prank(address(recurringCollector)); @@ -1279,10 +1277,10 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS assertTrue(agreementManager.isEnforcedJit()); // --- Cycle 1: Recover --- - token.mint(address(agreementManager), unfunded); - agreementManager.updateEscrow(address(recurringCollector), indexer); + token.mint(address(agreementManager), undeposited); + agreementManager.updateEscrow(_collector(), indexer); assertFalse(agreementManager.isEnforcedJit()); - assertEq(uint256(agreementManager.getFundingBasis()), uint256(IRecurringAgreementManager.FundingBasis.Full)); + assertEq(uint256(agreementManager.getEscrowBasis()), uint256(IRecurringAgreementManager.EscrowBasis.Full)); // After recovery, updateEscrow deposited into escrow. Drain again and create new deficit. samBalance = token.balanceOf(address(agreementManager)); @@ -1299,10 +1297,10 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS 3 ); vm.prank(operator); - agreementManager.offerAgreement(rca3, address(recurringCollector)); + agreementManager.offerAgreement(rca3, _collector()); - unfunded = agreementManager.getTotalUnfunded(); - assertTrue(0 < unfunded, "New unfunded deficit"); + undeposited = agreementManager.getTotalEscrowDeficit(); + assertTrue(0 < undeposited, "New undeposited deficit"); // --- Cycle 2: Trip --- vm.prank(address(recurringCollector)); @@ -1310,14 +1308,14 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS assertTrue(agreementManager.isEnforcedJit()); // --- Cycle 2: Recover --- - token.mint(address(agreementManager), unfunded); - agreementManager.updateEscrow(address(recurringCollector), indexer); + token.mint(address(agreementManager), undeposited); + agreementManager.updateEscrow(_collector(), indexer); assertFalse(agreementManager.isEnforcedJit()); - assertEq(uint256(agreementManager.getFundingBasis()), uint256(IRecurringAgreementManager.FundingBasis.Full)); + assertEq(uint256(agreementManager.getEscrowBasis()), uint256(IRecurringAgreementManager.EscrowBasis.Full)); } function test_EnforcedJit_MultiProvider() public { - // Offer rca1 (funded), drain SAM, offer rca2 (creates deficit → totalUnfunded > 0) + // Offer rca1 (deposited), drain SAM, offer rca2 (creates deficit → totalEscrowDeficit > 0) IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( indexer, 100 ether, @@ -1327,14 +1325,14 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS ); bytes16 id1 = _offerAgreement(rca1); - // Drain SAM so rca2 can't be funded + // Drain SAM so rca2 can't be deposited uint256 samBalance = token.balanceOf(address(agreementManager)); if (0 < samBalance) { vm.prank(address(agreementManager)); token.transfer(address(1), samBalance); } - // Offer rca2 directly (no mint) — escrow stays unfunded, creates deficit + // Offer rca2 directly (no mint) — escrow stays undeposited, creates deficit IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( indexer2, 100 ether, @@ -1343,8 +1341,8 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS 2 ); vm.prank(operator); - agreementManager.offerAgreement(rca2, address(recurringCollector)); - assertTrue(0 < agreementManager.getTotalUnfunded(), "should have unfunded escrow"); + agreementManager.offerAgreement(rca2, _collector()); + assertTrue(0 < agreementManager.getTotalEscrowDeficit(), "should have undeposited escrow"); // Trip via indexer's agreement vm.prank(address(recurringCollector)); @@ -1352,8 +1350,8 @@ contract RecurringAgreementManagerFundingModesTest is RecurringAgreementManagerS assertTrue(agreementManager.isEnforcedJit()); // Both providers should see JIT behavior (thaw everything) - agreementManager.updateEscrow(address(recurringCollector), indexer); - agreementManager.updateEscrow(address(recurringCollector), indexer2); + agreementManager.updateEscrow(_collector(), indexer); + agreementManager.updateEscrow(_collector(), indexer2); IPaymentsEscrow.EscrowAccount memory acc1 = paymentsEscrow.escrowAccounts( address(agreementManager), diff --git a/packages/issuance/test/unit/agreement-manager/fuzz.t.sol b/packages/issuance/test/unit/agreement-manager/fuzz.t.sol index 7964e773d..48f496faa 100644 --- a/packages/issuance/test/unit/agreement-manager/fuzz.t.sol +++ b/packages/issuance/test/unit/agreement-manager/fuzz.t.sol @@ -34,7 +34,7 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes uint256 expectedMaxClaim = uint256(maxOngoingTokensPerSecond) * uint256(maxSecondsPerCollection) + uint256(maxInitialTokens); assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), expectedMaxClaim); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), expectedMaxClaim); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), expectedMaxClaim); } function testFuzz_Offer_EscrowFundedUpToAvailable( @@ -58,7 +58,7 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes // Fund with a specific amount instead of the default 1M ether token.mint(address(agreementManager), availableTokens); vm.prank(operator); - bytes16 agreementId = agreementManager.offerAgreement(rca, address(recurringCollector)); + bytes16 agreementId = agreementManager.offerAgreement(rca, _collector()); uint256 maxNextClaim = agreementManager.getAgreementMaxNextClaim(agreementId); uint256 escrowBalance = paymentsEscrow @@ -66,8 +66,8 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes .balance; // In Full mode (default): - // If totalUnfunded <= available: Full deposits required. - // If totalUnfunded > available: degrades to OnDemand (deposit target = 0). + // If totalEscrowDeficit <= available: Full deposits required. + // If totalEscrowDeficit > available: degrades to OnDemand (deposit target = 0). // JIT beforeCollection is the safety net for underfunded escrow. if (maxNextClaim <= availableTokens) { assertEq(escrowBalance, maxNextClaim); @@ -106,10 +106,10 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes rca2.nonce = 2; _offerAgreement(rca1); - uint256 required1 = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 required1 = agreementManager.sumMaxNextClaim(_collector(), indexer); _offerAgreement(rca2); - uint256 required2 = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 required2 = agreementManager.sumMaxNextClaim(_collector(), indexer); uint256 maxClaim1 = uint256(maxOngoing1) * uint256(maxSec1) + uint256(maxInitial1); uint256 maxClaim2 = uint256(maxOngoing2) * uint256(maxSec2) + uint256(maxInitial2); @@ -132,13 +132,13 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes ); bytes16 agreementId = _offerAgreement(rca); - uint256 requiredBefore = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 requiredBefore = agreementManager.sumMaxNextClaim(_collector(), indexer); assertTrue(0 < requiredBefore || (maxInitial == 0 && maxOngoing == 0)); vm.prank(operator); agreementManager.revokeOffer(agreementId); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); assertEq(agreementManager.getProviderAgreementCount(indexer), 0); } @@ -158,7 +158,7 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes agreementManager.removeAgreement(agreementId); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); assertEq(agreementManager.getProviderAgreementCount(indexer), 0); assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); } @@ -184,7 +184,7 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes ); bytes16 agreementId = _offerAgreement(rca); - uint256 preAcceptRequired = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 preAcceptRequired = agreementManager.sumMaxNextClaim(_collector(), indexer); // Simulate acceptance and a collection at block.timestamp + timeElapsed uint64 acceptedAt = uint64(block.timestamp); @@ -196,7 +196,7 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes agreementManager.reconcileAgreement(agreementId); - uint256 postReconcileRequired = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 postReconcileRequired = agreementManager.sumMaxNextClaim(_collector(), indexer); // After collection, the maxNextClaim should reflect remaining window (no initial tokens) // and should be <= the pre-acceptance estimate @@ -226,7 +226,7 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes bytes16 agreementId = _offerAgreement(rca); uint256 originalMaxClaim = uint256(maxOngoing) * uint256(maxSec) + uint256(maxInitial); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), originalMaxClaim); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim); IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( agreementId, @@ -242,10 +242,7 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes uint256 pendingMaxClaim = uint256(updateMaxOngoing) * uint256(updateMaxSec) + uint256(updateMaxInitial); // Both original and pending are funded simultaneously - assertEq( - agreementManager.getRequiredEscrow(address(recurringCollector), indexer), - originalMaxClaim + pendingMaxClaim - ); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + pendingMaxClaim); } // -- removeAgreement deadline -- @@ -281,9 +278,9 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes assertEq(agreementManager.getProviderAgreementCount(indexer), 0); } - // -- getDeficit -- + // -- getEscrowAccount -- - function testFuzz_GetDeficit_MatchesShortfall(uint128 maxOngoing, uint32 maxSec, uint128 available) public { + function testFuzz_GetEscrowAccount_MatchesUnderlying(uint128 maxOngoing, uint32 maxSec, uint128 available) public { vm.assume(0 < maxSec); vm.assume(0 < maxOngoing); @@ -297,22 +294,18 @@ contract RecurringAgreementManagerFuzzTest is RecurringAgreementManagerSharedTes token.mint(address(agreementManager), available); vm.prank(operator); - agreementManager.offerAgreement(rca, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); - uint256 required = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); - IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + IPaymentsEscrow.EscrowAccount memory expected = paymentsEscrow.escrowAccounts( address(agreementManager), address(recurringCollector), indexer ); - uint256 balance = account.balance - account.tokensThawing; - uint256 deficit = agreementManager.getDeficit(address(recurringCollector), indexer); + IPaymentsEscrow.EscrowAccount memory actual = agreementManager.getEscrowAccount(_collector(), indexer); - if (balance < required) { - assertEq(deficit, required - balance); - } else { - assertEq(deficit, 0); - } + assertEq(actual.balance, expected.balance); + assertEq(actual.tokensThawing, expected.tokensThawing); + assertEq(actual.thawEndTimestamp, expected.thawEndTimestamp); } /* solhint-enable graph/func-name-mixedcase */ diff --git a/packages/issuance/test/unit/agreement-manager/helper.t.sol b/packages/issuance/test/unit/agreement-manager/helper.t.sol index a84d66f48..e95907ca1 100644 --- a/packages/issuance/test/unit/agreement-manager/helper.t.sol +++ b/packages/issuance/test/unit/agreement-manager/helper.t.sol @@ -64,14 +64,14 @@ contract RecurringAgreementHelperTest is RecurringAgreementManagerSharedTest { // 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(address(recurringCollector), indexer), 14400 ether); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 14400 ether); } function test_Reconcile_EmptyProvider() public { // reconcile for a provider with no agreements — should be a no-op address unknown = makeAddr("unknown"); agreementHelper.reconcile(unknown); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), unknown), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), unknown), 0); } function test_Reconcile_IdempotentWhenUnchanged() public { @@ -89,14 +89,14 @@ contract RecurringAgreementHelperTest is RecurringAgreementManagerSharedTest { // First reconcile agreementHelper.reconcile(indexer); - uint256 escrowAfterFirst = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 escrowAfterFirst = agreementManager.sumMaxNextClaim(_collector(), indexer); uint256 maxClaimAfterFirst = agreementManager.getAgreementMaxNextClaim(agreementId); // Second reconcile should produce identical results (idempotent) vm.recordLogs(); agreementHelper.reconcile(indexer); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), escrowAfterFirst); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), escrowAfterFirst); assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), maxClaimAfterFirst); // No reconcile event on the second call since nothing changed @@ -159,7 +159,7 @@ contract RecurringAgreementHelperTest is RecurringAgreementManagerSharedTest { assertEq(agreementManager.getAgreementMaxNextClaim(id2), 14400 ether); // 2e18 * 7200 // id3 unchanged: 3e18 * 1800 = 5400e18 (pre-offer estimate) assertEq(agreementManager.getAgreementMaxNextClaim(id3), 5400 ether); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 14400 ether + 5400 ether); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 14400 ether + 5400 ether); } // -- reconcileBatch tests -- @@ -188,7 +188,7 @@ contract RecurringAgreementHelperTest is RecurringAgreementManagerSharedTest { uint256 maxClaim1 = 1 ether * 3600 + 100 ether; uint256 maxClaim2 = 2 ether * 7200 + 200 ether; - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1 + maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim1 + maxClaim2); // Accept both and simulate CanceledBySP on agreement 1 _setAgreementCanceledBySP(id1, rca1); @@ -205,7 +205,7 @@ contract RecurringAgreementHelperTest is RecurringAgreementManagerSharedTest { // Agreement 2 accepted, never collected -> maxNextClaim = initial + ongoing assertEq(agreementManager.getAgreementMaxNextClaim(id2), maxClaim2); // Required should be just agreement 2 now - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim2); } function test_ReconcileBatch_SkipsNonExistent() public { @@ -268,8 +268,8 @@ contract RecurringAgreementHelperTest is RecurringAgreementManagerSharedTest { uint256 maxClaim1 = 1 ether * 3600 + 100 ether; uint256 maxClaim2 = 2 ether * 7200 + 200 ether; - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim1); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), maxClaim2); // Cancel both by SP _setAgreementCanceledBySP(id1, rca1); @@ -280,8 +280,8 @@ contract RecurringAgreementHelperTest is RecurringAgreementManagerSharedTest { ids[1] = id2; agreementHelper.reconcileBatch(ids); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), 0); } function test_ReconcileBatch_Permissionless() public { @@ -327,10 +327,7 @@ contract RecurringAgreementHelperTest is RecurringAgreementManagerSharedTest { uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; - assertEq( - agreementManager.getRequiredEscrow(address(recurringCollector), indexer), - originalMaxClaim + pendingMaxClaim - ); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + pendingMaxClaim); // Simulate: accepted with the update already applied (updateNonce >= pending) recurringCollector.setAgreement( @@ -358,7 +355,7 @@ contract RecurringAgreementHelperTest is RecurringAgreementManagerSharedTest { // Pending should be cleared; required escrow should be based on new terms uint256 newMaxClaim = 2 ether * 7200 + 200 ether; - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), newMaxClaim); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), newMaxClaim); } /* solhint-enable graph/func-name-mixedcase */ diff --git a/packages/issuance/test/unit/agreement-manager/mocks/MockPaymentsEscrow.sol b/packages/issuance/test/unit/agreement-manager/mocks/MockPaymentsEscrow.sol index 5b4260f63..7525b74e4 100644 --- a/packages/issuance/test/unit/agreement-manager/mocks/MockPaymentsEscrow.sol +++ b/packages/issuance/test/unit/agreement-manager/mocks/MockPaymentsEscrow.sol @@ -74,7 +74,7 @@ contract MockPaymentsEscrow is IPaymentsEscrow { function withdraw(address collector, address receiver) external returns (uint256 tokens) { Account storage account = accounts[msg.sender][collector][receiver]; - if (account.thawEndTimestamp == 0 || block.timestamp <= account.thawEndTimestamp) { + if (account.thawEndTimestamp == 0 || block.timestamp < account.thawEndTimestamp) { return 0; } tokens = account.tokensThawing; @@ -103,6 +103,21 @@ contract MockPaymentsEscrow is IPaymentsEscrow { return account.balance > account.tokensThawing ? account.balance - account.tokensThawing : 0; } + /// @notice Test helper: set arbitrary account state for data-driven tests + function setAccount( + address payer, + address collector, + address receiver, + uint256 balance_, + uint256 tokensThawing_, + uint256 thawEndTimestamp_ + ) external { + Account storage account = accounts[payer][collector][receiver]; + account.balance = balance_; + account.tokensThawing = tokensThawing_; + account.thawEndTimestamp = thawEndTimestamp_; + } + // -- Stubs (not used by RecurringAgreementManager) -- function initialize() external {} diff --git a/packages/issuance/test/unit/agreement-manager/multiCollector.t.sol b/packages/issuance/test/unit/agreement-manager/multiCollector.t.sol index b56790e76..c2c988898 100644 --- a/packages/issuance/test/unit/agreement-manager/multiCollector.t.sol +++ b/packages/issuance/test/unit/agreement-manager/multiCollector.t.sol @@ -16,6 +16,9 @@ contract RecurringAgreementManagerMultiCollectorTest is RecurringAgreementManage super.setUp(); collector2 = new MockRecurringCollector(); vm.label(address(collector2), "RecurringCollector2"); + + vm.prank(governor); + agreementManager.grantRole(COLLECTOR_ROLE, address(collector2)); } // -- Helpers -- @@ -64,7 +67,7 @@ contract RecurringAgreementManagerMultiCollectorTest is RecurringAgreementManage ); token.mint(address(agreementManager), 1_000_000 ether); vm.prank(operator); - agreementManager.offerAgreement(rca1, address(recurringCollector)); + agreementManager.offerAgreement(rca1, _collector()); uint256 maxClaim1 = 1 ether * 3600 + 100 ether; @@ -78,13 +81,13 @@ contract RecurringAgreementManagerMultiCollectorTest is RecurringAgreementManage 2 ); vm.prank(operator); - agreementManager.offerAgreement(rca2, address(collector2)); + agreementManager.offerAgreement(rca2, IRecurringCollector(address(collector2))); uint256 maxClaim2 = 2 ether * 7200 + 200 ether; // Required escrow is independent per collector - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1); - assertEq(agreementManager.getRequiredEscrow(address(collector2), indexer), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim1); + assertEq(agreementManager.sumMaxNextClaim(IRecurringCollector(address(collector2)), indexer), maxClaim2); } function test_MultiCollector_BeforeCollectionOnlyOwnAgreements() public { @@ -99,7 +102,7 @@ contract RecurringAgreementManagerMultiCollectorTest is RecurringAgreementManage ); token.mint(address(agreementManager), 1_000_000 ether); vm.prank(operator); - agreementManager.offerAgreement(rca1, address(recurringCollector)); + agreementManager.offerAgreement(rca1, _collector()); // collector2 cannot call beforeCollection on collector1's agreement vm.prank(address(collector2)); @@ -123,7 +126,7 @@ contract RecurringAgreementManagerMultiCollectorTest is RecurringAgreementManage ); token.mint(address(agreementManager), 1_000_000 ether); vm.prank(operator); - agreementManager.offerAgreement(rca1, address(recurringCollector)); + agreementManager.offerAgreement(rca1, _collector()); // collector2 cannot call afterCollection on collector1's agreement vm.prank(address(collector2)); @@ -142,9 +145,10 @@ contract RecurringAgreementManagerMultiCollectorTest is RecurringAgreementManage 1 ); uint256 maxClaim1 = 1 ether * 3600 + 100 ether; - token.mint(address(agreementManager), maxClaim1); + // Fund with surplus so Full mode stays active (deficit < balance required) + token.mint(address(agreementManager), maxClaim1 + 1); vm.prank(operator); - agreementManager.offerAgreement(rca1, address(recurringCollector)); + agreementManager.offerAgreement(rca1, _collector()); // Offer via collector2 (IRecurringCollector.RecurringCollectionAgreement memory rca2, ) = _makeRCAForCollector( @@ -156,9 +160,10 @@ contract RecurringAgreementManagerMultiCollectorTest is RecurringAgreementManage 2 ); uint256 maxClaim2 = 2 ether * 7200 + 200 ether; - token.mint(address(agreementManager), maxClaim2); + // Fund with surplus so Full mode stays active (deficit < balance required) + token.mint(address(agreementManager), maxClaim2 + 1); vm.prank(operator); - agreementManager.offerAgreement(rca2, address(collector2)); + agreementManager.offerAgreement(rca2, IRecurringCollector(address(collector2))); // Escrow accounts are separate per (collector, provider) assertEq( @@ -183,7 +188,7 @@ contract RecurringAgreementManagerMultiCollectorTest is RecurringAgreementManage ); token.mint(address(agreementManager), 1_000_000 ether); vm.prank(operator); - agreementManager.offerAgreement(rca1, address(recurringCollector)); + agreementManager.offerAgreement(rca1, _collector()); (IRecurringCollector.RecurringCollectionAgreement memory rca2, ) = _makeRCAForCollector( collector2, @@ -194,7 +199,7 @@ contract RecurringAgreementManagerMultiCollectorTest is RecurringAgreementManage 2 ); vm.prank(operator); - agreementManager.offerAgreement(rca2, address(collector2)); + agreementManager.offerAgreement(rca2, IRecurringCollector(address(collector2))); uint256 maxClaim2 = 2 ether * 7200 + 200 ether; @@ -203,8 +208,8 @@ contract RecurringAgreementManagerMultiCollectorTest is RecurringAgreementManage agreementManager.revokeOffer(agreementId1); // Collector1 escrow cleared, collector2 unaffected - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); - assertEq(agreementManager.getRequiredEscrow(address(collector2), indexer), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(IRecurringCollector(address(collector2)), indexer), maxClaim2); } /* solhint-enable graph/func-name-mixedcase */ diff --git a/packages/issuance/test/unit/agreement-manager/multiIndexer.t.sol b/packages/issuance/test/unit/agreement-manager/multiIndexer.t.sol index 4f0405856..80068bda3 100644 --- a/packages/issuance/test/unit/agreement-manager/multiIndexer.t.sol +++ b/packages/issuance/test/unit/agreement-manager/multiIndexer.t.sol @@ -40,7 +40,7 @@ contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerS return rca; } - // -- Isolation: offer/requiredEscrow -- + // -- Isolation: offer/sumMaxNextClaim -- function test_MultiIndexer_OfferIsolation() public { IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( @@ -73,10 +73,10 @@ contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerS uint256 maxClaim2 = 2 ether * 7200 + 200 ether; uint256 maxClaim3 = 0.5 ether * 1800 + 50 ether; - // Each indexer has independent requiredEscrow - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer3), maxClaim3); + // Each indexer has independent sumMaxNextClaim + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim1); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer3), maxClaim3); // Each has exactly 1 agreement assertEq(agreementManager.getProviderAgreementCount(indexer), 1); @@ -126,11 +126,11 @@ contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerS agreementManager.revokeOffer(id1); // Indexer1 cleared - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); assertEq(agreementManager.getProviderAgreementCount(indexer), 0); // Indexer2 unaffected - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), maxClaim2); assertEq(agreementManager.getProviderAgreementCount(indexer2), 1); } @@ -162,10 +162,10 @@ contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerS agreementManager.removeAgreement(id1); // Indexer1 cleared - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); // Indexer2 unaffected - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), maxClaim2); } // -- Isolation: reconcile one indexer doesn't affect others -- @@ -198,10 +198,10 @@ contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerS agreementManager.reconcileAgreement(id1); // Indexer1 required escrow drops to 0 (CanceledBySP -> maxNextClaim=0) - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); // Indexer2 completely unaffected (still pre-offered estimate) - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), maxClaim2); assertEq(agreementManager.getAgreementMaxNextClaim(id2), maxClaim2); } @@ -240,19 +240,19 @@ contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerS uint256 maxClaim2 = 2 ether * 7200 + 200 ether; assertEq(agreementManager.getProviderAgreementCount(indexer), 2); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1a + maxClaim1b); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim1a + maxClaim1b); assertEq(agreementManager.getProviderAgreementCount(indexer2), 1); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), maxClaim2); // Remove one of indexer's agreements _setAgreementCanceledBySP(id1a, rca1a); agreementManager.removeAgreement(id1a); assertEq(agreementManager.getProviderAgreementCount(indexer), 1); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1b); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim1b); // Indexer2 still unaffected - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), maxClaim2); } // -- Cancel one indexer, reconcile another -- @@ -326,7 +326,7 @@ contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerS agreementManager.removeAgreement(id1); // Update escrow for indexer1 — should thaw excess - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); // Indexer1 escrow thawing (excess = maxClaim1, required = 0) IPaymentsEscrow.EscrowAccount memory acct1 = paymentsEscrow.escrowAccounts( @@ -343,7 +343,7 @@ contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerS ); // updateEscrow on indexer2 is a no-op (balance == required, no excess) - agreementManager.updateEscrow(address(recurringCollector), indexer2); + agreementManager.updateEscrow(_collector(), indexer2); } // -- Full lifecycle across multiple indexers -- @@ -371,8 +371,8 @@ contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerS bytes16 id1 = _offerAgreement(rca1); bytes16 id2 = _offerAgreement(rca2); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim1); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), maxClaim2); // 2. Accept both _setAgreementAccepted(id1, rca1, uint64(block.timestamp)); @@ -385,22 +385,22 @@ contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerS // 4. Reconcile indexer1 — required should decrease (no more initial tokens) agreementManager.reconcileAgreement(id1); - assertTrue(agreementManager.getRequiredEscrow(address(recurringCollector), indexer) < maxClaim1); + assertTrue(agreementManager.sumMaxNextClaim(_collector(), indexer) < maxClaim1); // Indexer2 unaffected - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), maxClaim2); // 5. Cancel indexer2 by SP _setAgreementCanceledBySP(id2, rca2); agreementManager.reconcileAgreement(id2); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer2), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer2), 0); // 6. Remove indexer2's agreement agreementManager.removeAgreement(id2); assertEq(agreementManager.getProviderAgreementCount(indexer2), 0); // 7. Update escrow for indexer2 (thaw excess) - agreementManager.updateEscrow(address(recurringCollector), indexer2); + agreementManager.updateEscrow(_collector(), indexer2); IPaymentsEscrow.EscrowAccount memory acct2 = paymentsEscrow.escrowAccounts( address(agreementManager), address(recurringCollector), @@ -410,7 +410,7 @@ contract RecurringAgreementManagerMultiIndexerTest is RecurringAgreementManagerS // 8. Indexer1 still active assertEq(agreementManager.getProviderAgreementCount(indexer), 1); - assertTrue(0 < agreementManager.getRequiredEscrow(address(recurringCollector), indexer)); + assertTrue(0 < agreementManager.sumMaxNextClaim(_collector(), indexer)); } // -- getAgreementInfo across indexers -- diff --git a/packages/issuance/test/unit/agreement-manager/offerUpdate.t.sol b/packages/issuance/test/unit/agreement-manager/offerUpdate.t.sol index 57fb9537e..0ad3da6df 100644 --- a/packages/issuance/test/unit/agreement-manager/offerUpdate.t.sol +++ b/packages/issuance/test/unit/agreement-manager/offerUpdate.t.sol @@ -39,10 +39,7 @@ contract RecurringAgreementManagerOfferUpdateTest is RecurringAgreementManagerSh uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; // Required escrow should include both - assertEq( - agreementManager.getRequiredEscrow(address(recurringCollector), indexer), - originalMaxClaim + expectedPendingMaxClaim - ); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + expectedPendingMaxClaim); // Original maxNextClaim unchanged assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), originalMaxClaim); } @@ -86,12 +83,12 @@ contract RecurringAgreementManagerOfferUpdateTest is RecurringAgreementManagerSh uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; - uint256 totalRequired = originalMaxClaim + pendingMaxClaim; + uint256 sumMaxNextClaim = originalMaxClaim + pendingMaxClaim; // Fund and offer agreement - token.mint(address(agreementManager), totalRequired); + token.mint(address(agreementManager), sumMaxNextClaim); vm.prank(operator); - bytes16 agreementId = agreementManager.offerAgreement(rca, address(recurringCollector)); + bytes16 agreementId = agreementManager.offerAgreement(rca, _collector()); // Offer update (should fund the deficit) token.mint(address(agreementManager), pendingMaxClaim); @@ -110,7 +107,7 @@ contract RecurringAgreementManagerOfferUpdateTest is RecurringAgreementManagerSh // Verify escrow was funded for both assertEq( paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, - totalRequired + sumMaxNextClaim ); } @@ -139,10 +136,7 @@ contract RecurringAgreementManagerOfferUpdateTest is RecurringAgreementManagerSh _offerAgreementUpdate(rcau1); uint256 pendingMaxClaim1 = 2 ether * 7200 + 200 ether; - assertEq( - agreementManager.getRequiredEscrow(address(recurringCollector), indexer), - originalMaxClaim + pendingMaxClaim1 - ); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + pendingMaxClaim1); // Second pending update (replaces first) IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = _makeRCAU( @@ -158,10 +152,7 @@ contract RecurringAgreementManagerOfferUpdateTest is RecurringAgreementManagerSh uint256 pendingMaxClaim2 = 0.5 ether * 1800 + 50 ether; // Old pending removed, new pending added - assertEq( - agreementManager.getRequiredEscrow(address(recurringCollector), indexer), - originalMaxClaim + pendingMaxClaim2 - ); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + pendingMaxClaim2); } function test_OfferUpdate_EmitsEvent() public { diff --git a/packages/issuance/test/unit/agreement-manager/reconcile.t.sol b/packages/issuance/test/unit/agreement-manager/reconcile.t.sol index 96b3f4ea5..802688ec7 100644 --- a/packages/issuance/test/unit/agreement-manager/reconcile.t.sol +++ b/packages/issuance/test/unit/agreement-manager/reconcile.t.sol @@ -38,7 +38,7 @@ contract RecurringAgreementManagerReconcileTest is RecurringAgreementManagerShar uint256 newMaxClaim = agreementManager.getAgreementMaxNextClaim(agreementId); assertEq(newMaxClaim, 3600 ether); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 3600 ether); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 3600 ether); } function test_ReconcileAgreement_CanceledByServiceProvider() public { @@ -58,7 +58,7 @@ contract RecurringAgreementManagerReconcileTest is RecurringAgreementManagerShar agreementManager.reconcileAgreement(agreementId); assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); } function test_ReconcileAgreement_CanceledByPayer_WindowOpen() public { @@ -175,9 +175,10 @@ contract RecurringAgreementManagerReconcileTest is RecurringAgreementManagerShar } } - function test_ReconcileAgreement_Revert_WhenNotOffered() public { + function test_ReconcileAgreement_Noop_WhenNotOffered() public { bytes16 fakeId = bytes16(keccak256("fake")); - vm.expectRevert(abi.encodeWithSelector(IRecurringAgreementManager.AgreementNotOffered.selector, fakeId)); + + // Silently returns when agreement not found (idempotent) agreementManager.reconcileAgreement(fakeId); } @@ -228,10 +229,7 @@ contract RecurringAgreementManagerReconcileTest is RecurringAgreementManagerShar _offerAgreementUpdate(rcau); uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; - assertEq( - agreementManager.getRequiredEscrow(address(recurringCollector), indexer), - originalMaxClaim + pendingMaxClaim - ); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + pendingMaxClaim); // Simulate: agreement accepted and update applied on-chain (updateNonce = 1) recurringCollector.setAgreement( @@ -260,7 +258,7 @@ contract RecurringAgreementManagerReconcileTest is RecurringAgreementManagerShar uint256 newMaxClaim = 2 ether * 7200 + 200 ether; assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), newMaxClaim); // Required = only new maxClaim (pending cleared) - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), newMaxClaim); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), newMaxClaim); } function test_ReconcileAgreement_KeepsPendingUpdate_WhenNotYetApplied() public { @@ -296,10 +294,7 @@ contract RecurringAgreementManagerReconcileTest is RecurringAgreementManagerShar // maxNextClaim recalculated from original terms (same value since never collected) assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), originalMaxClaim); // Pending still present - assertEq( - agreementManager.getRequiredEscrow(address(recurringCollector), indexer), - originalMaxClaim + pendingMaxClaim - ); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + pendingMaxClaim); } /* 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 index fad478a9a..1bac8814d 100644 --- a/packages/issuance/test/unit/agreement-manager/register.t.sol +++ b/packages/issuance/test/unit/agreement-manager/register.t.sol @@ -26,7 +26,7 @@ contract RecurringAgreementManagerOfferTest is RecurringAgreementManagerSharedTe // = 1e18 * 3600 + 100e18 = 3700e18 uint256 expectedMaxClaim = 1 ether * 3600 + 100 ether; assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), expectedMaxClaim); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), expectedMaxClaim); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), expectedMaxClaim); assertEq(agreementManager.getProviderAgreementCount(indexer), 1); } @@ -41,10 +41,10 @@ contract RecurringAgreementManagerOfferTest is RecurringAgreementManagerSharedTe uint256 expectedMaxClaim = 1 ether * 3600 + 100 ether; - // Fund and register - token.mint(address(agreementManager), expectedMaxClaim); + // Fund with surplus so Full mode stays active (deficit < balance required) + token.mint(address(agreementManager), expectedMaxClaim + 1); vm.prank(operator); - agreementManager.offerAgreement(rca, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); // Verify escrow was funded assertEq( @@ -68,7 +68,7 @@ contract RecurringAgreementManagerOfferTest is RecurringAgreementManagerSharedTe // Fund with less than needed token.mint(address(agreementManager), available); vm.prank(operator); - agreementManager.offerAgreement(rca, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); // Since available < required, Full degrades to OnDemand (deposit target = 0). // No proactive deposit; JIT beforeCollection is the safety net. @@ -76,8 +76,8 @@ contract RecurringAgreementManagerOfferTest is RecurringAgreementManagerSharedTe paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer).balance, 0 ); - // Deficit is full required since no deposit was made - assertEq(agreementManager.getDeficit(address(recurringCollector), indexer), expectedMaxClaim); + // Escrow balance is 0 since no deposit was made + assertEq(agreementManager.getEscrowAccount(_collector(), indexer).balance, 0); } function test_Offer_EmitsEvent() public { @@ -104,7 +104,7 @@ contract RecurringAgreementManagerOfferTest is RecurringAgreementManagerSharedTe emit IRecurringAgreementManager.AgreementOffered(expectedId, indexer, expectedMaxClaim); vm.prank(operator); - agreementManager.offerAgreement(rca, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); } function test_Offer_AuthorizesHash() public { @@ -151,7 +151,7 @@ contract RecurringAgreementManagerOfferTest is RecurringAgreementManagerSharedTe uint256 maxClaim1 = 1 ether * 3600 + 100 ether; uint256 maxClaim2 = 2 ether * 7200 + 200 ether; - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1 + maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim1 + maxClaim2); } function test_Offer_Revert_WhenPayerMismatch() public { @@ -172,7 +172,7 @@ contract RecurringAgreementManagerOfferTest is RecurringAgreementManagerSharedTe ) ); vm.prank(operator); - agreementManager.offerAgreement(rca, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); } function test_Offer_Revert_WhenAlreadyOffered() public { @@ -190,7 +190,7 @@ contract RecurringAgreementManagerOfferTest is RecurringAgreementManagerSharedTe abi.encodeWithSelector(IRecurringAgreementManager.AgreementAlreadyOffered.selector, agreementId) ); vm.prank(operator); - agreementManager.offerAgreement(rca, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); } function test_Offer_Revert_WhenNotOperator() public { @@ -207,7 +207,25 @@ contract RecurringAgreementManagerOfferTest is RecurringAgreementManagerSharedTe abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOperator, OPERATOR_ROLE) ); vm.prank(nonOperator); - agreementManager.offerAgreement(rca, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); + } + + function test_Offer_Revert_WhenUnauthorizedCollector() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + address fakeCollector = makeAddr("fakeCollector"); + token.mint(address(agreementManager), 10_000 ether); + vm.expectRevert( + abi.encodeWithSelector(IRecurringAgreementManager.UnauthorizedCollector.selector, fakeCollector) + ); + vm.prank(operator); + agreementManager.offerAgreement(rca, IRecurringCollector(fakeCollector)); } function test_Offer_Revert_WhenPaused() public { @@ -227,7 +245,7 @@ contract RecurringAgreementManagerOfferTest is RecurringAgreementManagerSharedTe vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); vm.prank(operator); - agreementManager.offerAgreement(rca, address(recurringCollector)); + agreementManager.offerAgreement(rca, _collector()); } /* 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 index 9b19441b0..619afee0a 100644 --- a/packages/issuance/test/unit/agreement-manager/remove.t.sol +++ b/packages/issuance/test/unit/agreement-manager/remove.t.sol @@ -29,7 +29,7 @@ contract RecurringAgreementManagerRemoveTest is RecurringAgreementManagerSharedT agreementManager.removeAgreement(agreementId); assertEq(agreementManager.getProviderAgreementCount(indexer), 0); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); } @@ -52,7 +52,7 @@ contract RecurringAgreementManagerRemoveTest is RecurringAgreementManagerSharedT agreementManager.removeAgreement(agreementId); assertEq(agreementManager.getProviderAgreementCount(indexer), 0); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); } function test_Remove_CanceledByPayer_WindowExpired() public { @@ -100,23 +100,24 @@ contract RecurringAgreementManagerRemoveTest is RecurringAgreementManagerSharedT uint256 maxClaim1 = 1 ether * 3600 + 100 ether; // 3700e18 uint256 maxClaim2 = 2 ether * 7200 + 200 ether; // 14600e18 - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim1 + maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), 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(address(recurringCollector), indexer), maxClaim2); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim2); assertEq(agreementManager.getProviderAgreementCount(indexer), 1); // Agreement 2 still tracked assertEq(agreementManager.getAgreementMaxNextClaim(id2), maxClaim2); } - function test_Remove_Revert_WhenNotOffered() public { + function test_Remove_Noop_WhenNotOffered() public { bytes16 fakeId = bytes16(keccak256("fake")); - vm.expectRevert(abi.encodeWithSelector(IRecurringAgreementManager.AgreementNotOffered.selector, fakeId)); + + // Silently returns when agreement not found (idempotent) agreementManager.removeAgreement(fakeId); } @@ -157,7 +158,7 @@ contract RecurringAgreementManagerRemoveTest is RecurringAgreementManagerSharedT agreementManager.removeAgreement(agreementId); assertEq(agreementManager.getProviderAgreementCount(indexer), 0); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); } function test_Remove_Revert_WhenStillClaimable_NotAccepted() public { @@ -252,18 +253,15 @@ contract RecurringAgreementManagerRemoveTest is RecurringAgreementManagerSharedT uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; - assertEq( - agreementManager.getRequiredEscrow(address(recurringCollector), indexer), - originalMaxClaim + pendingMaxClaim - ); + assertEq(agreementManager.sumMaxNextClaim(_collector(), 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(address(recurringCollector), indexer), 0); + // Both original and pending should be cleared from sumMaxNextClaim + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); assertEq(agreementManager.getProviderAgreementCount(indexer), 0); } diff --git a/packages/issuance/test/unit/agreement-manager/revokeOffer.t.sol b/packages/issuance/test/unit/agreement-manager/revokeOffer.t.sol index dd2d083d2..5a44082a9 100644 --- a/packages/issuance/test/unit/agreement-manager/revokeOffer.t.sol +++ b/packages/issuance/test/unit/agreement-manager/revokeOffer.t.sol @@ -23,13 +23,13 @@ contract RecurringAgreementManagerRevokeOfferTest is RecurringAgreementManagerSh assertEq(agreementManager.getProviderAgreementCount(indexer), 1); uint256 maxClaim = 1 ether * 3600 + 100 ether; - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), maxClaim); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), maxClaim); vm.prank(operator); agreementManager.revokeOffer(agreementId); assertEq(agreementManager.getProviderAgreementCount(indexer), 0); - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); } @@ -78,16 +78,13 @@ contract RecurringAgreementManagerRevokeOfferTest is RecurringAgreementManagerSh uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; - assertEq( - agreementManager.getRequiredEscrow(address(recurringCollector), indexer), - originalMaxClaim + pendingMaxClaim - ); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), originalMaxClaim + pendingMaxClaim); vm.prank(operator); agreementManager.revokeOffer(agreementId); // Both original and pending should be cleared - assertEq(agreementManager.getRequiredEscrow(address(recurringCollector), indexer), 0); + assertEq(agreementManager.sumMaxNextClaim(_collector(), indexer), 0); } function test_RevokeOffer_EmitsEvent() public { diff --git a/packages/issuance/test/unit/agreement-manager/shared.t.sol b/packages/issuance/test/unit/agreement-manager/shared.t.sol index fc0b0f2fa..73ebf6452 100644 --- a/packages/issuance/test/unit/agreement-manager/shared.t.sol +++ b/packages/issuance/test/unit/agreement-manager/shared.t.sol @@ -4,8 +4,10 @@ pragma solidity ^0.8.27; import { Test } from "forge-std/Test.sol"; import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import { IGraphToken } from "../../../contracts/common/IGraphToken.sol"; import { RecurringAgreementManager } from "../../../contracts/agreement/RecurringAgreementManager.sol"; import { RecurringAgreementHelper } from "../../../contracts/agreement/RecurringAgreementHelper.sol"; import { MockGraphToken } from "./mocks/MockGraphToken.sol"; @@ -32,6 +34,8 @@ contract RecurringAgreementManagerSharedTest is Test { // -- Constants -- bytes32 internal constant GOVERNOR_ROLE = keccak256("GOVERNOR_ROLE"); bytes32 internal constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + bytes32 internal constant DATA_SERVICE_ROLE = keccak256("DATA_SERVICE_ROLE"); + bytes32 internal constant COLLECTOR_ROLE = keccak256("COLLECTOR_ROLE"); function setUp() public virtual { governor = makeAddr("governor"); @@ -46,7 +50,10 @@ contract RecurringAgreementManagerSharedTest is Test { dataService = address(mockSubgraphService); // Deploy RecurringAgreementManager behind proxy - RecurringAgreementManager impl = new RecurringAgreementManager(address(token), address(paymentsEscrow)); + RecurringAgreementManager impl = new RecurringAgreementManager( + IGraphToken(address(token)), + IPaymentsEscrow(address(paymentsEscrow)) + ); bytes memory initData = abi.encodeCall(RecurringAgreementManager.initialize, (governor)); TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( address(impl), @@ -58,9 +65,12 @@ contract RecurringAgreementManagerSharedTest is Test { // Deploy RecurringAgreementHelper pointing at the manager agreementHelper = new RecurringAgreementHelper(address(agreementManager)); - // Grant operator role - vm.prank(governor); + // Grant roles + vm.startPrank(governor); agreementManager.grantRole(OPERATOR_ROLE, operator); + agreementManager.grantRole(DATA_SERVICE_ROLE, dataService); + agreementManager.grantRole(COLLECTOR_ROLE, address(recurringCollector)); + vm.stopPrank(); // Label addresses for trace output vm.label(address(token), "GraphToken"); @@ -73,6 +83,11 @@ contract RecurringAgreementManagerSharedTest is Test { // -- Helpers -- + /// @notice Get the default recurring collector as a typed IRecurringCollector + function _collector() internal view returns (IRecurringCollector) { + return IRecurringCollector(address(recurringCollector)); + } + /// @notice Create a standard RCA with RecurringAgreementManager as payer function _makeRCA( uint256 maxInitialTokens, @@ -120,7 +135,7 @@ contract RecurringAgreementManagerSharedTest is Test { token.mint(address(agreementManager), 1_000_000 ether); vm.prank(operator); - return agreementManager.offerAgreement(rca, address(recurringCollector)); + return agreementManager.offerAgreement(rca, _collector()); } /// @notice Create a standard RCAU for an existing agreement diff --git a/packages/issuance/test/unit/agreement-manager/updateEscrow.t.sol b/packages/issuance/test/unit/agreement-manager/updateEscrow.t.sol index 1f0f67af2..e4c236a6c 100644 --- a/packages/issuance/test/unit/agreement-manager/updateEscrow.t.sol +++ b/packages/issuance/test/unit/agreement-manager/updateEscrow.t.sol @@ -70,7 +70,7 @@ contract RecurringAgreementManagerUpdateEscrowTest is RecurringAgreementManagerS vm.expectEmit(address(agreementManager)); emit IRecurringAgreementManager.EscrowWithdrawn(indexer, address(recurringCollector), maxClaim); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); // Tokens should be back in RecurringAgreementManager uint256 agreementManagerBalanceAfter = token.balanceOf(address(agreementManager)); @@ -79,7 +79,7 @@ contract RecurringAgreementManagerUpdateEscrowTest is RecurringAgreementManagerS function test_UpdateEscrow_NoopWhenNoBalance() public { // No agreements, no balance — should succeed silently - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); } function test_UpdateEscrow_NoopWhenStillThawing() public { @@ -97,7 +97,7 @@ contract RecurringAgreementManagerUpdateEscrowTest is RecurringAgreementManagerS agreementManager.removeAgreement(agreementId); // Subsequent call before thaw complete: no-op (thaw in progress, amount is correct) - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); // Balance should still be fully thawing IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( @@ -112,7 +112,7 @@ contract RecurringAgreementManagerUpdateEscrowTest is RecurringAgreementManagerS // Anyone can call updateEscrow address anyone = makeAddr("anyone"); vm.prank(anyone); - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); } // ==================== Excess Thawing With Active Agreements ==================== @@ -137,7 +137,7 @@ contract RecurringAgreementManagerUpdateEscrowTest is RecurringAgreementManagerS // Reconcile — should reduce required escrow agreementManager.reconcileAgreement(agreementId); - uint256 newRequired = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 newRequired = agreementManager.sumMaxNextClaim(_collector(), indexer); assertTrue(newRequired < maxClaim, "Required should have decreased"); // Escrow balance is still maxClaim — excess exists @@ -226,7 +226,7 @@ contract RecurringAgreementManagerUpdateEscrowTest is RecurringAgreementManagerS assertEq(accountAfter.thawEndTimestamp, thawEndBefore, "Thaw timer should be preserved"); // Liquid balance should cover new required - uint256 newRequired = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 newRequired = agreementManager.sumMaxNextClaim(_collector(), indexer); uint256 liquid = accountAfter.balance - accountAfter.tokensThawing; assertEq(liquid, newRequired, "Liquid should cover required"); } @@ -338,6 +338,153 @@ contract RecurringAgreementManagerUpdateEscrowTest is RecurringAgreementManagerS assertEq(accountAfter.tokensThawing, maxClaimEach, "Thaw should stay at original amount"); } + // ==================== Data-driven: _updateEscrow combinations ==================== + // + // Tests all (escrowBasis, accountState) combinations via a helper that: + // 1. Sets escrowBasis (controls min/max) + // 2. Overrides mock escrow to desired (balance, tokensThawing, thawReady) + // 3. Calls updateEscrow + // 4. Asserts expected (balance, tokensThawing) + // + // Desired behavior (the 4 objectives): + // Obj 1: liquid stays in [min, max] + // Obj 2: withdraw excess above min if thaw completed + // Obj 3: never increase thaw amount (would reset timer) + // Obj 4: minimize transactions — no needless deposit/thaw/cancel + + function _check( + IRecurringAgreementManager.EscrowBasis basis, + uint256 bal, + uint256 thawing, + bool ready, + uint256 expBal, + uint256 expThaw, + string memory label + ) internal { + uint256 snap = vm.snapshot(); + + vm.prank(governor); + agreementManager.setEscrowBasis(basis); + + paymentsEscrow.setAccount( + address(agreementManager), + address(recurringCollector), + indexer, + bal, + thawing, + ready ? block.timestamp - 1 : (0 < thawing ? block.timestamp + 1 days : 0) + ); + + agreementManager.updateEscrow(_collector(), indexer); + + IPaymentsEscrow.EscrowAccount memory r = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(r.balance, expBal, string.concat(label, ": balance")); + assertEq(r.tokensThawing, expThaw, string.concat(label, ": thawing")); + + assertTrue(vm.revertTo(snap)); + } + + /// @dev Like _check but sets thawEndTimestamp to an exact value (for boundary testing) + function _checkAtTimestamp( + IRecurringAgreementManager.EscrowBasis basis, + uint256 bal, + uint256 thawing, + uint256 thawEndTimestamp, + uint256 expBal, + uint256 expThaw, + string memory label + ) internal { + uint256 snap = vm.snapshot(); + + vm.prank(governor); + agreementManager.setEscrowBasis(basis); + + paymentsEscrow.setAccount( + address(agreementManager), + address(recurringCollector), + indexer, + bal, + thawing, + thawEndTimestamp + ); + + agreementManager.updateEscrow(_collector(), indexer); + + IPaymentsEscrow.EscrowAccount memory r = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(r.balance, expBal, string.concat(label, ": balance")); + assertEq(r.tokensThawing, expThaw, string.concat(label, ": thawing")); + + assertTrue(vm.revertTo(snap)); + } + + function test_UpdateEscrow_Combinations() public { + // S = sumMaxNextClaim, established by offering one agreement in Full mode. + // After offer: escrow balance = S, manager minted 1M in setUp. + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + _offerAgreement(rca); + uint256 S = 1 ether * 3600 + 100 ether; // 3700 ether + + // Ensure mock has enough ERC20 for large-balance test cases + token.mint(address(paymentsEscrow), 10 * S); + // Ensure block.timestamp > 1 so "thawReady" timestamps are non-zero + vm.warp(100); + + // ── Full mode: min = S, max = S ───────────────────────────────── + IRecurringAgreementManager.EscrowBasis F = IRecurringAgreementManager.EscrowBasis.Full; + + // basis bal thaw ready expBal expThaw + _check(F, S, 0, false, S, 0, "F1:balanced"); + _check(F, 2 * S, 0, false, 2 * S, S, "F2:excess->thaw"); + _check(F, S / 2, 0, false, S, 0, "F3:deficit->deposit"); + _check(F, 0, 0, false, S, 0, "F4:empty->deposit"); + _check(F, 2 * S, S, false, 2 * S, S, "F5:thaw,liquid=min->leave"); + _check(F, 2 * S, (S * 3) / 2, false, 2 * S, S, "F6:thaw,liquidcancel-to-min"); + _check(F, 2 * S, S, true, S, 0, "F7:ready,liquid=min->withdraw"); + _check(F, S, S, true, S, 0, "F8:ready,liquid=0->cancel-all"); + _check(F, S, S, false, S, 0, "F9:thaw,liquid=0->cancel-all"); + + // ── OnDemand mode: min = 0, max = S ───────────────────────────── + IRecurringAgreementManager.EscrowBasis O = IRecurringAgreementManager.EscrowBasis.OnDemand; + + _check(O, S, 0, false, S, 0, "O1:balanced"); + _check(O, 2 * S, 0, false, 2 * S, S, "O2:excess->thaw"); + _check(O, S / 2, 0, false, S / 2, 0, "O3:no-deposit(min=0)"); + _check(O, 0, 0, false, 0, 0, "O4:empty,no-op"); + _check(O, 2 * S, S, false, 2 * S, S, "O5:thaw,liquid>=min->leave"); + _check(O, 2 * S, (S * 3) / 2, false, 2 * S, (S * 3) / 2, "O6:thaw,liquid>=min->LEAVE(key)"); + _check(O, 2 * S, S, true, S, 0, "O7:ready->withdraw"); + _check(O, S, S, true, 0, 0, "O8:ready,all-thaw->withdraw-all"); + _check(O, S, S, false, S, S, "O9:thaw,liquid=0>=min->leave"); + + // ── JIT mode: min = 0, max = 0 ────────────────────────────────── + IRecurringAgreementManager.EscrowBasis J = IRecurringAgreementManager.EscrowBasis.JustInTime; + + _check(J, S, 0, false, S, S, "J1:thaw-all(max=0)"); + _check(J, 0, 0, false, 0, 0, "J2:empty,no-op"); + _check(J, 2 * S, S, false, 2 * S, 2 * S, "J3:same-block->increase-ok"); + _check(J, S, S, true, 0, 0, "J4:ready->withdraw-all"); + _check(J, 2 * S, S, true, S, S, "J5:ready->withdraw,thaw-rest"); + + // ── Boundary: thawEndTimestamp == block.timestamp should withdraw ── + // Thaw period ends AT this timestamp, so withdraw should fire. + _checkAtTimestamp(F, 2 * S, S, block.timestamp, S, 0, "B1:boundary-full->withdraw"); + _checkAtTimestamp(O, 2 * S, S, block.timestamp, S, 0, "B2:boundary-ondemand->withdraw"); + _checkAtTimestamp(J, S, S, block.timestamp, 0, 0, "B3:boundary-jit->withdraw-all"); + } + // ==================== Cross-Indexer Isolation ==================== function test_UpdateEscrow_CrossIndexerIsolation() public { @@ -386,7 +533,7 @@ contract RecurringAgreementManagerUpdateEscrowTest is RecurringAgreementManagerS ); // updateEscrow on indexer2 should be a no-op (balance == required) - agreementManager.updateEscrow(address(recurringCollector), indexer2); + agreementManager.updateEscrow(_collector(), indexer2); assertEq( paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer2).balance, maxClaim2 @@ -413,7 +560,7 @@ contract RecurringAgreementManagerUpdateEscrowTest is RecurringAgreementManagerS ); // updateEscrow should be a no-op - agreementManager.updateEscrow(address(recurringCollector), indexer); + agreementManager.updateEscrow(_collector(), indexer); // Nothing changed assertEq( @@ -458,10 +605,117 @@ contract RecurringAgreementManagerUpdateEscrowTest is RecurringAgreementManagerS address(recurringCollector), indexer ); - uint256 newRequired = agreementManager.getRequiredEscrow(address(recurringCollector), indexer); + uint256 newRequired = agreementManager.sumMaxNextClaim(_collector(), indexer); uint256 expectedExcess = maxClaim - newRequired; assertEq(account.tokensThawing, expectedExcess, "Excess should auto-thaw after reconcile"); } + // ==================== Withdraw guard: compare against liquid, not total ==================== + + function test_UpdateEscrow_WithdrawsPartialWhenLiquidCoversMin() public { + // Two agreements: keep the big one, remove the small one. + // After thaw completes, liquid (= big max claim) >= min -> withdraw proceeds. + // Only the small agreement's tokens leave escrow; min stays behind. + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 50 ether, + 0.5 ether, + 60, + 1800, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; // 3700 ether + uint256 maxClaim2 = 0.5 ether * 1800 + 50 ether; // 950 ether + + // Cancel and remove rca2 -> excess (950) thawed, rca1 remains + _setAgreementCanceledBySP(id2, rca2); + agreementManager.removeAgreement(id2); + + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.tokensThawing, maxClaim2, "Excess from rca2 should be thawing"); + assertEq(account.balance - account.tokensThawing, maxClaim1, "Liquid should cover rca1"); + + // Wait for thaw to complete + vm.warp(block.timestamp + 1 days + 1); + + // Expect the withdraw event for the thawed amount + vm.expectEmit(address(agreementManager)); + emit IRecurringAgreementManager.EscrowWithdrawn(indexer, address(recurringCollector), maxClaim2); + + agreementManager.updateEscrow(_collector(), indexer); + + // After withdraw: only rca1's required amount remains, nothing thawing + account = paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer); + assertEq(account.balance, maxClaim1, "Balance should equal remaining min"); + assertEq(account.tokensThawing, 0, "Nothing should be thawing after withdraw"); + } + + function test_UpdateEscrow_PartialCancelAndWithdrawInOneCall() public { + // Scenario: all tokens thawing and ready, offer a smaller replacement. + // _updateEscrow partial-cancels thaw (to balance - min), then withdraws the + // reduced amount in a single call. No round-trip: balance ends at min, no redeposit. + + (IRecurringCollector.RecurringCollectionAgreement memory rca1, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 id1 = _offerAgreement(rca1); + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; // 3700 ether + + // Remove -> full thaw + _setAgreementCanceledBySP(id1, rca1); + agreementManager.removeAgreement(id1); + + // Verify: entire balance is thawing, liquid = 0 + IPaymentsEscrow.EscrowAccount memory account = paymentsEscrow.escrowAccounts( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(account.tokensThawing, maxClaim1, "All should be thawing"); + assertEq(account.balance - account.tokensThawing, 0, "Liquid should be zero"); + + // Wait for thaw to complete + vm.warp(block.timestamp + 1 days + 1); + + // Offer smaller replacement -> _updateEscrow fires + // Partial-cancels thaw (3700 -> 2750), then withdraws 2750. Balance = 950 = min. + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 50 ether, + 0.5 ether, + 60, + 1800, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + uint256 maxClaim2 = 0.5 ether * 1800 + 50 ether; // 950 ether + + _offerAgreement(rca2); + + account = paymentsEscrow.escrowAccounts(address(agreementManager), address(recurringCollector), indexer); + assertEq(account.balance, maxClaim2, "Balance should equal min after partial-cancel + withdraw"); + assertEq(account.tokensThawing, 0, "Nothing thawing after withdraw"); + } + /* solhint-enable graph/func-name-mixedcase */ } diff --git a/packages/issuance/test/unit/allocator/construction.t.sol b/packages/issuance/test/unit/allocator/construction.t.sol index 149dd31ac..552863397 100644 --- a/packages/issuance/test/unit/allocator/construction.t.sol +++ b/packages/issuance/test/unit/allocator/construction.t.sol @@ -5,6 +5,7 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { BaseUpgradeable } from "../../../contracts/common/BaseUpgradeable.sol"; +import { IGraphToken } from "../../../contracts/common/IGraphToken.sol"; import { IssuanceAllocator } from "../../../contracts/allocate/IssuanceAllocator.sol"; import { IssuanceAllocatorSharedTest } from "./shared.t.sol"; @@ -14,11 +15,11 @@ contract IssuanceAllocatorConstructionTest is IssuanceAllocatorSharedTest { function test_Revert_ZeroGraphTokenAddress() public { vm.expectRevert(BaseUpgradeable.GraphTokenCannotBeZeroAddress.selector); - new IssuanceAllocator(address(0)); + new IssuanceAllocator(IGraphToken(address(0))); } function test_Revert_ZeroGovernorAddress() public { - IssuanceAllocator impl = new IssuanceAllocator(address(token)); + IssuanceAllocator impl = new IssuanceAllocator(IGraphToken(address(token))); bytes memory initData = abi.encodeCall(IssuanceAllocator.initialize, (address(0))); vm.expectRevert(BaseUpgradeable.GovernorCannotBeZeroAddress.selector); new TransparentUpgradeableProxy(address(impl), address(this), initData); diff --git a/packages/issuance/test/unit/allocator/defensiveChecks.t.sol b/packages/issuance/test/unit/allocator/defensiveChecks.t.sol index de23e47ad..f8f3f0a41 100644 --- a/packages/issuance/test/unit/allocator/defensiveChecks.t.sol +++ b/packages/issuance/test/unit/allocator/defensiveChecks.t.sol @@ -6,6 +6,7 @@ import { Test } from "forge-std/Test.sol"; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { IssuanceAllocator } from "../../../contracts/allocate/IssuanceAllocator.sol"; +import { IGraphToken } from "../../../contracts/common/IGraphToken.sol"; import { IssuanceAllocatorTestHarness } from "../../../contracts/test/allocate/IssuanceAllocatorTestHarness.sol"; import { MockGraphToken } from "../mocks/MockGraphToken.sol"; @@ -17,7 +18,7 @@ contract IssuanceAllocatorDefensiveChecksTest is Test { function setUp() public { MockGraphToken token = new MockGraphToken(); - IssuanceAllocatorTestHarness impl = new IssuanceAllocatorTestHarness(address(token)); + IssuanceAllocatorTestHarness impl = new IssuanceAllocatorTestHarness(IGraphToken(address(token))); bytes memory initData = abi.encodeCall(IssuanceAllocator.initialize, (address(this))); TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(address(impl), address(this), initData); harness = IssuanceAllocatorTestHarness(address(proxy)); diff --git a/packages/issuance/test/unit/allocator/shared.t.sol b/packages/issuance/test/unit/allocator/shared.t.sol index 9846bfdde..5be20cc33 100644 --- a/packages/issuance/test/unit/allocator/shared.t.sol +++ b/packages/issuance/test/unit/allocator/shared.t.sol @@ -6,6 +6,7 @@ import { Test } from "forge-std/Test.sol"; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { IssuanceAllocator } from "../../../contracts/allocate/IssuanceAllocator.sol"; +import { IGraphToken } from "../../../contracts/common/IGraphToken.sol"; import { MockGraphToken } from "../mocks/MockGraphToken.sol"; import { MockSimpleTarget } from "../../../contracts/test/allocate/MockSimpleTarget.sol"; import { MockNotificationTracker } from "../../../contracts/test/allocate/MockNotificationTracker.sol"; @@ -51,7 +52,7 @@ contract IssuanceAllocatorSharedTest is Test { token = new MockGraphToken(); // Deploy IssuanceAllocator behind proxy - IssuanceAllocator impl = new IssuanceAllocator(address(token)); + IssuanceAllocator impl = new IssuanceAllocator(IGraphToken(address(token))); bytes memory initData = abi.encodeCall(IssuanceAllocator.initialize, (governor)); TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(address(impl), address(this), initData); allocator = IssuanceAllocator(address(proxy)); diff --git a/packages/issuance/test/unit/direct-allocation/DirectAllocation.t.sol b/packages/issuance/test/unit/direct-allocation/DirectAllocation.t.sol index dc7e539fd..112126a38 100644 --- a/packages/issuance/test/unit/direct-allocation/DirectAllocation.t.sol +++ b/packages/issuance/test/unit/direct-allocation/DirectAllocation.t.sol @@ -12,6 +12,7 @@ import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/al import { ISendTokens } from "@graphprotocol/interfaces/contracts/issuance/allocate/ISendTokens.sol"; import { BaseUpgradeable } from "../../../contracts/common/BaseUpgradeable.sol"; +import { IGraphToken } from "../../../contracts/common/IGraphToken.sol"; import { DirectAllocation } from "../../../contracts/allocate/DirectAllocation.sol"; import { MockGraphToken } from "../mocks/MockGraphToken.sol"; @@ -39,7 +40,7 @@ contract DirectAllocationTest is Test { token = new MockGraphToken(); - DirectAllocation impl = new DirectAllocation(address(token)); + DirectAllocation impl = new DirectAllocation(IGraphToken(address(token))); bytes memory initData = abi.encodeCall(DirectAllocation.initialize, (governor)); TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(address(impl), address(this), initData); directAlloc = DirectAllocation(address(proxy)); @@ -52,11 +53,11 @@ contract DirectAllocationTest is Test { function test_Revert_ZeroGraphTokenAddress() public { vm.expectRevert(BaseUpgradeable.GraphTokenCannotBeZeroAddress.selector); - new DirectAllocation(address(0)); + new DirectAllocation(IGraphToken(address(0))); } function test_Revert_ZeroGovernorAddress() public { - DirectAllocation impl = new DirectAllocation(address(token)); + DirectAllocation impl = new DirectAllocation(IGraphToken(address(token))); bytes memory initData = abi.encodeCall(DirectAllocation.initialize, (address(0))); vm.expectRevert(BaseUpgradeable.GovernorCannotBeZeroAddress.selector); new TransparentUpgradeableProxy(address(impl), address(this), initData); @@ -178,7 +179,7 @@ contract DirectAllocationTest is Test { function test_Revert_SendTokens_TransferReturnsFalse() public { // Deploy DirectAllocation with a mock token that returns false on transfer MockFalseTransferToken falseToken = new MockFalseTransferToken(); - DirectAllocation impl2 = new DirectAllocation(address(falseToken)); + DirectAllocation impl2 = new DirectAllocation(IGraphToken(address(falseToken))); bytes memory initData2 = abi.encodeCall(DirectAllocation.initialize, (governor)); TransparentUpgradeableProxy proxy2 = new TransparentUpgradeableProxy(address(impl2), address(this), initData2); DirectAllocation da2 = DirectAllocation(address(proxy2)); diff --git a/packages/issuance/test/unit/eligibility/construction.t.sol b/packages/issuance/test/unit/eligibility/construction.t.sol index 068741f31..d63964c5b 100644 --- a/packages/issuance/test/unit/eligibility/construction.t.sol +++ b/packages/issuance/test/unit/eligibility/construction.t.sol @@ -5,6 +5,7 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { BaseUpgradeable } from "../../../contracts/common/BaseUpgradeable.sol"; +import { IGraphToken } from "../../../contracts/common/IGraphToken.sol"; import { RewardsEligibilityOracle } from "../../../contracts/eligibility/RewardsEligibilityOracle.sol"; import { RewardsEligibilityOracleSharedTest } from "./shared.t.sol"; @@ -16,11 +17,11 @@ contract RewardsEligibilityOracleConstructionTest is RewardsEligibilityOracleSha function test_Revert_ZeroGraphTokenAddress() public { vm.expectRevert(BaseUpgradeable.GraphTokenCannotBeZeroAddress.selector); - new RewardsEligibilityOracle(address(0)); + new RewardsEligibilityOracle(IGraphToken(address(0))); } function test_Revert_ZeroGovernorAddress() public { - RewardsEligibilityOracle impl = new RewardsEligibilityOracle(address(token)); + RewardsEligibilityOracle impl = new RewardsEligibilityOracle(IGraphToken(address(token))); bytes memory initData = abi.encodeCall(RewardsEligibilityOracle.initialize, (address(0))); vm.expectRevert(BaseUpgradeable.GovernorCannotBeZeroAddress.selector); diff --git a/packages/issuance/test/unit/eligibility/shared.t.sol b/packages/issuance/test/unit/eligibility/shared.t.sol index 6cd442063..17df47816 100644 --- a/packages/issuance/test/unit/eligibility/shared.t.sol +++ b/packages/issuance/test/unit/eligibility/shared.t.sol @@ -6,6 +6,7 @@ import { Test } from "forge-std/Test.sol"; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { RewardsEligibilityOracle } from "../../../contracts/eligibility/RewardsEligibilityOracle.sol"; +import { IGraphToken } from "../../../contracts/common/IGraphToken.sol"; import { MockGraphToken } from "../mocks/MockGraphToken.sol"; /// @notice Shared test setup for RewardsEligibilityOracle tests. @@ -46,7 +47,7 @@ contract RewardsEligibilityOracleSharedTest is Test { token = new MockGraphToken(); // Deploy RewardsEligibilityOracle behind proxy - RewardsEligibilityOracle impl = new RewardsEligibilityOracle(address(token)); + RewardsEligibilityOracle impl = new RewardsEligibilityOracle(IGraphToken(address(token))); bytes memory initData = abi.encodeCall(RewardsEligibilityOracle.initialize, (governor)); TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(address(impl), address(this), initData); oracle = RewardsEligibilityOracle(address(proxy));