From 349bb440f45a2d5f573e337dca127ead7372935d Mon Sep 17 00:00:00 2001 From: Siddharth Sharma Date: Thu, 9 Apr 2026 09:55:13 -0700 Subject: [PATCH 1/4] guardian: integrate guardian rate limiting into withdrawal flow Wire up the guardian gRPC client in hashi and call it during withdrawal processing to enforce rate limits before committing withdrawal transactions on-chain. --- crates/hashi/src/config.rs | 9 ++ crates/hashi/src/grpc/guardian_client.rs | 48 ++++++++++ crates/hashi/src/grpc/mod.rs | 1 + crates/hashi/src/leader/mod.rs | 102 +++++++++++++++++++++ crates/hashi/src/lib.rs | 42 +++++++++ crates/hashi/src/withdrawals.rs | 110 ++++++++++++++++++++++- 6 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 crates/hashi/src/grpc/guardian_client.rs 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/guardian_client.rs b/crates/hashi/src/grpc/guardian_client.rs new file mode 100644 index 000000000..d6a475f6b --- /dev/null +++ b/crates/hashi/src/grpc/guardian_client.rs @@ -0,0 +1,48 @@ +// 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 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..b23c8769d 100644 --- a/crates/hashi/src/leader/mod.rs +++ b/crates/hashi/src/leader/mod.rs @@ -1029,6 +1029,81 @@ impl LeaderService { } }; + // --- Guardian rate limiting gate --- + if let Some(guardian) = inner.guardian_client() { + let timestamp_secs = checkpoint_timestamp_ms / 1000; + let seq = inner.next_guardian_seq(); + + let guardian_request = match crate::withdrawals::build_guardian_withdrawal_request( + &inner, + &approval, + timestamp_secs, + seq, + ) { + Ok(req) => req, + Err(e) => { + error!("Failed to build guardian withdrawal request: {e}"); + retry_tracker.record_failure( + WithdrawalCommitmentErrorKind::GuardianUnavailable, + checkpoint_timestamp_ms, + ); + return Ok(()); + } + }; + + let signed_request = match sign_guardian_request(&inner, guardian_request) { + Ok(req) => req, + Err(e) => { + error!("Failed to sign guardian withdrawal request: {e}"); + retry_tracker.record_failure( + WithdrawalCommitmentErrorKind::GuardianUnavailable, + checkpoint_timestamp_ms, + ); + return Ok(()); + } + }; + + 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) => { + info!("Guardian approved withdrawal batch (rate limit passed)"); + } + Err(status) + if status.code() == tonic::Code::Internal + && status.message().contains("Rate limit exceeded") => + { + warn!("Guardian rate-limited withdrawal batch, will retry later"); + inner + .metrics + .leader_retries_total + .with_label_values(&["withdrawal_commitment", "GuardianRateLimited"]) + .inc(); + retry_tracker.record_failure( + WithdrawalCommitmentErrorKind::GuardianRateLimited, + checkpoint_timestamp_ms, + ); + return Ok(()); + } + Err(status) => { + error!("Guardian call failed: {}", status.message()); + inner + .metrics + .leader_retries_total + .with_label_values(&["withdrawal_commitment", "GuardianUnavailable"]) + .inc(); + retry_tracker.record_failure( + WithdrawalCommitmentErrorKind::GuardianUnavailable, + checkpoint_timestamp_ms, + ); + return Ok(()); + } + } + } + // Submit commit_withdrawal_tx to Sui Self::submit_commit_withdrawal_tx(&inner, &approval, signed_approval.committee_signature()) .await @@ -1897,3 +1972,30 @@ impl WithdrawalTxSigning { } } } + +/// Create a `HashiSigned` using the leader's own BLS key. +/// +/// For devnet the guardian's `committee_threshold` is configured to match a single +/// validator's weight, so a single-signer certificate is sufficient. For production, +/// this should be replaced with a full committee fan-out. +fn sign_guardian_request( + inner: &Hashi, + request: hashi_types::guardian::StandardWithdrawalRequest, +) -> anyhow::Result< + hashi_types::committee::SignedMessage, +> { + let committee = inner + .onchain_state() + .current_committee() + .ok_or_else(|| anyhow::anyhow!("No current committee"))?; + let private_key = inner + .config + .protocol_private_key() + .ok_or_else(|| anyhow::anyhow!("No protocol private key"))?; + let address = inner.config.validator_address()?; + let epoch = inner.onchain_state().epoch(); + let signature = private_key.sign(epoch, address, &request); + let mut aggregator = BlsSignatureAggregator::new(&committee, request); + aggregator.add_signature_from(address, signature.signature().clone())?; + Ok(aggregator.finish()?) +} diff --git a/crates/hashi/src/lib.rs b/crates/hashi/src/lib.rs index 79d53388d..8a89f5f55 100644 --- a/crates/hashi/src/lib.rs +++ b/crates/hashi/src/lib.rs @@ -51,6 +51,9 @@ 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, /// Reconfig completion signatures by epoch. reconfig_signatures: RwLock>>, } @@ -72,6 +75,8 @@ 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), reconfig_signatures: RwLock::new(HashMap::new()), })) } @@ -96,6 +101,8 @@ 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), reconfig_signatures: RwLock::new(HashMap::new()), })) } @@ -199,6 +206,15 @@ 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) + } + async fn initialize_onchain_state(&self) -> anyhow::Result { let (onchain_state, service) = onchain::OnchainState::new( self.config.sui_rpc.as_deref().unwrap(), @@ -401,6 +417,32 @@ 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()); + 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..795e71422 100644 --- a/crates/hashi/src/withdrawals.rs +++ b/crates/hashi/src/withdrawals.rs @@ -953,13 +953,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 +1017,108 @@ fn output_weight_for_address(bitcoin_address: &[u8]) -> anyhow::Result { } } +/// Build a guardian `StandardWithdrawalRequest` from a `WithdrawalTxCommitment`. +/// +/// Converts hashi's on-chain UTXO types into the guardian's `TxUTXOs` format, +/// computing the bitcoin addresses and taproot leaf hashes from the MPC pubkey. +pub fn build_guardian_withdrawal_request( + hashi: &Hashi, + approval: &WithdrawalTxCommitment, + 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 selected UTXOs. + let utxo_records = hashi + .onchain_state() + .state() + .hashi() + .utxo_pool + .utxo_records() + .clone(); + let inputs = approval + .selected_utxos + .iter() + .map(|utxo_id| { + let record = utxo_records + .get(utxo_id) + .ok_or_else(|| anyhow!("UTXO {utxo_id:?} not found for guardian request"))?; + let utxo = &record.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 num_requests = approval.request_ids.len(); + let outputs = approval + .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. + // The derivation_path for the root key is all zeros. + 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(&approval.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, From bf00c7eb3cf24ed64bb0a48c64da01ccc00c754a Mon Sep 17 00:00:00 2001 From: Siddharth Sharma Date: Thu, 9 Apr 2026 10:06:03 -0700 Subject: [PATCH 2/4] guardian: use proper committee BLS signing for guardian requests Add SignGuardianWithdrawalRequest proto endpoint so each validator independently reconstructs and BLS-signs the StandardWithdrawalRequest. The leader fans out to the committee and aggregates signatures before forwarding to the guardian, matching the pattern used by all other withdrawal signing steps. Move the guardian gate from Step 2 to Step 3 so the PendingWithdrawal exists on-chain and validators can reconstruct the request from it. --- .../sui/hashi/v1alpha/bridge_service.proto | 20 ++ .../proto/generated/sui.hashi.v1alpha.fds.bin | Bin 13792 -> 14211 bytes .../src/proto/generated/sui.hashi.v1alpha.rs | 112 ++++++++ crates/hashi/src/grpc/bridge_service.rs | 29 ++ crates/hashi/src/leader/mod.rs | 269 +++++++++++------- crates/hashi/src/withdrawals.rs | 61 ++-- 6 files changed, 364 insertions(+), 127 deletions(-) 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/src/proto/generated/sui.hashi.v1alpha.fds.bin b/crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.fds.bin index 5d90d92a45e7856f6f27aa5bd0413a5e765f485f..1f2547e922551199f471e0f40afad34401f2d2dc 100644 GIT binary patch delta 259 zcmaEm-JH+Nbxd|6a{{N5(qu+1mEg?uJonPXqLj?UyztDDjFh6p^2D5=)WXu#;*!Z+ z?AB32s$6^}nYlo2Vs1fvacXk01d{@z21^hxTr?OcC?vqeT%1}c!3>rIF_p@=RPh^c u#}UqKbe3at8s`q?$?;sAlDJ*c$R(+SQx@u6DT~P;<-~CtvRRDxlm-A6omztc delta 26 icmZq9e~``0wODE+a{?!`(GK>_C0sk0Hy`9XqyYeaJPC0C 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..ec4ff4708 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( 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/leader/mod.rs b/crates/hashi/src/leader/mod.rs index b23c8769d..ac01c71db 100644 --- a/crates/hashi/src/leader/mod.rs +++ b/crates/hashi/src/leader/mod.rs @@ -1029,81 +1029,6 @@ impl LeaderService { } }; - // --- Guardian rate limiting gate --- - if let Some(guardian) = inner.guardian_client() { - let timestamp_secs = checkpoint_timestamp_ms / 1000; - let seq = inner.next_guardian_seq(); - - let guardian_request = match crate::withdrawals::build_guardian_withdrawal_request( - &inner, - &approval, - timestamp_secs, - seq, - ) { - Ok(req) => req, - Err(e) => { - error!("Failed to build guardian withdrawal request: {e}"); - retry_tracker.record_failure( - WithdrawalCommitmentErrorKind::GuardianUnavailable, - checkpoint_timestamp_ms, - ); - return Ok(()); - } - }; - - let signed_request = match sign_guardian_request(&inner, guardian_request) { - Ok(req) => req, - Err(e) => { - error!("Failed to sign guardian withdrawal request: {e}"); - retry_tracker.record_failure( - WithdrawalCommitmentErrorKind::GuardianUnavailable, - checkpoint_timestamp_ms, - ); - return Ok(()); - } - }; - - 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) => { - info!("Guardian approved withdrawal batch (rate limit passed)"); - } - Err(status) - if status.code() == tonic::Code::Internal - && status.message().contains("Rate limit exceeded") => - { - warn!("Guardian rate-limited withdrawal batch, will retry later"); - inner - .metrics - .leader_retries_total - .with_label_values(&["withdrawal_commitment", "GuardianRateLimited"]) - .inc(); - retry_tracker.record_failure( - WithdrawalCommitmentErrorKind::GuardianRateLimited, - checkpoint_timestamp_ms, - ); - return Ok(()); - } - Err(status) => { - error!("Guardian call failed: {}", status.message()); - inner - .metrics - .leader_retries_total - .with_label_values(&["withdrawal_commitment", "GuardianUnavailable"]) - .inc(); - retry_tracker.record_failure( - WithdrawalCommitmentErrorKind::GuardianUnavailable, - checkpoint_timestamp_ms, - ); - return Ok(()); - } - } - } - // Submit commit_withdrawal_tx to Sui Self::submit_commit_withdrawal_tx(&inner, &approval, signed_approval.committee_signature()) .await @@ -1204,6 +1129,59 @@ 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) => { + info!( + pending_withdrawal_id = %pending.id, + "Guardian approved withdrawal (rate limit passed)" + ); + } + 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. @@ -1522,6 +1500,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, @@ -1972,30 +2064,3 @@ impl WithdrawalTxSigning { } } } - -/// Create a `HashiSigned` using the leader's own BLS key. -/// -/// For devnet the guardian's `committee_threshold` is configured to match a single -/// validator's weight, so a single-signer certificate is sufficient. For production, -/// this should be replaced with a full committee fan-out. -fn sign_guardian_request( - inner: &Hashi, - request: hashi_types::guardian::StandardWithdrawalRequest, -) -> anyhow::Result< - hashi_types::committee::SignedMessage, -> { - let committee = inner - .onchain_state() - .current_committee() - .ok_or_else(|| anyhow::anyhow!("No current committee"))?; - let private_key = inner - .config - .protocol_private_key() - .ok_or_else(|| anyhow::anyhow!("No protocol private key"))?; - let address = inner.config.validator_address()?; - let epoch = inner.onchain_state().epoch(); - let signature = private_key.sign(epoch, address, &request); - let mut aggregator = BlsSignatureAggregator::new(&committee, request); - aggregator.add_signature_from(address, signature.signature().clone())?; - Ok(aggregator.finish()?) -} diff --git a/crates/hashi/src/withdrawals.rs b/crates/hashi/src/withdrawals.rs index 795e71422..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( @@ -1017,13 +1041,13 @@ fn output_weight_for_address(bitcoin_address: &[u8]) -> anyhow::Result { } } -/// Build a guardian `StandardWithdrawalRequest` from a `WithdrawalTxCommitment`. +/// Build a guardian `StandardWithdrawalRequest` from on-chain `PendingWithdrawal` data. /// -/// Converts hashi's on-chain UTXO types into the guardian's `TxUTXOs` format, -/// computing the bitcoin addresses and taproot leaf hashes from the MPC pubkey. +/// 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, - approval: &WithdrawalTxCommitment, + pending: &PendingWithdrawal, timestamp_secs: u64, seq: u64, ) -> anyhow::Result { @@ -1034,23 +1058,11 @@ pub fn build_guardian_withdrawal_request( let hashi_pubkey = hashi.get_hashi_pubkey()?; let network = hashi.config.bitcoin_network(); - // Build guardian InputUTXOs from selected UTXOs. - let utxo_records = hashi - .onchain_state() - .state() - .hashi() - .utxo_pool - .utxo_records() - .clone(); - let inputs = approval - .selected_utxos + // Build guardian InputUTXOs from the pending withdrawal's inputs. + let inputs = pending + .inputs .iter() - .map(|utxo_id| { - let record = utxo_records - .get(utxo_id) - .ok_or_else(|| anyhow!("UTXO {utxo_id:?} not found for guardian request"))?; - let utxo = &record.utxo; - + .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); @@ -1074,9 +1086,9 @@ pub fn build_guardian_withdrawal_request( // Build guardian OutputUTXOs. Withdrawal outputs are External; the change output // (last output when there are more outputs than requests) is Internal. - let num_requests = approval.request_ids.len(); - let outputs = approval - .outputs + let all_outputs = pending.all_outputs(); + let num_requests = pending.request_ids.len(); + let outputs = all_outputs .iter() .enumerate() .map(|(i, output)| { @@ -1093,7 +1105,6 @@ pub fn build_guardian_withdrawal_request( .map_err(|e| anyhow!("Failed to build guardian external OutputUTXO: {e}")) } else { // Internal change output back to hashi. - // The derivation_path for the root key is all zeros. let derivation_path = [0u8; 32]; Ok(OutputUTXO::new_internal( derivation_path, @@ -1107,7 +1118,7 @@ pub fn build_guardian_withdrawal_request( .map_err(|e| anyhow!("Failed to build guardian TxUTXOs: {e}"))?; // Derive a deterministic wid from the request IDs. - let wid_bytes = bcs::to_bytes(&approval.request_ids).expect("serialization should succeed"); + 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()); From 508bc131bc4fcb9d70f4ee084e2bde54d5d96d0a Mon Sep 17 00:00:00 2001 From: Siddharth Sharma Date: Thu, 9 Apr 2026 10:44:35 -0700 Subject: [PATCH 3/4] guardian: initialize seq counter from guardian limiter state Add LimiterState to GetGuardianInfoResponse so hashi can read the guardian's current next_seq at startup. This ensures the seq counter survives node restarts and leader rotations without drifting. --- crates/hashi-guardian/src/getters.rs | 2 ++ crates/hashi-guardian/src/main.rs | 6 ++++ .../sui/hashi/v1alpha/guardian_service.proto | 3 ++ crates/hashi-types/src/guardian/mod.rs | 2 ++ .../src/guardian/proto_conversions.rs | 4 +++ crates/hashi-types/src/guardian/test_utils.rs | 1 + .../proto/generated/sui.hashi.v1alpha.fds.bin | Bin 14211 -> 14281 bytes .../src/proto/generated/sui.hashi.v1alpha.rs | 3 ++ crates/hashi/src/grpc/guardian_client.rs | 8 ++++++ crates/hashi/src/lib.rs | 26 ++++++++++++++++++ 10 files changed, 55 insertions(+) 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/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 1f2547e922551199f471e0f40afad34401f2d2dc..01dc2283fd7532fd567ead1f65c44a39d1536b61 100644 GIT binary patch delta 54 zcmZq9KbgPb6dzNM`s6cwGnwWyO-|?EDlFu}#ha6vn^}@t6klADSduEiGI^t#, + /// 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/grpc/guardian_client.rs b/crates/hashi/src/grpc/guardian_client.rs index d6a475f6b..50dc51e89 100644 --- a/crates/hashi/src/grpc/guardian_client.rs +++ b/crates/hashi/src/grpc/guardian_client.rs @@ -38,6 +38,14 @@ impl GuardianClient { 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, diff --git a/crates/hashi/src/lib.rs b/crates/hashi/src/lib.rs index 8a89f5f55..47bbaef57 100644 --- a/crates/hashi/src/lib.rs +++ b/crates/hashi/src/lib.rs @@ -421,6 +421,32 @@ impl Hashi { match grpc::guardian_client::GuardianClient::new(endpoint) { Ok(client) => { tracing::info!("Guardian client configured for {}", client.endpoint()); + // Fetch the guardian's current limiter state to initialize our seq counter. + match client.get_guardian_info().await { + Ok(info) => { + if let Some(limiter_state) = info.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 for seq initialization: {}; \ + seq counter starts at 0", + e + ); + } + } Some(client) } Err(e) => { From 89e582f964e70345af97cf6245a64c479c160e37 Mon Sep 17 00:00:00 2001 From: Siddharth Sharma Date: Thu, 9 Apr 2026 10:56:55 -0700 Subject: [PATCH 4/4] guardian: verify guardian Ed25519 signature on withdrawal responses Fetch the guardian's signing pubkey at startup alongside the seq counter. After each standard_withdrawal call, deserialize the response into GuardianSigned and verify the Ed25519 signature against the cached pubkey. --- crates/hashi/src/leader/mod.rs | 28 ++++++++++++++++++----- crates/hashi/src/lib.rs | 41 +++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/hashi/src/leader/mod.rs b/crates/hashi/src/leader/mod.rs index ac01c71db..1f8786726 100644 --- a/crates/hashi/src/leader/mod.rs +++ b/crates/hashi/src/leader/mod.rs @@ -1152,11 +1152,29 @@ impl LeaderService { ); match guardian.standard_withdrawal(proto_request).await { - Ok(_response) => { - info!( - pending_withdrawal_id = %pending.id, - "Guardian approved withdrawal (rate limit passed)" - ); + 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") { diff --git a/crates/hashi/src/lib.rs b/crates/hashi/src/lib.rs index 47bbaef57..484435323 100644 --- a/crates/hashi/src/lib.rs +++ b/crates/hashi/src/lib.rs @@ -54,6 +54,9 @@ pub struct Hashi { 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>>, } @@ -77,6 +80,7 @@ impl Hashi { 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()), })) } @@ -103,6 +107,7 @@ impl Hashi { 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()), })) } @@ -215,6 +220,12 @@ impl Hashi { .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(), @@ -421,10 +432,30 @@ impl Hashi { match grpc::guardian_client::GuardianClient::new(endpoint) { Ok(client) => { tracing::info!("Guardian client configured for {}", client.endpoint()); - // Fetch the guardian's current limiter state to initialize our seq counter. + // Fetch guardian info to initialize seq counter and signing pubkey. match client.get_guardian_info().await { - Ok(info) => { - if let Some(limiter_state) = info.limiter_state { + 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); @@ -441,8 +472,8 @@ impl Hashi { } Err(e) => { tracing::warn!( - "Failed to fetch guardian info for seq initialization: {}; \ - seq counter starts at 0", + "Failed to fetch guardian info: {}; \ + seq starts at 0, response verification disabled", e ); }