Skip to content

Commit 869c9fa

Browse files
committed
Apply the bot reviews of the slashing-protection interchange's second round. An export lists a key once however many times it is requested: a DELETE naming one key twice answered deleted then not_active and handed over an interchange naming it twice, which this client's own record reader refuses, and a repeated --pubkeys did the same. The CLI import parses its file once, staging it before the record is opened for the chain it names, so interchange_chain is gone and StagedHistory exposes its root. The keymanager import parses the interchange on the blocking pool, since a complete-format file runs to tens of megabytes and the handler's thread also drives the duty services. absorb warns when a registered key already has a record and brought no history, since that record is what this client last signed with the key and says nothing about what it signed elsewhere since. The DELETE comments say why the export may run after the keys leave the store (the duty services record before taking the store's read lock) and that a failed export leaves the deletions in place with their records kept for a retry. The interchange_vectors target drops an unused import that failed make lint, and the workflow's by-name clippy step now names the target as the Makefile does, which is why CI had not caught it. Four tests: an import raises an existing watermark and the raise survives a restart, an export lists a key requested twice once, a DELETE listing a key twice exports it once, and history naming a key outside the request gives it a record that a later DELETE answers not_active with. The CLI reference no longer says a failed import leaves a lock file, since the lock is now on the directory.
1 parent fbc265e commit 869c9fa

6 files changed

Lines changed: 205 additions & 33 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ jobs:
6363
# lag leanVM, and `--all-targets` leaves such targets out. Name them so
6464
# they keep compiling until they run again.
6565
- name: Clippy (skipped spectests)
66-
run: cargo clippy --locked --workspace --test forkchoice_spectests --test signature_spectests --test stf_spectests --test ssz_spectests -- -D warnings
66+
run: cargo clippy --locked --workspace --test forkchoice_spectests --test signature_spectests --test stf_spectests --test ssz_spectests --test interchange_vectors -- -D warnings
6767

6868
# Two features gate code that no step in `lint` reaches, so both would rot
6969
# silently between merges.

‎bin/ethlambda/src/slashing_protection.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,12 @@ pub(crate) fn run(options: SlashingProtectionOptions) -> eyre::Result<()> {
5555
} => {
5656
let interchange = std::fs::read_to_string(&file)
5757
.map_err(|err| eyre::eyre!("{}: {err}", file.display()))?;
58-
let chain = SlashingProtection::interchange_chain(&interchange)?;
59-
let mut protection = SlashingProtection::open_offline(&validators_dir, Some(chain))?;
60-
let validators = protection.import(&interchange)?;
58+
// Parsed once: the file can be tens of megabytes in the complete
59+
// format, and its chain is needed before the record is opened.
60+
let staged = SlashingProtection::stage(&interchange)?;
61+
let mut protection =
62+
SlashingProtection::open_offline(&validators_dir, Some(staged.root()))?;
63+
let (validators, _) = protection.absorb(Some(&staged), &[])?;
6164
info!(
6265
validators,
6366
file = %file.display(),

‎crates/validator/src/http_api/keystores.rs‎

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ pub async fn list(State(context): State<KeymanagerContext>) -> Json<ListResponse
151151

152152
pub async fn import(
153153
State(context): State<KeymanagerContext>,
154-
Json(request): Json<ImportRequest>,
154+
Json(mut request): Json<ImportRequest>,
155155
) -> Json<StatusResponse> {
156156
if request.keystores.len() > MAX_KEYSTORES_PER_IMPORT {
157157
let message = format!(
@@ -174,11 +174,23 @@ pub async fn import(
174174
// record's chain before anything is touched, so a request whose history
175175
// would be refused imports no key at all, rather than some keys without
176176
// it. It is written later, in the same write as the keys' registration,
177-
// once the keys it came with are known to be saved.
178-
let history = match &request.slashing_protection {
177+
// once the keys it came with are known to be saved. Parsed on the
178+
// blocking pool: a complete-format interchange runs to tens of megabytes
179+
// (see `KEYMANAGER_BODY_LIMIT`), and the executor thread this handler
180+
// runs on also drives the duty services.
181+
let history = match request.slashing_protection.take() {
179182
None => None,
180183
Some(interchange) => {
181-
let staged = crate::slashing_protection::SlashingProtection::stage(interchange);
184+
let staged = tokio::task::spawn_blocking(move || {
185+
crate::slashing_protection::SlashingProtection::stage(&interchange)
186+
})
187+
.await
188+
.unwrap_or_else(|err| {
189+
Err(Error::SlashingProtection {
190+
path: "interchange".to_string(),
191+
reason: format!("could not be parsed: {err}"),
192+
})
193+
});
182194
let judged = match staged {
183195
Ok(staged) => {
184196
let staged = Arc::new(staged);
@@ -573,10 +585,14 @@ pub async fn delete(
573585

574586
// Exported only now, with the keys out of the store: a signature with one
575587
// of them either finished before the write lock above was granted, and is
576-
// in the record, or can no longer happen. Only for the keys that are
577-
// `deleted` or may become `not_active`, as the specification requires: a
578-
// key whose delete failed is still on disk and comes back on restart, so
579-
// its history is not handed over. Teku and Prysm filter the same way.
588+
// in the record, or can no longer happen. That holds because the duty
589+
// services record before they take the store's read lock to sign (see
590+
// `attestation.rs` and `proposal.rs`), so no signature exists without its
591+
// record already on disk. Only for the keys that are `deleted` or may
592+
// become `not_active`, as the specification requires: a key whose delete
593+
// failed is still on disk and comes back on restart, so its history is
594+
// not handed over. Teku and Prysm filter the same way. A key listed twice
595+
// is `deleted` once and `not_found` once; `export` lists it once.
580596
let requested: Vec<Option<BlsPubkey>> = results
581597
.iter()
582598
.map(|(pubkey, status)| match status.status.as_str() {
@@ -601,7 +617,11 @@ pub async fn delete(
601617
// history from one whose history could not be read, so every key
602618
// that parsed becomes an error, as Prysm answers: a `not_found`
603619
// here would tell a migrator there is nothing to carry. A
604-
// malformed pubkey keeps its own error.
620+
// malformed pubkey keeps its own error. The deletions themselves
621+
// already happened and are not undone: the keys' records are
622+
// kept, so a retry answers `not_active` with their history. The
623+
// field is required, so it carries an empty string rather than
624+
// an interchange that would read as "nothing signed".
605625
warn!(%err, "Deleted keys whose slashing-protection history could not be exported");
606626
let message = format!("slashing_protection could not be exported: {err}");
607627
let data = results
@@ -1280,10 +1300,14 @@ mod tests {
12801300

12811301
/// A minimal interchange for `KEYSTORE_PUBKEY` on the chain `root`.
12821302
fn history(root: &str, slot: u64, source: u64, target: u64) -> String {
1303+
history_for(KEYSTORE_PUBKEY, root, slot, source, target)
1304+
}
1305+
1306+
fn history_for(pubkey: &str, root: &str, slot: u64, source: u64, target: u64) -> String {
12831307
serde_json::json!({
12841308
"metadata": { "interchange_format_version": "5", "genesis_validators_root": root },
12851309
"data": [{
1286-
"pubkey": KEYSTORE_PUBKEY,
1310+
"pubkey": pubkey,
12871311
"signed_blocks": [{ "slot": slot.to_string() }],
12881312
"signed_attestations": [{
12891313
"source_epoch": source.to_string(),
@@ -1425,6 +1449,94 @@ mod tests {
14251449
);
14261450
}
14271451

1452+
/// The history may name a validator that is not in the request: it gets a
1453+
/// record too, so a later delete of it answers `not_active` with that
1454+
/// history rather than `not_found`.
1455+
#[tokio::test]
1456+
async fn history_for_a_key_outside_the_request_gets_a_record() {
1457+
let dir = tempfile::tempdir().expect("temp dir");
1458+
let (context, protection) = context_with_record(dir.path());
1459+
let app = router(context, TOKEN.to_string());
1460+
let other = format!("0x{}", "ab".repeat(48));
1461+
1462+
let (_, body) = send(
1463+
app.clone(),
1464+
authed(
1465+
"POST",
1466+
"/eth/v1/keystores",
1467+
serde_json::json!({
1468+
"keystores": [keystore_json()],
1469+
"passwords": [PASSWORD],
1470+
"slashing_protection": history_for(&other, ZERO_ROOT, 100, 5, 10),
1471+
}),
1472+
),
1473+
)
1474+
.await;
1475+
assert_eq!(body["data"][0]["status"], "imported", "got {body}");
1476+
1477+
let other_pubkey = parse_pubkey(&other).expect("pubkey");
1478+
{
1479+
let protection = protection.lock().expect("not poisoned");
1480+
assert!(protection.contains(&other_pubkey));
1481+
assert!(protection.check_block(&other_pubkey, 100).is_err());
1482+
assert!(protection.contains(&parse_pubkey(KEYSTORE_PUBKEY).expect("pubkey")));
1483+
}
1484+
1485+
let (_, body) = send(
1486+
app,
1487+
authed(
1488+
"DELETE",
1489+
"/eth/v1/keystores",
1490+
serde_json::json!({ "pubkeys": [other] }),
1491+
),
1492+
)
1493+
.await;
1494+
assert_eq!(body["data"][0]["status"], "not_active", "got {body}");
1495+
let interchange: serde_json::Value =
1496+
serde_json::from_str(body["slashing_protection"].as_str().expect("a string"))
1497+
.expect("json");
1498+
assert_eq!(interchange["data"][0]["pubkey"], other);
1499+
assert_eq!(interchange["data"][0]["signed_blocks"][0]["slot"], "100");
1500+
}
1501+
1502+
/// A key listed twice in one delete is `deleted` once and `not_active`
1503+
/// once, and the returned interchange names it once: one that named a
1504+
/// validator twice would be refused on import.
1505+
#[tokio::test]
1506+
async fn a_delete_listing_a_key_twice_exports_it_once() {
1507+
let dir = tempfile::tempdir().expect("temp dir");
1508+
let (context, _) = context_with_record(dir.path());
1509+
let app = router(context, TOKEN.to_string());
1510+
1511+
let (_, body) = send(
1512+
app.clone(),
1513+
authed(
1514+
"POST",
1515+
"/eth/v1/keystores",
1516+
serde_json::json!({ "keystores": [keystore_json()], "passwords": [PASSWORD] }),
1517+
),
1518+
)
1519+
.await;
1520+
assert_eq!(body["data"][0]["status"], "imported", "got {body}");
1521+
1522+
let (_, body) = send(
1523+
app,
1524+
authed(
1525+
"DELETE",
1526+
"/eth/v1/keystores",
1527+
serde_json::json!({ "pubkeys": [KEYSTORE_PUBKEY, KEYSTORE_PUBKEY] }),
1528+
),
1529+
)
1530+
.await;
1531+
assert_eq!(body["data"][0]["status"], "deleted", "got {body}");
1532+
assert_eq!(body["data"][1]["status"], "not_active", "got {body}");
1533+
let interchange: serde_json::Value =
1534+
serde_json::from_str(body["slashing_protection"].as_str().expect("a string"))
1535+
.expect("json");
1536+
assert_eq!(interchange["data"].as_array().expect("array").len(), 1);
1537+
assert_eq!(interchange["data"][0]["pubkey"], KEYSTORE_PUBKEY);
1538+
}
1539+
14281540
/// History that came with a key that then failed to import is not
14291541
/// written: nothing it describes is held here.
14301542
#[cfg(unix)]

‎crates/validator/src/slashing_protection/mod.rs‎

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
6060
pub mod file;
6161

62-
use std::collections::HashMap;
62+
use std::collections::{HashMap, HashSet};
6363
use std::path::{Path, PathBuf};
6464
use std::sync::{Arc, Mutex};
6565

@@ -150,6 +150,14 @@ pub struct StagedHistory {
150150
entries: Vec<(BlsPubkey, Record)>,
151151
}
152152

153+
impl StagedHistory {
154+
/// The chain the interchange names: what a record created for it must
155+
/// belong to.
156+
pub fn root(&self) -> Root {
157+
self.root
158+
}
159+
}
160+
153161
/// What one validator has signed: its watermarks.
154162
///
155163
/// `None` is "never signed", kept distinct from zero so that slot 0 and epoch
@@ -531,17 +539,6 @@ impl SlashingProtection {
531539
self.absorb(None, pubkeys).map(|(_, registered)| registered)
532540
}
533541

534-
/// The chain an EIP-3076 interchange names, read without importing it: what
535-
/// a record created for it must belong to.
536-
pub fn interchange_chain(interchange: &str) -> Result<Root> {
537-
file::parse(interchange)
538-
.map(|(root, _)| root)
539-
.map_err(|reason| Error::SlashingProtection {
540-
path: "interchange".to_string(),
541-
reason,
542-
})
543-
}
544-
545542
/// The chain this record belongs to.
546543
pub fn genesis_validators_root(&self) -> Root {
547544
self.genesis_validators_root
@@ -607,6 +604,11 @@ impl SlashingProtection {
607604
/// every key in `register` that then still has no record, in one durable
608605
/// write: either all of it is on disk or none of it is. Returns how many
609606
/// validators the history named and how many keys were newly registered.
607+
///
608+
/// A key in `register` that already has a record and is not in the
609+
/// history keeps that record, with a warning: it is what this client last
610+
/// signed with the key, which says nothing about what the key signed
611+
/// elsewhere since.
610612
pub fn absorb(
611613
&mut self,
612614
history: Option<&StagedHistory>,
@@ -627,10 +629,19 @@ impl SlashingProtection {
627629
let named = staged.len();
628630
let mut registered = 0;
629631
for pubkey in register {
630-
if !staged.contains_key(pubkey) && !self.records.contains_key(pubkey) {
631-
staged.insert(*pubkey, Record::default());
632-
registered += 1;
632+
if staged.contains_key(pubkey) {
633+
continue;
633634
}
635+
if self.records.contains_key(pubkey) {
636+
warn!(
637+
pubkey = %encode_hex(&pubkey.0),
638+
"Registering a validator that already has a slashing-protection record and \
639+
brought no history; it continues from what this client last signed with it"
640+
);
641+
continue;
642+
}
643+
staged.insert(*pubkey, Record::default());
644+
registered += 1;
634645
}
635646
if !staged.is_empty() {
636647
self.commit(staged)?;
@@ -642,10 +653,14 @@ impl SlashingProtection {
642653
///
643654
/// A key with no record is left out rather than listed as never having
644655
/// signed: an empty entry would tell the importer something this client
645-
/// does not know.
656+
/// does not know. A key listed twice is exported once: an interchange that
657+
/// names a validator twice is refused by stricter importers, this client's
658+
/// own record reader among them.
646659
pub fn export(&self, pubkeys: &[BlsPubkey]) -> Result<String> {
660+
let mut seen = HashSet::with_capacity(pubkeys.len());
647661
let selected = pubkeys
648662
.iter()
663+
.filter(|pubkey| seen.insert(**pubkey))
649664
.filter_map(|pubkey| self.records.get_key_value(pubkey));
650665
file::render(self.genesis_validators_root, selected).map_err(|err| {
651666
Error::SlashingProtection {
@@ -1189,6 +1204,30 @@ mod tests {
11891204
assert!(attest(&mut protection, 1, &data(128, 3, 4, 0xaa)).is_ok());
11901205
}
11911206

1207+
/// A newer history imported over an older record raises it, and the raised
1208+
/// watermarks survive a restart: the merge updates an existing entry, not
1209+
/// only inserts a missing one.
1210+
#[test]
1211+
fn an_import_raises_an_existing_watermark() {
1212+
let dir = tempfile::tempdir().expect("temp dir");
1213+
{
1214+
let mut protection = open(dir.path(), &[1], true).expect("init");
1215+
propose(&mut protection, 1, 10).expect("propose");
1216+
attest(&mut protection, 1, &data(96, 2, 3, 0xaa)).expect("attest");
1217+
1218+
protection
1219+
.import(&interchange(0x42, 1, 50, 4, 6))
1220+
.expect("imports");
1221+
assert!(propose(&mut protection, 1, 40).is_err());
1222+
assert!(attest(&mut protection, 1, &data(160, 3, 5, 0xaa)).is_err());
1223+
}
1224+
let mut protection = open(dir.path(), &[1], false).expect("reopens");
1225+
assert!(propose(&mut protection, 1, 50).is_err());
1226+
assert!(propose(&mut protection, 1, 51).is_ok());
1227+
assert!(attest(&mut protection, 1, &data(192, 4, 6, 0xaa)).is_err());
1228+
assert!(attest(&mut protection, 1, &data(224, 4, 7, 0xaa)).is_ok());
1229+
}
1230+
11921231
/// An older history imported over a newer record must not lower it.
11931232
#[test]
11941233
fn an_import_never_lowers_a_watermark() {
@@ -1233,6 +1272,24 @@ mod tests {
12331272
assert!(propose(&mut fresh, 1, 97).is_err());
12341273
}
12351274

1275+
/// A key requested twice is exported once: this client's own record
1276+
/// reader refuses a file naming a validator twice, and so may others'.
1277+
#[test]
1278+
fn an_export_lists_a_key_requested_twice_once() {
1279+
let dir = tempfile::tempdir().expect("temp dir");
1280+
let mut protection = open(dir.path(), &[1], true).expect("init");
1281+
propose(&mut protection, 1, 97).expect("propose");
1282+
1283+
let exported = protection.export(&[pubkey(1), pubkey(1)]).expect("exports");
1284+
let json: serde_json::Value = serde_json::from_str(&exported).expect("json");
1285+
assert_eq!(json["data"].as_array().expect("array").len(), 1);
1286+
1287+
let other = tempfile::tempdir().expect("temp dir");
1288+
let mut fresh = open(other.path(), &[], false).expect("opens");
1289+
fresh.import(&exported).expect("round trip");
1290+
assert!(propose(&mut fresh, 1, 97).is_err());
1291+
}
1292+
12361293
#[test]
12371294
fn opening_offline_needs_a_record_or_a_chain_to_create_one_for() {
12381295
let dir = tempfile::tempdir().expect("temp dir");
@@ -1276,7 +1333,7 @@ mod tests {
12761333
let mut protection = open(dir.path(), &[], false).expect("opens");
12771334
for text in ["", "{}", "[]"] {
12781335
protection.import(text).expect_err("not an interchange");
1279-
SlashingProtection::interchange_chain(text).expect_err("not an interchange");
1336+
SlashingProtection::stage(text).expect_err("not an interchange");
12801337
}
12811338
assert!(protection.is_empty());
12821339
}

‎crates/validator/tests/interchange_vectors.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use std::collections::HashSet;
1515
use std::path::{Path, PathBuf};
1616

1717
use ethlambda_types::beacon::containers::shared::{AttestationData, Checkpoint};
18-
use ethlambda_types::beacon::primitives::{BlsPubkey, H256, Root};
18+
use ethlambda_types::beacon::primitives::{BlsPubkey, Root};
1919
use ethlambda_validator::beacon_node::dto::parse_pubkey;
2020
use ethlambda_validator::slashing_protection::SlashingProtection;
2121
use serde::Deserialize;

‎docs/cli.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ ethlambda slashing-protection export --validators-dir ./validators --pubkeys 0xa
470470

471471
| Sub-command | Does |
472472
|---|---|
473-
| `import <file>` | Merges a version 5 interchange, complete or minimal, into the record. Each validator is reduced to its highest slot, source and target and merged by maximum, so nothing already recorded is lowered and slashable data in the file is absorbed rather than refused. All or nothing. With no record yet, the import creates one for the interchange's chain, and a failed import creates nothing but the lock file; the client refuses the record at its next start if that is not its beacon node's chain |
473+
| `import <file>` | Merges a version 5 interchange, complete or minimal, into the record. Each validator is reduced to its highest slot, source and target and merged by maximum, so nothing already recorded is lowered and slashable data in the file is absorbed rather than refused. All or nothing. With no record yet, the import creates one for the interchange's chain, and a failed import creates nothing; the client refuses the record at its next start if that is not its beacon node's chain |
474474
| `export [--pubkeys ...] <file>` | Writes the record, or the named validators' part of it, as a minimal version 5 interchange, readable by its owner only. A named validator with no record fails the export, so a typo or the wrong directory cannot produce a file that reads as complete. Takes the record's lock, so the directory must be writable. Refuses to overwrite an existing file |
475475

476476
## `benchmark import` flags

0 commit comments

Comments
 (0)