Skip to content

Commit 8048c4c

Browse files
fix: [TRST-H-3] collect() checks provision
1 parent 7695c9e commit 8048c4c

File tree

5 files changed

+86
-1
lines changed

5 files changed

+86
-1
lines changed

packages/horizon/contracts/interfaces/IRecurringCollector.sol

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,11 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
253253
* @param unauthorizedDataService The address of the unauthorized data service
254254
*/
255255
error RecurringCollectorDataServiceNotAuthorized(bytes16 agreementId, address unauthorizedDataService);
256+
/**
257+
* @notice Thrown when the data service is not authorized for the service provider
258+
* @param dataService The address of the unauthorized data service
259+
*/
260+
error RecurringCollectorUnauthorizedDataService(address dataService);
256261

257262
/**
258263
* @notice Thrown when interacting with an agreement with an elapsed deadline

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,17 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
284284
RecurringCollectorDataServiceNotAuthorized(_params.agreementId, msg.sender)
285285
);
286286

287+
// Check the service provider has an active provision with the data service
288+
// This prevents an attack where the payer can deny the service provider from collecting payments
289+
// by using a signer as data service to syphon off the tokens in the escrow to an account they control
290+
{
291+
uint256 tokensAvailable = _graphStaking().getProviderTokensAvailable(
292+
agreement.serviceProvider,
293+
agreement.dataService
294+
);
295+
require(tokensAvailable > 0, RecurringCollectorUnauthorizedDataService(agreement.dataService));
296+
}
297+
287298
uint256 tokensToCollect = 0;
288299
if (_params.tokens != 0) {
289300
tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens);

packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,9 @@ contract HorizonStakingMock {
2929
function setIsAuthorized(address serviceProvider, address verifier, address operator, bool authorized) external {
3030
authorizations[serviceProvider][verifier][operator] = authorized;
3131
}
32+
33+
function getProviderTokensAvailable(address serviceProvider, address verifier) external view returns (uint256) {
34+
IHorizonStakingTypes.Provision memory provision = provisions[serviceProvider][verifier];
35+
return provision.tokens - provision.tokensThawing;
36+
}
3237
}

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

Lines changed: 37 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 { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol";
56

67
import { RecurringCollectorSharedTest } from "./shared.t.sol";
78

@@ -44,6 +45,42 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest {
4445
_recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data);
4546
}
4647

48+
function test_Collect_Revert_WhenUnauthorizedDataService(FuzzyTestCollect calldata fuzzy) public {
49+
(IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept);
50+
IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams;
51+
52+
collectParams.agreementId = accepted.rca.agreementId;
53+
collectParams.tokens = bound(collectParams.tokens, 1, type(uint256).max);
54+
bytes memory data = _generateCollectData(collectParams);
55+
56+
// Set up the scenario where service provider has no tokens staked with data service
57+
// This simulates an unauthorized data service attack
58+
_horizonStaking.setProvision(
59+
accepted.rca.serviceProvider,
60+
accepted.rca.dataService,
61+
IHorizonStakingTypes.Provision({
62+
tokens: 0, // No tokens staked - this triggers the vulnerability
63+
tokensThawing: 0,
64+
sharesThawing: 0,
65+
maxVerifierCut: 100000,
66+
thawingPeriod: 604800,
67+
createdAt: uint64(block.timestamp),
68+
maxVerifierCutPending: 100000,
69+
thawingPeriodPending: 604800,
70+
lastParametersStagedAt: 0,
71+
thawingNonce: 0
72+
})
73+
);
74+
75+
bytes memory expectedErr = abi.encodeWithSelector(
76+
IRecurringCollector.RecurringCollectorUnauthorizedDataService.selector,
77+
accepted.rca.dataService
78+
);
79+
vm.expectRevert(expectedErr);
80+
vm.prank(accepted.rca.dataService);
81+
_recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data);
82+
}
83+
4784
function test_Collect_Revert_WhenUnknownAgreement(FuzzyTestCollect memory fuzzy, address dataService) public {
4885
bytes memory data = _generateCollectData(fuzzy.collectParams);
4986

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import { Test } from "forge-std/Test.sol";
66
import { IGraphPayments } from "../../../../contracts/interfaces/IGraphPayments.sol";
77
import { IPaymentsCollector } from "../../../../contracts/interfaces/IPaymentsCollector.sol";
88
import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol";
9+
import { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol";
910
import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol";
1011

1112
import { Bounder } from "../../../unit/utils/Bounder.t.sol";
1213
import { PartialControllerMock } from "../../mocks/PartialControllerMock.t.sol";
14+
import { HorizonStakingMock } from "../../mocks/HorizonStakingMock.t.sol";
1315
import { PaymentsEscrowMock } from "./PaymentsEscrowMock.t.sol";
1416
import { RecurringCollectorHelper } from "./RecurringCollectorHelper.t.sol";
1517

@@ -32,12 +34,15 @@ contract RecurringCollectorSharedTest is Test, Bounder {
3234

3335
RecurringCollector internal _recurringCollector;
3436
PaymentsEscrowMock internal _paymentsEscrow;
37+
HorizonStakingMock internal _horizonStaking;
3538
RecurringCollectorHelper internal _recurringCollectorHelper;
3639

3740
function setUp() public {
3841
_paymentsEscrow = new PaymentsEscrowMock();
39-
PartialControllerMock.Entry[] memory entries = new PartialControllerMock.Entry[](1);
42+
_horizonStaking = new HorizonStakingMock();
43+
PartialControllerMock.Entry[] memory entries = new PartialControllerMock.Entry[](2);
4044
entries[0] = PartialControllerMock.Entry({ name: "PaymentsEscrow", addr: address(_paymentsEscrow) });
45+
entries[1] = PartialControllerMock.Entry({ name: "Staking", addr: address(_horizonStaking) });
4146
_recurringCollector = new RecurringCollector(
4247
"RecurringCollector",
4348
"1",
@@ -71,6 +76,9 @@ contract RecurringCollectorSharedTest is Test, Bounder {
7176
}
7277

7378
function _accept(IRecurringCollector.SignedRCA memory _signedRCA) internal {
79+
// Set up valid staking provision by default to allow collections to succeed
80+
_setupValidProvision(_signedRCA.rca.serviceProvider, _signedRCA.rca.dataService);
81+
7482
vm.expectEmit(address(_recurringCollector));
7583
emit IRecurringCollector.AgreementAccepted(
7684
_signedRCA.rca.dataService,
@@ -88,6 +96,25 @@ contract RecurringCollectorSharedTest is Test, Bounder {
8896
_recurringCollector.accept(_signedRCA);
8997
}
9098

99+
function _setupValidProvision(address _serviceProvider, address _dataService) internal {
100+
_horizonStaking.setProvision(
101+
_serviceProvider,
102+
_dataService,
103+
IHorizonStakingTypes.Provision({
104+
tokens: 1000 ether,
105+
tokensThawing: 0,
106+
sharesThawing: 0,
107+
maxVerifierCut: 100000, // 10%
108+
thawingPeriod: 604800, // 7 days
109+
createdAt: uint64(block.timestamp),
110+
maxVerifierCutPending: 100000,
111+
thawingPeriodPending: 604800,
112+
lastParametersStagedAt: 0,
113+
thawingNonce: 0
114+
})
115+
);
116+
}
117+
91118
function _cancel(
92119
IRecurringCollector.RecurringCollectionAgreement memory _rca,
93120
IRecurringCollector.CancelAgreementBy _by

0 commit comments

Comments
 (0)