diff --git a/packages/api/src/beacon/routes/beacon/index.ts b/packages/api/src/beacon/routes/beacon/index.ts index 92fcc2093188..af1fcdaecfe0 100644 --- a/packages/api/src/beacon/routes/beacon/index.ts +++ b/packages/api/src/beacon/routes/beacon/index.ts @@ -20,7 +20,7 @@ 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} from "./rewards.js"; +export type {BlockRewards, 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 42dced7d5c3f..926cb3033f06 100644 --- a/packages/api/src/beacon/routes/beacon/rewards.ts +++ b/packages/api/src/beacon/routes/beacon/rewards.ts @@ -7,10 +7,12 @@ import { Schema, ReqSerializers, ContainerDataExecutionOptimistic, + ArrayOf, } from "../../../utils/index.js"; import {HttpStatusCode} from "../../../utils/client/httpStatusCode.js"; import {ApiClientResponse} from "../../../interfaces.js"; import {BlockId} from "./block.js"; +import {ValidatorId} from "./state.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes @@ -38,6 +40,14 @@ export type BlockRewards = { attesterSlashings: number; }; +/** + * 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 + * participating in sync committee. Please refer to `BlockRewards.syncAggregate` for rewards of proposer including sync committee + * outputs into their block + */ +export type SyncCommitteeRewards = {validatorIndex: ValidatorIndex; reward: number}[]; + export type Api = { /** * Get block rewards @@ -54,6 +64,24 @@ export type Api = { HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND > >; + + /** + * Get sync committee rewards + * Returns participant reward value for each sync committee member at the given block. + * + * @param blockId Block identifier. + * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \, \. + * @param validatorIds List of validator indices or pubkeys to filter in + */ + getSyncCommitteeRewards( + blockId: BlockId, + validatorIds?: ValidatorId[] + ): Promise< + ApiClientResponse< + {[HttpStatusCode.OK]: {data: SyncCommitteeRewards; executionOptimistic: ExecutionOptimistic}}, + HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND + > + >; }; /** @@ -61,11 +89,13 @@ export type Api = { */ export const routesData: RoutesData = { getBlockRewards: {url: "/eth/v1/beacon/rewards/blocks/{block_id}", method: "GET"}, + 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}}; + getSyncCommitteeRewards: {params: {block_id: string}; body: ValidatorId[]}; }; export function getReqSerializers(): ReqSerializers { @@ -75,6 +105,14 @@ export function getReqSerializers(): ReqSerializers { parseReq: ({params}) => [params.block_id], schema: {params: {block_id: Schema.StringRequired}}, }, + getSyncCommitteeRewards: { + writeReq: (block_id, validatorIds) => ({params: {block_id: String(block_id)}, body: validatorIds || []}), + parseReq: ({params, body}) => [params.block_id, body], + schema: { + params: {block_id: Schema.StringRequired}, + body: Schema.UintOrStringArray, + }, + }, }; } @@ -91,7 +129,16 @@ export function getReturnTypes(): ReturnTypes { {jsonCase: "eth2"} ); + const SyncCommitteeRewardsResponse = new ContainerType( + { + validatorIndex: ssz.ValidatorIndex, + reward: ssz.UintNum64, + }, + {jsonCase: "eth2"} + ); + return { getBlockRewards: ContainerDataExecutionOptimistic(BlockRewardsResponse), + 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 4d036fb2dd8d..500b524ccf05 100644 --- a/packages/api/test/unit/beacon/oapiSpec.test.ts +++ b/packages/api/test/unit/beacon/oapiSpec.test.ts @@ -87,7 +87,6 @@ const testDatas = { const ignoredOperations = [ /* missing route */ /* https://github.com/ChainSafe/lodestar/issues/5694 */ - "getSyncCommitteeRewards", "getAttestationsRewards", "getDepositSnapshot", // Won't fix for now, see https://github.com/ChainSafe/lodestar/issues/5697 "getBlindedBlock", // https://github.com/ChainSafe/lodestar/issues/5699 @@ -123,6 +122,7 @@ const ignoredProperties: Record = { getBlockAttestations: {response: ["finalized"]}, getStateV2: {response: ["finalized"]}, getBlockRewards: {response: ["finalized"]}, + getSyncCommitteeRewards: {response: ["finalized"]}, /* https://github.com/ChainSafe/lodestar/issues/6168 diff --git a/packages/api/test/unit/beacon/testData/beacon.ts b/packages/api/test/unit/beacon/testData/beacon.ts index 9c39849906de..1c944a4d6db8 100644 --- a/packages/api/test/unit/beacon/testData/beacon.ts +++ b/packages/api/test/unit/beacon/testData/beacon.ts @@ -12,6 +12,7 @@ import {GenericServerTestCases} from "../../../utils/genericServerTest.js"; const root = new Uint8Array(32).fill(1); const randao = new Uint8Array(32).fill(1); const balance = 32e9; +const reward = 32e9; const pubkeyHex = toHexString(Buffer.alloc(48, 1)); const blockHeaderResponse: BlockHeaderResponse = { @@ -184,6 +185,10 @@ export const testData: GenericServerTestCases = { }, }, }, + getSyncCommitteeRewards: { + args: ["head", ["1300"]], + res: {executionOptimistic: true, data: [{validatorIndex: 1300, reward}]}, + }, // - 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 03a182359d90..780068ebd518 100644 --- a/packages/beacon-node/src/api/impl/beacon/rewards/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/rewards/index.ts @@ -9,5 +9,10 @@ export function getBeaconRewardsApi({chain}: Pick): ServerA const data = await chain.getBlockRewards(block.message); return {data, executionOptimistic}; }, + async getSyncCommitteeRewards(blockId, validatorIds) { + const {block, executionOptimistic} = await resolveBlockId(chain, blockId); + const data = await chain.getSyncCommitteeRewards(block.message, validatorIds); + return {data, executionOptimistic}; + }, }; } diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 20a6ca343565..08743165cd05 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -81,6 +81,7 @@ import {ShufflingCache} from "./shufflingCache.js"; 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"; /** * Arbitrary constants, blobs and payloads should be consumed immediately in the same slot @@ -995,12 +996,26 @@ export class BeaconChain implements IBeaconChain { async getBlockRewards(block: allForks.FullOrBlindedBeaconBlock): Promise { const preState = this.regen.getPreStateSync(block); - const postState = this.regen.getStateSync(toHexString(block.stateRoot)) ?? undefined; if (preState === null) { throw Error(`Pre-state is unavailable given block's parent root ${toHexString(block.parentRoot)}`); } + const postState = this.regen.getStateSync(toHexString(block.stateRoot)) ?? undefined; + return computeBlockRewards(block, preState.clone(), postState?.clone()); } + + async getSyncCommitteeRewards( + block: allForks.FullOrBlindedBeaconBlock, + validatorIds?: (ValidatorIndex | string)[] + ): Promise { + const preState = this.regen.getPreStateSync(block); + + if (preState === null) { + throw Error(`Pre-state is unavailable given block's parent root ${toHexString(block.parentRoot)}`); + } + + return computeSyncCommitteeRewards(block, preState.clone(), validatorIds); + } } diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 99c1b7ea0c4a..55f5ebf485a2 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 {SyncCommitteeRewards} from "./rewards/syncCommitteeRewards.js"; export {BlockType, type AssembledBlockType}; export {type ProposerPreparationData}; @@ -201,6 +202,10 @@ export interface IBeaconChain { blsThreadPoolCanAcceptWork(): boolean; getBlockRewards(blockRef: allForks.FullOrBlindedBeaconBlock): Promise; + getSyncCommitteeRewards( + blockRef: allForks.FullOrBlindedBeaconBlock, + validatorIds?: (ValidatorIndex | string)[] + ): Promise; } export type SSZObjectType = diff --git a/packages/beacon-node/src/chain/rewards/syncCommitteeRewards.ts b/packages/beacon-node/src/chain/rewards/syncCommitteeRewards.ts new file mode 100644 index 000000000000..ba45d03adbab --- /dev/null +++ b/packages/beacon-node/src/chain/rewards/syncCommitteeRewards.ts @@ -0,0 +1,57 @@ +import {CachedBeaconStateAllForks, CachedBeaconStateAltair} from "@lodestar/state-transition"; +import {ValidatorIndex, allForks, altair} from "@lodestar/types"; +import {ForkName, SYNC_COMMITTEE_SIZE} from "@lodestar/params"; +import {routes} from "@lodestar/api"; + +export type SyncCommitteeRewards = routes.beacon.SyncCommitteeRewards; +type BalanceRecord = {val: number}; // Use val for convenient way to increment/decrement balance + +export async function computeSyncCommitteeRewards( + block: allForks.BeaconBlock, + preState: CachedBeaconStateAllForks, + validatorIds?: (ValidatorIndex | string)[] +): Promise { + const fork = preState.config.getForkName(block.slot); + if (fork === ForkName.phase0) { + throw Error("Cannot get sync rewards as phase0 block does not have sync committee"); + } + + const altairBlock = block as altair.BeaconBlock; + const preStateAltair = preState as CachedBeaconStateAltair; + const {index2pubkey} = preStateAltair.epochCtx; + + // Bound committeeIndices in case it goes beyond SYNC_COMMITTEE_SIZE just to be safe + const committeeIndices = preStateAltair.epochCtx.currentSyncCommitteeIndexed.validatorIndices.slice( + 0, + SYNC_COMMITTEE_SIZE + ); + const {syncParticipantReward} = preStateAltair.epochCtx; + const {syncCommitteeBits} = altairBlock.body.syncAggregate; + + // Use balance of each committee as starting point such that we cap the penalty to avoid balance dropping below 0 + const balances: Map = new Map( + committeeIndices.map((i) => [i, {val: preStateAltair.balances.get(i)}]) + ); + + for (const i of committeeIndices) { + const balanceRecord = balances.get(i) as BalanceRecord; + if (syncCommitteeBits.get(i)) { + // Positive rewards for participants + balanceRecord.val += syncParticipantReward; + } else { + // Negative rewards for non participants + balanceRecord.val = Math.max(0, balanceRecord.val - syncParticipantReward); + } + } + + const rewards = Array.from(balances, ([validatorIndex, v]) => ({validatorIndex, reward: v.val})); + + if (validatorIds !== undefined) { + const filtersSet = new Set(validatorIds); + return rewards.filter( + (reward) => filtersSet.has(reward.validatorIndex) || filtersSet.has(index2pubkey[reward.validatorIndex].toHex()) + ); + } else { + return rewards; + } +}