Skip to content

Commit e5fefbe

Browse files
fix: [TRST-M-3] Add nonce-based replay protection
Fixes signature replay attack vulnerability where old signed RecurringCollectionAgreementUpdate messages could be replayed to revert agreements to previous terms. ## Changes - Add `nonce` field to RecurringCollectionAgreementUpdate struct (uint32) - Add `updateNonce` field to AgreementData struct to track current nonce - Add nonce validation in RecurringCollector.update() to ensure sequential updates - Update EIP712_RCAU_TYPEHASH to include nonce field - Add comprehensive tests for nonce validation and replay attack prevention - Add RecurringCollectorInvalidUpdateNonce error for invalid nonce attempts ## Implementation Details - Nonces start at 0 when agreement is accepted - Each update must use current nonce + 1 - Nonce is incremented after successful update - Uses uint32 for gas optimization (supports 4B+ updates per agreement) - Single source of truth: nonce stored in AgreementData struct
1 parent 17355fe commit e5fefbe

File tree

6 files changed

+189
-7
lines changed

6 files changed

+189
-7
lines changed

packages/horizon/contracts/interfaces/IRecurringCollector.sol

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
9090
* except for the first collection
9191
* @param minSecondsPerCollection The minimum amount of seconds that must pass between collections
9292
* @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections
93+
* @param nonce The nonce for preventing replay attacks (must be current nonce + 1)
9394
* @param metadata Arbitrary metadata to extend functionality if a data service requires it
9495
*/
9596
struct RecurringCollectionAgreementUpdate {
@@ -100,6 +101,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
100101
uint256 maxOngoingTokensPerSecond;
101102
uint32 minSecondsPerCollection;
102103
uint32 maxSecondsPerCollection;
104+
uint32 nonce;
103105
bytes metadata;
104106
}
105107

@@ -118,6 +120,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
118120
* except for the first collection
119121
* @param minSecondsPerCollection The minimum amount of seconds that must pass between collections
120122
* @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections
123+
* @param updateNonce The current nonce for updates (prevents replay attacks)
121124
* @param canceledAt The timestamp when the agreement was canceled
122125
* @param state The state of the agreement
123126
*/
@@ -132,6 +135,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
132135
uint256 maxOngoingTokensPerSecond;
133136
uint32 minSecondsPerCollection;
134137
uint32 maxSecondsPerCollection;
138+
uint32 updateNonce;
135139
uint64 canceledAt;
136140
AgreementState state;
137141
}
@@ -357,6 +361,14 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
357361
*/
358362
error RecurringCollectorCollectionTooLate(bytes16 agreementId, uint64 secondsSinceLast, uint32 maxSeconds);
359363

364+
/**
365+
* @notice Thrown when calling update() with an invalid nonce
366+
* @param agreementId The agreement ID
367+
* @param expected The expected nonce
368+
* @param provided The provided nonce
369+
*/
370+
error RecurringCollectorInvalidUpdateNonce(bytes16 agreementId, uint32 expected, uint32 provided);
371+
360372
/**
361373
* @dev Accept an indexing agreement.
362374
* @param signedRCA The signed Recurring Collection Agreement which is to be accepted.

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
3535
/// @notice The EIP712 typehash for the RecurringCollectionAgreementUpdate struct
3636
bytes32 public constant EIP712_RCAU_TYPEHASH =
3737
keccak256(
38-
"RecurringCollectionAgreementUpdate(bytes16 agreementId,uint64 deadline,uint64 endsAt,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,bytes metadata)"
38+
"RecurringCollectionAgreementUpdate(bytes16 agreementId,uint64 deadline,uint64 endsAt,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,uint32 nonce,bytes metadata)"
3939
);
4040

4141
/// @notice Tracks agreements
@@ -120,6 +120,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
120120
agreement.maxOngoingTokensPerSecond = signedRCA.rca.maxOngoingTokensPerSecond;
121121
agreement.minSecondsPerCollection = signedRCA.rca.minSecondsPerCollection;
122122
agreement.maxSecondsPerCollection = signedRCA.rca.maxSecondsPerCollection;
123+
agreement.updateNonce = 0;
123124

124125
emit AgreementAccepted(
125126
agreement.dataService,
@@ -193,6 +194,13 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
193194
// check that the voucher is signed by the payer (or proxy)
194195
_requireAuthorizedRCAUSigner(signedRCAU, agreement.payer);
195196

197+
// validate nonce to prevent replay attacks
198+
uint32 expectedNonce = agreement.updateNonce + 1;
199+
require(
200+
signedRCAU.rcau.nonce == expectedNonce,
201+
RecurringCollectorInvalidUpdateNonce(signedRCAU.rcau.agreementId, expectedNonce, signedRCAU.rcau.nonce)
202+
);
203+
196204
_requireValidCollectionWindowParams(
197205
signedRCAU.rcau.endsAt,
198206
signedRCAU.rcau.minSecondsPerCollection,
@@ -205,6 +213,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
205213
agreement.maxOngoingTokensPerSecond = signedRCAU.rcau.maxOngoingTokensPerSecond;
206214
agreement.minSecondsPerCollection = signedRCAU.rcau.minSecondsPerCollection;
207215
agreement.maxSecondsPerCollection = signedRCAU.rcau.maxSecondsPerCollection;
216+
agreement.updateNonce = signedRCAU.rcau.nonce;
208217

209218
emit AgreementUpdated(
210219
agreement.dataService,
@@ -482,6 +491,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
482491
_rcau.maxOngoingTokensPerSecond,
483492
_rcau.minSecondsPerCollection,
484493
_rcau.maxSecondsPerCollection,
494+
_rcau.nonce,
485495
keccak256(_rcau.metadata)
486496
)
487497
)

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ contract RecurringCollectorHelper is AuthorizableHelper, Bounder {
4545
return signedRCAU;
4646
}
4747

48+
function generateSignedRCAUWithCorrectNonce(
49+
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau,
50+
uint256 signerPrivateKey
51+
) public view returns (IRecurringCollector.SignedRCAU memory) {
52+
// Automatically set the correct nonce based on current agreement state
53+
IRecurringCollector.AgreementData memory agreement = collector.getAgreement(rcau.agreementId);
54+
rcau.nonce = agreement.updateNonce + 1;
55+
56+
return generateSignedRCAU(rcau, signerPrivateKey);
57+
}
58+
4859
function withElapsedAcceptDeadline(
4960
IRecurringCollector.RecurringCollectionAgreement memory rca
5061
) public view returns (IRecurringCollector.RecurringCollectionAgreement memory) {

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

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest {
7676
);
7777
rcau.agreementId = accepted.rca.agreementId;
7878

79-
IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU(
79+
IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAUWithCorrectNonce(
8080
rcau,
8181
signerKey
8282
);
@@ -124,6 +124,8 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest {
124124
fuzzyTestUpdate.rcau
125125
);
126126
rcau.agreementId = accepted.rca.agreementId;
127+
// Don't use fuzzed nonce - use correct nonce for first update
128+
rcau.nonce = 1;
127129
IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU(
128130
rcau,
129131
signerKey
@@ -151,6 +153,151 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest {
151153
assertEq(rcau.maxOngoingTokensPerSecond, agreement.maxOngoingTokensPerSecond);
152154
assertEq(rcau.minSecondsPerCollection, agreement.minSecondsPerCollection);
153155
assertEq(rcau.maxSecondsPerCollection, agreement.maxSecondsPerCollection);
156+
assertEq(rcau.nonce, agreement.updateNonce);
157+
}
158+
159+
function test_Update_Revert_WhenInvalidNonce_TooLow(FuzzyTestUpdate calldata fuzzyTestUpdate) public {
160+
(IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept(
161+
fuzzyTestUpdate.fuzzyTestAccept
162+
);
163+
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU(
164+
fuzzyTestUpdate.rcau
165+
);
166+
rcau.agreementId = accepted.rca.agreementId;
167+
rcau.nonce = 0; // Invalid: should be 1 for first update
168+
169+
IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU(
170+
rcau,
171+
signerKey
172+
);
173+
174+
bytes memory expectedErr = abi.encodeWithSelector(
175+
IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector,
176+
rcau.agreementId,
177+
1, // expected
178+
0 // provided
179+
);
180+
vm.expectRevert(expectedErr);
181+
vm.prank(accepted.rca.dataService);
182+
_recurringCollector.update(signedRCAU);
183+
}
184+
185+
function test_Update_Revert_WhenInvalidNonce_TooHigh(FuzzyTestUpdate calldata fuzzyTestUpdate) public {
186+
(IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept(
187+
fuzzyTestUpdate.fuzzyTestAccept
188+
);
189+
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU(
190+
fuzzyTestUpdate.rcau
191+
);
192+
rcau.agreementId = accepted.rca.agreementId;
193+
rcau.nonce = 5; // Invalid: should be 1 for first update
194+
195+
IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU(
196+
rcau,
197+
signerKey
198+
);
199+
200+
bytes memory expectedErr = abi.encodeWithSelector(
201+
IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector,
202+
rcau.agreementId,
203+
1, // expected
204+
5 // provided
205+
);
206+
vm.expectRevert(expectedErr);
207+
vm.prank(accepted.rca.dataService);
208+
_recurringCollector.update(signedRCAU);
209+
}
210+
211+
function test_Update_Revert_WhenReplayAttack(FuzzyTestUpdate calldata fuzzyTestUpdate) public {
212+
(IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept(
213+
fuzzyTestUpdate.fuzzyTestAccept
214+
);
215+
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _recurringCollectorHelper.sensibleRCAU(
216+
fuzzyTestUpdate.rcau
217+
);
218+
rcau1.agreementId = accepted.rca.agreementId;
219+
rcau1.nonce = 1;
220+
221+
// First update succeeds
222+
IRecurringCollector.SignedRCAU memory signedRCAU1 = _recurringCollectorHelper.generateSignedRCAU(
223+
rcau1,
224+
signerKey
225+
);
226+
vm.prank(accepted.rca.dataService);
227+
_recurringCollector.update(signedRCAU1);
228+
229+
// Second update with different terms and nonce 2 succeeds
230+
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = rcau1;
231+
rcau2.nonce = 2;
232+
rcau2.maxOngoingTokensPerSecond = rcau1.maxOngoingTokensPerSecond * 2; // Different terms
233+
234+
IRecurringCollector.SignedRCAU memory signedRCAU2 = _recurringCollectorHelper.generateSignedRCAU(
235+
rcau2,
236+
signerKey
237+
);
238+
vm.prank(accepted.rca.dataService);
239+
_recurringCollector.update(signedRCAU2);
240+
241+
// Attempting to replay first update should fail
242+
bytes memory expectedErr = abi.encodeWithSelector(
243+
IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector,
244+
rcau1.agreementId,
245+
3, // expected (current nonce + 1)
246+
1 // provided (old nonce)
247+
);
248+
vm.expectRevert(expectedErr);
249+
vm.prank(accepted.rca.dataService);
250+
_recurringCollector.update(signedRCAU1);
251+
}
252+
253+
function test_Update_OK_NonceIncrementsCorrectly(FuzzyTestUpdate calldata fuzzyTestUpdate) public {
254+
(IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept(
255+
fuzzyTestUpdate.fuzzyTestAccept
256+
);
257+
258+
// Initial nonce should be 0
259+
IRecurringCollector.AgreementData memory initialAgreement = _recurringCollector.getAgreement(
260+
accepted.rca.agreementId
261+
);
262+
assertEq(initialAgreement.updateNonce, 0);
263+
264+
// First update with nonce 1
265+
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _recurringCollectorHelper.sensibleRCAU(
266+
fuzzyTestUpdate.rcau
267+
);
268+
rcau1.agreementId = accepted.rca.agreementId;
269+
rcau1.nonce = 1;
270+
271+
IRecurringCollector.SignedRCAU memory signedRCAU1 = _recurringCollectorHelper.generateSignedRCAU(
272+
rcau1,
273+
signerKey
274+
);
275+
vm.prank(accepted.rca.dataService);
276+
_recurringCollector.update(signedRCAU1);
277+
278+
// Verify nonce incremented to 1
279+
IRecurringCollector.AgreementData memory updatedAgreement1 = _recurringCollector.getAgreement(
280+
accepted.rca.agreementId
281+
);
282+
assertEq(updatedAgreement1.updateNonce, 1);
283+
284+
// Second update with nonce 2
285+
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = rcau1;
286+
rcau2.nonce = 2;
287+
rcau2.maxOngoingTokensPerSecond = rcau1.maxOngoingTokensPerSecond * 2; // Different terms
288+
289+
IRecurringCollector.SignedRCAU memory signedRCAU2 = _recurringCollectorHelper.generateSignedRCAU(
290+
rcau2,
291+
signerKey
292+
);
293+
vm.prank(accepted.rca.dataService);
294+
_recurringCollector.update(signedRCAU2);
295+
296+
// Verify nonce incremented to 2
297+
IRecurringCollector.AgreementData memory updatedAgreement2 = _recurringCollector.getAgreement(
298+
accepted.rca.agreementId
299+
);
300+
assertEq(updatedAgreement2.updateNonce, 2);
154301
}
155302

156303
/* solhint-enable graph/func-name-mixedcase */

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,11 +255,11 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun
255255
Context storage _ctx,
256256
IRecurringCollector.RecurringCollectionAgreement memory _rca
257257
) internal view returns (IRecurringCollector.SignedRCAU memory) {
258-
return
259-
_recurringCollectorHelper.generateSignedRCAU(
260-
_generateAcceptableRecurringCollectionAgreementUpdate(_ctx, _rca),
261-
_ctx.payer.signerPrivateKey
262-
);
258+
IRecurringCollector.RecurringCollectionAgreementUpdate
259+
memory rcau = _generateAcceptableRecurringCollectionAgreementUpdate(_ctx, _rca);
260+
// Set correct nonce for first update (should be 1)
261+
rcau.nonce = 1;
262+
return _recurringCollectorHelper.generateSignedRCAU(rcau, _ctx.payer.signerPrivateKey);
263263
}
264264

265265
function _generateAcceptableRecurringCollectionAgreementUpdate(

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA
127127
IRecurringCollector.RecurringCollectionAgreementUpdate
128128
memory acceptableUpdate = _generateAcceptableRecurringCollectionAgreementUpdate(ctx, accepted.rca);
129129
acceptableUpdate.metadata = bytes("invalid");
130+
// Set correct nonce for first update (should be 1)
131+
acceptableUpdate.nonce = 1;
130132
IRecurringCollector.SignedRCAU memory unacceptableUpdate = _recurringCollectorHelper.generateSignedRCAU(
131133
acceptableUpdate,
132134
ctx.payer.signerPrivateKey

0 commit comments

Comments
 (0)