Skip to content
Merged
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ axum = "0.8.4"
tower = "0.5.3"
reqwest = { version = "0.12", default-features = false, features = ["http2", "json", "rustls-tls"] }
tokio = { version = "1.46.1", features = ["full"] }
tokio-stream = "0.1"
tokio-util = { version = "0.7", features = ["rt"] }
prometheus = "0.14"
tracing = "0.1.41"
Expand Down
6 changes: 6 additions & 0 deletions crates/e2e-tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ edition = "2024"

[dependencies]
hashi = { path = "../hashi" }
hashi-guardian = { path = "../hashi-guardian", features = [
"test-utils",
"non-enclave-dev",
] }
hashi-screener = { path = "../hashi-screener", features = ["test-utils"] }
hashi-types = { path = "../hashi-types" }

Expand Down Expand Up @@ -32,6 +36,8 @@ clap.workspace = true
colored.workspace = true
rand.workspace = true
nix = { version = "0.26.4", features = ["signal"] }
tokio-stream.workspace = true
tonic.workspace = true
tracing-subscriber.workspace = true

[[bin]]
Expand Down
46 changes: 38 additions & 8 deletions crates/e2e-tests/src/e2e_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ mod tests {
use crate::test_helpers::lookup_vout;
use crate::test_helpers::txid_to_address;

async fn setup_test_networks() -> Result<TestNetworks> {
async fn setup_test_networks(builder: TestNetworksBuilder) -> Result<TestNetworks> {
info!("Setting up test networks...");
let networks = TestNetworksBuilder::new().with_nodes(4).build().await?;
let networks = builder.build().await?;

info!("Test networks initialized");
info!(" - Sui RPC: {}", networks.sui_network.rpc_url);
Expand Down Expand Up @@ -236,7 +236,7 @@ mod tests {
init_test_logging();
info!("=== Starting Bitcoin Deposit E2E Test ===");

let mut networks = setup_test_networks().await?;
let mut networks = setup_test_networks(TestNetworksBuilder::new().with_nodes(4)).await?;
let amount_sats = 31337u64;
let hbtc_recipient = create_deposit_and_wait(&mut networks, amount_sats).await?;

Expand All @@ -255,10 +255,40 @@ mod tests {

#[tokio::test]
async fn test_bitcoin_withdrawal_e2e_flow() -> Result<()> {
run_bitcoin_withdrawal_e2e(false).await
}

#[tokio::test]
async fn test_bitcoin_withdrawal_with_guardian_e2e_flow() -> Result<()> {
run_bitcoin_withdrawal_e2e(true).await
}

async fn run_bitcoin_withdrawal_e2e(with_guardian: bool) -> Result<()> {
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.

I suppose this makes sense while we are getting the integration hooked up. We may want to evaluate if we want to have all tests run with guardian by default eventually.

init_test_logging();
info!("=== Starting Bitcoin Withdrawal E2E Test ===");
let label = if with_guardian {
" (with guardian)"
} else {
""
};
info!("=== Starting Bitcoin Withdrawal E2E Test{label} ===");

let mut builder = TestNetworksBuilder::new().with_nodes(4);
if with_guardian {
builder = builder.with_guardian();
}
let mut networks = setup_test_networks(builder).await?;

let mut networks = setup_test_networks().await?;
if with_guardian {
for node in networks.hashi_network.nodes() {
assert!(node.hashi().config.guardian_endpoint().is_some());
assert!(node.hashi().guardian_client().is_some());
}
let harness = networks
.guardian_harness
.as_ref()
.expect("harness present when .with_guardian() is set");
assert!(harness.enclave().is_fully_initialized());
}

let deposit_amount_sats = 100_000u64;
let hbtc_recipient = create_deposit_and_wait(&mut networks, deposit_amount_sats).await?;
Expand Down Expand Up @@ -335,7 +365,7 @@ mod tests {
)
.await?;

info!("=== Bitcoin Withdrawal E2E Test Passed ===");
info!("=== Bitcoin Withdrawal E2E Test{label} Passed ===");
Ok(())
}

Expand Down Expand Up @@ -382,7 +412,7 @@ mod tests {
#[tokio::test]
async fn test_presigning_recovery_within_batch() -> Result<()> {
init_test_logging();
let mut networks = setup_test_networks().await?;
let mut networks = setup_test_networks(TestNetworksBuilder::new().with_nodes(4)).await?;
let deposit_amount_sats = 100_000u64;
let withdrawal_amount_sats = 30_000u64;
let user_key = networks.sui_network.user_keys.first().unwrap().clone();
Expand Down Expand Up @@ -643,7 +673,7 @@ mod tests {
init_test_logging();
info!("=== Starting Unconfirmed Change UTXO Chaining Test ===");

let mut networks = setup_test_networks().await?;
let mut networks = setup_test_networks(TestNetworksBuilder::new().with_nodes(4)).await?;

// Deposit enough that after withdrawal 1 there is substantial change.
let deposit_amount_sats = 200_000u64;
Expand Down
136 changes: 136 additions & 0 deletions crates/e2e-tests/src/guardian_harness.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

//! In-process `hashi-guardian` for integration tests. Two stages:
//! [`GuardianHarness::start`] serves gRPC; [`GuardianHarness::finalize`]
//! runs provisioner-init once hashi DKG output is on chain.

use anyhow::Context;
use anyhow::Result;
use bitcoin::Network;
use hashi_guardian::Enclave;
use hashi_guardian::OperatorInitTestArgs;
use hashi_guardian::create_operator_initialized_enclave;
use hashi_guardian::rpc::GuardianGrpc;
use hashi_types::committee::Committee as HashiCommittee;
use hashi_types::guardian::BitcoinPubkey;
use hashi_types::guardian::LimiterState;
use hashi_types::guardian::WithdrawalConfig;
use hashi_types::proto::guardian_service_server::GuardianServiceServer;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use tonic::transport::Server;

/// In-process guardian reachable over gRPC on a local TCP socket.
pub struct GuardianHarness {
enclave: Arc<Enclave>,
endpoint: String,
shutdown_tx: Option<oneshot::Sender<()>>,
server_handle: Option<JoinHandle<()>>,
}

impl GuardianHarness {
/// Start an operator-init'd guardian. Withdrawal RPCs stay gated until
/// [`Self::finalize`] completes provisioner-init.
pub async fn start(network: Network) -> Result<Self> {
Comment on lines +36 to +38
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.

At which stage is the guardian pubkey available? Here or after finalize? we do need to commit to it, and likely a url of some such, onchain and it would probably be beneficial to be able to have the key available at contract init time.

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.

From a conversation we had today, the committing onchain will be done post this series

let enclave = create_operator_initialized_enclave(
OperatorInitTestArgs::default().with_network(network),
)
.await;

let listener = TcpListener::bind("127.0.0.1:0")
.await
.context("bind guardian harness listener")?;
let addr: SocketAddr = listener.local_addr()?;
let endpoint = format!("http://{addr}");
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.

Just a note, do we want to use traditional TLS termination or require mTLS from the validators to the guardian?


let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let svc = GuardianGrpc {
enclave: enclave.clone(),
setup_mode: false,
};
let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
let server_handle = tokio::spawn(async move {
let result = Server::builder()
.add_service(GuardianServiceServer::new(svc))
.serve_with_incoming_shutdown(incoming, async move {
let _ = shutdown_rx.await;
})
.await;
if let Err(e) = result {
tracing::warn!("guardian harness server exited: {e}");
}
});

Ok(Self {
enclave,
endpoint,
shutdown_tx: Some(shutdown_tx),
server_handle: Some(server_handle),
})
}

/// Complete provisioner-init using the committee and master pubkey from hashi DKG.
pub async fn finalize(
&self,
committee: HashiCommittee,
master_pubkey: BitcoinPubkey,
withdrawal_config: WithdrawalConfig,
limiter_state: LimiterState,
) -> Result<()> {
hashi_guardian::test_utils::finalize_enclave(
&self.enclave,
committee,
master_pubkey,
withdrawal_config,
limiter_state,
)
.map_err(|e| anyhow::anyhow!("finalize guardian enclave: {e:?}"))?;

anyhow::ensure!(
self.enclave.is_fully_initialized(),
"guardian did not reach fully-initialized state"
);
Ok(())
}

pub fn endpoint(&self) -> &str {
&self.endpoint
}

pub fn enclave(&self) -> &Arc<Enclave> {
&self.enclave
}
}

impl Drop for GuardianHarness {
fn drop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.server_handle.take() {
handle.abort();
}
}
}

pub fn default_test_withdrawal_config(committee: &HashiCommittee) -> WithdrawalConfig {
let total_weight = committee.total_weight();
let committee_threshold = total_weight.div_ceil(3) * 2;
WithdrawalConfig {
committee_threshold,
refill_rate_sats_per_sec: 0,
max_bucket_capacity_sats: 100_000_000,
}
}

pub fn full_bucket(config: &WithdrawalConfig) -> LimiterState {
LimiterState {
num_tokens_available: config.max_bucket_capacity_sats,
last_updated_at: 0,
next_seq: 0,
}
}
10 changes: 10 additions & 0 deletions crates/e2e-tests/src/hashi_network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ pub struct HashiNetworkBuilder {
/// Node index whose shares should be corrupted by all other nodes,
/// triggering the complaint recovery flow.
pub test_corrupt_shares_target: Option<usize>,
pub guardian_endpoint: Option<String>,
}

impl HashiNetworkBuilder {
Expand All @@ -261,9 +262,15 @@ impl HashiNetworkBuilder {
withdrawal_max_batch_size: None,
max_mempool_chain_depth: None,
test_corrupt_shares_target: None,
guardian_endpoint: None,
}
}

pub fn with_guardian_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.guardian_endpoint = Some(endpoint.into());
self
}

pub fn with_num_nodes(mut self, num_nodes: usize) -> Self {
self.num_nodes = num_nodes;
self
Expand Down Expand Up @@ -363,6 +370,9 @@ impl HashiNetworkBuilder {
config.bitcoin_chain_id = Some(hashi::constants::BITCOIN_REGTEST_CHAIN_ID.to_string());
config.sui_chain_id = service_info.chain_id.clone();
config.screener_endpoint = Some(screener_endpoint.clone());
if let Some(ref guardian_endpoint) = self.guardian_endpoint {
config.guardian_endpoint = Some(guardian_endpoint.clone());
}
config.db = Some(dir.join(validator_address.to_string()));
configs.push(config);
}
Expand Down
Loading
Loading