Skip to content

[audit-03] fix: [TRST-H-3] collect() checks provision #1200

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: ma/indexing-payments-audit-fixes-02-H-2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/horizon/contracts/interfaces/IRecurringCollector.sol
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,11 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
* @param unauthorizedDataService The address of the unauthorized data service
*/
error RecurringCollectorDataServiceNotAuthorized(bytes16 agreementId, address unauthorizedDataService);
/**
* @notice Thrown when the data service is not authorized for the service provider
* @param dataService The address of the unauthorized data service
*/
error RecurringCollectorUnauthorizedDataService(address dataService);

/**
* @notice Thrown when interacting with an agreement with an elapsed deadline
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,17 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
RecurringCollectorDataServiceNotAuthorized(_params.agreementId, msg.sender)
);

// Check the service provider has an active provision with the data service
// This prevents an attack where the payer can deny the service provider from collecting payments
// by using a signer as data service to syphon off the tokens in the escrow to an account they control
{
uint256 tokensAvailable = _graphStaking().getProviderTokensAvailable(
agreement.serviceProvider,
agreement.dataService
);
require(tokensAvailable > 0, RecurringCollectorUnauthorizedDataService(agreement.dataService));
}

uint256 tokensToCollect = 0;
if (_params.tokens != 0) {
tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens);
Expand Down
5 changes: 5 additions & 0 deletions packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ contract HorizonStakingMock {
function setIsAuthorized(address serviceProvider, address verifier, address operator, bool authorized) external {
authorizations[serviceProvider][verifier][operator] = authorized;
}

function getProviderTokensAvailable(address serviceProvider, address verifier) external view returns (uint256) {
IHorizonStakingTypes.Provision memory provision = provisions[serviceProvider][verifier];
return provision.tokens - provision.tokensThawing;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
pragma solidity 0.8.27;

import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol";
import { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol";

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

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

function test_Collect_Revert_WhenUnauthorizedDataService(FuzzyTestCollect calldata fuzzy) public {
(IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept);
IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams;

collectParams.agreementId = accepted.rca.agreementId;
collectParams.tokens = bound(collectParams.tokens, 1, type(uint256).max);
bytes memory data = _generateCollectData(collectParams);

// Set up the scenario where service provider has no tokens staked with data service
// This simulates an unauthorized data service attack
_horizonStaking.setProvision(
accepted.rca.serviceProvider,
accepted.rca.dataService,
IHorizonStakingTypes.Provision({
tokens: 0, // No tokens staked - this triggers the vulnerability
tokensThawing: 0,
sharesThawing: 0,
maxVerifierCut: 100000,
thawingPeriod: 604800,
createdAt: uint64(block.timestamp),
maxVerifierCutPending: 100000,
thawingPeriodPending: 604800,
lastParametersStagedAt: 0,
thawingNonce: 0
})
);

bytes memory expectedErr = abi.encodeWithSelector(
IRecurringCollector.RecurringCollectorUnauthorizedDataService.selector,
accepted.rca.dataService
);
vm.expectRevert(expectedErr);
vm.prank(accepted.rca.dataService);
_recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data);
}

function test_Collect_Revert_WhenUnknownAgreement(FuzzyTestCollect memory fuzzy, address dataService) public {
bytes memory data = _generateCollectData(fuzzy.collectParams);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { Test } from "forge-std/Test.sol";
import { IGraphPayments } from "../../../../contracts/interfaces/IGraphPayments.sol";
import { IPaymentsCollector } from "../../../../contracts/interfaces/IPaymentsCollector.sol";
import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol";
import { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol";
import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol";

import { Bounder } from "../../../unit/utils/Bounder.t.sol";
import { PartialControllerMock } from "../../mocks/PartialControllerMock.t.sol";
import { HorizonStakingMock } from "../../mocks/HorizonStakingMock.t.sol";
import { PaymentsEscrowMock } from "./PaymentsEscrowMock.t.sol";
import { RecurringCollectorHelper } from "./RecurringCollectorHelper.t.sol";

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

RecurringCollector internal _recurringCollector;
PaymentsEscrowMock internal _paymentsEscrow;
HorizonStakingMock internal _horizonStaking;
RecurringCollectorHelper internal _recurringCollectorHelper;

function setUp() public {
_paymentsEscrow = new PaymentsEscrowMock();
PartialControllerMock.Entry[] memory entries = new PartialControllerMock.Entry[](1);
_horizonStaking = new HorizonStakingMock();
PartialControllerMock.Entry[] memory entries = new PartialControllerMock.Entry[](2);
entries[0] = PartialControllerMock.Entry({ name: "PaymentsEscrow", addr: address(_paymentsEscrow) });
entries[1] = PartialControllerMock.Entry({ name: "Staking", addr: address(_horizonStaking) });
_recurringCollector = new RecurringCollector(
"RecurringCollector",
"1",
Expand Down Expand Up @@ -71,6 +76,9 @@ contract RecurringCollectorSharedTest is Test, Bounder {
}

function _accept(IRecurringCollector.SignedRCA memory _signedRCA) internal {
// Set up valid staking provision by default to allow collections to succeed
_setupValidProvision(_signedRCA.rca.serviceProvider, _signedRCA.rca.dataService);

vm.expectEmit(address(_recurringCollector));
emit IRecurringCollector.AgreementAccepted(
_signedRCA.rca.dataService,
Expand All @@ -88,6 +96,25 @@ contract RecurringCollectorSharedTest is Test, Bounder {
_recurringCollector.accept(_signedRCA);
}

function _setupValidProvision(address _serviceProvider, address _dataService) internal {
_horizonStaking.setProvision(
_serviceProvider,
_dataService,
IHorizonStakingTypes.Provision({
tokens: 1000 ether,
tokensThawing: 0,
sharesThawing: 0,
maxVerifierCut: 100000, // 10%
thawingPeriod: 604800, // 7 days
createdAt: uint64(block.timestamp),
maxVerifierCutPending: 100000,
thawingPeriodPending: 604800,
lastParametersStagedAt: 0,
thawingNonce: 0
})
);
}

function _cancel(
IRecurringCollector.RecurringCollectionAgreement memory _rca,
IRecurringCollector.CancelAgreementBy _by
Expand Down