Skip to content
Closed
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
46 changes: 45 additions & 1 deletion crates/hashi-guardian/src/enclave.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ impl EnclaveState {

pub async fn consume_from_limiter(
&self,
wid: u64,
seq: u64,
timestamp: u64,
amount_sats: u64,
Expand All @@ -323,10 +324,53 @@ 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))
}

/// Soft-reserve headroom for `amount_sats` against `wid`. Idempotent:
/// repeat calls for the same wid return (and refresh) the existing
/// reservation. Reservations are dropped either by a matching
/// `consume_from_limiter` call or by a TTL sweep.
pub async fn soft_reserve(
&self,
wid: u64,
timestamp_secs: u64,
amount_sats: u64,
now_unix_secs: u64,
) -> GuardianResult<hashi_types::guardian::PendingReserve> {
let rate_limiter = self
.rate_limiter
.get()
.ok_or_else(|| InvalidInputs("rate_limiter not initialized".into()))?;
let mut guard = tokio::time::timeout(
Self::LIMITER_LOCK_TIMEOUT,
rate_limiter.clone().lock_owned(),
)
.await
.map_err(|_| InvalidInputs("timed out waiting for rate limiter lock".into()))?;
guard.soft_reserve(wid, timestamp_secs, amount_sats, now_unix_secs)
}

/// Drop any soft reservation whose TTL has elapsed. Called periodically
/// from the guardian's background sweep task.
pub async fn expire_pending_reserves(&self, now_unix_secs: u64) -> usize {
let Some(rate_limiter) = self.rate_limiter.get() else {
return 0;
};
// Use a short timeout — if the limiter is held longer than that by
// an in-flight withdrawal, we'll just try again next tick.
match tokio::time::timeout(
Self::LIMITER_LOCK_TIMEOUT,
rate_limiter.clone().lock_owned(),
)
.await
{
Ok(mut guard) => guard.expire_pending(now_unix_secs),
Err(_) => 0,
}
}

/// Snapshot the current rate limiter state, if the limiter has been
/// initialized. Used by `GetGuardianInfo` so that clients can seed their
/// local `seq` counter at startup.
Expand Down
27 changes: 26 additions & 1 deletion crates/hashi-guardian/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use hashi_types::guardian::GuardianEncKeyPair;
use hashi_types::guardian::GuardianSignKeyPair;
use hashi_types::proto::guardian_service_server::GuardianServiceServer;
use std::sync::Arc;
use std::time::Duration;
use tonic::transport::Server;
use tonic_health::server::health_reporter;
use tracing::info;
Expand Down Expand Up @@ -61,15 +62,39 @@ async fn main() -> Result<()> {
.add_service(GuardianServiceServer::new(svc))
.serve(addr);

let heartbeat_future = HeartbeatWriter::new(enclave, MAX_HEARTBEAT_FAILURES_INTERVAL)
let heartbeat_future = HeartbeatWriter::new(enclave.clone(), MAX_HEARTBEAT_FAILURES_INTERVAL)
.run(HEARTBEAT_INTERVAL, HEARTBEAT_RETRY_INTERVAL);

let ttl_sweep_future = run_soft_reserve_sweep(enclave.clone());

tokio::select! {
res = server_future => {
res.map_err(|e| anyhow::anyhow!("Server error: {}", e))
}
res = heartbeat_future => {
panic!("Heartbeat failed: {:?}", res)
}
_ = ttl_sweep_future => {
unreachable!("soft-reserve sweep loop should run forever")
}
}
}

/// Periodically drop soft reservations whose TTL has elapsed. Runs until
/// shutdown. Swept entries free up capacity for subsequent soft reserves.
async fn run_soft_reserve_sweep(enclave: Arc<Enclave>) -> ! {
const SWEEP_INTERVAL: Duration = Duration::from_secs(1);
let mut ticker = tokio::time::interval(SWEEP_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let now = hashi_types::guardian::now_timestamp_secs();
let removed = enclave.state.expire_pending_reserves(now).await;
if removed > 0 {
info!(
removed,
"soft-reserve TTL sweep dropped {removed} expired reservations"
);
}
}
}
35 changes: 35 additions & 0 deletions crates/hashi-guardian/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,39 @@ impl proto::guardian_service_server::GuardianService for GuardianGrpc {
let resp_pb = proto_conversions::standard_withdrawal_response_signed_to_pb(response);
Ok(Response::new(resp_pb))
}

async fn soft_reserve_withdrawal(
&self,
request: Request<proto::SoftReserveWithdrawalRequest>,
) -> Result<Response<proto::SoftReserveWithdrawalResponse>, Status> {
if self.setup_mode {
return Err(Status::failed_precondition(
"soft_reserve_withdrawal is disabled when SETUP_MODE=true",
));
}

let req = request.into_inner();
let wid = req
.wid
.ok_or_else(|| Status::invalid_argument("wid is required"))?;
let amount_sats = req
.amount_sats
.ok_or_else(|| Status::invalid_argument("amount_sats is required"))?;
let timestamp_secs = req
.timestamp_secs
.ok_or_else(|| Status::invalid_argument("timestamp_secs is required"))?;

let reserve = withdraw::soft_reserve_withdrawal(
self.enclave.clone(),
wid,
timestamp_secs,
amount_sats,
)
.await
.map_err(to_status)?;

Ok(Response::new(proto::SoftReserveWithdrawalResponse {
expires_at_secs: Some(reserve.expires_at_secs),
}))
}
}
57 changes: 57 additions & 0 deletions crates/hashi-guardian/src/withdraw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use hashi_types::guardian::GuardianResult;
use hashi_types::guardian::GuardianSigned;
use hashi_types::guardian::HashiCommittee;
use hashi_types::guardian::HashiSigned;
use hashi_types::guardian::PendingReserve;
use hashi_types::guardian::RateLimiter;
use hashi_types::guardian::StandardWithdrawalRequest;
use hashi_types::guardian::StandardWithdrawalRequestWire;
Expand Down Expand Up @@ -67,6 +68,60 @@ pub async fn standard_withdrawal(
}
}

/// Light-touch pre-construction rate-limit probe.
///
/// Idempotent on `wid`: callers may re-submit the same wid from any node
/// at any time to learn whether capacity is available. A successful
/// reserve debits against future `soft_reserve` capacity checks but does
/// NOT advance `next_seq` or `last_updated_at`. The reservation is
/// dropped either by a matching `standard_withdrawal` commit or by the
/// TTL sweep.
///
/// Unlike `standard_withdrawal`, soft reserve does not require a
/// committee certificate — the 5-minute TTL bounds the DoS blast radius
/// of a rogue or buggy caller to at most one bucket's worth of headroom.
pub async fn soft_reserve_withdrawal(
enclave: Arc<Enclave>,
wid: u64,
timestamp_secs: u64,
amount_sats: u64,
) -> GuardianResult<PendingReserve> {
info!(wid, amount_sats, "soft reserve");

if !enclave.is_fully_initialized() {
return Err(EnclaveUninitialized);
}

// A wid that has already been fully processed has no meaning in the
// pending queue; short-circuit to avoid confusing callers.
if let Some(_cached) = enclave.state.get_cached_response(wid) {
// Already hard-reserved: nothing to pend. Return a sentinel
// reservation pointing at "now" so callers can proceed to the
// hard reserve (which will hit the idempotency cache).
let now = now_timestamp_secs();
return Ok(PendingReserve {
amount_sats,
timestamp_secs,
expires_at_secs: now,
});
}

// Reject timestamps too far in the future (clock skew protection).
const MAX_CLOCK_SKEW_SECS: u64 = 5 * 60;
let guardian_now = now_timestamp_secs();
if timestamp_secs > guardian_now + MAX_CLOCK_SKEW_SECS {
return Err(InvalidInputs(format!(
"soft reserve timestamp {timestamp_secs} is too far in the future \
(guardian clock: {guardian_now})"
)));
}

enclave
.state
.soft_reserve(wid, timestamp_secs, amount_sats, guardian_now)
.await
}

// TODO: Support batched withdrawals (multiple wids per transaction).
async fn normal_withdrawal_inner(
enclave: Arc<Enclave>,
Expand Down Expand Up @@ -109,9 +164,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();
let limiter_guard = enclave
.state
.consume_from_limiter(
wid,
request.seq(),
request.timestamp_secs(),
consumed_amount_sats,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ service GuardianService {
// Provisioner initialization: submit encrypted share and readiness info.
rpc ProvisionerInit(ProvisionerInitRequest) returns (ProvisionerInitResponse);

// Soft-reserve a withdrawal slot (light-touch pre-construction check).
// Any node may call this before committing an on-chain withdrawal tx to
// learn whether rate-limit headroom exists for `amount_sats`. Soft
// reserves are idempotent on `wid`, do not debit the bucket, and expire
// after a server-enforced TTL (so a rogue or crashed caller cannot lock
// capacity indefinitely).
rpc SoftReserveWithdrawal(SoftReserveWithdrawalRequest) returns (SoftReserveWithdrawalResponse);

// Standard withdrawal: request immediate withdrawal signature.
rpc StandardWithdrawal(SignedStandardWithdrawalRequest) returns (SignedStandardWithdrawalResponse);
}
Expand All @@ -34,8 +42,10 @@ 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). Lets clients seed their
// local seq counter at startup so it survives restarts and leader rotations.
// Current rate limiter state (if initialized). Clients query this
// just-in-time per withdrawal to read `next_seq`; the guardian's
// wid-keyed response cache makes repeated attempts idempotent so no
// client-side seq bookkeeping is needed.
LimiterState limiter_state = 4;
}

Expand Down Expand Up @@ -249,3 +259,33 @@ message StandardWithdrawalResponseData {
// Bitcoin signatures for each input (64 bytes each, Schnorr signatures).
repeated bytes enclave_signatures = 1;
}

// ============================
// SoftReserveWithdrawal
// ============================

// Pre-construction rate-limit check. Soft reserves are idempotent on
// `wid`: re-submitting the same wid extends / returns the existing
// reservation. The guardian does not debit the bucket until a matching
// StandardWithdrawal lands (or the reservation TTL elapses).
message SoftReserveWithdrawalRequest {
// Deterministic withdrawal identifier — same as
// `StandardWithdrawalRequestData.wid`. Derived on the client from the
// on-chain request ids so any validator converges on the same value.
optional uint64 wid = 1;

// Upper-bound amount in satoshis being reserved (pre-fee sum of the
// batched user-facing withdrawal amounts).
optional uint64 amount_sats = 2;

// Unix seconds timestamp for the reserve, monotonically non-decreasing
// across successful soft+hard reserves. Clients use the most recent
// Sui checkpoint clock.
optional uint64 timestamp_secs = 3;
}

message SoftReserveWithdrawalResponse {
// Unix seconds at which this reservation will be garbage-collected if
// no matching StandardWithdrawal has committed it by then.
optional uint64 expires_at_secs = 1;
}
Loading
Loading