Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions crates/e2e-tests/src/e2e_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,14 @@ mod tests {
.as_ref()
.expect("harness present when .with_guardian() is set");
assert!(harness.enclave().is_fully_initialized());
futures::future::try_join_all(
networks
.hashi_network
.nodes()
.iter()
.map(|node| node.wait_for_local_limiter(Duration::from_secs(60))),
)
.await?;
}

let deposit_amount_sats = 100_000u64;
Expand Down Expand Up @@ -365,6 +373,25 @@ mod tests {
)
.await?;

if with_guardian {
let guardian_state = networks
.guardian_harness
.as_ref()
.expect("harness present when .with_guardian() is set")
.enclave()
.state
.limiter_state()
.await
.expect("guardian limiter state present after a successful withdrawal");
assert_eq!(guardian_state.next_seq, 1);
let local_state = hashi
.local_limiter()
.expect("local limiter present after bootstrap")
.snapshot()
.await;
assert_eq!(local_state, guardian_state);
}

info!("=== Bitcoin Withdrawal E2E Test{label} Passed ===");
Ok(())
}
Expand Down
15 changes: 15 additions & 0 deletions crates/e2e-tests/src/hashi_network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,21 @@ impl HashiNodeHandle {
}
}

pub async fn wait_for_local_limiter(&self, timeout: std::time::Duration) -> Result<()> {
tokio::time::timeout(timeout, self.wait_for_local_limiter_inner())
.await
.map_err(|_| anyhow::anyhow!("local limiter bootstrap timed out after {:?}", timeout))
}

async fn wait_for_local_limiter_inner(&self) {
loop {
if self.hashi().local_limiter().is_some() {
return;
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}

pub fn current_epoch(&self) -> Option<u64> {
self.hashi()
.onchain_state_opt()
Expand Down
3 changes: 2 additions & 1 deletion crates/hashi-guardian/src/enclave.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ impl EnclaveState {

pub async fn consume_from_limiter(
&self,
wid: u64,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But wid is not actually used inside consume, no? is this for future logging purposes?

seq: u64,
timestamp: u64,
amount_sats: u64,
Expand All @@ -324,7 +325,7 @@ impl EnclaveState {
)
.await
.map_err(|_| InvalidInputs("timed out waiting for rate limiter lock".into()))?;
guard.consume(seq, timestamp, amount_sats)?;
guard.consume(wid, seq, timestamp, amount_sats)?;
Ok(LimiterGuard::new(guard))
}

Expand Down
2 changes: 2 additions & 0 deletions crates/hashi-guardian/src/withdraw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,11 @@ async fn normal_withdrawal_inner(

info!("Checking rate limits.");
let consumed_amount_sats = request.utxos().external_out_amount().to_sat();
let wid = *request.wid();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this a u64 if the actual id is 32 bytes?

let limiter_guard = enclave
.state
.consume_from_limiter(
wid,
request.seq(),
request.timestamp_secs(),
consumed_amount_sats,
Expand Down
21 changes: 21 additions & 0 deletions crates/hashi-types/proto/sui/hashi/v1alpha/bridge_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ service BridgeService {
rpc SignWithdrawalTxConstruction(SignWithdrawalTxConstructionRequest) returns (SignWithdrawalTxConstructionResponse);
// Step 2b: Sign a bitcoin withdrawal transaction (MPC Schnorr).
rpc SignWithdrawalTransaction(SignWithdrawalTransactionRequest) returns (SignWithdrawalTransactionResponse);
// Sign a guardian rate-limiting request so the leader can aggregate a
// committee certificate and forward it to the guardian after MPC quorum.
rpc SignGuardianWithdrawalRequest(SignGuardianWithdrawalRequestRequest) returns (SignGuardianWithdrawalRequestResponse);
// Step 3: Sign the BLS certificate over the witness signatures for on-chain storage.
rpc SignWithdrawalTxSigning(SignWithdrawalTxSigningRequest) returns (SignWithdrawalTxSigningResponse);
// Step 4: Sign committee approval to confirm a processed withdrawal on-chain.
Expand Down Expand Up @@ -165,3 +168,21 @@ message SignWithdrawalConfirmationRequest {
message SignWithdrawalConfirmationResponse {
MemberSignature member_signature = 1;
}

// The leader sends the withdrawal-transaction id along with the guardian-specific
// fields (timestamp, seq) so each validator can independently reconstruct and
// BLS-sign the same `StandardWithdrawalRequest`.
message SignGuardianWithdrawalRequestRequest {
// The id of the WithdrawalTransaction on Sui (32 bytes).
bytes withdrawal_txn_id = 1;

// Timestamp in unix seconds (used for guardian rate limiting).
uint64 timestamp_secs = 2;
Comment on lines +179 to +180
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is expected to be a semi-recent timestamp from a checkpoint correct?


// Monotonic sequence number (used by guardian for replay prevention).
uint64 seq = 3;
}

message SignGuardianWithdrawalRequestResponse {
MemberSignature member_signature = 1;
}
27 changes: 16 additions & 11 deletions crates/hashi-types/src/guardian/limiter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,13 @@ impl RateLimiter {

/// Consume tokens from the bucket. Validates seq and timestamp ordering,
/// refills based on elapsed time, then debits the requested amount.
pub fn consume(&mut self, seq: u64, timestamp: u64, amount_sats: u64) -> GuardianResult<()> {
pub fn consume(
&mut self,
_wid: u64,
seq: u64,
timestamp: u64,
amount_sats: u64,
) -> GuardianResult<()> {
if seq != self.state.next_seq {
return Err(InvalidInputs(format!(
"seq mismatch: expected {}, got {}",
Expand Down Expand Up @@ -95,7 +101,6 @@ impl RateLimiter {
return Err(RateLimitExceeded);
}

// Snapshot for revert, then mutate.
self.prev_state = self.state;
self.state.last_updated_at = timestamp;
self.state.num_tokens_available = capacity - amount_sats;
Expand Down Expand Up @@ -131,18 +136,18 @@ mod test {
fn test_basic() {
let (config, state) = make_limiter();
let mut limiter = RateLimiter::new(config, state).unwrap();
assert!(limiter.consume(0, 1, config.refill_rate).is_ok());
assert!(limiter.consume(1, 0, 1, config.refill_rate).is_ok());

let target_amount = 1_000_000u64;
let num_secs_required = target_amount.div_ceil(config.refill_rate);
assert!(
limiter
.consume(1, num_secs_required, target_amount)
.consume(2, 1, num_secs_required, target_amount)
.is_err()
);
assert!(
limiter
.consume(1, 1 + num_secs_required, target_amount)
.consume(2, 1, 1 + num_secs_required, target_amount)
.is_ok()
);
}
Expand All @@ -153,12 +158,12 @@ mod test {
let mut limiter = RateLimiter::new(config, state).unwrap();
assert!(
limiter
.consume(0, u64::MAX, config.max_bucket_capacity + 1)
.consume(1, 0, u64::MAX, config.max_bucket_capacity + 1)
.is_err()
);
assert!(
limiter
.consume(0, u64::MAX, config.max_bucket_capacity)
.consume(1, 0, u64::MAX, config.max_bucket_capacity)
.is_ok()
);
}
Expand All @@ -168,7 +173,7 @@ mod test {
let (config, state) = make_limiter();
let mut limiter = RateLimiter::new(config, state).unwrap();
// Consume after refill, then revert — should restore original state.
limiter.consume(0, 100, 50_000).unwrap();
limiter.consume(1, 0, 100, 50_000).unwrap();
assert_eq!(limiter.state().num_tokens_available, 50_000); // 100*1000 - 50_000
limiter.revert();
assert_eq!(limiter.state().num_tokens_available, 0);
Expand All @@ -181,10 +186,10 @@ mod test {
let (config, state) = make_limiter();
let mut limiter = RateLimiter::new(config, state).unwrap();
// Wrong seq.
assert!(limiter.consume(1, 0, 0).is_err());
assert!(limiter.consume(1, 1, 0, 0).is_err());
// Advance state.
limiter.consume(0, 100, 1_000).unwrap();
limiter.consume(1, 0, 100, 1_000).unwrap();
// Old timestamp.
assert!(limiter.consume(1, 50, 1_000).is_err());
assert!(limiter.consume(2, 1, 50, 1_000).is_err());
}
}
Binary file modified crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.fds.bin
Binary file not shown.
114 changes: 114 additions & 0 deletions crates/hashi-types/src/proto/generated/sui.hashi.v1alpha.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,26 @@ pub struct SignWithdrawalConfirmationResponse {
#[prost(message, optional, tag = "1")]
pub member_signature: ::core::option::Option<MemberSignature>,
}
/// The leader sends the withdrawal-transaction id along with the guardian-specific
/// fields (timestamp, seq) so each validator can independently reconstruct and
/// BLS-sign the same `StandardWithdrawalRequest`.
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct SignGuardianWithdrawalRequestRequest {
/// The id of the WithdrawalTransaction on Sui (32 bytes).
#[prost(bytes = "bytes", tag = "1")]
pub withdrawal_txn_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<MemberSignature>,
}
/// Generated client implementations.
pub mod bridge_service_client {
#![allow(
Expand Down Expand Up @@ -394,6 +414,37 @@ pub mod bridge_service_client {
);
self.inner.unary(req, path, codec).await
}
/// Sign a guardian rate-limiting request so the leader can aggregate a
/// committee certificate and forward it to the guardian after MPC quorum.
pub async fn sign_guardian_withdrawal_request(
&mut self,
request: impl tonic::IntoRequest<super::SignGuardianWithdrawalRequestRequest>,
) -> std::result::Result<
tonic::Response<super::SignGuardianWithdrawalRequestResponse>,
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
}
/// Step 3: Sign the BLS certificate over the witness signatures for on-chain storage.
pub async fn sign_withdrawal_tx_signing(
&mut self,
Expand Down Expand Up @@ -510,6 +561,15 @@ pub mod bridge_service_server {
tonic::Response<super::SignWithdrawalTransactionResponse>,
tonic::Status,
>;
/// Sign a guardian rate-limiting request so the leader can aggregate a
/// committee certificate and forward it to the guardian after MPC quorum.
async fn sign_guardian_withdrawal_request(
&self,
request: tonic::Request<super::SignGuardianWithdrawalRequestRequest>,
) -> std::result::Result<
tonic::Response<super::SignGuardianWithdrawalRequestResponse>,
tonic::Status,
>;
/// Step 3: Sign the BLS certificate over the witness signatures for on-chain storage.
async fn sign_withdrawal_tx_signing(
&self,
Expand Down Expand Up @@ -858,6 +918,60 @@ pub mod bridge_service_server {
};
Box::pin(fut)
}
"/sui.hashi.v1alpha.BridgeService/SignGuardianWithdrawalRequest" => {
#[allow(non_camel_case_types)]
struct SignGuardianWithdrawalRequestSvc<T: BridgeService>(
pub Arc<T>,
);
impl<
T: BridgeService,
> tonic::server::UnaryService<
super::SignGuardianWithdrawalRequestRequest,
> for SignGuardianWithdrawalRequestSvc<T> {
type Response = super::SignGuardianWithdrawalRequestResponse;
type Future = BoxFuture<
tonic::Response<Self::Response>,
tonic::Status,
>;
fn call(
&mut self,
request: tonic::Request<
super::SignGuardianWithdrawalRequestRequest,
>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as BridgeService>::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)
}
"/sui.hashi.v1alpha.BridgeService/SignWithdrawalTxSigning" => {
#[allow(non_camel_case_types)]
struct SignWithdrawalTxSigningSvc<T: BridgeService>(pub Arc<T>);
Expand Down
Loading
Loading