Skip to content

Conversation

@serichoi65
Copy link
Contributor

@serichoi65 serichoi65 commented Nov 20, 2025

Description

Implements Rewards v2.2 - Operator Set rewards with automatic stake-weighted distribution for both unique allocated stake and total delegated stake. This brings forward-looking reward capabilities (up to 2 years) to operator sets managed via AllocationManager, closing the architectural gap where only AVSDirectory-based AVSs could create forward-looking rewards.

Unique Stake Rewards (Tables 15-17)

Rewards based on allocated unique stake to operator sets:

  • Table 15: Operator rewards - calculates pro-rata distribution based on (shares × magnitude / max_magnitude) × multiplier
  • Table 16: Staker rewards - distributes staker portion from Table 15
  • Table 17: AVS refunds - refunds for operators without unique stake allocations

Total Stake Rewards (Tables 18-20)

Rewards based on total delegated stake (operator set analog to Rewards v1):

  • Table 18: Operator rewards - calculates pro-rata distribution based on shares × multiplier
  • Table 19: Staker rewards - distributes staker portion from Table 18
  • Table 20: AVS refunds - refunds for operators without sufficient total stake

Refund Logic

Tables 17 and 20 calculate separate refund buckets (not subtractions) for three scenarios:

  • Operators not registered to operator set
  • Operators registered but without required stake (unique stake for 15-17, total stake for 18-20)
  • Operators with stake but strategies not registered

These refunds are tracked separately and combined in final staging tables.

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

Integration testing

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules
  • I have checked my code and corrected any misspellings

@serichoi65
Copy link
Contributor Author

refer to this PR for previous comments: #466

@serichoi65 serichoi65 force-pushed the seri.choi/rewards-v2.2 branch 3 times, most recently from fb368d8 to 8b8dbb4 Compare November 22, 2025 21:41
@serichoi65 serichoi65 marked this pull request as ready for review November 22, 2025 21:42
@serichoi65 serichoi65 requested a review from a team as a code owner November 22, 2025 21:42
@serichoi65 serichoi65 force-pushed the seri.choi/rewards-v2.2 branch from 8b8dbb4 to eded96a Compare November 22, 2025 21:42
@serichoi65 serichoi65 changed the title feat: implement Operator Set Rewards with Unique & Total Stake feat: implement Rewards v2.2 - Operator Set Rewards with Unique & Total Stake Nov 22, 2025
Copy link
Contributor

@ypatil12 ypatil12 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass on unique stake reward

AND la.strategy = lmm.strategy
AND lmm.rn = 1
WHERE la.rn = 1
AND la.magnitude > 0 -- Only include active allocations
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious why we're combining max magnitudes and allocations into one table here? Generally practice has been to separate out each data type. Max Magnitudes and allocations will update in separate events

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the gold table, we need both max magnitudes and allocations together, so separating adds more complexity of adding more tables and unnecessarily complex query. We do ensure separating on the event level, but I think some snapshots has lightly combined events before.

Copy link
Contributor

@ypatil12 ypatil12 Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im trying to remember which does but I don't think we've combined any snapshot events before, aside from delegation/undelegation and registration/deregistration and staker shares. Those need to be combined because those events are effectively pairs. Magnitude and allocations aren't though. When we snapshot the above, we take into account time series data on each event and then combine into a snapshot.

What we're doing here is creating snapshots and then joining these tables together. I'm a bit worried by the final join, because the allocations can always change irrespective of the max magnitude changing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought about this some more. One con of a separate table is state bloat. Max magnitudes update infrequently. However, we do need extensive test coverage on magnitudes not being affected by allocations/deallocation

Copy link
Contributor

@ypatil12 ypatil12 Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, maxMagnitude is initialized at 1e18 in the contracts and is only decreasing. In our queries we assume is is 0 if there is no event, which we shouldn't do

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, updating maxMagnitude default to 1e18 when no MagnitudeUpdated event exists, matching contract initialization.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think keeping combined as is is the better approach, so I agree that we need an extensive test coverage to validate its correctness.

AND ap.operator = osor.operator
),
operators_with_allocated_stake AS (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also check that the operator is registered to the operatorSet here. If the operator is not registered, then they should not earn rewards. Note that deregistrations have a 14 day queue (like deallocations).

The OperatorRemovedFromOperatorSet event emits the operator and operatorSet. However, the operator is still slashable for 14 days here. Likely need to create a rewards only registration table to handle this (similar to the shares table)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could update the event to add a slashableUntil parameter if that helps here. Note that for total stake rewards (and the other opSet rewards types) they should immediately stop earning for a deregistration but for slashable stake we need to wait 14 days.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Added a new column slashable_until to track 14 day queue after deregistration occurs
  • migration file backfills all previous registration snapshot rows -> do you think this is an overkill? i don't think this will take long too do

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That should be fine. One thing we will have to document is that we are adding 14 day in times but the contracts add 14 days in blocks (we do this for the withdrawal queue too). Blocks can be missed onchain but time is continuous. It's a fine edge case.

@serichoi65 serichoi65 force-pushed the seri.choi/rewards-v2.2 branch from b9da0a9 to 59f0d93 Compare December 15, 2025 15:45
@serichoi65 serichoi65 changed the base branch from master to seri.choi/new-queue-calculation December 15, 2025 15:45
// Create index for max_magnitude queries for performance
`CREATE INDEX IF NOT EXISTS idx_operator_allocation_snapshots_max_magnitude
ON operator_allocation_snapshots(operator, strategy, snapshot)
WHERE max_magnitude > 0`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Max magnitude can theoretically go down to 0 for an operator. Is it fine if the migration checks on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the partial index is fine as max_magnitude = 0 is always filtered out by the query in gold table 15 (line 60). Operators with zero capacity correctly receive no rewards, whether they're in the index or not.

-- Mark the next_block_time as the end_time for the range
-- Use coalesce because if the next_block_time for a registration is not closed, then we use cutoff_date
COALESCE(next_block_time, '{{.cutoffDate}}')::timestamp AS end_time,
-- Calculate slashable_until based on deregistration
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably have to deal with backwards compatibility here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backwards compatibility is already handled! When adding the slashable_until column, the migration backfills slashable_until by recalculating from historical deregistration events in the operator_set_operator_registrations table

-- Still active (no deregistration event): NULL
NULL
ELSE
-- Re-registered: use next event time
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit confused on the use case for re-registered? Is this when an operator deregisters then registers again? Is there a case when next_is_active is not false or null? How is that triggered?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was initially thinking of deregister then register again, but that case is correctly covered. Removing ELSE clause.
Now:

  • next_is_active = FALSE → Deregistration with 14-day slashability
  • next_is_active IS NULL → Still active (no deregistration)


const operatorAllocationSnapshotsQuery = `
insert into operator_allocation_snapshots(operator, avs, strategy, operator_set_id, magnitude, snapshot)
insert into operator_allocation_snapshots(operator, avs, strategy, operator_set_id, magnitude, max_magnitude, snapshot)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that since this table itself is net-new as part of this feature, no need to add hard fork if cases

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted and updated!

block_time,
LAG(magnitude) OVER (PARTITION BY operator, avs, strategy, operator_set_id ORDER BY block_time, block_number, log_index) as previous_magnitude,
-- Backward compatibility: Apply new rounding logic only after Sabine fork
-- Pre-Sabine: Always round down to current day (old behavior)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, I thought this entire table was post sabine???

AND la.strategy = lmm.strategy
AND lmm.rn = 1
WHERE la.rn = 1
AND la.magnitude > 0 -- Only include active allocations
Copy link
Contributor

@ypatil12 ypatil12 Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im trying to remember which does but I don't think we've combined any snapshot events before, aside from delegation/undelegation and registration/deregistration and staker shares. Those need to be combined because those events are effectively pairs. Magnitude and allocations aren't though. When we snapshot the above, we take into account time series data on each event and then combine into a snapshot.

What we're doing here is creating snapshots and then joining these tables together. I'm a bit worried by the final join, because the allocations can always change irrespective of the max magnitude changing.

@serichoi65 serichoi65 force-pushed the seri.choi/rewards-v2.2 branch from 0f3b753 to 7d776d0 Compare December 17, 2025 10:07
-- Step 4: Calculate cumulative slash multiplier during deregistration queue
-- Slashing only affects rewards during the 14-day deregistration period
operators_with_slash_multiplier AS (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of the slash multiplier here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a slash has occurred, it should already be reflected in the operator's shares. All we need to do is verify that the operator is still slashable (ie. registered OR slashable_until not hit)

osor.avs,
osor.operator_set_id,
DATE(b.block_time) as deregistration_date,
DATE(b.block_time) + INTERVAL '14 days' as slashable_until_date
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slashable_until_date is different on mainnet and other envs

"github.com/Layr-Labs/sidecar/pkg/rewardsUtils"
"go.uber.org/zap"
)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need to add the events to the EigenState so they are indexed. See OperatorDirectedOperatorSetRewardsSubmissionCreated for an example

@serichoi65 serichoi65 force-pushed the seri.choi/rewards-v2.2 branch from 9246213 to 8863a05 Compare January 5, 2026 20:57
@serichoi65 serichoi65 force-pushed the seri.choi/new-queue-calculation branch from 1c4e8ae to 5ba36c3 Compare January 5, 2026 20:58
@serichoi65 serichoi65 force-pushed the seri.choi/rewards-v2.2 branch from 8863a05 to cefb196 Compare January 5, 2026 20:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants