mirror of
https://github.com/status-im/nimbus-eth2.git
synced 2025-03-01 06:30:52 +00:00
This PR reduces the number of database queries for slashing protection from 5 reads and 1 write to 2 reads and 1 write in the optimistic case. In the process, it removes user-level support for writing the database in the version 1 format in order to simplify the code flow, and prevent code rot. In particular, the v1 format was not covered by any unit tests and has no advantages over v2. The concrete code to read and write it remains for now, in particular to support upgrades from v1 to v2. The branch also removes the use of concepts which doesn't work with checked exceptions - in particular, this highlights code that both raises exceptions and returns error codes, which could be cleaned up in the future. * Cache internal validator ID * Rely on unique index to check for trivial duplicate votes * Combine two surround vote queries into one * Combine API for checking and registering slashing into single function The slashing DB is normally not a bottleneck, but may become one with high attached validator counts.
401 lines
14 KiB
Nim
401 lines
14 KiB
Nim
# beacon_chain
|
|
# Copyright (c) 2018-2021 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: [Defect].}
|
|
|
|
import
|
|
# Stdlib
|
|
std/[typetraits, strutils, algorithm],
|
|
# Status
|
|
eth/db/[kvstore, kvstore_sqlite3],
|
|
stew/results,
|
|
stew/byteutils,
|
|
serialization,
|
|
json_serialization,
|
|
chronicles,
|
|
# Internal
|
|
../spec/[datatypes, digest, crypto]
|
|
|
|
export 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*: Eth2Digest0x # compute_signing_root(block, domain)
|
|
|
|
SPDIR_SignedAttestation* = object
|
|
source_epoch*: EpochString
|
|
target_epoch*: EpochString
|
|
signing_root*: 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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
# Serialization
|
|
# --------------------------------------------
|
|
|
|
proc writeValue*(writer: var JsonWriter, value: PubKey0x)
|
|
{.inline, raises: [IOError, Defect].} =
|
|
writer.writeValue("0x" & value.PubKeyBytes.toHex())
|
|
|
|
proc readValue*(reader: var JsonReader, value: var PubKey0x)
|
|
{.raises: [SerializationError, IOError, Defect].} =
|
|
try:
|
|
value = PubKey0x reader.readValue(string).hexToByteArray(RawPubKeySize)
|
|
except ValueError:
|
|
raiseUnexpectedValue(reader, "Hex string expected")
|
|
|
|
proc writeValue*(w: var JsonWriter, a: Eth2Digest0x)
|
|
{.inline, raises: [IOError, Defect].} =
|
|
w.writeValue "0x" & a.Eth2Digest.data.toHex()
|
|
|
|
proc readValue*(r: var JsonReader, a: var Eth2Digest0x)
|
|
{.raises: [SerializationError, IOError, Defect].} =
|
|
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, Defect].} =
|
|
w.writeValue $distinctBase(a)
|
|
|
|
proc readValue*(r: var JsonReader, a: var (SlotString or EpochString))
|
|
{.raises: [SerializationError, IOError, Defect].} =
|
|
try:
|
|
a = (typeof a)(r.readValue(string).parseBiggestUint())
|
|
except ValueError:
|
|
raiseUnexpectedValue(r, "Integer in a string expected")
|
|
|
|
proc exportSlashingInterchange*(
|
|
db: auto,
|
|
path: string, prettify = true) {.raises: [Defect, IOError].} =
|
|
## Export a database to the Slashing Protection Database Interchange Format
|
|
let spdir = db.toSPDIR()
|
|
Json.saveFile(path, spdir, prettify)
|
|
echo "Exported slashing protection DB to '", path, "'"
|
|
|
|
proc importSlashingInterchange*(
|
|
db: auto,
|
|
path: string): SlashingImportStatus {.raises: [Defect, 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: SPDIR_SignedBlock): auto =
|
|
(
|
|
slot: shortLog(v.slot.Slot),
|
|
signing_root: shortLog(v.signing_root.Eth2Digest)
|
|
)
|
|
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.Eth2Digest)
|
|
)
|
|
|
|
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, Defect].} =
|
|
## 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().loadWithCache().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()
|
|
|
|
# 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()
|
|
|
|
# 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
|
|
|
|
for b in 0 ..< spdir.data[v].signed_blocks.len:
|
|
template B: untyped = spdir.data[v].signed_blocks[b]
|
|
let status = db.registerBlock(
|
|
parsedKey, B.slot.Slot, B.signing_root.Eth2Digest
|
|
)
|
|
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
|
|
B.signing_root.Eth2Digest != ZeroDigest and
|
|
status.error.existingBlock == B.signing_root.Eth2Digest:
|
|
warn "Block already exists in the DB",
|
|
pubkey = spdir.data[v].pubkey.PubKeyBytes.toHex(),
|
|
candidateBlock = B
|
|
continue
|
|
else:
|
|
error "Slashable block. Skipping its import.",
|
|
pubkey = spdir.data[v].pubkey.PubKeyBytes.toHex(),
|
|
candidateBlock = B,
|
|
conflict = status.error()
|
|
result = siPartial
|
|
continue
|
|
|
|
if B.slot.int > maxValidSlotSeen:
|
|
maxValidSlotSeen = B.slot.int
|
|
|
|
# Now prune everything that predates
|
|
# this interchange file max slot
|
|
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
|
|
|
|
for a in 0 ..< spdir.data[v].signed_attestations.len:
|
|
template A: untyped = spdir.data[v].signed_attestations[a]
|
|
let status = db.registerAttestation(
|
|
parsedKey,
|
|
A.source_epoch.Epoch,
|
|
A.target_epoch.Epoch,
|
|
A.signing_root.Eth2Digest
|
|
)
|
|
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 checkSlashableAttestation would have rejected it.
|
|
# We special-case that for imports.
|
|
if status.error.kind == DoubleVote and
|
|
A.signing_root.Eth2Digest != ZeroDigest and
|
|
status.error.existingAttestation == A.signing_root.Eth2Digest:
|
|
warn "Attestation already exists in the DB",
|
|
pubkey = spdir.data[v].pubkey.PubKeyBytes.toHex(),
|
|
candidateAttestation = A
|
|
continue
|
|
else:
|
|
error "Slashable vote. Skipping its import.",
|
|
pubkey = spdir.data[v].pubkey.PubKeyBytes.toHex(),
|
|
candidateAttestation = A,
|
|
conflict = status.error()
|
|
result = siPartial
|
|
continue
|
|
|
|
if A.source_epoch.int > maxValidSourceEpochSeen:
|
|
maxValidSourceEpochSeen = A.source_epoch.int
|
|
if A.target_epoch.int > maxValidTargetEpochSeen:
|
|
maxValidTargetEpochSeen = A.target_epoch.int
|
|
|
|
# Now prune everything that predates
|
|
# this interchange file max slot
|
|
if maxValidSourceEpochSeen < 0 or maxValidTargetEpochSeen < 0:
|
|
doAssert maxValidSourceEpochSeen == -1 and maxValidTargetEpochSeen == -1
|
|
notice "No attestation found in slashing interchange file"
|
|
return
|
|
db.pruneAttestations(parsedKey, maxValidSourceEpochSeen, maxValidTargetEpochSeen)
|