diff --git a/packages/api/src/beacon/routes/beacon/index.ts b/packages/api/src/beacon/routes/beacon/index.ts index af1fcdaecfe0..3f3d01fe17eb 100644 --- a/packages/api/src/beacon/routes/beacon/index.ts +++ b/packages/api/src/beacon/routes/beacon/index.ts @@ -20,7 +20,13 @@ export * as rewards from "./rewards.js"; export {BroadcastValidation} from "./block.js"; export type {BlockId, BlockHeaderResponse} from "./block.js"; export type {AttestationFilters} from "./pool.js"; -export type {BlockRewards, SyncCommitteeRewards} from "./rewards.js"; +export type { + BlockRewards, + AttestationsRewards, + IdealAttestationsReward, + TotalAttestationsReward, + SyncCommitteeRewards, +} from "./rewards.js"; // TODO: Review if re-exporting all these types is necessary export type { StateId, diff --git a/packages/api/src/beacon/routes/beacon/rewards.ts b/packages/api/src/beacon/routes/beacon/rewards.ts index 926cb3033f06..e317587df3bd 100644 --- a/packages/api/src/beacon/routes/beacon/rewards.ts +++ b/packages/api/src/beacon/routes/beacon/rewards.ts @@ -1,5 +1,5 @@ import {ContainerType} from "@chainsafe/ssz"; -import {ssz, ValidatorIndex} from "@lodestar/types"; +import {Epoch, ssz, ValidatorIndex} from "@lodestar/types"; import { RoutesData, @@ -40,6 +40,38 @@ export type BlockRewards = { attesterSlashings: number; }; +/** + * Rewards for a single set of (ideal or actual depending on usage) attestations. Reward value is in Gwei + */ +type AttestationsReward = { + /** Reward for head vote. Could be negative to indicate penalty */ + head: number; + /** Reward for target vote. Could be negative to indicate penalty */ + target: number; + /** Reward for source vote. Could be negative to indicate penalty */ + source: number; + /** Inclusion delay reward (phase0 only) */ + inclusionDelay: number; + /** Inactivity penalty. Should be a negative number to indicate penalty */ + inactivity: number; +}; + +/** + * Rewards info for ideal attestations ie. Maximum rewards could be earned by making timely head, target and source vote. + * `effectiveBalance` is in Gwei + */ +export type IdealAttestationsReward = AttestationsReward & {effectiveBalance: number}; + +/** + * Rewards info for actual attestations + */ +export type TotalAttestationsReward = AttestationsReward & {validatorIndex: ValidatorIndex}; + +export type AttestationsRewards = { + idealRewards: IdealAttestationsReward[]; + totalRewards: TotalAttestationsReward[]; +}; + /** * Rewards info for sync committee participation. Every reward value is in Gwei. * Note: In the case that block proposer is present in `SyncCommitteeRewards`, the reward value only reflects rewards for @@ -64,6 +96,22 @@ export type Api = { HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND > >; + /** + * Get attestations rewards + * Negative values indicate penalties. `inactivity` can only be either 0 or negative number since it is penalty only + * + * @param epoch The epoch to get rewards info from + * @param validatorIds List of validator indices or pubkeys to filter in + */ + getAttestationsRewards( + epoch: Epoch, + validatorIds?: ValidatorId[] + ): Promise< + ApiClientResponse< + {[HttpStatusCode.OK]: {data: AttestationsRewards; executionOptimistic: ExecutionOptimistic}}, + HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND + > + >; /** * Get sync committee rewards @@ -89,12 +137,14 @@ export type Api = { */ export const routesData: RoutesData = { getBlockRewards: {url: "/eth/v1/beacon/rewards/blocks/{block_id}", method: "GET"}, + getAttestationsRewards: {url: "/eth/v1/beacon/rewards/attestations/{epoch}", method: "POST"}, getSyncCommitteeRewards: {url: "/eth/v1/beacon/rewards/sync_committee/{block_id}", method: "POST"}, }; export type ReqTypes = { /* eslint-disable @typescript-eslint/naming-convention */ getBlockRewards: {params: {block_id: string}}; + getAttestationsRewards: {params: {epoch: number}; body: ValidatorId[]}; getSyncCommitteeRewards: {params: {block_id: string}; body: ValidatorId[]}; }; @@ -105,6 +155,14 @@ export function getReqSerializers(): ReqSerializers { parseReq: ({params}) => [params.block_id], schema: {params: {block_id: Schema.StringRequired}}, }, + getAttestationsRewards: { + writeReq: (epoch, validatorIds) => ({params: {epoch: epoch}, body: validatorIds || []}), + parseReq: ({params, body}) => [params.epoch, body], + schema: { + params: {epoch: Schema.UintRequired}, + body: Schema.UintOrStringArray, + }, + }, getSyncCommitteeRewards: { writeReq: (block_id, validatorIds) => ({params: {block_id: String(block_id)}, body: validatorIds || []}), parseReq: ({params, body}) => [params.block_id, body], @@ -129,6 +187,38 @@ export function getReturnTypes(): ReturnTypes { {jsonCase: "eth2"} ); + const IdealAttestationsRewardsResponse = new ContainerType( + { + head: ssz.UintNum64, + target: ssz.UintNum64, + source: ssz.UintNum64, + inclusionDelay: ssz.UintNum64, + inactivity: ssz.UintNum64, + effectiveBalance: ssz.UintNum64, + }, + {jsonCase: "eth2"} + ); + + const TotalAttestationsRewardsResponse = new ContainerType( + { + head: ssz.UintNum64, + target: ssz.UintNum64, + source: ssz.UintNum64, + inclusionDelay: ssz.UintNum64, + inactivity: ssz.UintNum64, + validatorIndex: ssz.ValidatorIndex, + }, + {jsonCase: "eth2"} + ); + + const AttestationsRewardsResponse = new ContainerType( + { + idealRewards: ArrayOf(IdealAttestationsRewardsResponse), + totalRewards: ArrayOf(TotalAttestationsRewardsResponse), + }, + {jsonCase: "eth2"} + ); + const SyncCommitteeRewardsResponse = new ContainerType( { validatorIndex: ssz.ValidatorIndex, @@ -139,6 +229,7 @@ export function getReturnTypes(): ReturnTypes { return { getBlockRewards: ContainerDataExecutionOptimistic(BlockRewardsResponse), + getAttestationsRewards: ContainerDataExecutionOptimistic(AttestationsRewardsResponse), getSyncCommitteeRewards: ContainerDataExecutionOptimistic(ArrayOf(SyncCommitteeRewardsResponse)), }; } diff --git a/packages/api/test/unit/beacon/oapiSpec.test.ts b/packages/api/test/unit/beacon/oapiSpec.test.ts index f5d9a6c98c92..229098dc7a3e 100644 --- a/packages/api/test/unit/beacon/oapiSpec.test.ts +++ b/packages/api/test/unit/beacon/oapiSpec.test.ts @@ -86,8 +86,6 @@ const testDatas = { const ignoredOperations = [ /* missing route */ - /* https://github.com/ChainSafe/lodestar/issues/5694 */ - "getAttestationsRewards", /* https://github.com/ChainSafe/lodestar/issues/6058 */ "postStateValidators", "postStateValidatorBalances", @@ -125,6 +123,7 @@ const ignoredProperties: Record = { getBlockAttestations: {response: ["finalized"]}, getStateV2: {response: ["finalized"]}, getBlockRewards: {response: ["finalized"]}, + getAttestationsRewards: {response: ["finalized"]}, getSyncCommitteeRewards: {response: ["finalized"]}, /* diff --git a/packages/api/test/unit/beacon/testData/beacon.ts b/packages/api/test/unit/beacon/testData/beacon.ts index 1c944a4d6db8..fb7ea4efeaae 100644 --- a/packages/api/test/unit/beacon/testData/beacon.ts +++ b/packages/api/test/unit/beacon/testData/beacon.ts @@ -190,6 +190,35 @@ export const testData: GenericServerTestCases = { res: {executionOptimistic: true, data: [{validatorIndex: 1300, reward}]}, }, + getAttestationsRewards: { + args: [10, ["1300"]], + res: { + executionOptimistic: true, + data: { + idealRewards: [ + { + head: 0, + target: 10, + source: 20, + inclusionDelay: 30, + inactivity: 40, + effectiveBalance: 50, + }, + ], + totalRewards: [ + { + head: 0, + target: 10, + source: 20, + inclusionDelay: 30, + inactivity: 40, + validatorIndex: 50, + }, + ], + }, + }, + }, + // - getGenesis: { diff --git a/packages/beacon-node/src/api/impl/beacon/rewards/index.ts b/packages/beacon-node/src/api/impl/beacon/rewards/index.ts index 780068ebd518..8b94c1c29174 100644 --- a/packages/beacon-node/src/api/impl/beacon/rewards/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/rewards/index.ts @@ -9,6 +9,10 @@ export function getBeaconRewardsApi({chain}: Pick): ServerA const data = await chain.getBlockRewards(block.message); return {data, executionOptimistic}; }, + async getAttestationsRewards(epoch, validatorIds) { + const {rewards, executionOptimistic} = await chain.getAttestationsRewards(epoch, validatorIds); + return {data: rewards, executionOptimistic}; + }, async getSyncCommitteeRewards(blockId, validatorIds) { const {block, executionOptimistic} = await resolveBlockId(chain, blockId); const data = await chain.getSyncCommitteeRewards(block.message, validatorIds); diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 08743165cd05..d6e832fc007e 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -12,6 +12,7 @@ import { Index2PubkeyCache, PubkeyIndexMap, EpochShuffling, + computeEndSlotAtEpoch, } from "@lodestar/state-transition"; import {BeaconConfig} from "@lodestar/config"; import { @@ -82,6 +83,7 @@ import {StateContextCache} from "./stateCache/stateContextCache.js"; import {SeenGossipBlockInput} from "./seenCache/index.js"; import {CheckpointStateCache} from "./stateCache/stateContextCheckpointsCache.js"; import {SyncCommitteeRewards, computeSyncCommitteeRewards} from "./rewards/syncCommitteeRewards.js"; +import {AttestationsRewards, computeAttestationsRewards} from "./rewards/attestationsRewards.js"; /** * Arbitrary constants, blobs and payloads should be consumed immediately in the same slot @@ -1006,6 +1008,32 @@ export class BeaconChain implements IBeaconChain { return computeBlockRewards(block, preState.clone(), postState?.clone()); } + async getAttestationsRewards( + epoch: Epoch, + validatorIds?: (ValidatorIndex | string)[] + ): Promise<{rewards: AttestationsRewards; executionOptimistic: boolean}> { + // We use end slot of (epoch + 1) to ensure we have seen all attestations. On-time or late. Any late attestation beyond this slot is not considered + const slot = computeEndSlotAtEpoch(epoch + 1); + const stateResult = await this.getStateBySlot(slot, {allowRegen: false}); // No regen if state not in cache + + if (stateResult === null) { + throw Error(`State is unavailable for slot ${slot}`); + } + + const {executionOptimistic} = stateResult; + const stateRoot = toHexString(stateResult.state.hashTreeRoot()); + + const cachedState = this.regen.getStateSync(stateRoot); + + if (cachedState === null) { + throw Error(`State is not in cache for slot ${slot}`); + } + + const rewards = await computeAttestationsRewards(epoch, cachedState, this.config, validatorIds); + + return {rewards, executionOptimistic}; + } + async getSyncCommitteeRewards( block: allForks.FullOrBlindedBeaconBlock, validatorIds?: (ValidatorIndex | string)[] diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 55f5ebf485a2..8ad85539b0ac 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -53,6 +53,7 @@ import {SeenAttestationDatas} from "./seenCache/seenAttestationData.js"; import {SeenGossipBlockInput} from "./seenCache/index.js"; import {ShufflingCache} from "./shufflingCache.js"; import {BlockRewards} from "./rewards/blockRewards.js"; +import {AttestationsRewards} from "./rewards/attestationsRewards.js"; import {SyncCommitteeRewards} from "./rewards/syncCommitteeRewards.js"; export {BlockType, type AssembledBlockType}; @@ -202,6 +203,10 @@ export interface IBeaconChain { blsThreadPoolCanAcceptWork(): boolean; getBlockRewards(blockRef: allForks.FullOrBlindedBeaconBlock): Promise; + getAttestationsRewards( + epoch: Epoch, + validatorIds?: (ValidatorIndex | string)[] + ): Promise<{rewards: AttestationsRewards; executionOptimistic: boolean}>; getSyncCommitteeRewards( blockRef: allForks.FullOrBlindedBeaconBlock, validatorIds?: (ValidatorIndex | string)[] diff --git a/packages/beacon-node/src/chain/rewards/attestationsRewards.ts b/packages/beacon-node/src/chain/rewards/attestationsRewards.ts new file mode 100644 index 000000000000..a59aa6a2b4d2 --- /dev/null +++ b/packages/beacon-node/src/chain/rewards/attestationsRewards.ts @@ -0,0 +1,190 @@ +import {Epoch, ValidatorIndex} from "@lodestar/types"; +import { + EFFECTIVE_BALANCE_INCREMENT, + ForkName, + INACTIVITY_PENALTY_QUOTIENT_ALTAIR, + MAX_EFFECTIVE_BALANCE, + PARTICIPATION_FLAG_WEIGHTS, + TIMELY_HEAD_FLAG_INDEX, + TIMELY_SOURCE_FLAG_INDEX, + TIMELY_TARGET_FLAG_INDEX, + WEIGHT_DENOMINATOR, +} from "@lodestar/params"; +import {routes} from "@lodestar/api"; +import { + CachedBeaconStateAllForks, + CachedBeaconStateAltair, + EpochTransitionCache, + FLAG_ELIGIBLE_ATTESTER, + FLAG_PREV_HEAD_ATTESTER_UNSLASHED, + FLAG_PREV_SOURCE_ATTESTER_UNSLASHED, + FLAG_PREV_TARGET_ATTESTER_UNSLASHED, + beforeProcessEpoch, + hasMarkers, + isInInactivityLeak, +} from "@lodestar/state-transition"; +import {BeaconConfig} from "@lodestar/config"; + +export type AttestationsRewards = routes.beacon.AttestationsRewards; +type IdealAttestationsReward = routes.beacon.IdealAttestationsReward; +type TotalAttestationsReward = routes.beacon.TotalAttestationsReward; +/** Attestations penalty with respect to effective balance in Gwei */ +type AttestationsPenalty = {target: number; source: number; effectiveBalance: number}; + +const defaultAttestationsReward = {head: 0, target: 0, source: 0, inclusionDelay: 0, inactivity: 0}; +const defaultAttestationsPenalty = {target: 0, source: 0}; + +export async function computeAttestationsRewards( + epoch: Epoch, + state: CachedBeaconStateAllForks, + config: BeaconConfig, + validatorIds?: (ValidatorIndex | string)[] +): Promise { + const fork = state.config.getForkName(state.slot); + if (fork === ForkName.phase0) { + throw Error("Unsupported fork. Attestations rewards calculation is not available in phase0"); + } + + const stateAltair = state as CachedBeaconStateAltair; + const transitionCache = beforeProcessEpoch(stateAltair); + + const [idealRewards, penalties] = computeIdealAttestationsRewardsAndPenaltiesAltair(stateAltair, transitionCache); + const totalRewards = computeTotalAttestationsRewardsAltair( + stateAltair, + transitionCache, + idealRewards, + penalties, + validatorIds + ); + + return {idealRewards, totalRewards}; +} + +function computeIdealAttestationsRewardsAndPenaltiesAltair( + state: CachedBeaconStateAllForks, + transitionCache: EpochTransitionCache +): [IdealAttestationsReward[], AttestationsPenalty[]] { + const baseRewardPerIncrement = transitionCache.baseRewardPerIncrement; + const activeBalanceByIncrement = transitionCache.totalActiveStakeByIncrement; + const maxEffectiveBalanceByIncrement = Math.floor(MAX_EFFECTIVE_BALANCE / EFFECTIVE_BALANCE_INCREMENT); + + const idealRewards = Array.from({length: maxEffectiveBalanceByIncrement + 1}, (_, effectiveBalanceByIncrement) => ({ + ...defaultAttestationsReward, + effectiveBalance: effectiveBalanceByIncrement * EFFECTIVE_BALANCE_INCREMENT, + })); + + const attestationsPenalties: AttestationsPenalty[] = Array.from( + {length: maxEffectiveBalanceByIncrement + 1}, + (_, effectiveBalanceByIncrement) => ({ + ...defaultAttestationsPenalty, + effectiveBalance: effectiveBalanceByIncrement * EFFECTIVE_BALANCE_INCREMENT, + }) + ); + + for (let i = 0; i < PARTICIPATION_FLAG_WEIGHTS.length; i++) { + const weight = PARTICIPATION_FLAG_WEIGHTS[i]; + + let unslashedStakeByIncrement; + let flagName: keyof IdealAttestationsReward; + + switch (i) { + case TIMELY_SOURCE_FLAG_INDEX: { + unslashedStakeByIncrement = transitionCache.prevEpochUnslashedStake.sourceStakeByIncrement; + flagName = "source"; + break; + } + case TIMELY_TARGET_FLAG_INDEX: { + unslashedStakeByIncrement = transitionCache.prevEpochUnslashedStake.targetStakeByIncrement; + flagName = "target"; + break; + } + case TIMELY_HEAD_FLAG_INDEX: { + unslashedStakeByIncrement = transitionCache.prevEpochUnslashedStake.headStakeByIncrement; + flagName = "head"; + break; + } + default: { + throw Error(`Unable to retrieve unslashed stake. Unknown participation flag index: ${i}`); + } + } + + for ( + let effectiveBalanceByIncrement = 0; + effectiveBalanceByIncrement <= maxEffectiveBalanceByIncrement; + effectiveBalanceByIncrement++ + ) { + const baseReward = effectiveBalanceByIncrement * baseRewardPerIncrement; + const rewardNumerator = baseReward * weight * unslashedStakeByIncrement; + const idealReward = rewardNumerator / activeBalanceByIncrement / WEIGHT_DENOMINATOR; + const penalty = (baseReward * weight) / WEIGHT_DENOMINATOR; // Positive number indicates penalty + + const idealAttestationsReward = idealRewards[effectiveBalanceByIncrement]; + idealAttestationsReward[flagName] = isInInactivityLeak(state) ? 0 : idealReward; // No attestations rewards during inactivity leak + + if (flagName !== "head") { + const attestationPenalty = attestationsPenalties[effectiveBalanceByIncrement]; + attestationPenalty[flagName] = penalty; + } + } + } + + return [idealRewards, attestationsPenalties]; +} + +// Same calculation as `getRewardsAndPenaltiesAltair` but returns the breakdown of rewards instead of aggregated +function computeTotalAttestationsRewardsAltair( + state: CachedBeaconStateAltair, + transitionCache: EpochTransitionCache, + idealRewards: IdealAttestationsReward[], + penalties: AttestationsPenalty[], + validatorIds?: (ValidatorIndex | string)[] // validatorIds filter +): TotalAttestationsReward[] { + const rewards = []; + const {statuses} = transitionCache; + const {epochCtx, config} = state; + const validatorIndices = validatorIds + ?.map((id) => (typeof id === "number" ? id : epochCtx.pubkey2index.get(id))) + .filter((index) => index !== undefined); // Validator indices to include in the result + + const inactivityPenaltyDenominator = config.INACTIVITY_SCORE_BIAS * INACTIVITY_PENALTY_QUOTIENT_ALTAIR; + + for (let i = 0; i < statuses.length; i++) { + if (validatorIndices !== undefined && !validatorIndices.includes(i)) { + continue; + } + + const status = statuses[i]; + if (!hasMarkers(status.flags, FLAG_ELIGIBLE_ATTESTER)) { + continue; + } + + const effectiveBalanceIncrement = epochCtx.effectiveBalanceIncrements[i]; + + const currentRewards = {...defaultAttestationsReward, validatorIndex: i}; + + if (hasMarkers(status.flags, FLAG_PREV_SOURCE_ATTESTER_UNSLASHED)) { + currentRewards.source = idealRewards[effectiveBalanceIncrement].source; + } else { + currentRewards.source = penalties[effectiveBalanceIncrement].source * -1; // Negative reward to indicate penalty + } + + if (hasMarkers(status.flags, FLAG_PREV_TARGET_ATTESTER_UNSLASHED)) { + currentRewards.target = idealRewards[effectiveBalanceIncrement].target; + } else { + currentRewards.target = penalties[effectiveBalanceIncrement].target * -1; + + // Also incur inactivity penalty if not voting target correctly + const inactivityPenaltyNumerator = + effectiveBalanceIncrement * EFFECTIVE_BALANCE_INCREMENT * state.inactivityScores.get(i); + currentRewards.inactivity = Math.floor(inactivityPenaltyNumerator / inactivityPenaltyDenominator) * -1; + } + + if (hasMarkers(status.flags, FLAG_PREV_HEAD_ATTESTER_UNSLASHED)) { + currentRewards.head = idealRewards[effectiveBalanceIncrement].head; + } + + rewards.push(currentRewards); + } + + return rewards; +}