Skip to content

Indexing payments management audit#1301

Open
RembrandtK wants to merge 79 commits intomainfrom
indexing-payments-management-audit
Open

Indexing payments management audit#1301
RembrandtK wants to merge 79 commits intomainfrom
indexing-payments-management-audit

Conversation

@RembrandtK
Copy link
Copy Markdown
Contributor

@RembrandtK RembrandtK commented Mar 1, 2026

Filter for files in audit scope:

git diff main --name-status --diff-filter=d -- \
    '*.sol' ':!*/test*' ':!*.t.sol' ':!*Helper.sol' ':!*/mock*' ':!*/toolshed/*'`

bf6d4cb Indexing payments (merge)
Previously audited (2025-06-Indexing-Payments.pdf) implementation of indexing agreements for the Graph Protocol's recurring payment system. Adds RecurringCollector, IndexingAgreement library, and integrates indexing fee collection into SubgraphService and DisputeManager.

28edcd7 Post-Horizon migration removal of transition code (merge)
Remove HorizonStakingExtension, ExponentialRebates, LibFixedMath, MathUtils, and all transition-period guard logic. Simplify HorizonStaking to direct implementation without delegatecall split. Clean up RewardsManager multi-issuer model to single subgraphService. Remove legacy allocation/dispute code from SubgraphService.

f4451f1 feat: add back legacy allocation id collision check
fa99514 fix: cap maxSecondsPerCollection instead of reverting
c6836a7 fix: enforce temporal validation on zero-token collections and remove zero-POI special case
8efaec9 feat: add adjustThaw to PaymentsEscrow
3f1578c refactor: rename IRewardsEligibility to IProviderEligibility
(For the purpose of this audit note that the interfaces being changed are not in production.)

d20bc84 feat: contract approver model for RecurringCollector accept/update
ec72360 feat: IDataServiceAgreements interface and SubgraphService integration
89def3d feat: enumerable indexer tracking for REO and issuance constructor cleanup
(Do not need storage compatibility for delpoying an upgrade.)

843fdd2 feat: RecurringAgreementManager with lifecycle, escrow funding, and agreement updates

matiasedgeandnode and others added 30 commits June 11, 2025 14:08
Fixes TRST-M-1 audit finding:

Wrong TYPEHASH string is used for agreement updates, limiting functionality.

* Fixed EIP712_RCAU_TYPEHASH to use correct uint64 types for deadline and endsAt fields (was incorrectly using uint256)
* This prevents signature verification failures for RecurringCollectionAgreementUpdate
Fixes TRST-M-2 audit finding:

Collection for an elapsed or canceled agreement could be wrong due to
temporal calculation inconsistencies between IndexingAgreement and
RecurringCollector layers.

* Replace isCollectable() with getCollectionInfo() that returns both collectability and duration
* Make RecurringCollector the single source of truth for temporal logic
* Update IndexingAgreement to call getCollectionInfo() once and pass duration to _tokensToCollect()
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
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"
Signed-off-by: Tomás Migone <tomas@edgeandnode.com>
Signed-off-by: Tomás Migone <tomas@edgeandnode.com>
Signed-off-by: Tomás Migone <tomas@edgeandnode.com>
Signed-off-by: Tomás Migone <tomas@edgeandnode.com>
Signed-off-by: Tomás Migone <tomas@edgeandnode.com>
Signed-off-by: Tomás Migone <tomas@edgeandnode.com>
Signed-off-by: Tomás Migone <tomas@edgeandnode.com>
Signed-off-by: Tomás Migone <tomas@edgeandnode.com>
Signed-off-by: Tomás Migone <tomas@edgeandnode.com>
Signed-off-by: Tomás Migone <tomas@edgeandnode.com>
Articulates trust assumptions between the five core actors
(payer, collector, data service, receiver, escrow) in the
Graph Horizon payments protocol, with implementation-specific
details for RecurringCollector, SubgraphService, and RAM.
Add packages/testing with real PaymentsEscrow, RecurringCollector,
IssuanceAllocator, and RecurringAgreementManager deployed together.
Gas results confirm 21-27x headroom on callback budgets.

Improve test coverage across all packages:
- horizon: RecurringCollector 86%→100% lines, 76%→100% functions
- subgraph-service: 92.86%→94.76% branches
- issuance: restore 100% coverage, remove dead getPageBytes16

Fix coverage scripts to print summary tables to stdout.
Trust Security audit (2026-03-03 to 2026-03-19) findings extracted from
Graph_PR1301_v01.pdf into PR1301/ directory with README index.
Includes auditor-supplied staleSnap PoC test.
Replace tempJit/fullReserveMargin with two configurable parameters:
minOnDemandBasisThreshold (default 128) and minFullBasisMargin
(default 16). Effective basis limited based on spare balance relative to
sumMaxNextClaimAll using strict <.

Delete staleSnap.t.sol (PoC scenario no longer applicable after
threshold-based degradation replaces tempJit).
…ST-M-1)

Added configurable `minThawFraction` (uint8, proportion of 256, default
16 = 6.25%) that skips thaws when the excess above max is below
`sumMaxNextClaim * fraction / 256` for the (collector, provider) pair.
Add revertOnIneligible flag to RewardsManager. When true, ineligible
indexers cause takeRewards to revert (blocking POI presentation and
preserving rewards for future collection). When false (default),
ineligible indexers have rewards reclaimed but takeRewards succeeds.
Over-allocated and stale allocations are now resized to zero tokens
instead of being force-closed. This keeps the allocation open so that
any bound indexing agreement remains active. The allocation stays as
a stakeless (altruistic) allocation rather than being deleted.

Changes:
- AllocationHandler.presentPOI: call _resizeAllocation(0) instead of
  _closeAllocation when over-allocated
- SubgraphService.closeStaleAllocation: resize to zero instead of
  _onCloseAllocation + _closeAllocation
- SubgraphService._collectIndexingRewards: remove _onCloseAllocation
  call after over-allocation (allocation stays open)
- Extract _resizeAllocation internal helper from resizeAllocation
Add a governor-controlled guard that prevents closing an allocation
when it has an active indexing agreement. When enabled, stopService
reverts with SubgraphServiceAllocationHasActiveAgreement instead of
auto-canceling the agreement.

- Add SubgraphServiceV2Storage with blockClosingAllocationWithActiveAgreement
- Add setter/getter and BlockClosingAllocationWithActiveAgreementSet event
- IndexingAgreement.onCloseAllocation: revert if active when guard enabled,
  otherwise cancel as ServiceProvider (forceClosed param removed since
  closeStaleAllocation now resizes instead of closing)
…terms

Add checked-arithmetic overflow guard on accept and update paths to
prevent agreements whose maxOngoingTokensPerSecond × maxSecondsPerCollection
product would overflow during collection, with 1024× headroom margin.
…ancel (TRST-L-2, L-5)

Payers pre-store RCA/RCAU offers on-chain; acceptance verifies the
stored hash instead of calling back to the payer contract. This
eliminates the callback-based approval flow and its associated
trust assumptions.

Escrow reservation uses max(active, pending) instead of additive
current + pending, so only the larger of the two term sets is
reserved (L-2). The claim formula caps the collection window at
min(remaining time, maxSecondsPerCollection), eliminating overestimates
for agreements near their deadline (L-5).

Scoped getMaxNextClaim lets callers query active terms, pending
offers, or both. Payer-initiated cancel targets specific terms by
hash and scope (ACTIVE/PENDING), with a guard preventing deletion
of offers whose terms are already active.
…TRST-H-1, H-2, H-4, L-1, SR-4)

Payer callbacks (beforeCollection, afterCollection, isEligible) now use
gas-capped low-level call/staticcall instead of try/catch, preventing
gas siphoning via the 63/64 rule (H-1) and caller-side ABI decode
reverts from malformed returndata (H-2). A gasleft() guard before each
callback reverts when insufficient gas remains (L-1). Failed callbacks
emit PayerCallbackFailed for off-chain monitoring (SR-4).

Eligibility checking is now opt-in via a conditions bitmask on RCA/RCAU.
CONDITION_ELIGIBILITY_CHECK gates the IProviderEligibility staticcall at
collection time. At acceptance, _requireEligibilityCapability validates
ERC-165 support — an EOA without code cannot set this flag, closing the
EIP-7702 attack vector where an EOA later acquires code to block
collection (H-4).
Replace getCollectors, getCollectorProviders, getProviderAgreements
(and their paginated overloads), getCollectorProviderCount,
getProviderAgreementCount, getPairAgreementCount, and
getTotalAgreementCount with indexed accessors: getCollectorAt,
getProviderCount/At, getAgreementCount/At, and getEscrowSnap.

Remove EnumerableSetUtil import and using statements. The getPage and
getPageBytes16 helpers inline substantial library code at each call
site — six call sites pushed RAM over the 24576-byte Spurious Dragon
deployable bytecode limit.

Also switches view param types from IRecurringCollector to
IAgreementCollector and renames getSumMaxNextClaimAll to
getSumMaxNextClaim to match the updated IRecurringAgreements interface.

Does not compile: new accessors reference future hierarchical storage
($.collectors[c].providers[p], $.collectorSet) introduced in a later
commit.
…atch

Add IEmergencyRoleControl implementation allowing pause guardians to
revoke any non-governor role as a fast-response emergency measure.
Governor role is excluded to prevent locking out governance.

Also adds emergencyClearEligibilityOracle for fail-open when the oracle
is broken, extracts _setProviderEligibilityOracle private helper to
share between the governor setter and emergency clear, and removes the
afterAgreementStateChange stub (obsolete for thin pass-through).
…ntCollector pass-throughs

offerAgreement now calls collector.offer() with opaque offer data instead
of constructing RCA hashes and storage directly. cancelAgreement calls
collector.cancel() with a version hash and options bitmask instead of
routing through the data service.

Both forward to the collector then reconcile locally — the collector owns
agreement state, RAM just caches maxNextClaim for escrow accounting.

Does not compile: new function bodies reference _reconcileAgreement with
a collector parameter that is introduced in a later commit.
…d revokeOffer

These functions are subsumed by the IAgreementCollector offer/cancel
interface: updates are offered via offerAgreement with an update offer
type, and revocations use cancelAgreement targeting the relevant terms
hash. The collector now owns offer/update lifecycle state.

Also add forceRemoveAgreement.
Replace flat storage mappings with hierarchical CollectorData and
CollectorProviderData structs. Agreements are now namespaced under their
collector ($.collectors[c].agreements[id]) instead of a global mapping,
preventing cross-collector ID collisions.

Add _getAgreementProvider for lazy discovery: on first encounter, validates
collector role, reads agreement details, checks payer/dataService, and
registers in tracking sets. Subsequent accesses use the cached provider.

Rewrite internal functions to use the hierarchical storage:
- _reconcileAgreement: discover-then-reconcile flow
- _removeAgreement: replaces _deleteAgreement
- _reconcileProvider: replaces _reconcilePairTracking
- _setAgreementMaxNextClaim: takes CollectorProviderData, reads old value
- _escrowMinMax: takes sumMaxNextClaim value parameter
- _providerEscrowDeficit: takes CollectorProviderData
- _reconcileProviderEscrow: replaces _updateEscrow, uses cpd
- _setEscrowSnap: takes CollectorProviderData

Add forceRemoveAgreement operator escape hatch. Update beforeCollection,
afterCollection, reconcileAgreement, and reconcileProvider to use the
new collector-parameterised internal functions.

Reduces AgreementInfo to {provider, maxNextClaim}.
Convert RecurringCollector to TransparentUpgradeableProxy pattern with
ERC-7201 namespaced storage. Authorizable storage also converted to
ERC-7201 for proxy compatibility.
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).
…celIndexingAgreement

cancelIndexingAgreement (introduced in a7fb875) required a valid
provision check (onlyValidProvision, later refactored to enforceService
with VALID_PROVISION | REGISTERED). Cancel should remain available
regardless of provision state. Combined with
blockClosingAllocationWithActiveAgreement (b124656), an indexer whose
provision drops below minimum is stuck in a catch-22: VALID_PROVISION
blocks cancel, and the active agreement blocks closing the allocation.

REGISTERED is not relevant to cancelling on accepted agreement either.
Changed to enforceService(indexer, DEFAULT). IndexingAgreement.cancel()
validates the caller and pause check remains.

Tests added (not all related to this issue):
- Cross-package integration lifecycle
- Cancel with below-minimum provision
- Horizon coverage gaps
@RembrandtK
Copy link
Copy Markdown
Contributor Author

Audited and merged: #1312
Audit fix response: #1325

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants