add EIP-7044 support to keymanager API (#5959)

* add EIP-7044 support to keymanager API

When trying to sign `VoluntaryExit` via keymanager API, the logic is not
yet aware of EIP-7044 (part of Deneb). This patch adds missing EIP-7044
support to the keymanager API as well.

As part of this, the VC needs to become aware about:

- `CAPELLA_FORK_VERSION`: To correctly form the EIP-7044 signing domain.
  The fork schedule does not indicate which of the results, if any,
  corresponds to Capella.
- `CAPELLA_FORK_EPOCH`: To detect whether Capella was scheduled.
  If a BN does not have it in its config while other BNs have it,
  this leads to a log if Capella has not activated yet, or marks the BN
  as incompatible if Capella already activated.
- `DENEB_FORK_EPOCH`: To check whether EIP-7044 logic should be used.

Related PRs:

- #5120 added support for processing EIP-7044 `VoluntaryExit` messages
  as part of the state transition functions (tested by EF spec tests).
- #5953 synced the support from #5120 to gossip validation.
- #5954 added support to the `nimbus_beacon_node deposits exit` command.
- #5956 contains an alternative generic version of `VCForkConfig`.

* address reviewer feedback: letter case, module location, double lookup

---------

Co-authored-by: cheatfate <eugene.kabanov@status.im>

* Update beacon_chain/rpc/rest_constants.nim

* move `VCRuntimeConfig` back to `rest_types`

---------

Co-authored-by: cheatfate <eugene.kabanov@status.im>

* fix `getForkVersion` helper

---------

Co-authored-by: cheatfate <eugene.kabanov@status.im>
This commit is contained in:
Etan Kissling 2024-02-26 09:48:07 +01:00 committed by GitHub
parent 00510a9d2f
commit 4e9bc7f570
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 270 additions and 67 deletions

View File

@ -220,33 +220,19 @@ proc restValidatorExit(config: BeaconNodeConf) {.async.} =
reason = exc.msg
quit 1
# https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.6/specs/phase0/beacon-chain.md#voluntary-exits
# https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.0/specs/deneb/beacon-chain.md#modified-process_voluntary_exit
let signingFork = try:
let response = await client.getSpecVC()
if response.status == 200:
let
spec = response.data.data
denebForkEpoch =
block:
let s = spec.getOrDefault("DENEB_FORK_EPOCH", $FAR_FUTURE_EPOCH)
Epoch(Base10.decode(uint64, s).get(uint64(FAR_FUTURE_EPOCH)))
# https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.6/specs/phase0/beacon-chain.md#voluntary-exits
# https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.0/specs/deneb/beacon-chain.md#modified-process_voluntary_exit
if currentEpoch >= denebForkEpoch:
let capellaForkVersion =
block:
var res: Version
# CAPELLA_FOR_VERSION has specific format - "0x01000000", so
# default empty string is invalid, so `hexToByteArrayStrict`
# will raise exception on empty string.
let s = spec.getOrDefault("CAPELLA_FORK_VERSION", "")
hexToByteArrayStrict(s, distinctBase(res))
res
Fork(
current_version: capellaForkVersion,
previous_version: capellaForkVersion,
epoch: GENESIS_EPOCH) # irrelevant when current/previous identical
else:
fork
let forkConfig = response.data.data.getConsensusForkConfig()
if forkConfig.isErr:
raise newException(RestError, "Invalid config: " & forkConfig.error)
let capellaForkVersion = forkConfig.get.capellaVersion.valueOr:
raise newException(RestError,
ConsensusFork.Capella.forkVersionConfigKey() & " missing")
voluntary_exit_signature_fork(
fork, capellaForkVersion, currentEpoch, forkConfig.get.denebEpoch)
else:
raise newException(RestError, "Error response (" & $response.status & ")")
except CatchableError as exc:

View File

@ -755,6 +755,12 @@ proc init*(T: type BeaconNode,
withState(dag.headState):
getValidator(forkyState().data.validators.asSeq(), pubkey)
func getCapellaForkVersion(): Opt[Version] =
Opt.some(cfg.CAPELLA_FORK_VERSION)
func getDenebForkEpoch(): Opt[Epoch] =
Opt.some(cfg.DENEB_FORK_EPOCH)
proc getForkForEpoch(epoch: Epoch): Opt[Fork] =
Opt.some(dag.forkAtEpoch(epoch))
@ -784,6 +790,8 @@ proc init*(T: type BeaconNode,
config.getPayloadBuilderAddress,
getValidatorAndIdx,
getBeaconTime,
getCapellaForkVersion,
getDenebForkEpoch,
getForkForEpoch,
getGenesisRoot)
else: nil

View File

@ -4,6 +4,9 @@
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
# at your option. This file may not be copied, modified, or distributed except according to those terms.
{.push raises: [].}
import
stew/io2, presto, metrics, metrics/chronos_httpserver,
./rpc/rest_key_management_api,
@ -348,6 +351,18 @@ proc asyncInit(vc: ValidatorClientRef): Future[ValidatorClientRef] {.async.} =
let
keymanagerInitResult = initKeymanagerServer(vc.config, nil)
func getCapellaForkVersion(): Opt[Version] =
if vc.runtimeConfig.forkConfig.isSome():
vc.runtimeConfig.forkConfig.get().capellaVersion
else:
Opt.none(Version)
func getDenebForkEpoch(): Opt[Epoch] =
if vc.runtimeConfig.forkConfig.isSome():
Opt.some(vc.runtimeConfig.forkConfig.get().denebEpoch)
else:
Opt.none(Epoch)
proc getForkForEpoch(epoch: Epoch): Opt[Fork] =
if len(vc.forks) > 0:
Opt.some(vc.forkAtEpoch(epoch))
@ -379,6 +394,8 @@ proc asyncInit(vc: ValidatorClientRef): Future[ValidatorClientRef] {.async.} =
Opt.none(string),
nil,
vc.beaconClock.getBeaconTimeFn,
getCapellaForkVersion,
getDenebForkEpoch,
getForkForEpoch,
getGenesisRoot
)

View File

@ -241,6 +241,10 @@ const
"The given Merkle proof is invalid"
InvalidMerkleProofIndexError* =
"The given Merkle proof index is invalid"
FailedToObtainForkVersionError* =
"Failed to obtain fork version"
FailedToObtainConsensusForkError* =
"Failed to obtain consensus fork information"
FailedToObtainForkError* =
"Failed to obtain fork information"
InvalidTimestampValue* =

View File

@ -1,9 +1,12 @@
# beacon_chain
# Copyright (c) 2021-2024 Status Research & Development GmbH
# Licensed and distributed under either of
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
# at your option. This file may not be copied, modified, or distributed except according to those terms.
{.push raises: [].}
# NOTE: This module has been used in both `beacon_node` and `validator_client`,
# please keep imports clear of `rest_utils` or any other module which imports
# beacon node's specific networking code.
@ -561,6 +564,7 @@ proc installKeymanagerHandlers*(router: var RestRouter, host: KeymanagerHost) =
let
qpubkey = pubkey.valueOr:
return keymanagerApiError(Http400, InvalidValidatorPublicKey)
currentEpoch = host.getBeaconTimeFn().slotOrZero().epoch()
qepoch =
if epoch.isSome():
let res = epoch.get()
@ -568,7 +572,7 @@ proc installKeymanagerHandlers*(router: var RestRouter, host: KeymanagerHost) =
return keymanagerApiError(Http400, InvalidEpochValueError)
res.get()
else:
host.getBeaconTimeFn().slotOrZero().epoch()
currentEpoch
validator =
block:
let res = host.validatorPool[].getValidator(qpubkey).valueOr:
@ -581,10 +585,16 @@ proc installKeymanagerHandlers*(router: var RestRouter, host: KeymanagerHost) =
validator_index: uint64(validator.index.get()))
fork = host.getForkFn(qepoch).valueOr:
return keymanagerApiError(Http500, FailedToObtainForkError)
capellaForkVersion = host.getCapellaForkVersionFn().valueOr:
return keymanagerApiError(Http500, FailedToObtainForkVersionError)
denebForkEpoch = host.getDenebForkEpochFn().valueOr:
return keymanagerApiError(Http500, FailedToObtainConsensusForkError)
signingFork = voluntary_exit_signature_fork(
fork, capellaForkVersion, currentEpoch, denebForkEpoch)
signature =
try:
let res = await validator.getValidatorExitSignature(
fork, host.getGenesisFn(), voluntaryExit)
signingFork, host.getGenesisFn(), voluntaryExit)
if res.isErr():
return keymanagerApiError(Http500, res.error())
res.get()

View File

@ -13,7 +13,8 @@ import
rest_beacon_calls, rest_builder_calls, rest_config_calls, rest_debug_calls,
rest_keymanager_calls, rest_light_client_calls,
rest_node_calls, rest_validator_calls,
rest_nimbus_calls, rest_event_calls, rest_common
rest_nimbus_calls, rest_event_calls, rest_common,
rest_fork_config
]
export
@ -21,4 +22,5 @@ export
rest_beacon_calls, rest_builder_calls, rest_config_calls, rest_debug_calls,
rest_keymanager_calls, rest_light_client_calls,
rest_node_calls, rest_validator_calls,
rest_nimbus_calls, rest_event_calls, rest_common
rest_nimbus_calls, rest_event_calls, rest_common,
rest_fork_config

View File

@ -0,0 +1,97 @@
# beacon_chain
# Copyright (c) 2018-2024 Status Research & Development GmbH
# Licensed and distributed under either of
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
# at your option. This file may not be copied, modified, or distributed except according to those terms.
{.push raises: [].}
import
std/strutils,
stew/[base10, byteutils],
../forks
from ./rest_types import VCRuntimeConfig
export forks, rest_types
type VCForkConfig* = object
altairEpoch*: Epoch
capellaVersion*: Opt[Version]
capellaEpoch*: Epoch
denebEpoch*: Epoch
func forkVersionConfigKey*(consensusFork: ConsensusFork): string =
if consensusFork > ConsensusFork.Phase0:
($consensusFork).toUpperAscii() & "_FORK_VERSION"
else:
"GENESIS_FORK_VERSION"
func forkEpochConfigKey*(consensusFork: ConsensusFork): string =
doAssert consensusFork > ConsensusFork.Phase0
($consensusFork).toUpperAscii() & "_FORK_EPOCH"
proc getOrDefault*(info: VCRuntimeConfig, name: string,
default: uint64): uint64 =
let numstr = info.getOrDefault(name, "missing")
if numstr == "missing": return default
Base10.decode(uint64, numstr).valueOr:
return default
proc getOrDefault*(info: VCRuntimeConfig, name: string, default: Epoch): Epoch =
Epoch(info.getOrDefault(name, uint64(default)))
func getForkVersion(
info: VCRuntimeConfig,
consensusFork: Consensusfork): Result[Opt[Version], string] =
let key = consensusFork.forkVersionConfigKey()
let stringValue = info.getOrDefault(key, "missing")
if stringValue == "missing": return ok Opt.none(Version)
var value: Version
try:
hexToByteArrayStrict(stringValue, distinctBase(value))
except ValueError as exc:
return err(key & " is invalid: " & exc.msg)
ok Opt.some value
func getForkEpoch(info: VCRuntimeConfig, consensusFork: ConsensusFork): Epoch =
if consensusFork > ConsensusFork.Phase0:
let key = consensusFork.forkEpochConfigKey()
info.getOrDefault(key, FAR_FUTURE_EPOCH)
else:
GENESIS_EPOCH
func getConsensusForkConfig*(
info: VCRuntimeConfig): Result[VCForkConfig, string] =
## This extracts all `_FORK_VERSION` and `_FORK_EPOCH` constants
## that are relevant for Validator Client operation.
##
## Note that the fork schedule (`/eth/v1/config/fork_schedule`) cannot be used
## because it does not indicate whether the forks refer to `ConsensusFork` or
## to a different fork sequence from an incompatible network (e.g., devnet)
let
res = VCForkConfig(
altairEpoch: info.getForkEpoch(ConsensusFork.Altair),
capellaVersion: ? info.getForkVersion(ConsensusFork.Capella),
capellaEpoch: info.getForkEpoch(ConsensusFork.Capella),
denebEpoch: info.getForkEpoch(ConsensusFork.Deneb))
if res.capellaEpoch < res.altairEpoch:
return err(
"Fork epochs are inconsistent, " & $ConsensusFork.Capella &
" is scheduled at epoch " & $res.capellaEpoch &
" which is before prior fork epoch " & $res.altairEpoch)
if res.denebEpoch < res.capellaEpoch:
return err(
"Fork epochs are inconsistent, " & $ConsensusFork.Deneb &
" is scheduled at epoch " & $res.denebEpoch &
" which is before prior fork epoch " & $res.capellaEpoch)
if res.capellaEpoch != FAR_FUTURE_EPOCH and res.capellaVersion.isNone:
return err(
"Beacon node has scheduled " &
ConsensusFork.Capella.forkEpochConfigKey() &
" but does not report " &
ConsensusFork.Capella.forkVersionConfigKey())
ok res

View File

@ -216,11 +216,11 @@ func compute_voluntary_exit_signing_root*(
fork, DOMAIN_VOLUNTARY_EXIT, epoch, genesis_validators_root)
compute_signing_root(voluntary_exit, domain)
func voluntary_exit_signature_fork*(
consensusFork: static ConsensusFork,
func voluntary_exit_signature_fork(
is_post_deneb: static bool,
state_fork: Fork,
capella_fork_version: Version): Fork =
when consensusFork >= ConsensusFork.Deneb:
when is_post_deneb:
# Always use Capella fork version, disregarding `VoluntaryExit` epoch
# [Modified in Deneb:EIP7044]
Fork(
@ -230,6 +230,27 @@ func voluntary_exit_signature_fork*(
else:
state_fork
func voluntary_exit_signature_fork*(
consensusFork: static ConsensusFork,
state_fork: Fork,
capella_fork_version: Version): Fork =
const is_post_deneb = (consensusFork >= ConsensusFork.Deneb)
voluntary_exit_signature_fork(is_post_deneb, state_fork, capella_fork_version)
func voluntary_exit_signature_fork*(
state_fork: Fork,
capella_fork_version: Version,
current_epoch: Epoch,
deneb_fork_epoch: Epoch): Fork =
if current_epoch >= deneb_fork_epoch:
const is_post_deneb = true
voluntary_exit_signature_fork(
is_post_deneb, state_fork, capella_fork_version)
else:
const is_post_deneb = false
voluntary_exit_signature_fork(
is_post_deneb, state_fork, capella_fork_version)
func get_voluntary_exit_signature*(
fork: Fork, genesis_validators_root: Eth2Digest,
voluntary_exit: VoluntaryExit,

View File

@ -192,7 +192,7 @@ type
waiters*: seq[BlockWaiter]
ValidatorRuntimeConfig* = object
altairEpoch*: Opt[Epoch]
forkConfig*: Opt[VCForkConfig]
ValidatorClient* = object
config*: ValidatorClientConf
@ -518,16 +518,6 @@ proc equals*(info: VCRuntimeConfig, name: string, check: DomainType): bool =
proc equals*(info: VCRuntimeConfig, name: string, check: Epoch): bool =
info.equals(name, uint64(check))
proc getOrDefault*(info: VCRuntimeConfig, name: string,
default: uint64): uint64 =
let numstr = info.getOrDefault(name, "missing")
if numstr == "missing": return default
Base10.decode(uint64, numstr).valueOr:
return default
proc getOrDefault*(info: VCRuntimeConfig, name: string, default: Epoch): Epoch =
Epoch(info.getOrDefault(name, uint64(default)))
proc checkConfig*(c: VCRuntimeConfig): bool =
c.equals("MAX_VALIDATORS_PER_COMMITTEE", MAX_VALIDATORS_PER_COMMITTEE) and
c.equals("SLOTS_PER_EPOCH", SLOTS_PER_EPOCH) and
@ -1436,33 +1426,84 @@ func `==`*(a, b: SyncCommitteeDuty): bool =
proc updateRuntimeConfig*(vc: ValidatorClientRef,
node: BeaconNodeServerRef,
info: VCRuntimeConfig): Result[void, string] =
if not(info.hasKey("ALTAIR_FORK_EPOCH")):
debug "Beacon node's configuration missing ALTAIR_FORK_EPOCH value",
node = node
var forkConfig = ? info.getConsensusForkConfig()
let
res = info.getOrDefault("ALTAIR_FORK_EPOCH", FAR_FUTURE_EPOCH)
wallEpoch = vc.beaconClock.now().slotOrZero().epoch()
if vc.runtimeConfig.forkConfig.isNone():
vc.runtimeConfig.forkConfig = Opt.some(forkConfig)
else:
template localForkConfig: untyped = vc.runtimeConfig.forkConfig.get()
let wallEpoch = vc.beaconClock.now().slotOrZero().epoch()
return
if vc.runtimeConfig.altairEpoch.get(FAR_FUTURE_EPOCH) == FAR_FUTURE_EPOCH:
vc.runtimeConfig.altairEpoch = Opt.some(res)
ok()
proc validateForkVersionCompatibility(
consensusFork: ConsensusFork,
localForkVersion: Opt[Version],
localForkEpoch: Epoch,
forkVersion: Opt[Version]): Result[void, string] =
if localForkVersion.isNone():
discard # Potentially discovered new fork, save it at end of function
else:
if res == vc.runtimeConfig.altairEpoch.get():
ok()
if forkVersion.isSome():
if forkVersion.get() == localForkVersion.get():
discard # Already known
else:
if res == FAR_FUTURE_EPOCH:
if wallEpoch < vc.runtimeConfig.altairEpoch.get():
debug "Beacon node must be updated before Altair activates",
return err("Beacon node has conflicting " &
consensusFork.forkVersionConfigKey() & " value")
else:
if wallEpoch < localForkEpoch:
debug "Beacon node must be updated before fork activates",
node = node,
altairForkEpoch = vc.runtimeConfig.altairEpoch.get()
consensusFork,
forkEpoch = localForkEpoch
else:
return err("Beacon node must be updated and report correct " &
$consensusFork & " config value")
? ConsensusFork.Capella.validateForkVersionCompatibility(
localForkConfig.capellaVersion,
localForkConfig.capellaEpoch,
forkConfig.capellaVersion)
proc validateForkEpochCompatibility(
consensusFork: ConsensusFork,
localForkEpoch: Epoch,
forkEpoch: Epoch): Result[void, string] =
if localForkEpoch == FAR_FUTURE_EPOCH:
discard # Potentially discovered new fork, save it at end of function
else:
if forkEpoch != FAR_FUTURE_EPOCH:
if forkEpoch == localForkEpoch:
discard # Already known
else:
return err("Beacon node has conflicting " &
consensusFork.forkEpochConfigKey() & " value")
else:
if wallEpoch < localForkEpoch:
debug "Beacon node must be updated before fork activates",
node = node,
consensusFork,
forkEpoch = localForkEpoch
else:
return err("Beacon node must be updated and report correct " &
$consensusFork & " config value")
? ConsensusFork.Altair.validateForkEpochCompatibility(
localForkConfig.altairEpoch, forkConfig.altairEpoch)
? ConsensusFork.Capella.validateForkEpochCompatibility(
localForkConfig.capellaEpoch, forkConfig.capellaEpoch)
? ConsensusFork.Deneb.validateForkEpochCompatibility(
localForkConfig.denebEpoch, forkConfig.denebEpoch)
# Save newly discovered forks.
if localForkConfig.altairEpoch == FAR_FUTURE_EPOCH:
localForkConfig.altairEpoch = forkConfig.altairEpoch
if localForkConfig.capellaVersion.isNone():
localForkConfig.capellaVersion = forkConfig.capellaVersion
if localForkConfig.capellaEpoch == FAR_FUTURE_EPOCH:
localForkConfig.capellaEpoch = forkConfig.capellaEpoch
if localForkConfig.denebEpoch == FAR_FUTURE_EPOCH:
localForkConfig.denebEpoch = forkConfig.denebEpoch
ok()
else:
err("Beacon node must be updated and report correct " &
"ALTAIR_FORK_EPOCH value")
else:
err("Beacon node has conflicting ALTAIR_FORK_EPOCH value")
proc `+`*(slot: Slot, epochs: Epoch): Slot =
slot + uint64(epochs) * SLOTS_PER_EPOCH

View File

@ -5,6 +5,8 @@
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
# at your option. This file may not be copied, modified, or distributed except according to those terms.
{.push raises: [].}
import std/[sets, sequtils]
import chronicles, metrics
import "."/[common, api, block_service, selection_proofs]
@ -210,7 +212,8 @@ proc pollForSyncCommitteeDuties*(
let
vc = service.client
indices = toSeq(vc.attachedValidators[].indices())
epoch = max(period.start_epoch(), vc.runtimeConfig.altairEpoch.get())
altairEpoch = vc.runtimeConfig.forkConfig.get().altairEpoch
epoch = max(period.start_epoch(), altairEpoch)
relevantDuties =
block:
var duties: seq[RestSyncCommitteeDuty]
@ -369,7 +372,10 @@ proc pollForSyncCommitteeDuties*(service: DutiesServiceRef) {.async.} =
let
currentSlot = vc.getCurrentSlot().get(Slot(0))
currentEpoch = currentSlot.epoch()
altairEpoch = vc.runtimeConfig.altairEpoch.valueOr:
altairEpoch =
if vc.runtimeConfig.forkConfig.isSome():
vc.runtimeConfig.forkConfig.get().altairEpoch
else:
return
if currentEpoch < altairEpoch:

View File

@ -73,6 +73,10 @@ type
proc (pubkey: ValidatorPubKey): Opt[ValidatorAndIndex]
{.raises: [], gcsafe.}
GetCapellaForkVersionFn* =
proc (): Opt[Version] {.raises: [], gcsafe.}
GetDenebForkEpochFn* =
proc (): Opt[Epoch] {.raises: [], gcsafe.}
GetForkFn* =
proc (epoch: Epoch): Opt[Fork] {.raises: [], gcsafe.}
GetGenesisFn* =
@ -90,6 +94,8 @@ type
defaultBuilderAddress*: Opt[string]
getValidatorAndIdxFn*: ValidatorPubKeyToDataFn
getBeaconTimeFn*: GetBeaconTimeFn
getCapellaForkVersionFn*: GetCapellaForkVersionFn
getDenebForkEpochFn*: GetDenebForkEpochFn
getForkFn*: GetForkFn
getGenesisFn*: GetGenesisFn
@ -122,6 +128,8 @@ func init*(T: type KeymanagerHost,
defaultBuilderAddress: Opt[string],
getValidatorAndIdxFn: ValidatorPubKeyToDataFn,
getBeaconTimeFn: GetBeaconTimeFn,
getCapellaForkVersionFn: GetCapellaForkVersionFn,
getDenebForkEpochFn: GetDenebForkEpochFn,
getForkFn: GetForkFn,
getGenesisFn: GetGenesisFn): T =
T(validatorPool: validatorPool,
@ -135,6 +143,8 @@ func init*(T: type KeymanagerHost,
defaultBuilderAddress: defaultBuilderAddress,
getValidatorAndIdxFn: getValidatorAndIdxFn,
getBeaconTimeFn: getBeaconTimeFn,
getCapellaForkVersionFn: getCapellaForkVersionFn,
getDenebForkEpochFn: getDenebForkEpochFn,
getForkFn: getForkFn,
getGenesisFn: getGenesisFn)

View File

@ -22,6 +22,7 @@ import
export
streams, keystore, phase0, altair, tables, uri, crypto,
signatures.voluntary_exit_signature_fork,
rest_types, eth2_rest_serialization, rest_remote_signer_calls,
slashing_protection