Prysm Validator Client

Although beacon nodes handle syncing across the network, applying proof of stake consensus, and doing a lot of other fancy things, we need actual validators who stake ETH to participate and perform block proposals and attestations throughout the lifecycle of the chain.

Prysm ships with a built in validator client that generates a private key and connects to any running beacon node to participate.

How Does a Validator Client Work?

Validators are pretty lightweight pieces of software that do very few things but need to do them well. They run in a single function that summarizes every step of their lifecycle succinctly. In order of operations, the client:

  1. Connects to a running beacon node's public RPC server via a gRPC client

  2. Waits for the ChainStart event log to have occurred in the Validator Deposit Contract

  3. Checks if the public key corresponding to the validator instance has been activated in the beacon chain

  4. Update validator assignments: a validator is assigned to a new shard and a new slot every new epoch (a period of 64 slots, with each slot lasting 6 seconds) either as a proposer, whom can create blocks, or attester, whom votes on blocks

  5. Then, the validator has a ticker that works every slot (every 6 seconds)

  6. If the slot it ticks at is the validator's assigned slot, we either attest or propose a beacon block depending on assigned role

  7. We then repeat forever until the validator decides to exit the system voluntarily, or is penalized by the system for acting maliciously or being idle when assigned to perform

type Validator interface {
Done()
WaitForChainStart(ctx context.Context) error
WaitForActivation(ctx context.Context) error
NextSlot() <-chan uint64
LogValidatorGainsAndLosses(ctx context.Context, slot uint64) error
UpdateAssignments(ctx context.Context, slot uint64) error
RoleAt(slot uint64) pb.ValidatorRole
AttestToBlockHead(ctx context.Context, slot uint64)
ProposeBlock(ctx context.Context, slot uint64)
}
// Run the main validator routine. This routine exits if the context is
// cancelled.
//
// Order of operations:
// 1 - Initialize validator data
// 2 - Wait for validator activation
// 3 - Wait for the next slot start
// 4 - Update assignments
// 5 - Determine role at current slot
// 6 - Perform assigned role, if any
func run(ctx context.Context, v Validator) {
if err := v.WaitForChainStart(ctx); err != nil {
log.Fatalf("Could not determine if beacon chain started: %v", err)
}
if err := v.WaitForActivation(ctx); err != nil {
log.Fatalf("Could not wait for validator activation: %v", err)
}
if err := v.UpdateAssignments(ctx, params.BeaconConfig().GenesisSlot); err != nil {
handleAssignmentError(err, params.BeaconConfig().GenesisSlot)
}
for {
select {
case <-ctx.Done():
log.Info("Context canceled, stopping validator")
return // Exit if context is canceled.
case slot := <-v.NextSlot():
// Report this validator client's rewards and penalties throughout its lifecycle.
if err := v.LogValidatorGainsAndLosses(ctx, slot); err != nil {
log.Errorf("Could not report validator's rewards/penalties for slot %d: %v", slot, err)
}
// Keep trying to update assignments if they are nil or if we are past an
// epoch transition in the beacon node's state.
if err := v.UpdateAssignments(ctx, slot); err != nil {
handleAssignmentError(err, slot)
continue
}
role := v.RoleAt(slot)
switch role {
case pb.ValidatorRole_BOTH:
v.ProposeBlock(ctx, slot)
v.AttestToBlockHead(ctx, slot)
case pb.ValidatorRole_ATTESTER:
v.AttestToBlockHead(ctx, slot)
case pb.ValidatorRole_PROPOSER:
v.ProposeBlock(ctx, slot)
case pb.ValidatorRole_UNKNOWN:
log.WithFields(logrus.Fields{
"slot": slot - params.BeaconConfig().GenesisSlot,
"role": role,
}).Info("No active assignment, doing nothing")
default:
// Do nothing :)
}
}
}
}

Every validator instance represents 32 ETH being at stake in the network. At Prysm, this is the default. However, we are adding support for running multiple public keys corresponding to multiple validators in a single runtime, making it easier for those who want to put more of their funds at stake to secure the network.

Let's take a look at the two critical functions: ProposeBlock and AttesterToBlockHead.

Proposing a Beacon Block

Proposing a block needs to include several items to meet the minimum requirements of verification by the protocol. Here are the required steps in order:

  1. Fetch the canonical head block from the beacon node and use its root as the parent root of the new block

  2. Fetch pending deposits which have not yet been included in the chain

  3. Fetch Eth1 data used to vote on deposit objects

  4. Fetch pending slashings

  5. Fetch pending validator voluntary exits

  6. Fetch latest fork data from the beacon chain

  7. Generate a randao reveal by BLS signing the epoch information

  8. Fetch any pending attestations from the beacon node

  9. Construct the block object by packaging the above items into a block data structure

  10. Compute the state root hash of calling ExecuteStateTransition with the block in question

  11. Sign the block with the validator's private key

  12. Propose the block by sending it to the beacon node via RPC

// ProposeBlock A new beacon block for a given slot. This method collects the
// previous beacon block, any pending deposits, and ETH1 data from the beacon
// chain node to construct the new block. The new block is then processed with
// the state root computation, and finally signed by the validator before being
// sent back to the beacon node for broadcasting.
func (v *validator) ProposeBlock(ctx context.Context, slot uint64) {
// 1. Fetch data from Beacon Chain node.
// Get current head beacon block.
headBlock, err := v.beaconClient.CanonicalHead(ctx, &ptypes.Empty{})
if err != nil {
log.Errorf("Failed to fetch CanonicalHead: %v", err)
return
}
parentTreeRoot, err := hashutil.HashBeaconBlock(headBlock)
if err != nil {
log.Errorf("Failed to hash parent block: %v", err)
return
}
// Get validator ETH1 deposits which have not been included in the beacon chain.
pDepResp, err := v.beaconClient.PendingDeposits(ctx, &ptypes.Empty{})
if err != nil {
log.Errorf("Failed to get pending pendings: %v", err)
return
}
// Get ETH1 data.
eth1DataResp, err := v.beaconClient.Eth1Data(ctx, &ptypes.Empty{})
if err != nil {
log.Errorf("Failed to get ETH1 data: %v", err)
return
}
// Retrieve any slashings.
slashingsResp, err := v.beaconClient.PendingSlashings(ctx, &ptypes.Empty{})
if err != nil {
log.Errorf("Failed to get pending slashings: %v", err)
return
}
// Retrieve any voluntary exits.
exitsResp, err := v.beaconClient.PendingExits(ctx, &ptypes.Empty{})
if err != nil {
log.Errorf("Failed to get pending exits: %v", err)
return
}
// Retrieve the current fork data from the beacon node.
fork, err := v.beaconClient.ForkData(ctx, &ptypes.Empty{})
if err != nil {
log.Errorf("Failed to get fork data from beacon node's state: %v", err)
return
}
// Then, we generate a RandaoReveal by signing the block's slot information using
// the validator's private key.
randaoReveal := generateRandaoReveal(block, fork, slot, v.key.SecretKey)
// Fetch pending attestations seen by the beacon node.
attResp, err := v.proposerClient.PendingAttestations(ctx, &pb.PendingAttestationsRequest{
FilterReadyForInclusion: true,
ProposalBlockSlot: slot,
})
if err != nil {
log.Errorf("Failed to fetch pending attestations from the beacon node: %v", err)
return
}
// 2. Construct block.
block := &pbp2p.BeaconBlock{
Slot: slot,
ParentRootHash32: parentTreeRoot[:],
RandaoReveal: epochSignature.Marshal(),
Eth1Data: eth1DataResp.Eth1Data,
Body: &pbp2p.BeaconBlockBody{
Attestations: attResp.PendingAttestations,
ProposerSlashings: slashingsResp.ProposerSlashings,
AttesterSlashings: slashingsResp.AttesterSlashings,
Deposits: pDepResp.PendingDeposits,
VoluntaryExits: exitsResp.Exits,
},
}
// 3. Compute state root transition from parent block to the new block.
resp, err := v.proposerClient.ComputeStateRoot(ctx, block)
if err != nil {
log.WithField(
"block", proto.MarshalTextString(block),
).Errorf("Not proposing! Unable to compute state root: %v", err)
return
}
block.StateRootHash32 = resp.GetStateRoot()
// 4. Sign the complete block.
block.Signature = signBlock(block, v.key.SecretKey)
// 5. Broadcast to the network via beacon chain node.
blkResp, err := v.proposerClient.ProposeBlock(ctx, block)
if err != nil {
log.WithError(err).Error("Failed to propose block")
return
}
}

Attesting to a Beacon Block

Attesting to a block is a similar process, albeit different in its steps.

  1. First we construct an attestation data structure

  2. We then fetch the validator's assigned shard

  3. We then make a request to the beacon node for all the information we need to attest to a block

  4. We construct an attestation bitfield using the validator index

  5. We then sign the attestation with a validator's secret key

  6. Then, we wait until halfway through the slot duration and then we send the attestation to the beacon node via RPC

// AttestToBlockHead completes the validator client's attester responsibility at a given slot.
// It fetches the latest beacon block head along with the latest canonical beacon state
// information in order to sign the block and include information about the validator's
// participation in voting on the block.
func (v *validator) AttestToBlockHead(ctx context.Context, slot uint64) {
// First the validator should construct attestation_data, an AttestationData
// object based upon the state at the assigned slot.
attData := &pbp2p.AttestationData{
Slot: slot,
CrosslinkDataRootHash32: params.BeaconConfig().ZeroHash[:], // Stub for Phase 0.
}
// We fetch the validator index as it is necessary to generate the aggregation
// bitfield of the attestation itself.
pubKey := v.key.PublicKey.Marshal()
idxReq := &pb.ValidatorIndexRequest{
PublicKey: pubKey,
}
validatorIndexRes, err := v.validatorClient.ValidatorIndex(ctx, idxReq)
if err != nil {
log.Errorf("Could not fetch validator index: %v", err)
return
}
req := &pb.ValidatorEpochAssignmentsRequest{
EpochStart: slot,
PublicKey: pubKey,
}
resp, err := v.validatorClient.CommitteeAssignment(ctx, req)
if err != nil {
log.Errorf("Could not fetch crosslink committees at slot %d: %v",
slot-params.BeaconConfig().GenesisSlot, err)
return
}
// Set the attestation data's shard as the shard associated with the validator's
// committee as retrieved by CrosslinkCommitteesAtSlot.
attData.Shard = resp.Shard
// Fetch other necessary information from the beacon node in order to attest
// including the justified epoch, epoch boundary information, and more.
infoReq := &pb.AttestationDataRequest{
Slot: slot,
Shard: resp.Shard,
}
infoRes, err := v.attesterClient.AttestationDataAtSlot(ctx, infoReq)
if err != nil {
log.Errorf("Could not fetch necessary info to produce attestation at slot %d: %v",
slot-params.BeaconConfig().GenesisSlot, err)
return
}
// Set the attestation data's beacon block root = hash_tree_root(head) where head
// is the validator's view of the head block of the beacon chain during the slot.
attData.BeaconBlockRootHash32 = infoRes.BeaconBlockRootHash32
// Set the attestation data's epoch boundary root = hash_tree_root(epoch_boundary)
// where epoch_boundary is the block at the most recent epoch boundary in the
// chain defined by head -- i.e. the BeaconBlock where block.slot == get_epoch_start_slot(slot_to_epoch(head.slot)).
attData.EpochBoundaryRootHash32 = infoRes.EpochBoundaryRootHash32
// Set the attestation data's latest crosslink root = state.latest_crosslinks[shard].shard_block_root
// where state is the beacon state at head and shard is the validator's assigned shard.
attData.LatestCrosslink = infoRes.LatestCrosslink
// Set the attestation data's justified epoch = state.justified_epoch where state
// is the beacon state at the head.
attData.JustifiedEpoch = infoRes.JustifiedEpoch
// Set the attestation data's justified block root = hash_tree_root(justified_block) where
// justified_block is the block at state.justified_epoch in the chain defined by head.
// On the server side, this is fetched by calling get_block_root(state, justified_epoch).
attData.JustifiedBlockRootHash32 = infoRes.JustifiedBlockRootHash32
// The validator now creates an Attestation object using the AttestationData as
// set in the code above after all properties have been set.
attestation := &pbp2p.Attestation{
Data: attData,
}
// We set the custody bitfield to an slice of zero values as a stub for phase 0
// of length len(committee)+7 // 8.
attestation.CustodyBitfield = make([]byte, (len(resp.Committee)+7)/8)
// Find the index in committee to be used for
// the aggregation bitfield
var indexInCommittee int
for i, vIndex := range resp.Committee {
if vIndex == validatorIndexRes.Index {
indexInCommittee = i
break
}
}
aggregationBitfield := bitutil.SetBitfield(indexInCommittee)
attestation.AggregationBitfield = aggregationBitfield
attestation.AggregateSignature = signAttestation(attData, v.key.SecretKey)
// We wait until halfway through the slot duration to attest.
duration := time.Duration(slot*params.BeaconConfig().SecondsPerSlot+delay) * time.Second
timeToBroadcast := time.Unix(int64(v.genesisTime), 0).Add(duration)
time.Sleep(time.Until(timeToBroadcast))
log.Debugf("Produced attestation: %v", attestation)
attResp, err := v.attesterClient.AttestHead(ctx, attestation)
if err != nil {
log.Errorf("Could not submit attestation to beacon node: %v", err)
return
}
log.WithFields(logrus.Fields{
"attestationHash": fmt.Sprintf("%#x", attResp.AttestationHash),
"shard": attData.Shard,
"slot": slot - params.BeaconConfig().GenesisSlot,
}).Info("Beacon node processed attestation successfully")
}