Skip to content

Commit cd51aba

Browse files
fix: [TRST-L-5] Add slippage protection
Implements slippage protection mechanism to prevent silent token loss during rate-limited collections in RecurringCollector agreements. The implementation uses type(uint256).max convention to disable slippage checks, providing users full control over acceptable token loss during rate limiting. Resolves audit finding TRST-L-5: "RecurringCollector silently reduces collected tokens without user consent"
1 parent 937fb97 commit cd51aba

File tree

7 files changed

+160
-4
lines changed

7 files changed

+160
-4
lines changed

packages/horizon/contracts/interfaces/IRecurringCollector.sol

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,15 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
147147
* @param tokens The amount of tokens to collect
148148
* @param dataServiceCut The data service cut in parts per million
149149
* @param receiverDestination The address where the collected fees should be sent
150+
* @param maxSlippage Max acceptable tokens to lose due to rate limiting, or type(uint256).max to ignore
150151
*/
151152
struct CollectParams {
152153
bytes16 agreementId;
153154
bytes32 collectionId;
154155
uint256 tokens;
155156
uint256 dataServiceCut;
156157
address receiverDestination;
158+
uint256 maxSlippage;
157159
}
158160

159161
/**
@@ -369,6 +371,14 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
369371
*/
370372
error RecurringCollectorInvalidUpdateNonce(bytes16 agreementId, uint32 expected, uint32 provided);
371373

374+
/**
375+
* @notice Thrown when collected tokens are less than requested beyond the allowed slippage
376+
* @param requested The amount of tokens requested to collect
377+
* @param actual The actual amount that would be collected
378+
* @param maxSlippage The maximum allowed slippage
379+
*/
380+
error RecurringCollectorExcessiveSlippage(uint256 requested, uint256 actual, uint256 maxSlippage);
381+
372382
/**
373383
* @dev Accept an indexing agreement.
374384
* @param signedRCA The signed Recurring Collection Agreement which is to be accepted.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,12 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
336336
if (_params.tokens != 0) {
337337
tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens, collectionSeconds);
338338

339+
uint256 slippage = _params.tokens - tokensToCollect;
340+
require(
341+
slippage <= _params.maxSlippage,
342+
RecurringCollectorExcessiveSlippage(_params.tokens, tokensToCollect, _params.maxSlippage)
343+
);
344+
339345
_graphPaymentsEscrow().collect(
340346
_paymentType,
341347
agreement.payer,

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

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
pragma solidity 0.8.27;
33

44
import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol";
5+
import { IGraphPayments } from "../../../../contracts/interfaces/IGraphPayments.sol";
56
import { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol";
67

78
import { RecurringCollectorSharedTest } from "./shared.t.sol";
@@ -309,5 +310,138 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest {
309310
uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data);
310311
assertEq(collected, tokens);
311312
}
313+
314+
function test_Collect_RevertWhen_ExceedsMaxSlippage() public {
315+
// Setup: Create agreement with known parameters
316+
IRecurringCollector.RecurringCollectionAgreement memory rca;
317+
rca.deadline = uint64(block.timestamp + 1000);
318+
rca.endsAt = uint64(block.timestamp + 2000);
319+
rca.payer = address(0x123);
320+
rca.dataService = address(0x456);
321+
rca.serviceProvider = address(0x789);
322+
rca.maxInitialTokens = 0; // No initial tokens to keep calculation simple
323+
rca.maxOngoingTokensPerSecond = 1 ether; // 1 token per second
324+
rca.minSecondsPerCollection = 60; // 1 minute
325+
rca.maxSecondsPerCollection = 3600; // 1 hour
326+
rca.nonce = 1;
327+
rca.metadata = "";
328+
329+
// Accept the agreement
330+
_recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, 1);
331+
IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(rca, 1);
332+
bytes16 agreementId = _accept(signedRCA);
333+
334+
// Do a first collection to use up initial tokens allowance
335+
skip(rca.minSecondsPerCollection);
336+
IRecurringCollector.CollectParams memory firstCollection = IRecurringCollector.CollectParams({
337+
agreementId: agreementId,
338+
collectionId: keccak256("first"),
339+
tokens: 1 ether, // Small amount
340+
dataServiceCut: 0,
341+
receiverDestination: rca.serviceProvider,
342+
maxSlippage: type(uint256).max
343+
});
344+
vm.prank(rca.dataService);
345+
_recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, _generateCollectData(firstCollection));
346+
347+
// Wait minimum collection time again for second collection
348+
skip(rca.minSecondsPerCollection);
349+
350+
// Calculate expected narrowing: max allowed is 60 tokens (60 seconds * 1 token/second)
351+
uint256 maxAllowed = rca.maxOngoingTokensPerSecond * rca.minSecondsPerCollection; // 60 tokens
352+
uint256 requested = maxAllowed + 50 ether; // Request 110 tokens
353+
uint256 expectedSlippage = requested - maxAllowed; // 50 tokens
354+
uint256 maxSlippage = expectedSlippage - 1; // Allow up to 49 tokens slippage
355+
356+
// Create collect params with slippage protection
357+
IRecurringCollector.CollectParams memory collectParams = IRecurringCollector.CollectParams({
358+
agreementId: agreementId,
359+
collectionId: keccak256("test"),
360+
tokens: requested,
361+
dataServiceCut: 0,
362+
receiverDestination: rca.serviceProvider,
363+
maxSlippage: maxSlippage
364+
});
365+
366+
bytes memory data = _generateCollectData(collectParams);
367+
368+
// Expect revert due to excessive slippage (50 > 49)
369+
vm.expectRevert(
370+
abi.encodeWithSelector(
371+
IRecurringCollector.RecurringCollectorExcessiveSlippage.selector,
372+
requested,
373+
maxAllowed,
374+
maxSlippage
375+
)
376+
);
377+
vm.prank(rca.dataService);
378+
_recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data);
379+
}
380+
381+
function test_Collect_OK_WithMaxSlippageDisabled() public {
382+
// Setup: Create agreement with known parameters
383+
IRecurringCollector.RecurringCollectionAgreement memory rca;
384+
rca.deadline = uint64(block.timestamp + 1000);
385+
rca.endsAt = uint64(block.timestamp + 2000);
386+
rca.payer = address(0x123);
387+
rca.dataService = address(0x456);
388+
rca.serviceProvider = address(0x789);
389+
rca.maxInitialTokens = 0; // No initial tokens to keep calculation simple
390+
rca.maxOngoingTokensPerSecond = 1 ether; // 1 token per second
391+
rca.minSecondsPerCollection = 60; // 1 minute
392+
rca.maxSecondsPerCollection = 3600; // 1 hour
393+
rca.nonce = 1;
394+
rca.metadata = "";
395+
396+
// Accept the agreement
397+
_recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, 1);
398+
IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(rca, 1);
399+
bytes16 agreementId = _accept(signedRCA);
400+
401+
// Do a first collection to use up initial tokens allowance
402+
skip(rca.minSecondsPerCollection);
403+
IRecurringCollector.CollectParams memory firstCollection = IRecurringCollector.CollectParams({
404+
agreementId: agreementId,
405+
collectionId: keccak256("first"),
406+
tokens: 1 ether, // Small amount
407+
dataServiceCut: 0,
408+
receiverDestination: rca.serviceProvider,
409+
maxSlippage: type(uint256).max
410+
});
411+
vm.prank(rca.dataService);
412+
_recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, _generateCollectData(firstCollection));
413+
414+
// Wait minimum collection time again for second collection
415+
skip(rca.minSecondsPerCollection);
416+
417+
// Calculate expected narrowing: max allowed is 60 tokens (60 seconds * 1 token/second)
418+
uint256 maxAllowed = rca.maxOngoingTokensPerSecond * rca.minSecondsPerCollection; // 60 tokens
419+
uint256 requested = maxAllowed + 50 ether; // Request 110 tokens (will be narrowed to 60)
420+
421+
// Create collect params with slippage disabled (type(uint256).max)
422+
IRecurringCollector.CollectParams memory collectParams = IRecurringCollector.CollectParams({
423+
agreementId: agreementId,
424+
collectionId: keccak256("test"),
425+
tokens: requested,
426+
dataServiceCut: 0,
427+
receiverDestination: rca.serviceProvider,
428+
maxSlippage: type(uint256).max
429+
});
430+
431+
bytes memory data = _generateCollectData(collectParams);
432+
433+
// Should succeed despite slippage when maxSlippage is disabled
434+
_expectCollectCallAndEmit(
435+
rca,
436+
agreementId,
437+
IGraphPayments.PaymentTypes.IndexingFee,
438+
collectParams,
439+
maxAllowed // Will collect the narrowed amount
440+
);
441+
442+
vm.prank(rca.dataService);
443+
uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data);
444+
assertEq(collected, maxAllowed);
445+
}
312446
/* solhint-enable graph/func-name-mixedcase */
313447
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,8 @@ contract RecurringCollectorSharedTest is Test, Bounder {
233233
collectionId: _collectionId,
234234
tokens: _tokens,
235235
dataServiceCut: _dataServiceCut,
236-
receiverDestination: _rca.serviceProvider
236+
receiverDestination: _rca.serviceProvider,
237+
maxSlippage: type(uint256).max
237238
});
238239
}
239240

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,14 @@ library IndexingAgreement {
9696
* @param poi The proof of indexing (POI)
9797
* @param poiBlockNumber The block number of the POI
9898
* @param metadata Additional metadata associated with the collection
99+
* @param maxSlippage Max acceptable tokens to lose due to rate limiting, or type(uint256).max to ignore
99100
*/
100101
struct CollectIndexingFeeDataV1 {
101102
uint256 entities;
102103
bytes32 poi;
103104
uint256 poiBlockNumber;
104105
bytes metadata;
106+
uint256 maxSlippage;
105107
}
106108

107109
/**
@@ -565,7 +567,8 @@ library IndexingAgreement {
565567
collectionId: bytes32(uint256(uint160(wrapper.agreement.allocationId))),
566568
tokens: expectedTokens,
567569
dataServiceCut: 0,
568-
receiverDestination: params.receiverDestination
570+
receiverDestination: params.receiverDestination,
571+
maxSlippage: data.maxSlippage
569572
})
570573
)
571574
);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA
4545
collectionId: bytes32(uint256(uint160(indexerState.allocationId))),
4646
tokens: 0,
4747
dataServiceCut: 0,
48-
receiverDestination: indexerState.addr
48+
receiverDestination: indexerState.addr,
49+
maxSlippage: type(uint256).max
4950
})
5051
);
5152
uint256 tokensCollected = bound(unboundedTokensCollected, 1, indexerState.tokens / stakeToFeesRatio);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,8 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun
392392
entities: _entities,
393393
poi: _poi,
394394
poiBlockNumber: _poiBlock,
395-
metadata: _metadata
395+
metadata: _metadata,
396+
maxSlippage: type(uint256).max
396397
})
397398
);
398399
}

0 commit comments

Comments
 (0)