nimbus-eth2/beacon_chain/validators/slashing_protection_common.nim

430 lines
15 KiB
Nim

# 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
# Stdlib
std/[typetraits, strutils, algorithm],
# Status
stew/byteutils,
results,
serialization,
json_serialization, json_serialization/std/options,
chronicles,
# Internal
../spec/datatypes/base
export options, serialization, json_serialization # Generic sandwich https://github.com/nim-lang/Nim/issues/11225
# Slashing Protection Interop
# --------------------------------------------
# We use the SPDIR type as an intermediate representation
# between database versions and to generate
# the serialized interchanged format.
#
# References: https://eips.ethereum.org/EIPS/eip-3076
#
# SPDIR: Nimbus-specific, Slashing Protection Database Intermediate Representation
# SPDIF: Cross-client, JSON, Slashing Protection Database Interchange Format
type
SPDIR* = object
## Slashing Protection Database Interchange Format
metadata*: SPDIR_Meta
data*: seq[SPDIR_Validator]
Eth2Digest0x* = distinct Eth2Digest
## The spec mandates "0x" prefix on serialization
## So we need to set custom read/write
PubKeyBytes* = array[RawPubKeySize, byte]
## This is the serialized byte representation
## of a Validator Public Key.
## Portable between Miracl/BLST
## and limits serialization/deserialization call
PubKey0x* = distinct PubKeyBytes
## The spec mandates "0x" prefix on serialization
## So we need to set custom read/write
## We also assume that pubkeys in the database
## are valid points on the BLS12-381 G1 curve
## (so we skip fromRaw/serialization checks)
SlotString* = distinct Slot
## The spec mandates string serialization for wide compatibility (javascript)
EpochString* = distinct Epoch
## The spec mandates string serialization for wide compatibility (javascript)
SPDIR_Meta* = object
interchange_format_version*: string
genesis_validators_root*: Eth2Digest0x
SPDIR_Validator* = object
pubkey*: PubKey0x
signed_blocks*: seq[SPDIR_SignedBlock]
signed_attestations*: seq[SPDIR_SignedAttestation]
SPDIR_SignedBlock* = object
slot*: SlotString
signing_root*: Option[Eth2Digest0x] # compute_signing_root(block, domain)
SPDIR_SignedAttestation* = object
source_epoch*: EpochString
target_epoch*: EpochString
signing_root*: Option[Eth2Digest0x] # compute_signing_root(attestation, domain)
# Slashing Protection types
# --------------------------------------------
SlashingImportStatus* = enum
siSuccess
siFailure
siPartial
BadVoteKind* = enum
## Attestation bad vote kind
# h: height (i.e. epoch for attestation, slot for blocks)
# t: target
# s: source
# 1: existing attestations
# 2: candidate attestation
# Spec slashing condition
DoubleVote # h(t1) == h(t2)
SurroundVote # h(s1) < h(s2) < h(t2) < h(t1) or h(s2) < h(s1) < h(t1) < h(t2)
# Non-spec, should never happen in a well functioning client
TargetPrecedesSource # h(t1) < h(s1) - current epoch precedes last justified epoch
# EIP-3067 (https://eips.ethereum.org/EIPS/eip-3076)
MinSourceViolation # h(s2) < h(s1) - EIP3067 condition 4 (strict inequality)
MinTargetViolation # h(t2) <= h(t1) - EIP3067 condition 5
DatabaseError # Cannot read/write the slashing protection db
BadVote* {.pure.} = object
case kind*: BadVoteKind
of DoubleVote:
existingAttestation*: Eth2Digest
of SurroundVote:
existingAttestationRoot*: Eth2Digest # Many roots might be in conflict
sourceExisting*, targetExisting*: Epoch
sourceSlashable*, targetSlashable*: Epoch
of TargetPrecedesSource:
discard
of MinSourceViolation:
minSource*: Epoch
candidateSource*: Epoch
of MinTargetViolation:
minTarget*: Epoch
candidateTarget*: Epoch
of BadVoteKind.DatabaseError:
message*: string
BadProposalKind* {.pure.} = enum
# Spec slashing condition
DoubleProposal # h(t1) == h(t2)
# EIP-3067 (https://eips.ethereum.org/EIPS/eip-3076)
MinSlotViolation # h(t2) <= h(t1)
DatabaseError # Cannot read/write the slashing protection db
BadProposal* = object
case kind*: BadProposalKind
of DoubleProposal:
existingBlock*: Eth2Digest
of MinSlotViolation:
minSlot*: Slot
candidateSlot*: Slot
of BadProposalKind.DatabaseError:
message*: string
{.push warning[ProveField]:off.}
func `==`*(a, b: BadVote): bool =
## Comparison operator.
## Used implictily by Result when comparing the
## result of multiple DB versions
if a.kind != b.kind:
false
else:
case a.kind
of DoubleVote:
a.existingAttestation == b.existingAttestation
of SurroundVote:
(a.existingAttestationRoot == b.existingAttestationRoot) and
(a.sourceExisting == b.sourceExisting) and
(a.targetExisting == b.targetExisting) and
(a.sourceSlashable == b.sourceSlashable) and
(a.targetSlashable == b.targetSlashable)
of TargetPrecedesSource:
true
of MinSourceViolation:
(a.minSource == b.minSource) and
(a.candidateSource == b.candidateSource)
of MinTargetViolation:
(a.minTarget == b.minTarget) and
(a.candidateTarget == b.candidateTarget)
of BadVoteKind.DatabaseError:
true
{.pop.}
template `==`*(a, b: PubKey0x): bool =
PubKeyBytes(a) == PubKeyBytes(b)
template `<`*(a, b: PubKey0x): bool =
PubKeyBytes(a) < PubKeyBytes(b)
template cmp*(a, b: PubKey0x): bool =
cmp(PubKeyBytes(a), PubKeyBytes(b))
{.push warning[ProveField]:off.}
func `==`*(a, b: BadProposal): bool =
## Comparison operator.
## Used implictily by Result when comparing the
## result of multiple DB versions
##
## Except that V1 doesn't support low-watermark...
if a.kind != b.kind:
false
elif a.kind == DoubleProposal:
a.existingBlock == b.existingBlock
elif a.kind == MinSlotViolation:
a.minSlot == b.minSlot and
a.candidateSlot == b.candidateSlot
else: # Unreachable
false
{.pop.}
# Serialization
# --------------------------------------------
proc writeValue*(
writer: var JsonWriter, value: PubKey0x) {.inline, raises: [IOError].} =
writer.writeValue("0x" & value.PubKeyBytes.toHex())
proc readValue*(reader: var JsonReader, value: var PubKey0x)
{.raises: [SerializationError, IOError].} =
try:
value = PubKey0x hexToByteArray(reader.readValue(string), RawPubKeySize)
except ValueError:
raiseUnexpectedValue(reader, "Hex string expected")
proc writeValue*(
w: var JsonWriter, a: Eth2Digest0x) {.inline, raises: [IOError].} =
w.writeValue "0x" & a.Eth2Digest.data.toHex()
proc readValue*(r: var JsonReader, a: var Eth2Digest0x)
{.raises: [SerializationError, IOError].} =
try:
a = Eth2Digest0x fromHex(Eth2Digest, r.readValue(string))
except ValueError:
raiseUnexpectedValue(r, "Hex string expected")
proc writeValue*(
w: var JsonWriter, a: SlotString or EpochString
) {.inline, raises: [IOError].} =
w.writeValue $distinctBase(a)
proc readValue*(r: var JsonReader, a: var (SlotString or EpochString))
{.raises: [SerializationError, IOError].} =
try:
a = (typeof a)(r.readValue(string).parseBiggestUInt())
except ValueError:
raiseUnexpectedValue(r, "Integer in a string expected")
proc importSlashingInterchange*(
db: auto,
path: string): SlashingImportStatus {.raises: [IOError, SerializationError].} =
## Import a Slashing Protection Database Interchange Format
## into a Nimbus DB.
## This adds data to already existing data.
let spdir = Json.loadFile(path, SPDIR)
return db.inclSPDIR(spdir)
# Logging
# --------------------------------------------
func shortLog*(v: Option[Eth2Digest0x]): auto =
(
if v.isSome:
v.get.Eth2Digest.shortLog
else:
"none"
)
func shortLog*(v: SPDIR_SignedBlock): auto =
(
slot: shortLog(v.slot.Slot),
signing_root: shortLog(v.signing_root)
)
func shortLog*(v: SPDIR_SignedAttestation): auto =
(
source_epoch: shortLog(v.source_epoch.Epoch),
target_epoch: shortLog(v.target_epoch.Epoch),
signing_root: shortLog(v.signing_root)
)
chronicles.formatIt SlotString: it.Slot.shortLog
chronicles.formatIt EpochString: it.Slot.shortLog
chronicles.formatIt Eth2Digest0x: it.Eth2Digest.shortLog
chronicles.formatIt SPDIR_SignedBlock: it.shortLog
chronicles.formatIt SPDIR_SignedAttestation: it.shortLog
# Interchange import
# --------------------------------------------
proc importInterchangeV5Impl*(
db: auto,
spdir: var SPDIR
): SlashingImportStatus
{.raises: [SerializationError, IOError].} =
## Common implementation of interchange import
## according to https://eips.ethereum.org/EIPS/eip-3076
## spdir needs to be `var` as it will be sorted in-place
result = siSuccess
for v in 0 ..< spdir.data.len:
let parsedKey = block:
let key = ValidatorPubKey.fromRaw(spdir.data[v].pubkey.PubKeyBytes)
if key.isErr:
# The bytes does not describe a valid encoding (length error)
error "Invalid public key.",
pubkey = "0x" & spdir.data[v].pubkey.PubKeyBytes.toHex()
result = siPartial
continue
if key.get().load().isNone():
# The bytes don't deserialize to a valid BLS G1 elliptic curve point.
# Deserialization is costly but done only once per validator.
# and SlashingDB import is a very rare event.
error "Invalid public key.",
pubkey = "0x" & spdir.data[v].pubkey.PubKeyBytes.toHex()
result = siPartial
continue
key.get()
# TODO: with minification sorting is unnecessary, cleanup
# Sort by ascending minimum slot so that we don't trigger MinSlotViolation
spdir.data[v].signed_blocks.sort do (a, b: SPDIR_SignedBlock) -> int:
result = cmp(a.slot.int, b.slot.int)
spdir.data[v].signed_attestations.sort do (a, b: SPDIR_SignedAttestation) -> int:
result = cmp(a.source_epoch.int, b.source_epoch.int)
if result == 0: # Same epoch
result = cmp(a.target_epoch.int, b.target_epoch.int)
const ZeroDigest = Eth2Digest()
let (dbSlot, dbSource, dbTarget) = db.retrieveLatestValidatorData(parsedKey)
# Blocks
# ---------------------------------------------------
# After import we need to prune the DB from everything
# besides the last imported block slot.
# This ensures that even if 2 slashing DB are imported in the wrong order
# (the last before the earliest) the minSlotViolation check stays consistent.
var maxValidSlotSeen = -1
if dbSlot.isSome():
maxValidSlotSeen = int dbSlot.get()
if spdir.data[v].signed_blocks.len >= 1:
# Minification, to limit SQLite IO we only import the last block after sorting
template B: untyped = spdir.data[v].signed_blocks[^1]
let
signing_root =
if B.signing_root.isSome:
B.signing_root.get.Eth2Digest
else:
# https://eips.ethereum.org/EIPS/eip-3076#advice-for-complete-databases
# "If your database records the signing roots of messages in
# addition to their slot/epochs, you should ensure that imported
# messages without signing roots are assigned a suitable dummy
# signing root internally. We suggest using a special "null" value
# which is distinct from all other signing roots, although a value
# like 0x0 may be used instead (as it is extremely unlikely to
# collide with any real signing root)."
ZeroDigest
status = db.registerBlock(parsedKey, B.slot.Slot, signing_root)
if status.isErr():
# We might be importing a duplicate which EIP-3076 allows
# there is no reason during normal operation to integrate
# a duplicate so checkSlashableBlockProposal would have rejected it.
# We special-case that for imports.
# Note: rule 2 mentions repeat signing in the MinSlotViolation case
# having 2 blocks with the same signing root and different slots
# would break the blockchain so we only check for exact slot.
if status.error.kind == DoubleProposal and
signing_root != ZeroDigest and
status.error.existingBlock == signing_root:
warn "Block already exists in the DB",
pubkey = spdir.data[v].pubkey.PubKeyBytes.toHex(),
candidateBlock = B
else:
error "Slashable block. Skipping its import.",
pubkey = spdir.data[v].pubkey.PubKeyBytes.toHex(),
candidateBlock = B,
conflict = status.error()
result = siPartial
if B.slot.int > maxValidSlotSeen:
maxValidSlotSeen = int B.slot
# Now prune everything that predates
# this DB or interchange file max slot
# Even if the block is not imported, pruning will keep the latest one.
db.pruneBlocks(parsedKey, Slot maxValidSlotSeen)
# Attestations
# ---------------------------------------------------
# After import we need to prune the DB from everything
# besides the last imported attestation source and target epochs.
# This ensures that even if 2 slashing DB are imported in the wrong order
# (the last before the earliest) the minEpochViolation check stays consistent.
var maxValidSourceEpochSeen = -1
var maxValidTargetEpochSeen = -1
if dbSource.isSome():
maxValidSourceEpochSeen = int dbSource.get()
if dbTarget.isSome():
maxValidTargetEpochSeen = int dbTarget.get()
# We do a first pass over the data to find the max source/target seen
for a in 0 ..< spdir.data[v].signed_attestations.len:
template A: untyped = spdir.data[v].signed_attestations[a]
if A.source_epoch.int > maxValidSourceEpochSeen:
maxValidSourceEpochSeen = A.source_epoch.int
if A.target_epoch.int > maxValidTargetEpochSeen:
maxValidTargetEpochSeen = A.target_epoch.int
if maxValidSourceEpochSeen < 0 or maxValidTargetEpochSeen < 0:
doAssert maxValidSourceEpochSeen == -1 and maxValidTargetEpochSeen == -1
notice "No attestation found in slashing interchange file for validator",
pubkey = spdir.data[v].pubkey.PubKeyBytes.toHex()
continue
# See formal proof https://github.com/michaelsproul/slashing-proofs
# of synthetic attestation
if not(maxValidSourceEpochSeen < maxValidTargetEpochSeen) and
not(maxValidSourceEpochSeen == 0 and maxValidTargetEpochSeen == 0):
# Special-case genesis (Slashing prot is deactivated anyway)
warn "Invalid attestation(s), source epochs should be less than target epochs, skipping import",
pubkey = spdir.data[v].pubkey.PubKeyBytes.toHex(),
maxValidSourceEpochSeen = maxValidSourceEpochSeen,
maxValidTargetEpochSeen = maxValidTargetEpochSeen
result = siPartial
continue
db.registerSyntheticAttestation(
parsedKey,
Epoch maxValidSourceEpochSeen,
Epoch maxValidTargetEpochSeen
)
db.pruneAttestations(parsedKey, maxValidSourceEpochSeen, maxValidTargetEpochSeen)