Skip to content

Commit b53ca01

Browse files
fix: [TRST-H-1] IndexingAgreement.collect() on CanceledByPayer
1 parent 1651716 commit b53ca01

File tree

4 files changed

+196
-57
lines changed

4 files changed

+196
-57
lines changed

packages/horizon/contracts/interfaces/IRecurringCollector.sol

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,4 +413,13 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
413413
* @return The AgreementData struct containing the agreement's data.
414414
*/
415415
function getAgreement(bytes16 agreementId) external view returns (AgreementData memory);
416+
417+
/**
418+
* @notice Checks if an agreement is collectable.
419+
* @dev "Collectable" means the agreement is in a valid state that allows collection attempts,
420+
* not that there are necessarily funds available to collect.
421+
* @param agreement The agreement data
422+
* @return The boolean indicating if the agreement is collectable
423+
*/
424+
function isCollectable(AgreementData memory agreement) external view returns (bool);
416425
}

packages/horizon/contracts/payments/collectors/RecurringCollector.sol

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,11 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
249249
return _getAgreement(agreementId);
250250
}
251251

252+
/// @inheritdoc IRecurringCollector
253+
function isCollectable(AgreementData memory agreement) external pure returns (bool) {
254+
return _isCollectable(agreement);
255+
}
256+
252257
/**
253258
* @notice Decodes the collect data.
254259
* @param data The encoded collect parameters.
@@ -270,7 +275,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
270275
function _collect(CollectParams memory _params) private returns (uint256) {
271276
AgreementData storage agreement = _getAgreementStorage(_params.agreementId);
272277
require(
273-
agreement.state == AgreementState.Accepted || agreement.state == AgreementState.CanceledByPayer,
278+
_isCollectable(agreement),
274279
RecurringCollectorAgreementIncorrectState(_params.agreementId, agreement.state)
275280
);
276281

@@ -537,4 +542,13 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
537542
function _agreementCollectionStartAt(AgreementData memory _agreement) private pure returns (uint256) {
538543
return _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt;
539544
}
545+
546+
/**
547+
* @notice Requires that the agreement is collectable.
548+
* @param _agreement The agreement data
549+
* @return The boolean indicating if the agreement is collectable
550+
*/
551+
function _isCollectable(AgreementData memory _agreement) private pure returns (bool) {
552+
return _agreement.state == AgreementState.Accepted || _agreement.state == AgreementState.CanceledByPayer;
553+
}
540554
}

packages/subgraph-service/contracts/libraries/IndexingAgreement.sol

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,12 @@ library IndexingAgreement {
254254
*/
255255
error IndexingAgreementNotActive(bytes16 agreementId);
256256

257+
/**
258+
* @notice Thrown when the agreement is not collectable
259+
* @param agreementId The agreement ID
260+
*/
261+
error IndexingAgreementNotCollectable(bytes16 agreementId);
262+
257263
/**
258264
* @notice Thrown when trying to interact with an agreement not owned by the indexer
259265
* @param agreementId The agreement ID
@@ -517,7 +523,7 @@ library IndexingAgreement {
517523
wrapper.agreement.allocationId,
518524
wrapper.collectorAgreement.serviceProvider
519525
);
520-
require(_isActive(wrapper), IndexingAgreementNotActive(params.agreementId));
526+
require(_isCollectable(wrapper), IndexingAgreementNotCollectable(params.agreementId));
521527

522528
require(
523529
wrapper.agreement.version == IndexingAgreementVersion.V1,
@@ -692,17 +698,37 @@ library IndexingAgreement {
692698
/**
693699
* @notice Checks if the agreement is active
694700
* Requirements:
701+
* - The indexing agreement is valid
695702
* - The underlying collector agreement has been accepted
696-
* - The underlying collector agreement's data service is this contract
697-
* - The indexing agreement has been accepted and has a valid allocation ID
698703
* @param wrapper The agreement wrapper containing the indexing agreement and collector agreement data
699704
* @return True if the agreement is active, false otherwise
700705
**/
701706
function _isActive(AgreementWrapper memory wrapper) private view returns (bool) {
702-
return
703-
wrapper.collectorAgreement.dataService == address(this) &&
704-
wrapper.collectorAgreement.state == IRecurringCollector.AgreementState.Accepted &&
705-
wrapper.agreement.allocationId != address(0);
707+
return _isValid(wrapper) && wrapper.collectorAgreement.state == IRecurringCollector.AgreementState.Accepted;
708+
}
709+
710+
/**
711+
* @notice Checks if the agreement is collectable
712+
* Requirements:
713+
* - The indexing agreement is valid
714+
* - The underlying collector agreement is collectable
715+
* @param wrapper The agreement wrapper containing the indexing agreement and collector agreement data
716+
* @return True if the agreement is collectable, false otherwise
717+
**/
718+
function _isCollectable(AgreementWrapper memory wrapper) private view returns (bool) {
719+
return _isValid(wrapper) && _directory().recurringCollector().isCollectable(wrapper.collectorAgreement);
720+
}
721+
722+
/**
723+
* @notice Checks if the agreement is valid
724+
* Requirements:
725+
* - The underlying collector agreement's data service is this contract
726+
* - The indexing agreement has been accepted and has a valid allocation ID
727+
* @param wrapper The agreement wrapper containing the indexing agreement and collector agreement data
728+
* @return True if the agreement is valid, false otherwise
729+
**/
730+
function _isValid(AgreementWrapper memory wrapper) private view returns (bool) {
731+
return wrapper.collectorAgreement.dataService == address(this) && wrapper.agreement.allocationId != address(0);
706732
}
707733

708734
/**

packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol

Lines changed: 139 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex
1818
uint256 indexerTokensLocked;
1919
}
2020

21+
struct ExpectedTokens {
22+
uint256 expectedTotalTokensCollected;
23+
uint256 expectedTokensLocked;
24+
uint256 expectedProtocolTokensBurnt;
25+
uint256 expectedIndexerTokensCollected;
26+
}
27+
2128
/*
2229
* TESTS
2330
*/
@@ -27,81 +34,164 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex
2734
Seed memory seed,
2835
uint256 fuzzyTokensCollected
2936
) public {
30-
uint256 expectedTotalTokensCollected = bound(fuzzyTokensCollected, 1000, 1_000_000);
31-
uint256 expectedTokensLocked = stakeToFeesRatio * expectedTotalTokensCollected;
32-
uint256 expectedProtocolTokensBurnt = expectedTotalTokensCollected.mulPPMRoundUp(
33-
graphPayments.PROTOCOL_PAYMENT_CUT()
34-
);
35-
uint256 expectedIndexerTokensCollected = expectedTotalTokensCollected - expectedProtocolTokensBurnt;
36-
37+
// Setup
38+
ExpectedTokens memory expectedTokens = _newExpectedTokens(fuzzyTokensCollected);
3739
Context storage ctx = _newCtx(seed);
3840
IndexerState memory indexerState = _withIndexer(ctx);
39-
_addTokensToProvision(indexerState, expectedTokensLocked);
41+
_addTokensToProvision(indexerState, expectedTokens.expectedTokensLocked);
4042
IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA(
4143
ctx.ctxInternal.seed.rca
4244
);
43-
uint256 agreementTokensPerSecond = 1;
44-
rca.deadline = uint64(block.timestamp); // accept now
45-
rca.endsAt = type(uint64).max; // no expiration
46-
rca.maxInitialTokens = 0; // no initial payment
47-
rca.maxOngoingTokensPerSecond = type(uint32).max; // unlimited tokens per second
48-
rca.minSecondsPerCollection = 1; // 1 second between collections
49-
rca.maxSecondsPerCollection = type(uint32).max; // no maximum time between collections
50-
rca.serviceProvider = indexerState.addr; // service provider is the indexer
51-
rca.dataService = address(subgraphService); // data service is the subgraph service
52-
rca.metadata = _encodeAcceptIndexingAgreementMetadataV1(
53-
indexerState.subgraphDeploymentId,
54-
IndexingAgreement.IndexingAgreementTermsV1({
55-
tokensPerSecond: agreementTokensPerSecond,
56-
tokensPerEntityPerSecond: 0 // no payment for entities
57-
})
58-
);
45+
_sharedSetup(ctx, rca, indexerState, expectedTokens);
5946

60-
_setupPayerWithEscrow(rca.payer, ctx.payer.signerPrivateKey, indexerState.addr, expectedTotalTokensCollected);
47+
TestState memory beforeCollect = _getState(rca.payer, indexerState.addr);
6148

49+
// Collect
6250
resetPrank(indexerState.addr);
63-
// Set the payments destination to the indexer address
64-
subgraphService.setPaymentsDestination(indexerState.addr);
65-
// Accept the Indexing Agreement
66-
subgraphService.acceptIndexingAgreement(
67-
indexerState.allocationId,
68-
_recurringCollectorHelper.generateSignedRCA(rca, ctx.payer.signerPrivateKey)
51+
uint256 tokensCollected = subgraphService.collect(
52+
indexerState.addr,
53+
IGraphPayments.PaymentTypes.IndexingFee,
54+
_encodeCollectDataV1(
55+
rca.agreementId,
56+
1,
57+
keccak256(abi.encodePacked("poi")),
58+
epochManager.currentEpochBlock(),
59+
bytes("")
60+
)
6961
);
70-
// Skip ahead to collection point
71-
skip(expectedTotalTokensCollected / agreementTokensPerSecond);
72-
// vm.assume(block.timestamp < type(uint64).max);
62+
63+
TestState memory afterCollect = _getState(rca.payer, indexerState.addr);
64+
_sharedAssert(beforeCollect, afterCollect, expectedTokens, tokensCollected);
65+
}
66+
67+
function test_SubgraphService_CollectIndexingFee_WhenCanceledByPayer_Integration(
68+
Seed memory seed,
69+
uint256 fuzzyTokensCollected
70+
) public {
71+
// Setup
72+
ExpectedTokens memory expectedTokens = _newExpectedTokens(fuzzyTokensCollected);
73+
Context storage ctx = _newCtx(seed);
74+
IndexerState memory indexerState = _withIndexer(ctx);
75+
IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA(
76+
ctx.ctxInternal.seed.rca
77+
);
78+
_sharedSetup(ctx, rca, indexerState, expectedTokens);
79+
80+
// Cancel the indexing agreement by the payer
81+
resetPrank(ctx.payer.signer);
82+
subgraphService.cancelIndexingAgreementByPayer(rca.agreementId);
83+
7384
TestState memory beforeCollect = _getState(rca.payer, indexerState.addr);
74-
bytes16 agreementId = rca.agreementId;
85+
86+
// Collect
87+
resetPrank(indexerState.addr);
7588
uint256 tokensCollected = subgraphService.collect(
7689
indexerState.addr,
7790
IGraphPayments.PaymentTypes.IndexingFee,
7891
_encodeCollectDataV1(
79-
agreementId,
92+
rca.agreementId,
8093
1,
8194
keccak256(abi.encodePacked("poi")),
8295
epochManager.currentEpochBlock(),
8396
bytes("")
8497
)
8598
);
99+
86100
TestState memory afterCollect = _getState(rca.payer, indexerState.addr);
87-
uint256 indexerTokensCollected = afterCollect.indexerBalance - beforeCollect.indexerBalance;
88-
uint256 protocolTokensBurnt = tokensCollected - indexerTokensCollected;
101+
_sharedAssert(beforeCollect, afterCollect, expectedTokens, tokensCollected);
102+
}
103+
104+
/* solhint-enable graph/func-name-mixedcase */
105+
106+
function _sharedSetup(
107+
Context storage _ctx,
108+
IRecurringCollector.RecurringCollectionAgreement memory _rca,
109+
IndexerState memory _indexerState,
110+
ExpectedTokens memory _expectedTokens
111+
) internal {
112+
_addTokensToProvision(_indexerState, _expectedTokens.expectedTokensLocked);
113+
114+
IndexingAgreement.IndexingAgreementTermsV1 memory terms = IndexingAgreement.IndexingAgreementTermsV1({
115+
tokensPerSecond: 1,
116+
tokensPerEntityPerSecond: 0 // no payment for entities
117+
});
118+
_rca.deadline = uint64(block.timestamp); // accept now
119+
_rca.endsAt = type(uint64).max; // no expiration
120+
_rca.maxInitialTokens = 0; // no initial payment
121+
_rca.maxOngoingTokensPerSecond = type(uint32).max; // unlimited tokens per second
122+
_rca.minSecondsPerCollection = 1; // 1 second between collections
123+
_rca.maxSecondsPerCollection = type(uint32).max; // no maximum time between collections
124+
_rca.serviceProvider = _indexerState.addr; // service provider is the indexer
125+
_rca.dataService = address(subgraphService); // data service is the subgraph service
126+
_rca.metadata = _encodeAcceptIndexingAgreementMetadataV1(_indexerState.subgraphDeploymentId, terms);
127+
128+
_setupPayerWithEscrow(
129+
_rca.payer,
130+
_ctx.payer.signerPrivateKey,
131+
_indexerState.addr,
132+
_expectedTokens.expectedTotalTokensCollected
133+
);
134+
135+
resetPrank(_indexerState.addr);
136+
// Set the payments destination to the indexer address
137+
subgraphService.setPaymentsDestination(_indexerState.addr);
138+
139+
// Accept the Indexing Agreement
140+
subgraphService.acceptIndexingAgreement(
141+
_indexerState.allocationId,
142+
_recurringCollectorHelper.generateSignedRCA(_rca, _ctx.payer.signerPrivateKey)
143+
);
144+
145+
// Skip ahead to collection point
146+
skip(_expectedTokens.expectedTotalTokensCollected / terms.tokensPerSecond);
147+
}
148+
149+
function _newExpectedTokens(uint256 _fuzzyTokensCollected) internal view returns (ExpectedTokens memory) {
150+
uint256 expectedTotalTokensCollected = bound(_fuzzyTokensCollected, 1000, 1_000_000);
151+
uint256 expectedTokensLocked = stakeToFeesRatio * expectedTotalTokensCollected;
152+
uint256 expectedProtocolTokensBurnt = expectedTotalTokensCollected.mulPPMRoundUp(
153+
graphPayments.PROTOCOL_PAYMENT_CUT()
154+
);
155+
uint256 expectedIndexerTokensCollected = expectedTotalTokensCollected - expectedProtocolTokensBurnt;
156+
return
157+
ExpectedTokens({
158+
expectedTotalTokensCollected: expectedTotalTokensCollected,
159+
expectedTokensLocked: expectedTokensLocked,
160+
expectedProtocolTokensBurnt: expectedProtocolTokensBurnt,
161+
expectedIndexerTokensCollected: expectedIndexerTokensCollected
162+
});
163+
}
164+
165+
function _sharedAssert(
166+
TestState memory _beforeCollect,
167+
TestState memory _afterCollect,
168+
ExpectedTokens memory _expectedTokens,
169+
uint256 _tokensCollected
170+
) internal pure {
171+
uint256 indexerTokensCollected = _afterCollect.indexerBalance - _beforeCollect.indexerBalance;
172+
assertEq(_expectedTokens.expectedTotalTokensCollected, _tokensCollected, "Total tokens collected should match");
89173
assertEq(
90-
afterCollect.escrowBalance,
91-
beforeCollect.escrowBalance - tokensCollected,
92-
"Escrow balance should be reduced by the amount collected"
174+
_expectedTokens.expectedProtocolTokensBurnt,
175+
_tokensCollected - indexerTokensCollected,
176+
"Protocol tokens burnt should match"
93177
);
94-
assertEq(tokensCollected, expectedTotalTokensCollected, "Total tokens collected should match");
95-
assertEq(expectedProtocolTokensBurnt, protocolTokensBurnt, "Protocol tokens burnt should match");
96-
assertEq(indexerTokensCollected, expectedIndexerTokensCollected, "Indexer tokens collected should match");
97178
assertEq(
98-
afterCollect.indexerTokensLocked,
99-
beforeCollect.indexerTokensLocked + expectedTokensLocked,
100-
"Locked tokens should match"
179+
_expectedTokens.expectedIndexerTokensCollected,
180+
indexerTokensCollected,
181+
"Indexer tokens collected should match"
182+
);
183+
assertEq(
184+
_afterCollect.escrowBalance,
185+
_beforeCollect.escrowBalance - _expectedTokens.expectedTotalTokensCollected,
186+
"_Escrow balance should be reduced by the amount collected"
101187
);
102-
}
103188

104-
/* solhint-enable graph/func-name-mixedcase */
189+
assertEq(
190+
_afterCollect.indexerTokensLocked,
191+
_beforeCollect.indexerTokensLocked + _expectedTokens.expectedTokensLocked,
192+
"_Locked tokens should match"
193+
);
194+
}
105195

106196
function _addTokensToProvision(IndexerState memory _indexerState, uint256 _tokensToAddToProvision) private {
107197
deal({ token: address(token), to: _indexerState.addr, give: _tokensToAddToProvision });

0 commit comments

Comments
 (0)