Skip to content

Commit 17355fe

Browse files
fix: [TRST-M-2] shared collection window logic
Fixes TRST-M-2 audit finding: Collection for an elapsed or canceled agreement could be wrong due to temporal calculation inconsistencies between IndexingAgreement and RecurringCollector layers. * Replace isCollectable() with getCollectionInfo() that returns both collectability and duration * Make RecurringCollector the single source of truth for temporal logic * Update IndexingAgreement to call getCollectionInfo() once and pass duration to _tokensToCollect()
1 parent 68508b6 commit 17355fe

File tree

4 files changed

+79
-69
lines changed

4 files changed

+79
-69
lines changed

packages/horizon/contracts/interfaces/IRecurringCollector.sol

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -290,14 +290,6 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
290290
*/
291291
error RecurringCollectorInvalidCollectData(bytes invalidData);
292292

293-
/**
294-
* @notice Thrown when calling collect() on a payer canceled agreement
295-
* where the final collection has already been done
296-
* @param agreementId The agreement ID
297-
* @param finalCollectionAt The timestamp when the final collection was done
298-
*/
299-
error RecurringCollectorFinalCollectionDone(bytes16 agreementId, uint256 finalCollectionAt);
300-
301293
/**
302294
* @notice Thrown when interacting with an agreement that has an incorrect state
303295
* @param agreementId The agreement ID
@@ -420,11 +412,13 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
420412
function getAgreement(bytes16 agreementId) external view returns (AgreementData memory);
421413

422414
/**
423-
* @notice Checks if an agreement is collectable.
424-
* @dev "Collectable" means the agreement is in a valid state that allows collection attempts,
425-
* not that there are necessarily funds available to collect.
415+
* @notice Get collection info for an agreement
426416
* @param agreement The agreement data
427-
* @return The boolean indicating if the agreement is collectable
417+
* @return isCollectable Whether the agreement is in a valid state that allows collection attempts,
418+
* not that there are necessarily funds available to collect.
419+
* @return collectionSeconds The valid collection duration in seconds (0 if not collectable)
428420
*/
429-
function isCollectable(AgreementData memory agreement) external view returns (bool);
421+
function getCollectionInfo(
422+
AgreementData memory agreement
423+
) external view returns (bool isCollectable, uint256 collectionSeconds);
430424
}

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

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,10 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
246246
}
247247

248248
/// @inheritdoc IRecurringCollector
249-
function isCollectable(AgreementData memory agreement) external pure returns (bool) {
250-
return _isCollectable(agreement);
249+
function getCollectionInfo(
250+
AgreementData memory agreement
251+
) external view returns (bool isCollectable, uint256 collectionSeconds) {
252+
return _getCollectionInfo(agreement);
251253
}
252254

253255
/**
@@ -274,9 +276,14 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
274276
CollectParams memory _params
275277
) private returns (uint256) {
276278
AgreementData storage agreement = _getAgreementStorage(_params.agreementId);
279+
280+
// Check if agreement exists first (for unknown agreements)
281+
(bool isCollectable, uint256 collectionSeconds) = _getCollectionInfo(agreement);
282+
require(isCollectable, RecurringCollectorAgreementIncorrectState(_params.agreementId, agreement.state));
283+
277284
require(
278-
_isCollectable(agreement),
279-
RecurringCollectorAgreementIncorrectState(_params.agreementId, agreement.state)
285+
collectionSeconds > 0,
286+
RecurringCollectorZeroCollectionSeconds(_params.agreementId, block.timestamp, agreement.lastCollectionAt)
280287
);
281288

282289
require(
@@ -297,7 +304,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
297304

298305
uint256 tokensToCollect = 0;
299306
if (_params.tokens != 0) {
300-
tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens);
307+
tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens, collectionSeconds);
301308

302309
_graphPaymentsEscrow().collect(
303310
_paymentType,
@@ -374,53 +381,37 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
374381
* @param _agreement The agreement data
375382
* @param _agreementId The ID of the agreement
376383
* @param _tokens The number of tokens to collect
384+
* @param _collectionSeconds Collection duration from _getCollectionInfo()
377385
* @return The number of tokens that can be collected
378386
*/
379387
function _requireValidCollect(
380388
AgreementData memory _agreement,
381389
bytes16 _agreementId,
382-
uint256 _tokens
390+
uint256 _tokens,
391+
uint256 _collectionSeconds
383392
) private view returns (uint256) {
384393
bool canceledOrElapsed = _agreement.state == AgreementState.CanceledByPayer ||
385394
block.timestamp > _agreement.endsAt;
386-
uint256 canceledOrNow = _agreement.state == AgreementState.CanceledByPayer
387-
? _agreement.canceledAt
388-
: block.timestamp;
389-
390-
// if canceled by the payer allow collection till canceledAt
391-
// if elapsed allow collection till endsAt
392-
// if both are true, use the earlier one
393-
uint256 collectionEnd = canceledOrElapsed ? Math.min(canceledOrNow, _agreement.endsAt) : block.timestamp;
394-
uint256 collectionStart = _agreementCollectionStartAt(_agreement);
395-
require(
396-
collectionEnd != collectionStart,
397-
RecurringCollectorZeroCollectionSeconds(_agreementId, block.timestamp, uint64(collectionStart))
398-
);
399-
require(collectionEnd > collectionStart, RecurringCollectorFinalCollectionDone(_agreementId, collectionStart));
400-
401-
uint256 collectionSeconds = collectionEnd - collectionStart;
402-
// Check that the collection window is long enough
403-
// If the agreement is canceled or elapsed, allow a shorter collection window
404395
if (!canceledOrElapsed) {
405396
require(
406-
collectionSeconds >= _agreement.minSecondsPerCollection,
397+
_collectionSeconds >= _agreement.minSecondsPerCollection,
407398
RecurringCollectorCollectionTooSoon(
408399
_agreementId,
409-
uint32(collectionSeconds),
400+
uint32(_collectionSeconds),
410401
_agreement.minSecondsPerCollection
411402
)
412403
);
413404
}
414405
require(
415-
collectionSeconds <= _agreement.maxSecondsPerCollection,
406+
_collectionSeconds <= _agreement.maxSecondsPerCollection,
416407
RecurringCollectorCollectionTooLate(
417408
_agreementId,
418-
uint64(collectionSeconds),
409+
uint64(_collectionSeconds),
419410
_agreement.maxSecondsPerCollection
420411
)
421412
);
422413

423-
uint256 maxTokens = _agreement.maxOngoingTokensPerSecond * collectionSeconds;
414+
uint256 maxTokens = _agreement.maxOngoingTokensPerSecond * _collectionSeconds;
424415
maxTokens += _agreement.lastCollectionAt == 0 ? _agreement.maxInitialTokens : 0;
425416

426417
return Math.min(_tokens, maxTokens);
@@ -546,20 +537,47 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
546537
}
547538

548539
/**
549-
* @notice Gets the start time for the collection of an agreement.
540+
* @notice Internal function to get collection info for an agreement
541+
* @dev This is the single source of truth for collection window logic
550542
* @param _agreement The agreement data
551-
* @return The start time for the collection of the agreement
543+
* @return isCollectable Whether the agreement can be collected from
544+
* @return collectionSeconds The valid collection duration in seconds (0 if not collectable)
552545
*/
553-
function _agreementCollectionStartAt(AgreementData memory _agreement) private pure returns (uint256) {
554-
return _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt;
546+
function _getCollectionInfo(
547+
AgreementData memory _agreement
548+
) private view returns (bool isCollectable, uint256 collectionSeconds) {
549+
// Check if agreement is in collectable state
550+
isCollectable =
551+
_agreement.state == AgreementState.Accepted ||
552+
_agreement.state == AgreementState.CanceledByPayer;
553+
554+
if (!isCollectable) {
555+
return (false, 0);
556+
}
557+
558+
bool canceledOrElapsed = _agreement.state == AgreementState.CanceledByPayer ||
559+
block.timestamp > _agreement.endsAt;
560+
uint256 canceledOrNow = _agreement.state == AgreementState.CanceledByPayer
561+
? _agreement.canceledAt
562+
: block.timestamp;
563+
564+
uint256 collectionEnd = canceledOrElapsed ? Math.min(canceledOrNow, _agreement.endsAt) : block.timestamp;
565+
uint256 collectionStart = _agreementCollectionStartAt(_agreement);
566+
567+
if (collectionEnd < collectionStart) {
568+
return (false, 0);
569+
}
570+
571+
collectionSeconds = collectionEnd - collectionStart;
572+
return (isCollectable, collectionSeconds);
555573
}
556574

557575
/**
558-
* @notice Requires that the agreement is collectable.
576+
* @notice Gets the start time for the collection of an agreement.
559577
* @param _agreement The agreement data
560-
* @return The boolean indicating if the agreement is collectable
578+
* @return The start time for the collection of the agreement
561579
*/
562-
function _isCollectable(AgreementData memory _agreement) private pure returns (bool) {
563-
return _agreement.state == AgreementState.Accepted || _agreement.state == AgreementState.CanceledByPayer;
580+
function _agreementCollectionStartAt(AgreementData memory _agreement) private pure returns (uint256) {
581+
return _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt;
564582
}
565583
}

packages/horizon/test/unit/payments/recurring-collector/collect.t.sol

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest {
3232
(IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept);
3333
IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams;
3434

35+
skip(1);
36+
3537
collectParams.agreementId = accepted.rca.agreementId;
3638
bytes memory data = _generateCollectData(collectParams);
3739

@@ -53,6 +55,8 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest {
5355
collectParams.tokens = bound(collectParams.tokens, 1, type(uint256).max);
5456
bytes memory data = _generateCollectData(collectParams);
5557

58+
skip(1);
59+
5660
// Set up the scenario where service provider has no tokens staked with data service
5761
// This simulates an unauthorized data service attack
5862
_horizonStaking.setProvision(

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

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,11 @@ library IndexingAgreement {
529529
allocation.indexer == params.indexer,
530530
IndexingAgreementNotAuthorized(params.agreementId, params.indexer)
531531
);
532-
require(_isCollectable(wrapper), IndexingAgreementNotCollectable(params.agreementId));
532+
// Get collection info from RecurringCollector (single source of truth for temporal logic)
533+
(bool isCollectable, uint256 collectionSeconds) = _directory().recurringCollector().getCollectionInfo(
534+
wrapper.collectorAgreement
535+
);
536+
require(_isValid(wrapper) && isCollectable, IndexingAgreementNotCollectable(params.agreementId));
533537

534538
require(
535539
wrapper.agreement.version == IndexingAgreementVersion.V1,
@@ -540,7 +544,7 @@ library IndexingAgreement {
540544

541545
uint256 expectedTokens = (data.entities == 0 && data.poi == bytes32(0))
542546
? 0
543-
: _tokensToCollect(self, params.agreementId, wrapper.collectorAgreement, data.entities);
547+
: _tokensToCollect(self, params.agreementId, data.entities, collectionSeconds);
544548

545549
// `tokensCollected` <= `expectedTokens` because the recurring collector will further narrow
546550
// down the tokens allowed, based on the RCA terms.
@@ -677,28 +681,21 @@ library IndexingAgreement {
677681
}
678682

679683
/**
680-
* @notice Calculate the number of tokens to collect for an indexing agreement.
681-
*
682-
* @dev This function calculates the number of tokens to collect based on the agreement terms and the collection time.
683-
*
684-
* @param _manager The indexing agreement storage manager
685-
* @param _agreementId The id of the agreement
686-
* @param _agreement The collector agreement data
684+
* @notice Calculate tokens to collect based on pre-validated duration
685+
* @param _manager The storage manager
686+
* @param _agreementId The agreement ID
687687
* @param _entities The number of entities indexed
688+
* @param _collectionSeconds Pre-calculated valid collection duration
688689
* @return The number of tokens to collect
689690
*/
690691
function _tokensToCollect(
691692
StorageManager storage _manager,
692693
bytes16 _agreementId,
693-
IRecurringCollector.AgreementData memory _agreement,
694-
uint256 _entities
694+
uint256 _entities,
695+
uint256 _collectionSeconds
695696
) private view returns (uint256) {
696697
IndexingAgreementTermsV1 memory termsV1 = _manager.termsV1[_agreementId];
697-
698-
uint256 collectionSeconds = block.timestamp;
699-
collectionSeconds -= _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt;
700-
701-
return collectionSeconds * (termsV1.tokensPerSecond + termsV1.tokensPerEntityPerSecond * _entities);
698+
return _collectionSeconds * (termsV1.tokensPerSecond + termsV1.tokensPerEntityPerSecond * _entities);
702699
}
703700

704701
/**
@@ -721,9 +718,6 @@ library IndexingAgreement {
721718
* @param wrapper The agreement wrapper containing the indexing agreement and collector agreement data
722719
* @return True if the agreement is collectable, false otherwise
723720
**/
724-
function _isCollectable(AgreementWrapper memory wrapper) private view returns (bool) {
725-
return _isValid(wrapper) && _directory().recurringCollector().isCollectable(wrapper.collectorAgreement);
726-
}
727721

728722
/**
729723
* @notice Checks if the agreement is valid

0 commit comments

Comments
 (0)