Skip to content

Commit db82103

Browse files
committed
[guardian] drive committee handoff each leader tick
The guardian's committee is set once at ProvisionerInit and can never change, so signature verification fails as soon as hashi rotates past the bootstrap epoch. The guardian-side fix (new `UpdateCommittee` RPC and `current_committee_epoch` reporting) lands in the stacked PR underneath this one. This PR wires the hashi-server side that drives the handoff. - Each leader tick spawns a bounded one-shot reconcile task. It reads the guardian's `current_committee_epoch`, and for each missing step fans out `SignCommitteeTransition` across the OUTGOING committee, aggregates a BLS cert with each member's historical per-epoch BLS signing key from `db.signing_keys`, and sends an `UpdateCommittee` to advance the guardian by one epoch. - The new committee in the transition is reconstructed by each signer from on-chain state at `from_epoch + 1` — no committee bytes travel on the inter-node wire, so the leader can't get peers to sign attacker-crafted committees. - Idempotency lives on the guardian side, so leader churn / lost RPC results are safe — the next leader simply repeats. - New metric `hashi_guardian_current_committee_epoch` mirrors the guardian's reported epoch.
1 parent 8bcd31d commit db82103

8 files changed

Lines changed: 471 additions & 2 deletions

File tree

crates/hashi-types/proto/sui/hashi/v1alpha/bridge_service.proto

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ service BridgeService {
2525
rpc SignWithdrawalTxSigning(SignWithdrawalTxSigningRequest) returns (SignWithdrawalTxSigningResponse);
2626
// Step 4: Sign committee approval to confirm a processed withdrawal on-chain.
2727
rpc SignWithdrawalConfirmation(SignWithdrawalConfirmationRequest) returns (SignWithdrawalConfirmationResponse);
28+
// Sign a committee transition from `from_epoch` to `from_epoch + 1` so the
29+
// leader can aggregate a certificate to advance the guardian's committee.
30+
rpc SignCommitteeTransition(SignCommitteeTransitionRequest) returns (SignCommitteeTransitionResponse);
2831
}
2932

3033
message GetServiceInfoRequest {}
@@ -186,3 +189,16 @@ message SignGuardianWithdrawalRequestRequest {
186189
message SignGuardianWithdrawalRequestResponse {
187190
MemberSignature member_signature = 1;
188191
}
192+
193+
// Peers reconstruct the same `CommitteeTransition` from on-chain state at
194+
// `from_epoch + 1` and BLS-sign it with their historical epoch-`from_epoch`
195+
// signing key. No new committee bytes on the wire — peers can't be tricked
196+
// into signing an attacker-crafted committee.
197+
message SignCommitteeTransitionRequest {
198+
// The outgoing committee's epoch. Implies new_epoch = from_epoch + 1.
199+
uint64 from_epoch = 1;
200+
}
201+
202+
message SignCommitteeTransitionResponse {
203+
MemberSignature member_signature = 1;
204+
}
310 Bytes
Binary file not shown.

crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,21 @@ pub struct SignGuardianWithdrawalRequestResponse {
175175
#[prost(message, optional, tag = "1")]
176176
pub member_signature: ::core::option::Option<MemberSignature>,
177177
}
178+
/// Peers reconstruct the same `CommitteeTransition` from on-chain state at
179+
/// `from_epoch + 1` and BLS-sign it with their historical epoch-`from_epoch`
180+
/// signing key. No new committee bytes on the wire — peers can't be tricked
181+
/// into signing an attacker-crafted committee.
182+
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
183+
pub struct SignCommitteeTransitionRequest {
184+
/// The outgoing committee's epoch. Implies new_epoch = from_epoch + 1.
185+
#[prost(uint64, tag = "1")]
186+
pub from_epoch: u64,
187+
}
188+
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
189+
pub struct SignCommitteeTransitionResponse {
190+
#[prost(message, optional, tag = "1")]
191+
pub member_signature: ::core::option::Option<MemberSignature>,
192+
}
178193
/// Generated client implementations.
179194
pub mod bridge_service_client {
180195
#![allow(
@@ -505,6 +520,37 @@ pub mod bridge_service_client {
505520
);
506521
self.inner.unary(req, path, codec).await
507522
}
523+
/// Sign a committee transition from `from_epoch` to `from_epoch + 1` so the
524+
/// leader can aggregate a certificate to advance the guardian's committee.
525+
pub async fn sign_committee_transition(
526+
&mut self,
527+
request: impl tonic::IntoRequest<super::SignCommitteeTransitionRequest>,
528+
) -> std::result::Result<
529+
tonic::Response<super::SignCommitteeTransitionResponse>,
530+
tonic::Status,
531+
> {
532+
self.inner
533+
.ready()
534+
.await
535+
.map_err(|e| {
536+
tonic::Status::unknown(
537+
format!("Service was not ready: {}", e.into()),
538+
)
539+
})?;
540+
let codec = tonic_prost::ProstCodec::default();
541+
let path = http::uri::PathAndQuery::from_static(
542+
"/sui.hashi.v1alpha.BridgeService/SignCommitteeTransition",
543+
);
544+
let mut req = request.into_request();
545+
req.extensions_mut()
546+
.insert(
547+
GrpcMethod::new(
548+
"sui.hashi.v1alpha.BridgeService",
549+
"SignCommitteeTransition",
550+
),
551+
);
552+
self.inner.unary(req, path, codec).await
553+
}
508554
}
509555
}
510556
/// Generated server implementations.
@@ -586,6 +632,15 @@ pub mod bridge_service_server {
586632
tonic::Response<super::SignWithdrawalConfirmationResponse>,
587633
tonic::Status,
588634
>;
635+
/// Sign a committee transition from `from_epoch` to `from_epoch + 1` so the
636+
/// leader can aggregate a certificate to advance the guardian's committee.
637+
async fn sign_committee_transition(
638+
&self,
639+
request: tonic::Request<super::SignCommitteeTransitionRequest>,
640+
) -> std::result::Result<
641+
tonic::Response<super::SignCommitteeTransitionResponse>,
642+
tonic::Status,
643+
>;
589644
}
590645
#[derive(Debug)]
591646
pub struct BridgeServiceServer<T> {
@@ -1075,6 +1130,57 @@ pub mod bridge_service_server {
10751130
};
10761131
Box::pin(fut)
10771132
}
1133+
"/sui.hashi.v1alpha.BridgeService/SignCommitteeTransition" => {
1134+
#[allow(non_camel_case_types)]
1135+
struct SignCommitteeTransitionSvc<T: BridgeService>(pub Arc<T>);
1136+
impl<
1137+
T: BridgeService,
1138+
> tonic::server::UnaryService<super::SignCommitteeTransitionRequest>
1139+
for SignCommitteeTransitionSvc<T> {
1140+
type Response = super::SignCommitteeTransitionResponse;
1141+
type Future = BoxFuture<
1142+
tonic::Response<Self::Response>,
1143+
tonic::Status,
1144+
>;
1145+
fn call(
1146+
&mut self,
1147+
request: tonic::Request<
1148+
super::SignCommitteeTransitionRequest,
1149+
>,
1150+
) -> Self::Future {
1151+
let inner = Arc::clone(&self.0);
1152+
let fut = async move {
1153+
<T as BridgeService>::sign_committee_transition(
1154+
&inner,
1155+
request,
1156+
)
1157+
.await
1158+
};
1159+
Box::pin(fut)
1160+
}
1161+
}
1162+
let accept_compression_encodings = self.accept_compression_encodings;
1163+
let send_compression_encodings = self.send_compression_encodings;
1164+
let max_decoding_message_size = self.max_decoding_message_size;
1165+
let max_encoding_message_size = self.max_encoding_message_size;
1166+
let inner = self.inner.clone();
1167+
let fut = async move {
1168+
let method = SignCommitteeTransitionSvc(inner);
1169+
let codec = tonic_prost::ProstCodec::default();
1170+
let mut grpc = tonic::server::Grpc::new(codec)
1171+
.apply_compression_config(
1172+
accept_compression_encodings,
1173+
send_compression_encodings,
1174+
)
1175+
.apply_max_message_size_config(
1176+
max_decoding_message_size,
1177+
max_encoding_message_size,
1178+
);
1179+
let res = grpc.unary(method, req).await;
1180+
Ok(res)
1181+
};
1182+
Box::pin(fut)
1183+
}
10781184
_ => {
10791185
Box::pin(async move {
10801186
let mut response = http::Response::new(

crates/hashi/src/grpc/bridge_service.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ use crate::withdrawals::WithdrawalTxSigning;
1717
use hashi_types::bitcoin_txid::BitcoinTxid;
1818
use hashi_types::proto::GetServiceInfoRequest;
1919
use hashi_types::proto::GetServiceInfoResponse;
20+
use hashi_types::proto::SignCommitteeTransitionRequest;
21+
use hashi_types::proto::SignCommitteeTransitionResponse;
2022
use hashi_types::proto::SignDepositConfirmationRequest;
2123
use hashi_types::proto::SignDepositConfirmationResponse;
2224
use hashi_types::proto::SignGuardianWithdrawalRequestRequest;
@@ -133,6 +135,33 @@ impl BridgeService for HttpService {
133135
}))
134136
}
135137

138+
/// Validate and BLS-sign a `CommitteeTransition` from `from_epoch` to
139+
/// `from_epoch + 1` for the guardian. The transition's `new_committee`
140+
/// is reconstructed deterministically from on-chain state — clients
141+
/// cannot supply it on the wire.
142+
#[tracing::instrument(
143+
level = "info",
144+
skip_all,
145+
fields(from_epoch = tracing::field::Empty, caller = tracing::field::Empty),
146+
)]
147+
async fn sign_committee_transition(
148+
&self,
149+
request: Request<SignCommitteeTransitionRequest>,
150+
) -> Result<Response<SignCommitteeTransitionResponse>, Status> {
151+
let caller = authenticate_caller(&request)?;
152+
tracing::Span::current().record("caller", tracing::field::display(&caller));
153+
let from_epoch = request.get_ref().from_epoch;
154+
tracing::Span::current().record("from_epoch", from_epoch);
155+
let member_signature = self
156+
.inner
157+
.validate_and_sign_committee_transition(from_epoch)
158+
.map_err(|e| Status::failed_precondition(e.to_string()))?;
159+
tracing::info!(from_epoch, "Signed committee transition");
160+
Ok(Response::new(SignCommitteeTransitionResponse {
161+
member_signature: Some(member_signature),
162+
}))
163+
}
164+
136165
/// Validate and BLS-sign a `StandardWithdrawalRequest` for the guardian.
137166
#[tracing::instrument(
138167
level = "info",

crates/hashi/src/grpc/guardian_client.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,15 @@ impl GuardianClient {
5858
.await?;
5959
Ok(response.into_inner())
6060
}
61+
62+
pub async fn update_committee(
63+
&self,
64+
request: hashi_types::proto::SignedCommitteeTransition,
65+
) -> Result<hashi_types::proto::UpdateCommitteeResponse, tonic::Status> {
66+
let response = self
67+
.guardian_service_client()
68+
.update_committee(request)
69+
.await?;
70+
Ok(response.into_inner())
71+
}
6172
}

0 commit comments

Comments
 (0)