[ODAO] Proposal for Rewards Tree Spec v2

Hi everyone,

I would like to kick off the process submitted in the Watchtower Update Proposal Thread to propose the first upgrade, so-named Rewards Tree Spec v2. As you can see, a few of us have already spent some time discussing it and even implementing it in both the official Watchtower and the Rocketscan backend (thanks to @Peteris) but I wanted to ensure that everyone had time to comment on it and run through the process first.

Changes from v1

There are 2 notable breaking changes from the original (v1) version of the rewards tree spec:

  1. Validators that are staking according to Rocket Pool on the Execution Layer but do not currently have an index on the Beacon Chain are no longer included in the tree. This was something that wasn’t made explicitly clear in the spec, and thus my watchtower code incorrectly included minipools without indices while Peteris’s code did not. There is a very narrow window (4 hours) that can occur once in a validator’s life where both deposits have been submitted on the Execution Layer, but the Beacon Chain hasn’t picked up on either one yet and this hasn’t assigned the validator an index. At each interval, I store a mapping of indices (integers) to validator pubkeys. These validators always showed up with index 0 which is the default when the Beacon Chain does not have any data about them. This always overwrites anything with an existing index of 0. Thus, once during interval 2 and once during interval 3 there was one extra validator that copied validator 0’s performance when it should not have been there at all.

  2. Minipool rewards are now weighted by how long that minipool has been alive, in addition to how long the Node has been opted into the Smoothing Pool. This is an actual change to the way rewards are calculated, not just a clarification and a bug fix. Currently, minipools can be created merely a day or two before the rewards snapshot; if their owning Node has been opted in the whole time, the minipool will receive full credit as though it had been contributing the whole time when clearly it had not. In this change, we weigh the minipool’s rewards by the following (using pseudocode):

    intervalSlots := intervalEndSlot - intervalStartSlot
    modifier := min(intervalSlots, (validatorExitSlot - validatoAactivationSlot)) / intervalSlots
    

In other words, it is prorated based on how much time it has been active in the current interval. Being active for the whole duration effectively eliminates this weight.

In my mind, v1 should have shipped with these updates baked directly in. Since these changes are small and do not detract unfairly from anyone’s rewards, I do not believe them to be controversial. That being said, I would like the community and Oracle DAO to discuss them.

Implementation

As I mentioned, Peteris and I already built, tested, and compared implemntations of this code. You can view it in the comparison between v1 and v2 here:

https://github.com/rocket-pool/smartnode/tree/master/shared/services/rewards

Activation Timing

I would like to ship these as part of Smartnode v1.7.1, which is scheduled for mid-to-late next week depending on other client releases. The Oracle DAO change would not activate until Interval 4’s checkpoint on 2022/12/22 05:35:39 UTC. This would give them ample time to inspect and approve the changes before upgrading to v1.7.1 or beyond.

Thanks all, looking forward to your feedback on these changes!

7 Likes

This increases fairness. Gets my :seal:of approval.

Support - small spec change to better follow the spirit of determining the eligible period. :+1:

Ty for the writeup :pray:

I concur - Excellent write-up and has my full endorsement.

Consider the following scenario: Node A has one minipool and opts into the smoothing pool at the start of the interval and stays in for the entire duration. Node B activates one minipool and opts into the smoothing pool after 14 days and stays in until the end as well. It appears that with the proposed tree gen v2 (both the spec and the actual implementation on github), B would get 25% of the rewards of A: rewards for B get dinged for the later opt in time as well as for the later activation. But since the minipool was active the entire 14 days B was opted in, they contributed half as much as A did, not 25%.

Could we do weighting based on node’s attestations while opted in compared to all node’s attestations while opted in instead?

Taking a whack at some pseudo code to do this fairly:

NO_attestations = 0
reth_attestations = 0
minipool_attestations = {}

for each minipool:
  for each attestation:
    // Only include attestations that could have increased SP balance via proposal
    if attestation.missed() or not minipool.inSmoothingPoolForSlot(attestation) or not minipool.active():
      continue
    // Increase the total pseudo-attestation count owed to NOs
    NO_attestations += (0.5 + minipool.commission / 2)
    // Increase the total pseudo-attestation count owed to rETH
    reth_attestations += (0.5 - minipool.commission / 2)
    // Increase this minipool's pseudo-attestation performance
    minipool_attestations[minipool] += (0.5 + minipool.commission / 2)

node_rewards = {}
// rETH gets a bit less than half the balance
totalRETHSmoothingPoolBalance = totalSmoothingPoolBalance * (reth_attestations / (reth_attestations + NO_attestations))
// NOs get the rest
totalNOSmoothingPoolBalance = totalSmoothingPoolBalance - totalRETHSmoothingPoolBalance
for each minipool:
  // Each minipool gets its weighted attestation amount divided by the total NO weighted attestations, aggregated per node
  node_rewards[minipool.node] += totalNOSmoothingPoolBalance * minipool_attestations[minipool] / NO_attestations

edit: this post was a collaboration with knoshua who is better at math than i
edit 2: nb this solution fully accounts for commission, and if values are adjusted elsewhere, they need to be removed in favor of this approach.

3 Likes

(For those of your not in the Discord, all of the conversation around this since the posts above happened in Discord because it’s easier to have real-time conversation and do development there).

Rewards Spec v2 has been finalized using the notes above, and can be seen in the research repo. It was included with Smartnode v1.7.1 which was just released and the rules will apply to Interval 4 on December 22nd.

1 Like

I’ve decided to update this pseudocode for Atlas-

NO_attestations = 0
reth_attestations = 0
minipool_attestations = {}

for each minipool:
  for each attestation:
    // Only include attestations that could have increased SP balance via proposal
    if attestation.missed() or not minipool.inSmoothingPoolForSlot(attestation) or not minipool.active():
      continue
    let bond = minipool.bond/32
    let minipool_score = bond + (minipool.commission)(1-minipool.bond)

    // Increase the total pseudo-attestation count owed to NOs
    NO_attestations += minipool_score
    // Increase the total pseudo-attestation count owed to rETH
    reth_attestations += 1-minipool_score
    // Increase this minipool's pseudo-attestation performance
    minipool_attestations[minipool] += minipool_score

let node_rewards = {}
// rETH gets its share
let totalRETHSmoothingPoolBalance = totalSmoothingPoolBalance * (reth_attestations / (reth_attestations + NO_attestations))
// NOs get the rest
let totalNOSmoothingPoolBalance = totalSmoothingPoolBalance - totalRETHSmoothingPoolBalance
for each minipool:
  // Each minipool gets its weighted attestation amount divided by the total NO weighted attestations, aggregated per node
  node_rewards[minipool.node] += totalNOSmoothingPoolBalance * minipool_attestations[minipool] / NO_attestations

Critically, this is fair because
minipool_score + (1-minipool_score) evaluates to 1 (the atomic attestation), and minipool.bond/32 + (minipool.commission)(1-minipool.bond) evaluates to the percentage of the rewards for that attestation that are owed to the node operator… e.g., a LEB8 with 14% commission:

0.25 + (0.14)(0.75)

Or a 16e minipool with 15% commission:
0.5 + (0.15)(0.5)

2 Likes

I made some small adjustments for the sake of clarity:

NO_attestations = 0
reth_attestations = 0
minipool_attestations = {}
successful_attestations = 0

for each minipool:
  for each attestation:
    // Only include attestations that could have increased SP balance via proposal
    if attestation.missed() or not minipool.inSmoothingPoolForSlot(attestation) or not minipool.active():
      continue
    let bond = minipool.bond/32
    let minipool_score = bond + (minipool.commission)(1-bond)

    // Increase the successful attestation count
    successful_attestations++

    // Increase the total pseudo-attestation count owed to NOs
    NO_attestations += minipool_score
    // Increase the total pseudo-attestation count owed to rETH
    reth_attestations += 1-minipool_score
    // Increase this minipool's pseudo-attestation performance
    minipool_attestations[minipool] += minipool_score

let node_rewards = {}
// Calculate approximate NO total earned
let totalNOSmoothingPoolBalance = totalSmoothingPoolBalance * (NO_attestations / (successful_attestations))

true_no_total = 0
for each minipool:
  // Each minipool gets its weighted attestation amount divided by the total NO weighted attestations, aggregated per node
  mp_total = totalNOSmoothingPoolBalance * minipool_attestations[minipool] / NO_attestations
  node_rewards[minipool.node] += mp_total
  true_no_total += mp_total

// rETH gets its share
let totalRETHSmoothingPoolBalance = totalSmoothingPoolBalance - true_no_total
  1. I changed let minipool_score = bond + (minipool.commission)(1-minipool.bond) to let minipool_score = bond + (minipool.commission)(1-bond) because I assume you want to use the fractional bond at the end, not the actual bond value in ETH, based on how it’s used
  2. I calculate the total NO share explicitly instead of rETH explicitly by summing up the share each minipool / NO gets; the rETH share is just whatever’s left over, as it kind of needs to be to catch the leftovers from integer division
  3. reth_attestations + NO_attestations will always equal 1 times the number of successful attestations since, as you put it, minipool_score + 1-minipool_score = 1 by definition so I just replaced that with a successful_attestations count.
2 Likes