Skip to content

Commit 843fdd2

Browse files
committed
feat: RecurringAgreementManager with lifecycle, escrow funding, and agreement updates
Add RecurringAgreementManager with configurable escrow funding modes, enumerable agreement tracking, lifecycle management, and escrow reconciliation. Extends IAgreementOwner with beforeCollection/ afterCollection callbacks. Includes revokeAgreementUpdate and pending update escrow cleanup on cancel.
1 parent 89def3d commit 843fdd2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+12274
-9
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.27;
3+
4+
import { IAgreementOwner } from "@graphprotocol/interfaces/contracts/horizon/IAgreementOwner.sol";
5+
6+
/// @notice Minimal contract payer that implements IAgreementOwner but NOT IERC165.
7+
/// Calling supportsInterface on this contract will revert (no such function),
8+
/// exercising the catch {} fallthrough in RecurringCollector's eligibility gate.
9+
contract BareAgreementOwner is IAgreementOwner {
10+
mapping(bytes32 => bool) public authorizedHashes;
11+
12+
function authorize(bytes32 agreementHash) external {
13+
authorizedHashes[agreementHash] = true;
14+
}
15+
16+
function approveAgreement(bytes32 agreementHash) external view override returns (bytes4) {
17+
if (!authorizedHashes[agreementHash]) return bytes4(0);
18+
return IAgreementOwner.approveAgreement.selector;
19+
}
20+
21+
function beforeCollection(bytes16, uint256) external override {}
22+
23+
function afterCollection(bytes16, uint256) external override {}
24+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.27;
3+
4+
import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol";
5+
import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol";
6+
7+
import { RecurringCollectorSharedTest } from "./shared.t.sol";
8+
import { MockAgreementOwner } from "./MockAgreementOwner.t.sol";
9+
10+
/// @notice Tests for IAgreementOwner.beforeCollection and .afterCollection in RecurringCollector._collect()
11+
contract RecurringCollectorAfterCollectionTest is RecurringCollectorSharedTest {
12+
function _newApprover() internal returns (MockAgreementOwner) {
13+
return new MockAgreementOwner();
14+
}
15+
16+
function _acceptUnsignedAgreement(
17+
MockAgreementOwner approver
18+
) internal returns (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) {
19+
rca = _recurringCollectorHelper.sensibleRCA(
20+
IRecurringCollector.RecurringCollectionAgreement({
21+
deadline: uint64(block.timestamp + 1 hours),
22+
endsAt: uint64(block.timestamp + 365 days),
23+
payer: address(approver),
24+
dataService: makeAddr("ds"),
25+
serviceProvider: makeAddr("sp"),
26+
maxInitialTokens: 100 ether,
27+
maxOngoingTokensPerSecond: 1 ether,
28+
minSecondsPerCollection: 600,
29+
maxSecondsPerCollection: 3600,
30+
nonce: 1,
31+
metadata: ""
32+
})
33+
);
34+
35+
bytes32 agreementHash = _recurringCollector.hashRCA(rca);
36+
approver.authorize(agreementHash);
37+
_setupValidProvision(rca.serviceProvider, rca.dataService);
38+
39+
vm.prank(rca.dataService);
40+
agreementId = _recurringCollector.accept(rca, "");
41+
}
42+
43+
/* solhint-disable graph/func-name-mixedcase */
44+
45+
function test_BeforeCollection_CallbackInvoked() public {
46+
MockAgreementOwner approver = _newApprover();
47+
(IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement(
48+
approver
49+
);
50+
51+
skip(rca.minSecondsPerCollection);
52+
uint256 tokens = 1 ether;
53+
bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0));
54+
55+
vm.prank(rca.dataService);
56+
_recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data);
57+
58+
// beforeCollection should have been called with the tokens about to be collected
59+
assertEq(approver.lastBeforeCollectionAgreementId(), agreementId);
60+
assertEq(approver.lastBeforeCollectionTokens(), tokens);
61+
}
62+
63+
function test_BeforeCollection_CollectionSucceedsWhenCallbackReverts() public {
64+
MockAgreementOwner approver = _newApprover();
65+
(IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement(
66+
approver
67+
);
68+
69+
approver.setShouldRevertOnBeforeCollection(true);
70+
71+
skip(rca.minSecondsPerCollection);
72+
uint256 tokens = 1 ether;
73+
bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0));
74+
75+
// Collection should still succeed despite beforeCollection reverting
76+
vm.prank(rca.dataService);
77+
uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data);
78+
assertEq(collected, tokens);
79+
80+
// beforeCollection state not updated (it reverted), but afterCollection still runs
81+
assertEq(approver.lastBeforeCollectionAgreementId(), bytes16(0));
82+
assertEq(approver.lastCollectedAgreementId(), agreementId);
83+
}
84+
85+
function test_AfterCollection_CallbackInvoked() public {
86+
MockAgreementOwner approver = _newApprover();
87+
(IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement(
88+
approver
89+
);
90+
91+
// Skip past minSecondsPerCollection and collect
92+
skip(rca.minSecondsPerCollection);
93+
uint256 tokens = 1 ether;
94+
bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0));
95+
96+
vm.prank(rca.dataService);
97+
_recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data);
98+
99+
// Verify callback was invoked with correct parameters
100+
assertEq(approver.lastCollectedAgreementId(), agreementId);
101+
assertEq(approver.lastCollectedTokens(), tokens);
102+
}
103+
104+
function test_AfterCollection_CollectionSucceedsWhenCallbackReverts() public {
105+
MockAgreementOwner approver = _newApprover();
106+
(IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement(
107+
approver
108+
);
109+
110+
// Configure callback to revert
111+
approver.setShouldRevertOnCollected(true);
112+
113+
skip(rca.minSecondsPerCollection);
114+
uint256 tokens = 1 ether;
115+
bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0));
116+
117+
// Collection should still succeed despite callback reverting
118+
vm.prank(rca.dataService);
119+
uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data);
120+
assertEq(collected, tokens);
121+
122+
// Callback state should not have been updated (it reverted)
123+
assertEq(approver.lastCollectedAgreementId(), bytes16(0));
124+
assertEq(approver.lastCollectedTokens(), 0);
125+
}
126+
127+
function test_AfterCollection_NotCalledForEOAPayer(FuzzyTestCollect calldata fuzzy) public {
128+
// Use standard ECDSA-signed path (EOA payer, no contract)
129+
(IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, , , ) = _sensibleAuthorizeAndAccept(
130+
fuzzy.fuzzyTestAccept
131+
);
132+
133+
(bytes memory data, uint256 collectionSeconds, uint256 tokens) = _generateValidCollection(
134+
acceptedRca,
135+
fuzzy.collectParams,
136+
fuzzy.collectParams.tokens, // reuse as skip seed
137+
fuzzy.collectParams.tokens
138+
);
139+
140+
skip(collectionSeconds);
141+
// Should succeed without any callback issues (EOA has no code)
142+
vm.prank(acceptedRca.dataService);
143+
uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data);
144+
assertEq(collected, tokens);
145+
}
146+
147+
/* solhint-enable graph/func-name-mixedcase */
148+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.27;
3+
4+
import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol";
5+
import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol";
6+
7+
import { RecurringCollectorSharedTest } from "./shared.t.sol";
8+
import { MockAgreementOwner } from "./MockAgreementOwner.t.sol";
9+
import { BareAgreementOwner } from "./BareAgreementOwner.t.sol";
10+
11+
/// @notice Tests for the IProviderEligibility gate in RecurringCollector._collect()
12+
contract RecurringCollectorEligibilityTest is RecurringCollectorSharedTest {
13+
function _newApprover() internal returns (MockAgreementOwner) {
14+
return new MockAgreementOwner();
15+
}
16+
17+
function _acceptUnsignedAgreement(
18+
MockAgreementOwner approver
19+
) internal returns (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) {
20+
rca = _recurringCollectorHelper.sensibleRCA(
21+
IRecurringCollector.RecurringCollectionAgreement({
22+
deadline: uint64(block.timestamp + 1 hours),
23+
endsAt: uint64(block.timestamp + 365 days),
24+
payer: address(approver),
25+
dataService: makeAddr("ds"),
26+
serviceProvider: makeAddr("sp"),
27+
maxInitialTokens: 100 ether,
28+
maxOngoingTokensPerSecond: 1 ether,
29+
minSecondsPerCollection: 600,
30+
maxSecondsPerCollection: 3600,
31+
nonce: 1,
32+
metadata: ""
33+
})
34+
);
35+
36+
bytes32 agreementHash = _recurringCollector.hashRCA(rca);
37+
approver.authorize(agreementHash);
38+
_setupValidProvision(rca.serviceProvider, rca.dataService);
39+
40+
vm.prank(rca.dataService);
41+
agreementId = _recurringCollector.accept(rca, "");
42+
}
43+
44+
/* solhint-disable graph/func-name-mixedcase */
45+
46+
function test_Collect_OK_WhenEligible() public {
47+
MockAgreementOwner approver = _newApprover();
48+
(IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement(
49+
approver
50+
);
51+
52+
// Enable eligibility check and mark provider as eligible
53+
approver.setEligibilityEnabled(true);
54+
approver.setProviderEligible(rca.serviceProvider, true);
55+
56+
skip(rca.minSecondsPerCollection);
57+
uint256 tokens = 1 ether;
58+
bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0));
59+
60+
vm.prank(rca.dataService);
61+
uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data);
62+
assertEq(collected, tokens);
63+
}
64+
65+
function test_Collect_Revert_WhenNotEligible() public {
66+
MockAgreementOwner approver = _newApprover();
67+
(IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement(
68+
approver
69+
);
70+
71+
// Enable eligibility check but provider is NOT eligible
72+
approver.setEligibilityEnabled(true);
73+
// defaultEligible is false, and provider not explicitly set
74+
75+
skip(rca.minSecondsPerCollection);
76+
uint256 tokens = 1 ether;
77+
bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0));
78+
79+
vm.expectRevert(
80+
abi.encodeWithSelector(
81+
IRecurringCollector.RecurringCollectorCollectionNotEligible.selector,
82+
agreementId,
83+
rca.serviceProvider
84+
)
85+
);
86+
vm.prank(rca.dataService);
87+
_recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data);
88+
}
89+
90+
function test_Collect_OK_WhenPayerDoesNotSupportInterface() public {
91+
MockAgreementOwner approver = _newApprover();
92+
(IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement(
93+
approver
94+
);
95+
96+
// eligibilityEnabled is false by default — supportsInterface returns false for IProviderEligibility
97+
// Collection should proceed normally (backward compatible)
98+
99+
skip(rca.minSecondsPerCollection);
100+
uint256 tokens = 1 ether;
101+
bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0));
102+
103+
vm.prank(rca.dataService);
104+
uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data);
105+
assertEq(collected, tokens);
106+
}
107+
108+
function test_Collect_OK_WhenEOAPayer(FuzzyTestCollect calldata fuzzy) public {
109+
// Use standard ECDSA-signed path (EOA payer)
110+
(
111+
IRecurringCollector.RecurringCollectionAgreement memory acceptedRca,
112+
,
113+
,
114+
bytes16 agreementId
115+
) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept);
116+
117+
(bytes memory data, uint256 collectionSeconds, uint256 tokens) = _generateValidCollection(
118+
acceptedRca,
119+
fuzzy.collectParams,
120+
fuzzy.collectParams.tokens,
121+
fuzzy.collectParams.tokens
122+
);
123+
124+
skip(collectionSeconds);
125+
// EOA payer has no code — eligibility check is skipped entirely
126+
vm.prank(acceptedRca.dataService);
127+
uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data);
128+
assertEq(collected, tokens);
129+
}
130+
131+
function test_Collect_OK_WhenPayerHasNoERC165() public {
132+
// BareAgreementOwner implements IAgreementOwner but NOT IERC165.
133+
// The supportsInterface call will revert, hitting the catch {} branch.
134+
BareAgreementOwner bare = new BareAgreementOwner();
135+
136+
IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA(
137+
IRecurringCollector.RecurringCollectionAgreement({
138+
deadline: uint64(block.timestamp + 1 hours),
139+
endsAt: uint64(block.timestamp + 365 days),
140+
payer: address(bare),
141+
dataService: makeAddr("ds"),
142+
serviceProvider: makeAddr("sp"),
143+
maxInitialTokens: 100 ether,
144+
maxOngoingTokensPerSecond: 1 ether,
145+
minSecondsPerCollection: 600,
146+
maxSecondsPerCollection: 3600,
147+
nonce: 1,
148+
metadata: ""
149+
})
150+
);
151+
152+
bytes32 agreementHash = _recurringCollector.hashRCA(rca);
153+
bare.authorize(agreementHash);
154+
_setupValidProvision(rca.serviceProvider, rca.dataService);
155+
156+
vm.prank(rca.dataService);
157+
bytes16 agreementId = _recurringCollector.accept(rca, "");
158+
159+
skip(rca.minSecondsPerCollection);
160+
uint256 tokens = 1 ether;
161+
bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), tokens, 0));
162+
163+
// Collection succeeds — the catch {} swallows the revert from supportsInterface
164+
vm.prank(rca.dataService);
165+
uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data);
166+
assertEq(collected, tokens);
167+
}
168+
169+
function test_Collect_OK_ZeroTokensSkipsEligibilityCheck() public {
170+
MockAgreementOwner approver = _newApprover();
171+
(IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) = _acceptUnsignedAgreement(
172+
approver
173+
);
174+
175+
// Enable eligibility check, provider is NOT eligible
176+
approver.setEligibilityEnabled(true);
177+
// defaultEligible = false
178+
179+
// Zero-token collection should NOT trigger the eligibility gate
180+
// (the guard is inside `if (0 < tokensToCollect && ...)`)
181+
skip(rca.minSecondsPerCollection);
182+
bytes memory data = _generateCollectData(_generateCollectParams(rca, agreementId, bytes32("col1"), 0, 0));
183+
184+
vm.prank(rca.dataService);
185+
uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data);
186+
assertEq(collected, 0);
187+
}
188+
189+
/* solhint-enable graph/func-name-mixedcase */
190+
}

0 commit comments

Comments
 (0)