Skip to content

Commit e2c68c8

Browse files
Add new validator API for voluntary exit (#4119)
## Issue Addressed Addresses #4117 ## Proposed Changes See ethereum/keymanager-APIs#58 for proposed API specification. ## TODO - [x] ~~Add submission to BN~~ - removed, see discussion in [keymanager API](ethereum/keymanager-APIs#58) - [x] ~~Add flag to allow voluntary exit via the API~~ - no longer needed now the VC doesn't submit exit directly - [x] ~~Additional verification / checks, e.g. if validator on same network as BN~~ - to be done on client side - [x] ~~Potentially wait for the message to propagate and return some exit information in the response~~ - not required - [x] Update http tests - [x] ~~Update lighthouse book~~ - not required if this endpoint makes it to the standard keymanager API Co-authored-by: Paul Hauner <paul@paulhauner.com> Co-authored-by: Jimmy Chen <jimmy@sigmaprime.io>
1 parent 2de3451 commit e2c68c8

File tree

10 files changed

+256
-9
lines changed

10 files changed

+256
-9
lines changed

common/eth2/src/lighthouse_vc/http_client.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,30 @@ impl ValidatorClientHttpClient {
642642
let url = self.make_gas_limit_url(pubkey)?;
643643
self.delete_with_raw_response(url, &()).await
644644
}
645+
646+
/// `POST /eth/v1/validator/{pubkey}/voluntary_exit`
647+
pub async fn post_validator_voluntary_exit(
648+
&self,
649+
pubkey: &PublicKeyBytes,
650+
epoch: Option<Epoch>,
651+
) -> Result<SignedVoluntaryExit, Error> {
652+
let mut path = self.server.full.clone();
653+
654+
path.path_segments_mut()
655+
.map_err(|()| Error::InvalidUrl(self.server.clone()))?
656+
.push("eth")
657+
.push("v1")
658+
.push("validator")
659+
.push(&pubkey.to_string())
660+
.push("voluntary_exit");
661+
662+
if let Some(epoch) = epoch {
663+
path.query_pairs_mut()
664+
.append_pair("epoch", &epoch.to_string());
665+
}
666+
667+
self.post(path, &()).await
668+
}
645669
}
646670

647671
/// Returns `Ok(response)` if the response is a `200 OK` response or a

common/eth2/src/lighthouse_vc/types.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,8 @@ pub struct UpdateGasLimitRequest {
144144
#[serde(with = "eth2_serde_utils::quoted_u64")]
145145
pub gas_limit: u64,
146146
}
147+
148+
#[derive(Deserialize)]
149+
pub struct VoluntaryExitQuery {
150+
pub epoch: Option<Epoch>,
151+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use crate::validator_store::ValidatorStore;
2+
use bls::{PublicKey, PublicKeyBytes};
3+
use slog::{info, Logger};
4+
use slot_clock::SlotClock;
5+
use std::sync::Arc;
6+
use types::{Epoch, EthSpec, SignedVoluntaryExit, VoluntaryExit};
7+
8+
pub async fn create_signed_voluntary_exit<T: 'static + SlotClock + Clone, E: EthSpec>(
9+
pubkey: PublicKey,
10+
maybe_epoch: Option<Epoch>,
11+
validator_store: Arc<ValidatorStore<T, E>>,
12+
slot_clock: T,
13+
log: Logger,
14+
) -> Result<SignedVoluntaryExit, warp::Rejection> {
15+
let epoch = match maybe_epoch {
16+
Some(epoch) => epoch,
17+
None => get_current_epoch::<T, E>(slot_clock).ok_or_else(|| {
18+
warp_utils::reject::custom_server_error("Unable to determine current epoch".to_string())
19+
})?,
20+
};
21+
22+
let pubkey_bytes = PublicKeyBytes::from(pubkey);
23+
if !validator_store.has_validator(&pubkey_bytes) {
24+
return Err(warp_utils::reject::custom_not_found(format!(
25+
"{} is disabled or not managed by this validator client",
26+
pubkey_bytes.as_hex_string()
27+
)));
28+
}
29+
30+
let validator_index = validator_store
31+
.validator_index(&pubkey_bytes)
32+
.ok_or_else(|| {
33+
warp_utils::reject::custom_not_found(format!(
34+
"The validator index for {} is not known. The validator client \
35+
may still be initializing or the validator has not yet had a \
36+
deposit processed.",
37+
pubkey_bytes.as_hex_string()
38+
))
39+
})?;
40+
41+
let voluntary_exit = VoluntaryExit {
42+
epoch,
43+
validator_index,
44+
};
45+
46+
info!(
47+
log,
48+
"Signing voluntary exit";
49+
"validator" => pubkey_bytes.as_hex_string(),
50+
"epoch" => epoch
51+
);
52+
53+
let signed_voluntary_exit = validator_store
54+
.sign_voluntary_exit(pubkey_bytes, voluntary_exit)
55+
.await
56+
.map_err(|e| {
57+
warp_utils::reject::custom_server_error(format!(
58+
"Failed to sign voluntary exit: {:?}",
59+
e
60+
))
61+
})?;
62+
63+
Ok(signed_voluntary_exit)
64+
}
65+
66+
/// Calculates the current epoch from the genesis time and current time.
67+
fn get_current_epoch<T: 'static + SlotClock + Clone, E: EthSpec>(slot_clock: T) -> Option<Epoch> {
68+
slot_clock.now().map(|s| s.epoch(E::slots_per_epoch()))
69+
}

validator_client/src/http_api/mod.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
mod api_secret;
2+
mod create_signed_voluntary_exit;
23
mod create_validator;
34
mod keystores;
45
mod remotekeys;
56
mod tests;
67

8+
use crate::http_api::create_signed_voluntary_exit::create_signed_voluntary_exit;
79
use crate::{determine_graffiti, GraffitiFile, ValidatorStore};
810
use account_utils::{
911
mnemonic_from_phrase,
@@ -71,6 +73,7 @@ pub struct Context<T: SlotClock, E: EthSpec> {
7173
pub spec: ChainSpec,
7274
pub config: Config,
7375
pub log: Logger,
76+
pub slot_clock: T,
7477
pub _phantom: PhantomData<E>,
7578
}
7679

@@ -189,6 +192,9 @@ pub fn serve<T: 'static + SlotClock + Clone, E: EthSpec>(
189192
let inner_ctx = ctx.clone();
190193
let log_filter = warp::any().map(move || inner_ctx.log.clone());
191194

195+
let inner_slot_clock = ctx.slot_clock.clone();
196+
let slot_clock_filter = warp::any().map(move || inner_slot_clock.clone());
197+
192198
let inner_spec = Arc::new(ctx.spec.clone());
193199
let spec_filter = warp::any().map(move || inner_spec.clone());
194200

@@ -904,6 +910,46 @@ pub fn serve<T: 'static + SlotClock + Clone, E: EthSpec>(
904910
)
905911
.map(|reply| warp::reply::with_status(reply, warp::http::StatusCode::NO_CONTENT));
906912

913+
// POST /eth/v1/validator/{pubkey}/voluntary_exit
914+
let post_validators_voluntary_exits = eth_v1
915+
.and(warp::path("validator"))
916+
.and(warp::path::param::<PublicKey>())
917+
.and(warp::path("voluntary_exit"))
918+
.and(warp::query::<api_types::VoluntaryExitQuery>())
919+
.and(warp::path::end())
920+
.and(validator_store_filter.clone())
921+
.and(slot_clock_filter)
922+
.and(log_filter.clone())
923+
.and(signer.clone())
924+
.and(task_executor_filter.clone())
925+
.and_then(
926+
|pubkey: PublicKey,
927+
query: api_types::VoluntaryExitQuery,
928+
validator_store: Arc<ValidatorStore<T, E>>,
929+
slot_clock: T,
930+
log,
931+
signer,
932+
task_executor: TaskExecutor| {
933+
blocking_signed_json_task(signer, move || {
934+
if let Some(handle) = task_executor.handle() {
935+
let signed_voluntary_exit =
936+
handle.block_on(create_signed_voluntary_exit(
937+
pubkey,
938+
query.epoch,
939+
validator_store,
940+
slot_clock,
941+
log,
942+
))?;
943+
Ok(signed_voluntary_exit)
944+
} else {
945+
Err(warp_utils::reject::custom_server_error(
946+
"Lighthouse shutting down".into(),
947+
))
948+
}
949+
})
950+
},
951+
);
952+
907953
// GET /eth/v1/keystores
908954
let get_std_keystores = std_keystores
909955
.and(signer.clone())
@@ -1001,6 +1047,7 @@ pub fn serve<T: 'static + SlotClock + Clone, E: EthSpec>(
10011047
.or(post_validators_keystore)
10021048
.or(post_validators_mnemonic)
10031049
.or(post_validators_web3signer)
1050+
.or(post_validators_voluntary_exits)
10041051
.or(post_fee_recipient)
10051052
.or(post_gas_limit)
10061053
.or(post_std_keystores)

validator_client/src/http_api/tests.rs

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ struct ApiTester {
4545
initialized_validators: Arc<RwLock<InitializedValidators>>,
4646
validator_store: Arc<ValidatorStore<TestingSlotClock, E>>,
4747
url: SensitiveUrl,
48+
slot_clock: TestingSlotClock,
4849
_server_shutdown: oneshot::Sender<()>,
4950
_validator_dir: TempDir,
5051
_runtime_shutdown: exit_future::Signal,
@@ -90,8 +91,12 @@ impl ApiTester {
9091
let slashing_db_path = config.validator_dir.join(SLASHING_PROTECTION_FILENAME);
9192
let slashing_protection = SlashingDatabase::open_or_create(&slashing_db_path).unwrap();
9293

93-
let slot_clock =
94-
TestingSlotClock::new(Slot::new(0), Duration::from_secs(0), Duration::from_secs(1));
94+
let genesis_time: u64 = 0;
95+
let slot_clock = TestingSlotClock::new(
96+
Slot::new(0),
97+
Duration::from_secs(genesis_time),
98+
Duration::from_secs(1),
99+
);
95100

96101
let (runtime_shutdown, exit) = exit_future::signal();
97102
let (shutdown_tx, _) = futures::channel::mpsc::channel(1);
@@ -101,9 +106,9 @@ impl ApiTester {
101106
initialized_validators,
102107
slashing_protection,
103108
Hash256::repeat_byte(42),
104-
spec,
109+
spec.clone(),
105110
Some(Arc::new(DoppelgangerService::new(log.clone()))),
106-
slot_clock,
111+
slot_clock.clone(),
107112
&config,
108113
executor.clone(),
109114
log.clone(),
@@ -129,7 +134,8 @@ impl ApiTester {
129134
listen_port: 0,
130135
allow_origin: None,
131136
},
132-
log,
137+
log: log.clone(),
138+
slot_clock: slot_clock.clone(),
133139
_phantom: PhantomData,
134140
});
135141
let ctx = context.clone();
@@ -156,6 +162,7 @@ impl ApiTester {
156162
initialized_validators,
157163
validator_store,
158164
url,
165+
slot_clock,
159166
_server_shutdown: shutdown_tx,
160167
_validator_dir: validator_dir,
161168
_runtime_shutdown: runtime_shutdown,
@@ -494,6 +501,33 @@ impl ApiTester {
494501
self
495502
}
496503

504+
pub async fn test_sign_voluntary_exits(self, index: usize, maybe_epoch: Option<Epoch>) -> Self {
505+
let validator = &self.client.get_lighthouse_validators().await.unwrap().data[index];
506+
// manually setting validator index in `ValidatorStore`
507+
self.initialized_validators
508+
.write()
509+
.set_index(&validator.voting_pubkey, 0);
510+
511+
let expected_exit_epoch = maybe_epoch.unwrap_or_else(|| self.get_current_epoch());
512+
513+
let resp = self
514+
.client
515+
.post_validator_voluntary_exit(&validator.voting_pubkey, maybe_epoch)
516+
.await;
517+
518+
assert!(resp.is_ok());
519+
assert_eq!(resp.unwrap().message.epoch, expected_exit_epoch);
520+
521+
self
522+
}
523+
524+
fn get_current_epoch(&self) -> Epoch {
525+
self.slot_clock
526+
.now()
527+
.map(|s| s.epoch(E::slots_per_epoch()))
528+
.unwrap()
529+
}
530+
497531
pub async fn set_validator_enabled(self, index: usize, enabled: bool) -> Self {
498532
let validator = &self.client.get_lighthouse_validators().await.unwrap().data[index];
499533

@@ -778,6 +812,29 @@ fn hd_validator_creation() {
778812
});
779813
}
780814

815+
#[test]
816+
fn validator_exit() {
817+
let runtime = build_runtime();
818+
let weak_runtime = Arc::downgrade(&runtime);
819+
runtime.block_on(async {
820+
ApiTester::new(weak_runtime)
821+
.await
822+
.create_hd_validators(HdValidatorScenario {
823+
count: 2,
824+
specify_mnemonic: false,
825+
key_derivation_path_offset: 0,
826+
disabled: vec![],
827+
})
828+
.await
829+
.assert_enabled_validators_count(2)
830+
.assert_validators_count(2)
831+
.test_sign_voluntary_exits(0, None)
832+
.await
833+
.test_sign_voluntary_exits(0, Some(Epoch::new(256)))
834+
.await;
835+
});
836+
}
837+
781838
#[test]
782839
fn validator_enabling() {
783840
let runtime = build_runtime();

validator_client/src/http_metrics/metrics.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ lazy_static::lazy_static! {
8888
"Total count of attempted SyncSelectionProof signings",
8989
&["status"]
9090
);
91+
pub static ref SIGNED_VOLUNTARY_EXITS_TOTAL: Result<IntCounterVec> = try_create_int_counter_vec(
92+
"vc_signed_voluntary_exits_total",
93+
"Total count of VoluntaryExit signings",
94+
&["status"]
95+
);
9196
pub static ref SIGNED_VALIDATOR_REGISTRATIONS_TOTAL: Result<IntCounterVec> = try_create_int_counter_vec(
9297
"builder_validator_registrations_total",
9398
"Total count of ValidatorRegistrationData signings",

validator_client/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ pub struct ProductionValidatorClient<T: EthSpec> {
9494
doppelganger_service: Option<Arc<DoppelgangerService>>,
9595
preparation_service: PreparationService<SystemTimeSlotClock, T>,
9696
validator_store: Arc<ValidatorStore<SystemTimeSlotClock, T>>,
97+
slot_clock: SystemTimeSlotClock,
9798
http_api_listen_addr: Option<SocketAddr>,
9899
config: Config,
99100
}
@@ -461,7 +462,7 @@ impl<T: EthSpec> ProductionValidatorClient<T> {
461462
let sync_committee_service = SyncCommitteeService::new(
462463
duties_service.clone(),
463464
validator_store.clone(),
464-
slot_clock,
465+
slot_clock.clone(),
465466
beacon_nodes.clone(),
466467
context.service_context("sync_committee".into()),
467468
);
@@ -482,6 +483,7 @@ impl<T: EthSpec> ProductionValidatorClient<T> {
482483
preparation_service,
483484
validator_store,
484485
config,
486+
slot_clock,
485487
http_api_listen_addr: None,
486488
})
487489
}
@@ -544,6 +546,7 @@ impl<T: EthSpec> ProductionValidatorClient<T> {
544546
graffiti_flag: self.config.graffiti,
545547
spec: self.context.eth2_config.spec.clone(),
546548
config: self.config.http_api.clone(),
549+
slot_clock: self.slot_clock.clone(),
547550
log: log.clone(),
548551
_phantom: PhantomData,
549552
});

validator_client/src/signing_method.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ pub enum SignableMessage<'a, T: EthSpec, Payload: AbstractExecPayload<T> = FullP
4747
},
4848
SignedContributionAndProof(&'a ContributionAndProof<T>),
4949
ValidatorRegistration(&'a ValidatorRegistrationData),
50+
VoluntaryExit(&'a VoluntaryExit),
5051
}
5152

5253
impl<'a, T: EthSpec, Payload: AbstractExecPayload<T>> SignableMessage<'a, T, Payload> {
@@ -67,6 +68,7 @@ impl<'a, T: EthSpec, Payload: AbstractExecPayload<T>> SignableMessage<'a, T, Pay
6768
} => beacon_block_root.signing_root(domain),
6869
SignableMessage::SignedContributionAndProof(c) => c.signing_root(domain),
6970
SignableMessage::ValidatorRegistration(v) => v.signing_root(domain),
71+
SignableMessage::VoluntaryExit(exit) => exit.signing_root(domain),
7072
}
7173
}
7274
}
@@ -203,6 +205,7 @@ impl SigningMethod {
203205
SignableMessage::ValidatorRegistration(v) => {
204206
Web3SignerObject::ValidatorRegistration(v)
205207
}
208+
SignableMessage::VoluntaryExit(e) => Web3SignerObject::VoluntaryExit(e),
206209
};
207210

208211
// Determine the Web3Signer message type.

validator_client/src/signing_method/web3signer.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ pub enum Web3SignerObject<'a, T: EthSpec, Payload: AbstractExecPayload<T>> {
6262
RandaoReveal {
6363
epoch: Epoch,
6464
},
65-
#[allow(dead_code)]
6665
VoluntaryExit(&'a VoluntaryExit),
6766
SyncCommitteeMessage {
6867
beacon_block_root: Hash256,

0 commit comments

Comments
 (0)