430 lines
15 KiB
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)
|