Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9ae7643
test: add cross-package testing harness with callback gas measurements
RembrandtK Mar 20, 2026
efc5116
docs(audit): add PR1301 audit report and findings
RembrandtK Mar 20, 2026
956d983
feat(RAM): threshold-based escrow basis degradation (TRST-M-2, TRST-M-3)
RembrandtK Mar 28, 2026
e1d73c1
fix(RAM): refresh escrow snapshot in _updateEscrow (TRST-H-3)
RembrandtK Mar 28, 2026
e1a3c5a
fix(RAM): add minimum thaw fraction to prevent dust-thaw griefing (TR…
RembrandtK Mar 28, 2026
56322cc
feat(RM): add revert control for ineligible indexers
RembrandtK Mar 27, 2026
3b617b4
docs(audit): acknowledge audit findings (TRST-CR-1/3, L-4, R-1, SR-1/…
RembrandtK Mar 28, 2026
df93851
feat: resize allocations to zero instead of force-closing
RembrandtK Mar 30, 2026
b124656
feat: revert closing allocations with active indexing agreement
RembrandtK Mar 30, 2026
40c9104
fix(collector): reject agreements with overflow-prone token/duration …
RembrandtK Apr 1, 2026
83e2515
feat(collector): offer storage, stored-hash auth, scoped claims and c…
RembrandtK Mar 31, 2026
38b090c
fix(collector): harden payer callbacks, add opt-in eligibility gate (…
RembrandtK Mar 31, 2026
5b41005
fix: compiler stack overflow
RembrandtK Apr 1, 2026
608346e
refactor(RAM): replace set-based range views with indexed accessors
RembrandtK Apr 1, 2026
0b22a14
feat(RAM): add emergency role control and eligibility oracle escape h…
RembrandtK Apr 1, 2026
77fc87f
refactor(RAM): convert offerAgreement and cancelAgreement to IAgreeme…
RembrandtK Apr 1, 2026
64bc0f0
refactor(RAM): remove offerAgreementUpdate, revokeAgreementUpdate, an…
RembrandtK Apr 1, 2026
daf0b47
refactor(RAM): restructure storage into collector → provider hierarchy
RembrandtK Apr 1, 2026
9ec2c07
feat(collector): make RecurringCollector upgradeable
RembrandtK Apr 1, 2026
bbe0195
feat(collector): add pause mechanism to RecurringCollector (TRST-L-3)
RembrandtK Apr 1, 2026
0bbb476
fix(subgraph-service): remove VALID_PROVISION and REGISTERED from can…
RembrandtK Apr 2, 2026
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: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ packages/*/.eslintcache
dist/
dist-v5/
build/
packages/contracts/**/types/
deployments/hardhat/
*.js.map
*.d.ts.map
Expand Down Expand Up @@ -58,7 +59,9 @@ bin/
.env
.DS_Store
.vscode
core
# Forge core dumps
**/core
!**/core/

# Coverage and other reports
coverage/
Expand Down
2 changes: 1 addition & 1 deletion docs/PaymentsTrustModel.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ RecurringCollector adds payer callbacks when the payer is a contract:
<───┘
```

- **`isEligible`**: hard `require`contract payer can block collection for ineligible receivers. Only called when `0 < tokensToCollect`.
- **`isEligible`**: fail-open gateonly an explicit return of `0` blocks collection; call failures (reverts, malformed data) are ignored to prevent a buggy payer from griefing the receiver. Only called when `0 < tokensToCollect`.
- **`beforeCollection`**: try-catch — allows payer to top up escrow (RAM uses this for JIT deposits), but cannot block (though a malicious contract payer could consume excessive gas). Only called when `0 < tokensToCollect`.
- **`afterCollection`**: try-catch — allows payer to reconcile state post-collection, cannot block (same gas exhaustion caveat). Called even when `tokensToCollect == 0` (zero-token collections still trigger reconciliation).

Expand Down
42 changes: 42 additions & 0 deletions packages/contracts-test/tests/unit/rewards/rewards-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,5 +274,47 @@ describe('Rewards - Configuration', () => {
expect(await rewardsManager.minimumSubgraphSignal()).eq(newMinimumSignal)
})
})

describe('revertOnIneligible', function () {
it('should reject setRevertOnIneligible if unauthorized', async function () {
const tx = rewardsManager.connect(indexer1).setRevertOnIneligible(true)
await expect(tx).revertedWith('Only Controller governor')
})

it('should set revertOnIneligible to true', async function () {
const tx = rewardsManager.connect(governor).setRevertOnIneligible(true)
await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('revertOnIneligible')
expect(await rewardsManager.getRevertOnIneligible()).eq(true)
})

it('should set revertOnIneligible to false', async function () {
// First set to true
await rewardsManager.connect(governor).setRevertOnIneligible(true)

// Then set back to false
const tx = rewardsManager.connect(governor).setRevertOnIneligible(false)
await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('revertOnIneligible')
expect(await rewardsManager.getRevertOnIneligible()).eq(false)
})

it('should be a no-op when setting same value (false to false)', async function () {
// Default is false
expect(await rewardsManager.getRevertOnIneligible()).eq(false)

const tx = rewardsManager.connect(governor).setRevertOnIneligible(false)
await expect(tx).to.not.emit(rewardsManager, 'ParameterUpdated')

expect(await rewardsManager.getRevertOnIneligible()).eq(false)
})

it('should be a no-op when setting same value (true to true)', async function () {
await rewardsManager.connect(governor).setRevertOnIneligible(true)

const tx = rewardsManager.connect(governor).setRevertOnIneligible(true)
await expect(tx).to.not.emit(rewardsManager, 'ParameterUpdated')

expect(await rewardsManager.getRevertOnIneligible()).eq(true)
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,97 @@ describe('Rewards - Eligibility Oracle', () => {
expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount')
})

it('should revert for ineligible indexer when revertOnIneligible is true', async function () {
// Setup REO that denies indexer1
const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory(
'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle',
)
const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny
await mockOracle.deployed()
await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address)

// Enable revert on ineligible
await rewardsManager.connect(governor).setRevertOnIneligible(true)

// Align with the epoch boundary
await helpers.mineEpoch(epochManager)

// Setup allocation
await setupIndexerAllocation()

// Jump to next epoch
await helpers.mineEpoch(epochManager)

// Close allocation - should revert because indexer is ineligible
const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes())
await expect(tx).revertedWith('Indexer not eligible for rewards')
})

it('should not revert for eligible indexer when revertOnIneligible is true', async function () {
// Setup REO that allows indexer1
const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory(
'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle',
)
const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) // Allow
await mockOracle.deployed()
await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address)

// Enable revert on ineligible
await rewardsManager.connect(governor).setRevertOnIneligible(true)

// Align with the epoch boundary
await helpers.mineEpoch(epochManager)

// Setup allocation
await setupIndexerAllocation()

// Jump to next epoch
await helpers.mineEpoch(epochManager)

// Close allocation - should succeed (indexer is eligible)
const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes())
await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned')
})

it('should reclaim (not revert) for ineligible indexer when revertOnIneligible is false', async function () {
// Setup REO that denies indexer1
const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory(
'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle',
)
const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny
await mockOracle.deployed()
await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address)

// Ensure revertOnIneligible is false (default)
expect(await rewardsManager.getRevertOnIneligible()).eq(false)

// Align with the epoch boundary
await helpers.mineEpoch(epochManager)

// Setup allocation
await setupIndexerAllocation()

// Jump to next epoch
await helpers.mineEpoch(epochManager)

// Close allocation - should succeed but deny rewards
const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes())
const receipt = await tx.wait()

// Should emit RewardsDeniedDueToEligibility (not revert)
const rewardsDeniedEvents = receipt.logs
.map((log) => {
try {
return rewardsManager.interface.parseLog(log)
} catch {
return null
}
})
.filter((event) => event?.name === 'RewardsDeniedDueToEligibility')

expect(rewardsDeniedEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found')
})

it('should verify event structure differences between denial mechanisms', async function () {
// Test 1: Denylist denial - event WITHOUT amount
// Create allocation FIRST, then deny (so there are pre-denial rewards to deny)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('RewardsManager interfaces', () => {
})

it('IRewardsManager should have stable interface ID', () => {
expect(IRewardsManager__factory.interfaceId).to.equal('0x7e0447a1')
expect(IRewardsManager__factory.interfaceId).to.equal('0x337b092e')
})
})

Expand Down
18 changes: 18 additions & 0 deletions packages/contracts/contracts/rewards/RewardsManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,14 @@ contract RewardsManager is
}
}

/// @inheritdoc IRewardsManager
function setRevertOnIneligible(bool _revertOnIneligible) external override onlyGovernor {
if (revertOnIneligible != _revertOnIneligible) {
revertOnIneligible = _revertOnIneligible;
emit ParameterUpdated("revertOnIneligible");
}
}

// -- Denylist --

/**
Expand Down Expand Up @@ -344,6 +352,11 @@ contract RewardsManager is
return rewardsEligibilityOracle;
}

/// @inheritdoc IRewardsManager
function getRevertOnIneligible() external view override returns (bool) {
return revertOnIneligible;
}

/// @inheritdoc IRewardsManager
function getNewRewardsPerSignal() public view override returns (uint256 claimablePerSignal) {
(claimablePerSignal, ) = _getNewRewardsPerSignal();
Expand Down Expand Up @@ -772,6 +785,11 @@ contract RewardsManager is
bool isDeniedSubgraph = isDenied(subgraphDeploymentID);
bool isIneligible = address(rewardsEligibilityOracle) != address(0) &&
!rewardsEligibilityOracle.isEligible(indexer);

// When configured to revert, block collection so rewards remain claimable if
// the indexer becomes eligible and collects before the allocation goes stale.
require(!isIneligible || !revertOnIneligible, "Indexer not eligible for rewards");

if (!isDeniedSubgraph && !isIneligible) return false;

if (isDeniedSubgraph) emit RewardsDenied(indexer, allocationID);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,9 @@ abstract contract RewardsManagerV6Storage is RewardsManagerV5Storage {
/// @dev Default fallback address for reclaiming rewards when no reason-specific address is configured.
/// Zero address means rewards are dropped (not minted) if no specific reclaim address matches.
address internal defaultReclaimAddress;

/// @dev When true, ineligible indexers cause takeRewards to revert (blocking POI presentation
/// and allowing allocations to go stale). When false (default), ineligible indexers have
/// rewards reclaimed but takeRewards succeeds (returning 0).
bool internal revertOnIneligible;
}
Loading
Loading