Skip to content

Commit fa99514

Browse files
committed
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
1 parent fd96234 commit fa99514

File tree

4 files changed

+95
-46
lines changed

4 files changed

+95
-46
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# maxSecondsPerCollection: Cap, Not Deadline
2+
3+
## Problem
4+
5+
`_requireValidCollect` treats `maxSecondsPerCollection` as a hard deadline:
6+
7+
```solidity
8+
require(
9+
_collectionSeconds <= _agreement.maxSecondsPerCollection,
10+
RecurringCollectorCollectionTooLate(...)
11+
);
12+
uint256 maxTokens = _agreement.maxOngoingTokensPerSecond * _collectionSeconds;
13+
```
14+
15+
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.
16+
17+
## Fix
18+
19+
Cap `collectionSeconds` at `maxSecondsPerCollection` in `_getCollectionInfo`, so all callers (RC's `_collect` and SS's `IndexingAgreement.collect`) receive consistent capped seconds:
20+
21+
```solidity
22+
uint256 elapsed = collectionEnd - collectionStart;
23+
return (true, Math.min(elapsed, uint256(_agreement.maxSecondsPerCollection)), ...);
24+
```
25+
26+
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.
27+
28+
## Why this is correct
29+
30+
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.
31+
32+
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.
33+
34+
3. **No stuck agreements.** A missed window no longer requires cancellation or a zero-token hack to recover.
35+
36+
4. **`minSecondsPerCollection` is unaffected.** If elapsed time exceeds `maxSecondsPerCollection`, it trivially exceeds `minSecondsPerCollection` (since `max > min` is enforced at accept time).
37+
38+
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.
39+
40+
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.
41+
42+
## Zero-token temporal validation enforced
43+
44+
`_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.
45+
46+
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).
47+
48+
## Zero-POI special case removed
49+
50+
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.
51+
52+
## Contrast with indexing rewards
53+
54+
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.
55+
56+
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.

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

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -459,16 +459,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
459459
)
460460
);
461461
}
462-
require(
463-
// solhint-disable-next-line gas-strict-inequalities
464-
_collectionSeconds <= _agreement.maxSecondsPerCollection,
465-
RecurringCollectorCollectionTooLate(
466-
_agreementId,
467-
uint64(_collectionSeconds),
468-
_agreement.maxSecondsPerCollection
469-
)
470-
);
471-
462+
// _collectionSeconds is already capped at maxSecondsPerCollection by _getCollectionInfo
472463
uint256 maxTokens = _agreement.maxOngoingTokensPerSecond * _collectionSeconds;
473464
maxTokens += _agreement.lastCollectionAt == 0 ? _agreement.maxInitialTokens : 0;
474465

@@ -631,7 +622,12 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
631622
return (false, 0, AgreementNotCollectableReason.ZeroCollectionSeconds);
632623
}
633624

634-
return (true, collectionEnd - collectionStart, AgreementNotCollectableReason.None);
625+
uint256 elapsed = collectionEnd - collectionStart;
626+
return (
627+
true,
628+
Math.min(elapsed, uint256(_agreement.maxSecondsPerCollection)),
629+
AgreementNotCollectableReason.None
630+
);
635631
}
636632

637633
/**

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

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest {
168168
_recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data);
169169
}
170170

171-
function test_Collect_Revert_WhenCollectingTooLate(
171+
function test_Collect_OK_WhenCollectingPastMaxSeconds(
172172
FuzzyTestCollect calldata fuzzy,
173173
uint256 unboundedFirstCollectionSeconds,
174174
uint256 unboundedSecondCollectionSeconds
@@ -177,16 +177,15 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest {
177177
fuzzy.fuzzyTestAccept
178178
);
179179

180-
// skip to collectable time
181-
180+
// First valid collection to establish lastCollectionAt
182181
skip(
183182
boundSkip(
184183
unboundedFirstCollectionSeconds,
185184
accepted.rca.minSecondsPerCollection,
186185
accepted.rca.maxSecondsPerCollection
187186
)
188187
);
189-
bytes memory data = _generateCollectData(
188+
bytes memory firstData = _generateCollectData(
190189
_generateCollectParams(
191190
accepted.rca,
192191
agreementId,
@@ -195,35 +194,41 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest {
195194
fuzzy.collectParams.dataServiceCut
196195
)
197196
);
198-
vm.prank(accepted.rca.dataService);
199-
_recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data);
197+
vm.prank(acceptedRca.dataService);
198+
_recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), firstData);
200199

201-
// skip beyond collectable time but still within the agreement endsAt
200+
// Skip PAST maxSecondsPerCollection (but still within agreement endsAt)
202201
uint256 collectionSeconds = boundSkip(
203202
unboundedSecondCollectionSeconds,
204203
accepted.rca.maxSecondsPerCollection + 1,
205204
accepted.rca.endsAt - block.timestamp
206205
);
207206
skip(collectionSeconds);
208207

209-
data = _generateCollectData(
210-
_generateCollectParams(
211-
accepted.rca,
212-
agreementId,
213-
fuzzy.collectParams.collectionId,
214-
bound(fuzzy.collectParams.tokens, 1, type(uint256).max),
215-
fuzzy.collectParams.dataServiceCut
216-
)
208+
// Request more tokens than the cap allows
209+
uint256 cappedMaxTokens = acceptedRca.maxOngoingTokensPerSecond * acceptedRca.maxSecondsPerCollection;
210+
uint256 requestedTokens = cappedMaxTokens + 1;
211+
212+
IRecurringCollector.CollectParams memory collectParams = _generateCollectParams(
213+
acceptedRca,
214+
agreementId,
215+
fuzzy.collectParams.collectionId,
216+
requestedTokens,
217+
fuzzy.collectParams.dataServiceCut
217218
);
218-
bytes memory expectedErr = abi.encodeWithSelector(
219-
IRecurringCollector.RecurringCollectorCollectionTooLate.selector,
219+
bytes memory data = _generateCollectData(collectParams);
220+
221+
// Collection should SUCCEED with tokens capped at maxSecondsPerCollection worth
222+
_expectCollectCallAndEmit(
223+
acceptedRca,
220224
agreementId,
221-
collectionSeconds,
222-
accepted.rca.maxSecondsPerCollection
225+
_paymentType(fuzzy.unboundedPaymentType),
226+
collectParams,
227+
cappedMaxTokens
223228
);
224-
vm.expectRevert(expectedErr);
225-
vm.prank(accepted.rca.dataService);
226-
_recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data);
229+
vm.prank(acceptedRca.dataService);
230+
uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data);
231+
assertEq(collected, cappedMaxTokens, "Tokens should be capped at maxSecondsPerCollection worth");
227232
}
228233

229234
function test_Collect_OK_WhenCollectingTooMuch(

packages/interfaces/contracts/horizon/IRecurringCollector.sol

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
5959
* @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second
6060
* except for the first collection
6161
* @param minSecondsPerCollection The minimum amount of seconds that must pass between collections
62-
* @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections
62+
* @param maxSecondsPerCollection The maximum seconds of service that can be collected in a single collection
6363
* @param nonce A unique nonce for preventing collisions (user-chosen)
6464
* @param metadata Arbitrary metadata to extend functionality if a data service requires it
6565
*
@@ -99,7 +99,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
9999
* @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second
100100
* except for the first collection
101101
* @param minSecondsPerCollection The minimum amount of seconds that must pass between collections
102-
* @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections
102+
* @param maxSecondsPerCollection The maximum seconds of service that can be collected in a single collection
103103
* @param nonce The nonce for preventing replay attacks (must be current nonce + 1)
104104
* @param metadata Arbitrary metadata to extend functionality if a data service requires it
105105
*/
@@ -130,7 +130,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
130130
* @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second
131131
* except for the first collection
132132
* @param minSecondsPerCollection The minimum amount of seconds that must pass between collections
133-
* @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections
133+
* @param maxSecondsPerCollection The maximum seconds of service that can be collected in a single collection
134134
* @param updateNonce The current nonce for updates (prevents replay attacks)
135135
* @param canceledAt The timestamp when the agreement was canceled
136136
* @param state The state of the agreement
@@ -180,7 +180,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
180180
* @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection
181181
* @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second
182182
* @param minSecondsPerCollection The minimum amount of seconds that must pass between collections
183-
* @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections
183+
* @param maxSecondsPerCollection The maximum seconds of service that can be collected in a single collection
184184
*/
185185
event AgreementAccepted(
186186
address indexed dataService,
@@ -224,7 +224,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
224224
* @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection
225225
* @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second
226226
* @param minSecondsPerCollection The minimum amount of seconds that must pass between collections
227-
* @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections
227+
* @param maxSecondsPerCollection The maximum seconds of service that can be collected in a single collection
228228
*/
229229
event AgreementUpdated(
230230
address indexed dataService,
@@ -373,14 +373,6 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
373373
*/
374374
error RecurringCollectorCollectionTooSoon(bytes16 agreementId, uint32 secondsSinceLast, uint32 minSeconds);
375375

376-
/**
377-
* @notice Thrown when calling collect() too late
378-
* @param agreementId The agreement ID
379-
* @param secondsSinceLast Seconds since last collection
380-
* @param maxSeconds Maximum seconds between collections
381-
*/
382-
error RecurringCollectorCollectionTooLate(bytes16 agreementId, uint64 secondsSinceLast, uint32 maxSeconds);
383-
384376
/**
385377
* @notice Thrown when calling update() with an invalid nonce
386378
* @param agreementId The agreement ID

0 commit comments

Comments
 (0)