diff --git a/crates/hashi-guardian/src/getters.rs b/crates/hashi-guardian/src/getters.rs index 3db40a951..8ad3c114b 100644 --- a/crates/hashi-guardian/src/getters.rs +++ b/crates/hashi-guardian/src/getters.rs @@ -26,10 +26,12 @@ pub async fn get_guardian_info(enclave: Arc) -> GuardianResult Option { + let limiter = self.rate_limiter.get()?; + Some(*limiter.lock().await.state()) + } + fn status_check_inner(&self) -> (bool, bool) { let committee_init = self .committee diff --git a/crates/hashi-types/proto/sui/hashi/v1alpha/bridge_service.proto b/crates/hashi-types/proto/sui/hashi/v1alpha/bridge_service.proto index 3d87b2882..1a5f68234 100644 --- a/crates/hashi-types/proto/sui/hashi/v1alpha/bridge_service.proto +++ b/crates/hashi-types/proto/sui/hashi/v1alpha/bridge_service.proto @@ -22,6 +22,8 @@ service BridgeService { rpc SignWithdrawalTxSigning(SignWithdrawalTxSigningRequest) returns (SignWithdrawalTxSigningResponse); // Step 4: Sign committee approval to confirm a processed withdrawal on-chain. rpc SignWithdrawalConfirmation(SignWithdrawalConfirmationRequest) returns (SignWithdrawalConfirmationResponse); + // Sign a guardian withdrawal request so the leader can forward it to the guardian. + rpc SignGuardianWithdrawalRequest(SignGuardianWithdrawalRequestRequest) returns (SignGuardianWithdrawalRequestResponse); } message GetServiceInfoRequest {} @@ -159,3 +161,21 @@ message SignWithdrawalConfirmationRequest { message SignWithdrawalConfirmationResponse { MemberSignature member_signature = 1; } + +// The leader sends the pending withdrawal id along with the guardian-specific +// fields (timestamp, seq) so each validator can independently reconstruct and +// sign the same StandardWithdrawalRequest. +message SignGuardianWithdrawalRequestRequest { + // Pending withdrawal id on Sui (32 bytes). + bytes pending_withdrawal_id = 1; + + // Timestamp in unix seconds (used for guardian rate limiting). + uint64 timestamp_secs = 2; + + // Monotonic sequence number (used by guardian for replay prevention). + uint64 seq = 3; +} + +message SignGuardianWithdrawalRequestResponse { + MemberSignature member_signature = 1; +} diff --git a/crates/hashi-types/proto/sui/hashi/v1alpha/guardian_service.proto b/crates/hashi-types/proto/sui/hashi/v1alpha/guardian_service.proto index cf5b745d7..7fa815195 100644 --- a/crates/hashi-types/proto/sui/hashi/v1alpha/guardian_service.proto +++ b/crates/hashi-types/proto/sui/hashi/v1alpha/guardian_service.proto @@ -33,6 +33,9 @@ message GetGuardianInfoResponse { // Signed guardian info (includes server version, encryption pubkey, and optional S3/bucket info). SignedGuardianInfo signed_info = 3; + + // Current rate limiter state (if initialized). Includes next_seq. + LimiterState limiter_state = 4; } // Guardian-signed wrapper around `GuardianInfoData`. diff --git a/crates/hashi-types/src/guardian/mod.rs b/crates/hashi-types/src/guardian/mod.rs index 14e6ebb8c..42ccd530e 100644 --- a/crates/hashi-types/src/guardian/mod.rs +++ b/crates/hashi-types/src/guardian/mod.rs @@ -178,6 +178,8 @@ pub struct GetGuardianInfoResponse { pub signing_pub_key: GuardianPubKey, /// Signed guardian info pub signed_info: GuardianSigned, + /// Current rate limiter state (if initialized). Includes `next_seq`. + pub limiter_state: Option, } /// TODO: Add network? diff --git a/crates/hashi-types/src/guardian/proto_conversions.rs b/crates/hashi-types/src/guardian/proto_conversions.rs index 9d7795d9b..bbbaf14d5 100644 --- a/crates/hashi-types/src/guardian/proto_conversions.rs +++ b/crates/hashi-types/src/guardian/proto_conversions.rs @@ -196,10 +196,13 @@ impl TryFrom for GetGuardianInfoResponse { let signed_info_pb = resp.signed_info.ok_or_else(|| missing("signed_info"))?; let signed_info = pb_to_signed_guardian_info(signed_info_pb)?; + let limiter_state = resp.limiter_state.map(pb_to_limiter_state).transpose()?; + Ok(GetGuardianInfoResponse { attestation: attestation.to_vec(), signing_pub_key, signed_info, + limiter_state, }) } } @@ -331,6 +334,7 @@ pub fn get_guardian_info_response_to_pb(r: GetGuardianInfoResponse) -> pb::GetGu attestation: Some(r.attestation.into()), signing_pub_key: Some(r.signing_pub_key.to_bytes().to_vec().into()), signed_info: Some(signed_guardian_info_to_pb(r.signed_info)), + limiter_state: r.limiter_state.map(limiter_state_to_pb), } } diff --git a/crates/hashi-types/src/guardian/test_utils.rs b/crates/hashi-types/src/guardian/test_utils.rs index 0d2b2e482..3d6b370dc 100644 --- a/crates/hashi-types/src/guardian/test_utils.rs +++ b/crates/hashi-types/src/guardian/test_utils.rs @@ -80,6 +80,7 @@ impl GetGuardianInfoResponse { attestation: "abcd".as_bytes().to_vec(), signing_pub_key, signed_info: GuardianSigned::new(info, &signing_key, 1234), + limiter_state: None, } } } diff --git a/crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.fds.bin b/crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.fds.bin index 5d90d92a4..01dc2283f 100644 Binary files a/crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.fds.bin and b/crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.fds.bin differ diff --git a/crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.rs b/crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.rs index 274410159..2cda397a0 100644 --- a/crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.rs +++ b/crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.rs @@ -149,6 +149,26 @@ pub struct SignWithdrawalConfirmationResponse { #[prost(message, optional, tag = "1")] pub member_signature: ::core::option::Option, } +/// The leader sends the pending withdrawal id along with the guardian-specific +/// fields (timestamp, seq) so each validator can independently reconstruct and +/// sign the same StandardWithdrawalRequest. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SignGuardianWithdrawalRequestRequest { + /// Pending withdrawal id on Sui (32 bytes). + #[prost(bytes = "bytes", tag = "1")] + pub pending_withdrawal_id: ::prost::bytes::Bytes, + /// Timestamp in unix seconds (used for guardian rate limiting). + #[prost(uint64, tag = "2")] + pub timestamp_secs: u64, + /// Monotonic sequence number (used by guardian for replay prevention). + #[prost(uint64, tag = "3")] + pub seq: u64, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SignGuardianWithdrawalRequestResponse { + #[prost(message, optional, tag = "1")] + pub member_signature: ::core::option::Option, +} /// Generated client implementations. pub mod bridge_service_client { #![allow( @@ -448,6 +468,36 @@ pub mod bridge_service_client { ); self.inner.unary(req, path, codec).await } + /// Sign a guardian withdrawal request so the leader can forward it to the guardian. + pub async fn sign_guardian_withdrawal_request( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/sui.hashi.v1alpha.BridgeService/SignGuardianWithdrawalRequest", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "sui.hashi.v1alpha.BridgeService", + "SignGuardianWithdrawalRequest", + ), + ); + self.inner.unary(req, path, codec).await + } } } /// Generated server implementations. @@ -520,6 +570,14 @@ pub mod bridge_service_server { tonic::Response, tonic::Status, >; + /// Sign a guardian withdrawal request so the leader can forward it to the guardian. + async fn sign_guardian_withdrawal_request( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } #[derive(Debug)] pub struct BridgeServiceServer { @@ -955,6 +1013,60 @@ pub mod bridge_service_server { }; Box::pin(fut) } + "/sui.hashi.v1alpha.BridgeService/SignGuardianWithdrawalRequest" => { + #[allow(non_camel_case_types)] + struct SignGuardianWithdrawalRequestSvc( + pub Arc, + ); + impl< + T: BridgeService, + > tonic::server::UnaryService< + super::SignGuardianWithdrawalRequestRequest, + > for SignGuardianWithdrawalRequestSvc { + type Response = super::SignGuardianWithdrawalRequestResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::SignGuardianWithdrawalRequestRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::sign_guardian_withdrawal_request( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = SignGuardianWithdrawalRequestSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } _ => { Box::pin(async move { let mut response = http::Response::new( @@ -1008,6 +1120,9 @@ pub struct GetGuardianInfoResponse { /// Signed guardian info (includes server version, encryption pubkey, and optional S3/bucket info). #[prost(message, optional, tag = "3")] pub signed_info: ::core::option::Option, + /// Current rate limiter state (if initialized). Includes next_seq. + #[prost(message, optional, tag = "4")] + pub limiter_state: ::core::option::Option, } /// Guardian-signed wrapper around `GuardianInfoData`. #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/crates/hashi/src/config.rs b/crates/hashi/src/config.rs index a679bf2be..7780c1fcc 100644 --- a/crates/hashi/src/config.rs +++ b/crates/hashi/src/config.rs @@ -134,6 +134,11 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] pub screener_endpoint: Option, + /// URL of the guardian gRPC service endpoint (e.g. `http://guardian:3000`). + /// When not set, guardian rate limiting is skipped during withdrawals. + #[serde(skip_serializing_if = "Option::is_none")] + pub guardian_endpoint: Option, + /// Maximum gRPC decoding message size in bytes. /// /// Defaults to 16 MiB if not specified. Tonic's built-in default is 4 MiB, @@ -344,6 +349,10 @@ impl Config { self.screener_endpoint.as_deref() } + pub fn guardian_endpoint(&self) -> Option<&str> { + self.guardian_endpoint.as_deref() + } + pub fn grpc_max_decoding_message_size(&self) -> usize { self.grpc_max_decoding_message_size .unwrap_or(16 * 1024 * 1024) diff --git a/crates/hashi/src/grpc/bridge_service.rs b/crates/hashi/src/grpc/bridge_service.rs index d2e406142..77e9bb896 100644 --- a/crates/hashi/src/grpc/bridge_service.rs +++ b/crates/hashi/src/grpc/bridge_service.rs @@ -19,6 +19,8 @@ use hashi_types::proto::GetServiceInfoRequest; use hashi_types::proto::GetServiceInfoResponse; use hashi_types::proto::SignDepositConfirmationRequest; use hashi_types::proto::SignDepositConfirmationResponse; +use hashi_types::proto::SignGuardianWithdrawalRequestRequest; +use hashi_types::proto::SignGuardianWithdrawalRequestResponse; use hashi_types::proto::SignWithdrawalConfirmationRequest; use hashi_types::proto::SignWithdrawalConfirmationResponse; use hashi_types::proto::SignWithdrawalRequestApprovalRequest; @@ -183,6 +185,33 @@ impl BridgeService for HttpService { member_signature: Some(member_signature), })) } + + async fn sign_guardian_withdrawal_request( + &self, + request: Request, + ) -> Result, Status> { + authenticate_caller(&request)?; + let req = request.into_inner(); + let pending_withdrawal_id = parse_address(&req.pending_withdrawal_id) + .map_err(|e| Status::invalid_argument(format!("invalid pending_withdrawal_id: {e}")))?; + let timestamp_secs = req.timestamp_secs; + let seq = req.seq; + let member_signature = self + .inner + .validate_and_sign_guardian_withdrawal_request( + &pending_withdrawal_id, + timestamp_secs, + seq, + ) + .map_err(|e| Status::failed_precondition(e.to_string()))?; + tracing::info!( + pending_withdrawal_id = %pending_withdrawal_id, + "Signed guardian withdrawal request", + ); + Ok(Response::new(SignGuardianWithdrawalRequestResponse { + member_signature: Some(member_signature), + })) + } } fn authenticate_caller(request: &Request) -> Result { diff --git a/crates/hashi/src/grpc/guardian_client.rs b/crates/hashi/src/grpc/guardian_client.rs new file mode 100644 index 000000000..50dc51e89 --- /dev/null +++ b/crates/hashi/src/grpc/guardian_client.rs @@ -0,0 +1,56 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::time::Duration; + +use hashi_types::proto; +use hashi_types::proto::guardian_service_client::GuardianServiceClient; +use tonic::transport::Channel; +use tonic::transport::Endpoint; + +type BoxError = Box; + +#[derive(Clone, Debug)] +pub struct GuardianClient { + endpoint: String, + channel: Channel, +} + +impl GuardianClient { + pub fn new(endpoint: &str) -> Result { + let channel = Endpoint::from_shared(endpoint.to_string()) + .map_err(Into::::into) + .map_err(tonic::Status::from_error)? + .connect_timeout(Duration::from_secs(5)) + .http2_keep_alive_interval(Duration::from_secs(5)) + .connect_lazy(); + Ok(Self { + endpoint: endpoint.to_string(), + channel, + }) + } + + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + fn client(&self) -> GuardianServiceClient { + GuardianServiceClient::new(self.channel.clone()) + } + + pub async fn get_guardian_info(&self) -> Result { + let response = self + .client() + .get_guardian_info(proto::GetGuardianInfoRequest {}) + .await?; + Ok(response.into_inner()) + } + + pub async fn standard_withdrawal( + &self, + request: proto::SignedStandardWithdrawalRequest, + ) -> Result { + let response = self.client().standard_withdrawal(request).await?; + Ok(response.into_inner()) + } +} diff --git a/crates/hashi/src/grpc/mod.rs b/crates/hashi/src/grpc/mod.rs index e9115f7cb..e7348b6a8 100644 --- a/crates/hashi/src/grpc/mod.rs +++ b/crates/hashi/src/grpc/mod.rs @@ -13,6 +13,7 @@ mod client; pub use client::Client; pub mod bridge_service; +pub mod guardian_client; pub mod screener_client; /// Wrapper that triggers graceful HTTP server shutdown on drop. diff --git a/crates/hashi/src/leader/mod.rs b/crates/hashi/src/leader/mod.rs index 84fc5d93a..1f8786726 100644 --- a/crates/hashi/src/leader/mod.rs +++ b/crates/hashi/src/leader/mod.rs @@ -1129,6 +1129,77 @@ impl LeaderService { .current_committee_members() .expect("No current committee members"); + // 0. Guardian rate limiting gate (before MPC signing). + if let Some(guardian) = inner.guardian_client() { + let timestamp_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let seq = inner.next_guardian_seq(); + + let signed_request = Self::collect_guardian_withdrawal_signatures( + &inner, + &pending, + &members, + timestamp_secs, + seq, + ) + .await?; + + let proto_request = + hashi_types::guardian::proto_conversions::signed_standard_withdrawal_request_to_pb( + &signed_request, + ); + + match guardian.standard_withdrawal(proto_request).await { + Ok(response_pb) => { + // Verify the guardian's Ed25519 signature on the response. + if let Some(guardian_pubkey) = inner.guardian_signing_pubkey() { + let signed_response = hashi_types::guardian::GuardianSigned::< + hashi_types::guardian::StandardWithdrawalResponse, + >::try_from(response_pb) + .map_err(|e| { + anyhow::anyhow!("Failed to parse guardian withdrawal response: {e}") + })?; + signed_response.verify(guardian_pubkey).map_err(|e| { + anyhow::anyhow!("Guardian response signature verification failed: {e}") + })?; + info!( + pending_withdrawal_id = %pending.id, + "Guardian approved withdrawal (rate limit passed, signature verified)" + ); + } else { + warn!( + pending_withdrawal_id = %pending.id, + "Guardian approved withdrawal but signing pubkey not available; \ + skipping response verification" + ); + } + } + Err(status) => { + let label = if status.message().contains("Rate limit exceeded") { + warn!( + pending_withdrawal_id = %pending.id, + "Guardian rate-limited withdrawal, will retry later" + ); + "GuardianRateLimited" + } else { + error!( + pending_withdrawal_id = %pending.id, + "Guardian call failed: {}", status.message() + ); + "GuardianUnavailable" + }; + inner + .metrics + .leader_retries_total + .with_label_values(&["withdrawal_signing", label]) + .inc(); + anyhow::bail!("Guardian rejected withdrawal: {}", status.message()); + } + } + } + // 1. Request signed withdrawal tx witnesses from committee members. // MPC signing requires all threshold members to participate simultaneously // via P2P, so we must fan out requests in parallel. @@ -1447,6 +1518,120 @@ impl LeaderService { Ok(aggregator.finish()?.into_parts().0) } + /// Collect BLS signatures from the committee for a guardian `StandardWithdrawalRequest`. + /// + /// Each validator independently reconstructs the request from the on-chain + /// `PendingWithdrawal` and the leader-provided timestamp/seq, then BLS-signs it. + async fn collect_guardian_withdrawal_signatures( + inner: &Arc, + pending: &PendingWithdrawal, + members: &[CommitteeMember], + timestamp_secs: u64, + seq: u64, + ) -> anyhow::Result< + hashi_types::committee::SignedMessage, + > { + // Reconstruct the StandardWithdrawalRequest that validators will sign. + let guardian_request = crate::withdrawals::build_guardian_withdrawal_request( + inner, + pending, + timestamp_secs, + seq, + )?; + + let committee = inner + .onchain_state() + .current_committee() + .expect("No current committee"); + let required_weight = certificate_threshold(committee.total_weight()); + + let proto_request = hashi_types::proto::SignGuardianWithdrawalRequestRequest { + pending_withdrawal_id: pending.id.as_bytes().to_vec().into(), + timestamp_secs, + seq, + }; + + let mut sig_tasks = JoinSet::new(); + for member in members { + let inner = inner.clone(); + let proto_request = proto_request.clone(); + let member = member.clone(); + sig_tasks.spawn(async move { + Self::request_guardian_withdrawal_signature(&inner, proto_request, &member).await + }); + } + + let mut aggregator = BlsSignatureAggregator::new(&committee, guardian_request); + while let Some(result) = sig_tasks.join_next().await { + let Ok(Some(sig)) = result else { continue }; + if let Err(e) = aggregator.add_signature(sig) { + error!( + pending_withdrawal_id = %pending.id, + "Failed to add guardian withdrawal signature: {e}" + ); + } + if aggregator.weight() >= required_weight { + break; + } + } + + let weight = aggregator.weight(); + if weight < required_weight { + anyhow::bail!( + "Insufficient guardian withdrawal signatures: weight {weight} < {required_weight}" + ); + } + + Ok(aggregator.finish()?) + } + + async fn request_guardian_withdrawal_signature( + inner: &Arc, + proto_request: hashi_types::proto::SignGuardianWithdrawalRequestRequest, + member: &CommitteeMember, + ) -> Option { + let validator_address = member.validator_address(); + trace!( + "Requesting guardian withdrawal signature from {}", + validator_address + ); + + let mut rpc_client = inner + .onchain_state() + .bridge_service_client(&validator_address) + .or_else(|| { + error!( + "Cannot find client for validator address: {:?}", + validator_address + ); + None + })?; + + let response = rpc_client + .sign_guardian_withdrawal_request(proto_request) + .await + .inspect_err(|e| { + error!( + "Failed to get guardian withdrawal signature from {}: {e}", + validator_address + ); + }) + .ok()?; + + response + .into_inner() + .member_signature + .ok_or_else(|| anyhow::anyhow!("No member_signature in response")) + .and_then(parse_member_signature) + .inspect_err(|e| { + error!( + "Failed to parse guardian withdrawal signature from {}: {e}", + validator_address + ); + }) + .ok() + } + async fn request_withdrawal_tx_commitment_signature( inner: &Arc, proto_request: SignWithdrawalTxConstructionRequest, diff --git a/crates/hashi/src/lib.rs b/crates/hashi/src/lib.rs index 79d53388d..484435323 100644 --- a/crates/hashi/src/lib.rs +++ b/crates/hashi/src/lib.rs @@ -51,6 +51,12 @@ pub struct Hashi { mpc_handle: OnceLock, btc_monitor: OnceLock, screener_client: OnceLock>, + guardian_client: OnceLock>, + /// Monotonic sequence counter for guardian withdrawal requests. + guardian_seq: std::sync::atomic::AtomicU64, + /// Guardian's Ed25519 signing public key, used to verify guardian responses. + /// Fetched from `GetGuardianInfo` at startup. + guardian_signing_pubkey: OnceLock>, /// Reconfig completion signatures by epoch. reconfig_signatures: RwLock>>, } @@ -72,6 +78,9 @@ impl Hashi { mpc_handle: OnceLock::new(), btc_monitor: OnceLock::new(), screener_client: OnceLock::new(), + guardian_client: OnceLock::new(), + guardian_seq: std::sync::atomic::AtomicU64::new(0), + guardian_signing_pubkey: OnceLock::new(), reconfig_signatures: RwLock::new(HashMap::new()), })) } @@ -96,6 +105,9 @@ impl Hashi { mpc_handle: OnceLock::new(), btc_monitor: OnceLock::new(), screener_client: OnceLock::new(), + guardian_client: OnceLock::new(), + guardian_seq: std::sync::atomic::AtomicU64::new(0), + guardian_signing_pubkey: OnceLock::new(), reconfig_signatures: RwLock::new(HashMap::new()), })) } @@ -199,6 +211,21 @@ impl Hashi { self.screener_client.get().and_then(|opt| opt.as_ref()) } + pub fn guardian_client(&self) -> Option<&grpc::guardian_client::GuardianClient> { + self.guardian_client.get().and_then(|opt| opt.as_ref()) + } + + pub fn next_guardian_seq(&self) -> u64 { + self.guardian_seq + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + } + + pub fn guardian_signing_pubkey(&self) -> Option<&hashi_types::guardian::GuardianPubKey> { + self.guardian_signing_pubkey + .get() + .and_then(|opt| opt.as_ref()) + } + async fn initialize_onchain_state(&self) -> anyhow::Result { let (onchain_state, service) = onchain::OnchainState::new( self.config.sui_rpc.as_deref().unwrap(), @@ -401,6 +428,78 @@ impl Hashi { .set(screener) .map_err(|_| anyhow!("Screener client already initialized"))?; + let guardian = if let Some(endpoint) = self.config.guardian_endpoint() { + match grpc::guardian_client::GuardianClient::new(endpoint) { + Ok(client) => { + tracing::info!("Guardian client configured for {}", client.endpoint()); + // Fetch guardian info to initialize seq counter and signing pubkey. + match client.get_guardian_info().await { + Ok(info_pb) => { + // Parse the signing public key for response verification. + match hashi_types::guardian::GetGuardianInfoResponse::try_from( + info_pb.clone(), + ) { + Ok(info) => { + tracing::info!( + "Guardian signing pubkey loaded for response verification" + ); + let _ = self + .guardian_signing_pubkey + .set(Some(info.signing_pub_key)); + } + Err(e) => { + tracing::warn!( + "Failed to parse guardian info: {e}; \ + response verification will be skipped" + ); + } + } + // Initialize seq counter from limiter state. + if let Some(limiter_state) = info_pb.limiter_state { + let next_seq = limiter_state.next_seq.unwrap_or(0); + self.guardian_seq + .store(next_seq, std::sync::atomic::Ordering::SeqCst); + tracing::info!( + "Guardian seq initialized to {} from guardian limiter state", + next_seq + ); + } else { + tracing::warn!( + "Guardian is not fully initialized (no limiter state); \ + seq counter starts at 0" + ); + } + } + Err(e) => { + tracing::warn!( + "Failed to fetch guardian info: {}; \ + seq starts at 0, response verification disabled", + e + ); + } + } + Some(client) + } + Err(e) => { + tracing::warn!( + "Failed to configure guardian client for {}: {}", + endpoint, + e + ); + None + } + } + } else { + tracing::info!( + "No guardian endpoint configured; withdrawal rate limiting will be skipped" + ); + None + }; + + self.guardian_client + .set(guardian) + .map_err(|_| anyhow!("Guardian client already initialized"))?; + // Verify Sui RPC is on the expected chain before loading any state. self.verify_sui_chain_id().await?; diff --git a/crates/hashi/src/withdrawals.rs b/crates/hashi/src/withdrawals.rs index 2a2621cd2..76a2f9040 100644 --- a/crates/hashi/src/withdrawals.rs +++ b/crates/hashi/src/withdrawals.rs @@ -394,6 +394,30 @@ impl Hashi { self.sign_message_proto(&confirmation) } + // --- Guardian: Validate and sign a StandardWithdrawalRequest for the guardian --- + + /// Each validator independently reconstructs the `StandardWithdrawalRequest` + /// from on-chain `PendingWithdrawal` data and signs it with BLS. The leader + /// aggregates these into a committee certificate for the guardian. + pub fn validate_and_sign_guardian_withdrawal_request( + &self, + pending_withdrawal_id: &Address, + timestamp_secs: u64, + seq: u64, + ) -> anyhow::Result { + let pending = self + .onchain_state() + .pending_withdrawal(pending_withdrawal_id) + .ok_or_else(|| { + anyhow!("PendingWithdrawal {pending_withdrawal_id} not found on-chain") + })?; + + let guardian_request = + build_guardian_withdrawal_request(self, &pending, timestamp_secs, seq)?; + + self.sign_message_proto(&guardian_request) + } + // --- Step 3: Sign withdrawal (store witness signatures on-chain) --- pub fn validate_and_sign_withdrawal_tx_signing( @@ -953,13 +977,19 @@ pub enum WithdrawalCommitmentErrorKind { FailedQuorum, FeeEstimateFailed, UtxoSelectionFailed, + GuardianRateLimited, + GuardianUnavailable, TimedOut, TaskFailed, } impl RetryPolicy for WithdrawalCommitmentErrorKind { fn retry_base_delay_ms(self) -> u64 { - 5 * 1000 + match self { + // Token bucket needs time to refill. + Self::GuardianRateLimited => 30 * 1000, + _ => 5 * 1000, + } } fn max_delay_ms(self) -> u64 { @@ -1011,6 +1041,95 @@ fn output_weight_for_address(bitcoin_address: &[u8]) -> anyhow::Result { } } +/// Build a guardian `StandardWithdrawalRequest` from on-chain `PendingWithdrawal` data. +/// +/// Both the leader and validators call this to deterministically reconstruct the +/// same request from on-chain state + the leader-provided timestamp/seq. +pub fn build_guardian_withdrawal_request( + hashi: &Hashi, + pending: &PendingWithdrawal, + timestamp_secs: u64, + seq: u64, +) -> anyhow::Result { + use hashi_types::guardian::bitcoin_utils::InputUTXO; + use hashi_types::guardian::bitcoin_utils::OutputUTXO; + use hashi_types::guardian::bitcoin_utils::TxUTXOs; + + let hashi_pubkey = hashi.get_hashi_pubkey()?; + let network = hashi.config.bitcoin_network(); + + // Build guardian InputUTXOs from the pending withdrawal's inputs. + let inputs = pending + .inputs + .iter() + .map(|utxo| { + let pubkey = hashi.deposit_pubkey(&hashi_pubkey, utxo.derivation_path.as_ref())?; + let (_, _, leaf_hash) = + bitcoin_utils::single_key_taproot_script_path_spend_artifacts(&pubkey); + let address = hashi.bitcoin_address_from_pubkey(&pubkey); + + let outpoint = bitcoin::OutPoint { + txid: utxo.id.txid.into(), + vout: utxo.id.vout, + }; + + InputUTXO::new( + outpoint, + Amount::from_sat(utxo.amount), + address.into_unchecked(), + leaf_hash, + network, + ) + .map_err(|e| anyhow!("Failed to build guardian InputUTXO: {e}")) + }) + .collect::>>()?; + + // Build guardian OutputUTXOs. Withdrawal outputs are External; the change output + // (last output when there are more outputs than requests) is Internal. + let all_outputs = pending.all_outputs(); + let num_requests = pending.request_ids.len(); + let outputs = all_outputs + .iter() + .enumerate() + .map(|(i, output)| { + if i < num_requests { + // External output to the user's bitcoin address. + let script_pubkey = script_pubkey_from_raw_address(&output.bitcoin_address)?; + let address = BitcoinAddress::from_script(&script_pubkey, network) + .map_err(|e| anyhow!("Cannot derive address from output script: {e}"))?; + OutputUTXO::new_external( + address.into_unchecked(), + Amount::from_sat(output.amount), + network, + ) + .map_err(|e| anyhow!("Failed to build guardian external OutputUTXO: {e}")) + } else { + // Internal change output back to hashi. + let derivation_path = [0u8; 32]; + Ok(OutputUTXO::new_internal( + derivation_path, + Amount::from_sat(output.amount), + )) + } + }) + .collect::>>()?; + + let utxos = TxUTXOs::new(inputs, outputs) + .map_err(|e| anyhow!("Failed to build guardian TxUTXOs: {e}"))?; + + // Derive a deterministic wid from the request IDs. + let wid_bytes = bcs::to_bytes(&pending.request_ids).expect("serialization should succeed"); + let wid_hash = Blake2b256::digest(&wid_bytes); + let wid = u64::from_le_bytes(wid_hash.digest[..8].try_into().unwrap()); + + Ok(hashi_types::guardian::StandardWithdrawalRequest::new( + wid, + utxos, + timestamp_secs, + seq, + )) +} + fn withdrawal_input_signing_request_id( pending_withdrawal_id: &Address, input_index: u32,