Skip to content

Commit ff2f00a

Browse files
authored
Merge pull request #1265 from graphprotocol/rewards-eligibility-oracle-4-fix
REO: Documentation fixes for audit issues TRST-L-1 and TRST-L-2 Updated audit confirms issues addressed (via documentation) and audit is complete.
2 parents fec6aa7 + 1bdf837 commit ff2f00a

File tree

4 files changed

+211
-14
lines changed

4 files changed

+211
-14
lines changed

packages/horizon/test/unit/escrow/getters.t.sol

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,21 @@ contract GraphEscrowGettersTest is GraphEscrowTest {
3434
uint256 amountDeposit,
3535
uint256 amountThawing,
3636
uint256 amountCollected
37-
) public useGateway useDeposit(amountDeposit) {
38-
vm.assume(amountThawing > 0);
39-
vm.assume(amountDeposit > 0);
40-
vm.assume(amountDeposit >= amountThawing);
41-
vm.assume(amountDeposit >= amountCollected);
42-
vm.assume(amountDeposit - amountCollected < amountThawing);
37+
) public useGateway {
38+
// Limit thawing and collected to half of MAX_STAKING_TOKENS to ensure valid deposit range
39+
amountThawing = bound(amountThawing, 1, MAX_STAKING_TOKENS / 2);
40+
amountCollected = bound(amountCollected, 1, MAX_STAKING_TOKENS / 2);
41+
42+
// amountDeposit must be:
43+
// - >= amountThawing (so we can thaw that amount)
44+
// - >= amountCollected (so we can collect that amount)
45+
// - < amountThawing + amountCollected (so that after collecting, balance < thawing)
46+
// With the above bounds, this range is guaranteed to be valid
47+
uint256 minDeposit = amountThawing > amountCollected ? amountThawing : amountCollected;
48+
uint256 maxDeposit = amountThawing + amountCollected - 1;
49+
amountDeposit = bound(amountDeposit, minDeposit, maxDeposit);
50+
51+
_depositTokens(users.verifier, users.indexer, amountDeposit);
4352

4453
// thaw some funds
4554
_thawEscrow(users.verifier, users.indexer, amountThawing);

packages/issuance/contracts/eligibility/RewardsEligibilityOracle.md

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ The RewardsEligibilityOracle is a smart contract that manages indexer eligibilit
44

55
## Overview
66

7-
The contract operates on a "deny by default" principle - all indexers are initially ineligible for rewards until their eligibility is explicitly renewed by an authorized oracle. Once eligibility is renewed, indexers remain eligible for a configurable period before their eligibility expires and needs to be renewed again.
7+
The contract operates on a "deny by default" principle - indexers are not eligible for rewards until their eligibility is explicitly validated by an authorized oracle. (See edge case below for extremely large eligibility periods.) After eligibility is initially validated or renewed, indexers remain eligible for a configurable period before their eligibility expires and needs to be renewed again. We generally refer to all validation as "renewal" for simplicity.
8+
9+
**Very large eligibility period edge case**: If the eligibility period is set to an extremely large value that exceeds the current block timestamp relative to the genesis block, all indexers (including those who have never been registered) will be considered eligible. This is an edge case; if configured with an eligibility period that includes the genesis block, all indexers are eligible.
810

911
## Key Features
1012

@@ -141,13 +143,124 @@ This design ensures that:
141143
- Operators can disable eligibility validation entirely if needed
142144
- Individual indexer eligibility has time limits
143145

146+
### Edge Case: Large Eligibility Periods
147+
148+
The eligibility check `block.timestamp < indexerEligibilityTimestamps[indexer] + eligibilityPeriod` has specific behavior when the eligibility period is set to an extremely large value:
149+
150+
- For indexers who have never been registered, `indexerEligibilityTimestamps[indexer]` is 0 (zero-initialized storage)
151+
- If `block.timestamp < eligibilityPeriod`, then `block.timestamp < 0 + eligibilityPeriod`
152+
- This means **all indexers are eligible**, including those who have never been explicitly approved
153+
154+
For normal operations with reasonable eligibility periods (e.g., 14 days), indexers who have never been registered will correctly be ineligible since `block.timestamp < 0 + 14 days` will be false for any realistic block timestamp.
155+
144156
In normal operation, the first condition is expected to be the only one that applies. The other two conditions provide fail-safes for oracle failures, or in extreme cases an operator override. For normal operational failure of oracles, the system gracefully degrades into a "allow all" mode. This mechanism is not perfect in that oracles could still be updating but allowing far fewer indexers than they should. However this is regarded as simple mechanism that is good enough to start with and provide a foundation for future improvements and decentralization.
145157

146-
While this simple model allows the criteria for providing good service to evolve over time (which is essential for the long-term health of the network), it captures sufficient information on-chain for indexers to be able to monitor their eligibility. This is important to ensure that even in the absence of other sources of information regarding observed indexer service, indexers have a good transparency about if they are being observed to be providing good service, and for how long their current approval is valid.
158+
While this simple model allows the criteria for providing good service to evolve over time (which is essential for the long-term health of the network), it captures sufficient information on-chain for indexers to be able to monitor their eligibility. This is important to ensure that even in the absence of other sources of information regarding observed indexer service, indexers have good transparency about if they are being observed to be providing good service, and for how long their current approval is valid.
147159

148160
It might initially seem safer to allow indexers by default unless an oracle explicitly denies an indexer. While that might seem safer from the perspective of the RewardsEligibilityOracle in isolation, in the absence of a more sophisticated voting system it would make the system vulnerable to a single bad oracle denying many indexers. The design of deny by default is better suited to allowing redundant oracles to be working in parallel, where only one needs to be successfully detecting indexers that are providing quality service, as well as eventually allowing different oracles to have different approval criteria and/or inputs. Therefore deny by default facilitates a more resilient and open oracle system that is less vulnerable to a single points of failure, and more open to increasing decentralization over time.
149161

150-
In general to be rewarded for providing service on The Graph, there is expected to be proof provided of good operation (such as for proof of indexing). While proof should be required to receive rewards, the system is designed for participants to have confidence is being able to adequately prove good operation (and in the case of oracles, be seen by at least one observer) that is sufficient to allow the indexer to receive rewards. The oracle model is in general far more suited to collecting evidence of good operation, from multiple independent observers, rather than any observer being able to establish that an indexer is not providing good service.
162+
In general to be rewarded for providing service on The Graph, there is expected to be proof provided of good operation (such as for proof of indexing). While proof should be required to receive rewards, the system is designed for participants to have confidence in being able to adequately prove good operation (and in the case of oracles, be seen by at least one observer) that is sufficient to allow the indexer to receive rewards. The oracle model is in general far more suited to collecting evidence of good operation, from multiple independent observers, rather than any observer being able to establish that an indexer is not providing good service.
163+
164+
## Operational Considerations
165+
166+
### Race Conditions with Configuration Changes
167+
168+
Configuration changes can create race conditions with in-flight reward claim transactions, potentially causing indexers to permanently lose rewards.
169+
170+
When an indexer submits a transaction to claim rewards through the RewardsManager:
171+
172+
1. The indexer is eligible at the time of transaction submission
173+
2. The transaction enters the mempool and waits for execution
174+
3. A configuration change occurs (e.g., reducing `eligibilityPeriod` or enabling `eligibilityValidation`)
175+
4. The transaction executes after the indexer is no longer eligible
176+
5. **The indexer is denied rewards** resulting in permanent loss for the indexer
177+
178+
This occurs because the RewardsManager's `takeRewards()` function returns 0 rewards for ineligible indexers, but the calling contract (Staking or SubgraphService) still marks the allocation as processed.
179+
180+
Circumstances potentially leading to this race condition:
181+
182+
1. **Reducing eligibility period** (`setEligibilityPeriod`):
183+
- Shortening the eligibility window may cause recently-approved indexers to become ineligible
184+
- Indexers near the end of their eligibility period become ineligible immediately
185+
186+
2. **Enabling eligibility validation** (`setEligibilityValidation`):
187+
- Switching from disabled (all eligible) to enabled (oracle-based)
188+
- Indexers without recent oracle renewals become ineligible immediately
189+
190+
3. **Oracle update delays**:
191+
- If oracles do not renew an indexer's eligibility before it expires
192+
- Combined with network congestion delaying claim transactions
193+
194+
4. **Network conditions**:
195+
- High gas prices causing indexers to delay transaction submission
196+
- Network congestion delaying transaction execution
197+
- Multiple blocks between submission and execution
198+
199+
#### Mitigation Strategies
200+
201+
Operators and indexers should implement these practices:
202+
203+
**For Operators:**
204+
205+
1. **Announce configuration changes in advance**:
206+
- Publish planned changes to eligibility period or validation state
207+
- Provide sufficient notice (e.g., 24-48 hours) before executing changes
208+
- Use governance forums, Discord, or official communication channels
209+
210+
2. **Implement two-step process for critical changes**:
211+
- First transaction: Announce the pending change with a delay period
212+
- Second transaction: Execute the change after the delay
213+
- This is a governance/operational practice, not enforced by the contract
214+
215+
3. **Avoid sudden reductions in eligibility**:
216+
- When reducing eligibility period, consider gradual reductions
217+
- Monitor pending transactions in the mempool before making changes
218+
- Time changes for periods of low network activity
219+
220+
4. **Coordinate with oracle operations**:
221+
- Ensure oracles are actively renewing indexer eligibility
222+
- Verify oracle health before enabling eligibility validation
223+
- Monitor `lastOracleUpdateTime` to detect oracle failures
224+
225+
**For Indexers:**
226+
227+
1. **Monitor eligibility status closely**:
228+
- Regularly check `isEligible()` and `getEligibilityRenewalTime()`
229+
- Calculate when eligibility will expire (`renewalTime + eligibilityPeriod`)
230+
- Set up alerts for approaching expiration
231+
232+
2. **Claim rewards with sufficient margin**:
233+
- Don't wait until the last moment of eligibility period
234+
- Account for network congestion and gas price volatility
235+
- Consider claiming more frequently rather than in large batches
236+
237+
3. **Watch for configuration change announcements**:
238+
- Monitor governance communications and proposals
239+
- Subscribe to operator announcements
240+
- Plan claim transactions around announced changes
241+
242+
4. **Use appropriate gas pricing**:
243+
- During announced configuration changes, use higher gas prices
244+
- Ensure transactions execute quickly during critical windows
245+
- Monitor transaction status and resubmit if necessary
246+
247+
5. **Understand the risk**:
248+
- Be aware that rewards can be permanently lost due to race conditions
249+
- Factor this risk into reward claiming strategies
250+
251+
#### Monitoring and Detection
252+
253+
Operators should monitor:
254+
255+
- `RewardsDeniedDueToEligibility` events
256+
- Time between configuration changes and claim transactions
257+
258+
Indexers should monitor:
259+
260+
- Their own eligibility status via `isEligible()`
261+
- `EligibilityPeriodUpdated` events
262+
- `EligibilityValidationUpdated` events
263+
- `IndexerEligibilityRenewed` events for their address
151264

152265
## Events
153266

@@ -181,8 +294,8 @@ The system is deployed with reasonable defaults but can be adjusted as required.
181294

182295
### Normal Operation
183296

184-
1. Oracles periodically call `renewIndexerEligibility()` to renew eligibility for indexers
185-
2. Reward systems call `isEligible()` to check indexer eligibility
297+
1. Oracle nodes periodically call `renewIndexerEligibility()` to renew eligibility for indexers
298+
2. RewardsManager calls `isEligible()` to check indexer eligibility
186299
3. Operators adjust parameters as needed via configuration functions
187300
4. The operation of the system is monitored and adjusted as needed
188301

packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,18 @@ import { BaseUpgradeable } from "../common/BaseUpgradeable.sol";
1212
* @title RewardsEligibilityOracle
1313
* @author Edge & Node
1414
* @notice This contract allows authorized oracles to mark indexers as eligible to receive rewards
15-
* with an expiration mechanism. Indexers are denied by default until they are explicitly marked as eligible,
16-
* and their eligibility expires after a configurable eligible period.
17-
* The contract also includes a global eligibility check toggle and an oracle update timeout mechanism.
15+
* with an expiration mechanism. Under normal configuration with reasonable eligibility periods, indexers
16+
* are denied by default until they are explicitly marked as eligible, and their eligibility expires after
17+
* a configurable eligibility period. The contract also includes a global eligibility check toggle and an
18+
* oracle update timeout mechanism.
19+
* @dev Note: If the eligibility period is set to an extremely large value that exceeds the current
20+
* block timestamp, all indexers (including those never registered) will be eligible.
1821
* @custom:security-contact Please email [email protected] if you find any bugs. We might have an active bug bounty program.
22+
* @custom:security-warning Configuration changes (eligibility period, validation toggle) can create race
23+
* conditions with in-flight reward claim transactions. When configuration changes make an indexer ineligible
24+
* between transaction submission and execution, the indexer permanently loses those rewards.
25+
* Operators should announce configuration changes in advance and consider implementing
26+
* a two-step process (announce delay, then execute) for changes that reduce eligibility.
1927
*/
2028
contract RewardsEligibilityOracle is
2129
BaseUpgradeable,
@@ -130,6 +138,10 @@ contract RewardsEligibilityOracle is
130138
* @dev Only callable by accounts with the OPERATOR_ROLE
131139
* @param eligibilityPeriod New eligibility period in seconds
132140
* @return True if the state is as requested (eligibility period is set to the specified value)
141+
* @custom:warning Configuration changes can affect in-flight reward claim transactions. Reducing the
142+
* eligibility period may cause indexers to lose rewards if their claim transactions execute after
143+
* they become ineligible due to the configuration change. Consider announcing configuration changes
144+
* in advance and using a two-step process (announce, then execute) for changes that reduce eligibility.
133145
*/
134146
function setEligibilityPeriod(uint256 eligibilityPeriod) external override onlyRole(OPERATOR_ROLE) returns (bool) {
135147
RewardsEligibilityOracleData storage $ = _getRewardsEligibilityOracleStorage();
@@ -168,6 +180,10 @@ contract RewardsEligibilityOracle is
168180
* @dev Only callable by accounts with the OPERATOR_ROLE
169181
* @param enabled True to enable eligibility validation, false to disable
170182
* @return True if successfully set (always the case for current code)
183+
* @custom:warning Enabling eligibility validation can affect in-flight reward claim transactions.
184+
* Indexers who submitted claim transactions while validation was disabled may lose rewards if
185+
* validation is enabled before their transactions execute and they are not marked as eligible.
186+
* Consider announcing this change in advance to allow indexers to adjust their claiming behavior.
171187
*/
172188
function setEligibilityValidation(bool enabled) external override onlyRole(OPERATOR_ROLE) returns (bool) {
173189
RewardsEligibilityOracleData storage $ = _getRewardsEligibilityOracleStorage();
@@ -216,6 +232,14 @@ contract RewardsEligibilityOracle is
216232

217233
/**
218234
* @inheritdoc IRewardsEligibility
235+
* @dev Returns true if any of the following conditions are met:
236+
* 1. Eligibility validation is disabled globally
237+
* 2. Oracle timeout has been exceeded (fail-safe to allow all indexers)
238+
* 3. block.timestamp < indexerEligibilityTimestamps[indexer] + eligibilityPeriod
239+
*
240+
* Note on condition 3: For indexers who have never been registered, indexerEligibilityTimestamps[indexer]
241+
* is 0. If eligibilityPeriod is set to an extremely large value exceeding block.timestamp, the check
242+
* becomes (block.timestamp < 0 + eligibilityPeriod), which will be true, making all indexers eligible.
219243
*/
220244
function isEligible(address indexer) external view override returns (bool) {
221245
RewardsEligibilityOracleData storage $ = _getRewardsEligibilityOracleStorage();

0 commit comments

Comments
 (0)