Skip to content

Commit 5ec30ff

Browse files
committed
feat(collector): add pause mechanism to RecurringCollector (TRST-L-3)
Add pause guardian pattern gating accept, update, collect, cancel, and offer behind whenNotPaused. This provides a middle layer between the RAM-level pause (agreement lifecycle only) and the Controller-level nuclear pause (all escrow operations protocol-wide). The previous approveAgreement pause-bypass vector no longer exists since callback- based approval was replaced by stored-hash authorization (L-3).
1 parent 66f1134 commit 5ec30ff

6 files changed

Lines changed: 408 additions & 254 deletions

File tree

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

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pragma solidity ^0.8.27;
33

44
import { EIP712Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol";
55
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
6+
import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
67
import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
78
import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol";
89
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
@@ -36,7 +37,14 @@ import { PPMMath } from "../../libraries/PPMMath.sol";
3637
* @custom:security-contact Please email security+contracts@thegraph.com if you find any
3738
* bugs. We may have an active bug bounty program.
3839
*/
39-
contract RecurringCollector is Initializable, EIP712Upgradeable, GraphDirectory, Authorizable, IRecurringCollector {
40+
contract RecurringCollector is
41+
Initializable,
42+
EIP712Upgradeable,
43+
GraphDirectory,
44+
Authorizable,
45+
PausableUpgradeable,
46+
IRecurringCollector
47+
{
4048
using PPMMath for uint256;
4149

4250
/// @notice The minimum number of seconds that must be between two collections
@@ -94,9 +102,29 @@ contract RecurringCollector is Initializable, EIP712Upgradeable, GraphDirectory,
94102
}
95103

96104
/**
97-
* @notice Constructs a new instance of the RecurringCollector contract.
98-
* @param eip712Name The name of the EIP712 domain.
99-
* @param eip712Version The version of the EIP712 domain.
105+
* @notice List of pause guardians and their allowed status
106+
* @param pauseGuardian The address to check
107+
* @return Whether the address is a pause guardian
108+
*/
109+
function pauseGuardians(address pauseGuardian) public view override returns (bool) {
110+
return _getStorage().pauseGuardians[pauseGuardian];
111+
}
112+
113+
/**
114+
* @notice Checks if the caller is a pause guardian.
115+
*/
116+
modifier onlyPauseGuardian() {
117+
_checkPauseGuardian();
118+
_;
119+
}
120+
121+
function _checkPauseGuardian() internal view {
122+
require(_getStorage().pauseGuardians[msg.sender], RecurringCollectorNotPauseGuardian(msg.sender));
123+
}
124+
125+
/**
126+
* @notice Constructs a new instance of the RecurringCollector implementation contract.
127+
* @dev Immutables are set here; proxy state is initialized via {initialize}.
100128
* @param controller The address of the Graph controller.
101129
* @param revokeSignerThawingPeriod The duration (in seconds) in which a signer is thawing before they can be revoked.
102130
*/
@@ -115,16 +143,47 @@ contract RecurringCollector is Initializable, EIP712Upgradeable, GraphDirectory,
115143
*/
116144
function initialize(string memory eip712Name, string memory eip712Version) external initializer {
117145
__EIP712_init(eip712Name, eip712Version);
146+
__Pausable_init();
118147
}
119148
/* solhint-enable gas-calldata-parameters */
120149

150+
/// @inheritdoc IRecurringCollector
151+
function pause() external override onlyPauseGuardian {
152+
_pause();
153+
}
154+
155+
/// @inheritdoc IRecurringCollector
156+
function unpause() external override onlyPauseGuardian {
157+
_unpause();
158+
}
159+
160+
/**
161+
* @notice Sets a pause guardian.
162+
* @dev Only callable by the governor.
163+
* @param _pauseGuardian The address of the pause guardian
164+
* @param _allowed Whether the address should be a pause guardian
165+
*/
166+
function setPauseGuardian(address _pauseGuardian, bool _allowed) external {
167+
require(msg.sender == _graphController().getGovernor(), RecurringCollectorNotGovernor(msg.sender));
168+
RecurringCollectorStorage storage $ = _getStorage();
169+
require(
170+
$.pauseGuardians[_pauseGuardian] != _allowed,
171+
RecurringCollectorPauseGuardianNoChange(_pauseGuardian, _allowed)
172+
);
173+
$.pauseGuardians[_pauseGuardian] = _allowed;
174+
emit PauseGuardianSet(_pauseGuardian, _allowed);
175+
}
176+
121177
/**
122178
* @inheritdoc IPaymentsCollector
123179
* @notice Initiate a payment collection through the payments protocol.
124180
* See {IPaymentsCollector.collect}.
125181
* @dev Caller must be the data service the RCA was issued to.
126182
*/
127-
function collect(IGraphPayments.PaymentTypes paymentType, bytes calldata data) external returns (uint256) {
183+
function collect(
184+
IGraphPayments.PaymentTypes paymentType,
185+
bytes calldata data
186+
) external whenNotPaused returns (uint256) {
128187
try this.decodeCollectData(data) returns (CollectParams memory collectParams) {
129188
return _collect(paymentType, collectParams);
130189
} catch {
@@ -137,7 +196,10 @@ contract RecurringCollector is Initializable, EIP712Upgradeable, GraphDirectory,
137196
* @notice Accept a Recurring Collection Agreement.
138197
* @dev Caller must be the data service the RCA was issued to.
139198
*/
140-
function accept(RecurringCollectionAgreement calldata rca, bytes calldata signature) external returns (bytes16) {
199+
function accept(
200+
RecurringCollectionAgreement calldata rca,
201+
bytes calldata signature
202+
) external whenNotPaused returns (bytes16) {
141203
/* solhint-disable gas-strict-inequalities */
142204
require(
143205
rca.deadline >= block.timestamp,
@@ -230,7 +292,7 @@ contract RecurringCollector is Initializable, EIP712Upgradeable, GraphDirectory,
230292
* See {IRecurringCollector.cancel}.
231293
* @dev Caller must be the data service for the agreement.
232294
*/
233-
function cancel(bytes16 agreementId, CancelAgreementBy by) external {
295+
function cancel(bytes16 agreementId, CancelAgreementBy by) external whenNotPaused {
234296
AgreementData storage agreement = _getAgreementStorage(agreementId);
235297
require(
236298
agreement.state == AgreementState.Accepted,
@@ -264,7 +326,7 @@ contract RecurringCollector is Initializable, EIP712Upgradeable, GraphDirectory,
264326
* @dev Note: Updated pricing terms apply immediately and will affect the next collection
265327
* for the entire period since lastCollectionAt.
266328
*/
267-
function update(RecurringCollectionAgreementUpdate calldata rcau, bytes calldata signature) external {
329+
function update(RecurringCollectionAgreementUpdate calldata rcau, bytes calldata signature) external whenNotPaused {
268330
AgreementData storage agreement = _requireValidUpdateTarget(rcau.agreementId);
269331

270332
/* solhint-disable gas-strict-inequalities */
@@ -343,7 +405,7 @@ contract RecurringCollector is Initializable, EIP712Upgradeable, GraphDirectory,
343405
uint8 offerType,
344406
bytes calldata data,
345407
uint16 /* options */
346-
) external returns (AgreementDetails memory details) {
408+
) external whenNotPaused returns (AgreementDetails memory details) {
347409
if (offerType == OFFER_TYPE_NEW) details = _offerNew(data);
348410
else if (offerType == OFFER_TYPE_UPDATE) details = _offerUpdate(data);
349411
else revert RecurringCollectorInvalidCollectData(data);

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,10 @@ contract RecurringCollectorCoverageGapsTest is RecurringCollectorSharedTest {
225225
vm.prank(rca.dataService);
226226
_recurringCollector.accept(rca, "");
227227

228-
// After accept: offer is cleaned up
229-
(, bytes memory postAcceptData) = _recurringCollector.getAgreementOfferAt(agreementId, 0);
230-
assertEq(postAcceptData.length, 0, "RCA offer should be cleaned up after accept");
228+
// After accept: offer persists
229+
(uint8 postOfferType, bytes memory postAcceptData) = _recurringCollector.getAgreementOfferAt(agreementId, 0);
230+
assertEq(postOfferType, OFFER_TYPE_NEW, "Index 0 should still be OFFER_TYPE_NEW after accept");
231+
assertTrue(postAcceptData.length > 0, "RCA offer should persist after accept");
231232
}
232233

233234
function test_GetAgreementOfferAt_Index1_WithPending() public {

0 commit comments

Comments
 (0)