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
245 changes: 6 additions & 239 deletions crates/e2e-tests/src/e2e_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,37 +10,27 @@ mod tests {

use futures::StreamExt;
use hashi::sui_tx_executor::SuiTxExecutor;
use hashi_types::move_types::DepositConfirmedEvent;
use hashi_types::move_types::WithdrawalConfirmedEvent;
use hashi_types::move_types::WithdrawalPickedForProcessingEvent;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use sui_rpc::field::FieldMask;
use sui_rpc::field::FieldMaskUtil;
use sui_rpc::proto::sui::rpc::v2::Checkpoint;
use sui_rpc::proto::sui::rpc::v2::GetBalanceRequest;
use sui_rpc::proto::sui::rpc::v2::SubscribeCheckpointsRequest;
use sui_sdk_types::Address;
use sui_sdk_types::StructTag;
use sui_sdk_types::bcs::FromBcs;
use tracing::debug;
use tracing::info;

use crate::TestNetworks;
use crate::TestNetworksBuilder;

fn init_test_logging() {
tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.try_init()
.ok();
}
use crate::test_helpers::BackgroundMiner;
use crate::test_helpers::create_deposit_and_wait;
use crate::test_helpers::get_hbtc_balance;
use crate::test_helpers::init_test_logging;
use crate::test_helpers::lookup_vout;
use crate::test_helpers::txid_to_address;

async fn setup_test_networks() -> Result<TestNetworks> {
info!("Setting up test networks...");
Expand All @@ -60,84 +50,6 @@ mod tests {
Ok(networks)
}

fn txid_to_address(txid: &Txid) -> Address {
hashi_types::bitcoin_txid::BitcoinTxid::from(*txid).into()
}

async fn wait_for_deposit_confirmation(
sui_client: &mut sui_rpc::Client,
request_id: Address,
timeout: Duration,
) -> Result<()> {
info!(
"Waiting for deposit confirmation for request_id: {}",
request_id
);

let start = std::time::Instant::now();
let subscription_read_mask = FieldMask::from_paths([Checkpoint::path_builder()
.transactions()
.events()
.events()
.contents()
.finish()]);
let mut subscription = sui_client
.subscription_client()
.subscribe_checkpoints(
SubscribeCheckpointsRequest::default().with_read_mask(subscription_read_mask),
)
.await?
.into_inner();

while let Some(item) = subscription.next().await {
if start.elapsed() > timeout {
return Err(anyhow!(
"Timeout waiting for deposit confirmation after {:?}",
timeout
));
}

let checkpoint = match item {
Ok(checkpoint) => checkpoint,
Err(e) => {
debug!("Error in checkpoint stream: {}", e);
continue;
}
};

debug!(
"Received checkpoint {}, checking for DepositConfirmedEvent...",
checkpoint.cursor()
);

for txn in checkpoint.checkpoint().transactions() {
for event in txn.events().events() {
let event_type = event.contents().name();

if event_type.contains("DepositConfirmedEvent") {
match DepositConfirmedEvent::from_bcs(event.contents().value()) {
Ok(event_data) => {
if event_data.request_id == request_id {
info!(
"Deposit confirmed! Found DepositConfirmedEvent for request_id: {}",
request_id
);
return Ok(());
}
}
Err(e) => {
debug!("Failed to parse DepositConfirmedEvent: {}", e);
}
}
}
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
}

Err(anyhow!("Checkpoint subscription ended unexpectedly"))
}

async fn wait_for_withdrawal_confirmation(
sui_client: &mut sui_rpc::Client,
timeout: Duration,
Expand Down Expand Up @@ -207,151 +119,6 @@ mod tests {
Err(anyhow!("Checkpoint subscription ended unexpectedly"))
}

async fn get_hbtc_balance(
sui_client: &mut sui_rpc::Client,
package_id: Address,
owner: Address,
) -> Result<u64> {
let btc_type = format!("{}::btc::BTC", package_id);
let btc_struct_tag: StructTag = btc_type.parse()?;
let request = GetBalanceRequest::default()
.with_owner(owner.to_string())
.with_coin_type(btc_struct_tag.to_string());

let response = sui_client
.state_client()
.get_balance(request)
.await?
.into_inner();

let balance = response.balance().balance_opt().unwrap_or(0);
debug!("hBTC balance for {}: {} sats", owner, balance);
Ok(balance)
}

fn lookup_vout(
networks: &TestNetworks,
txid: Txid,
address: bitcoin::Address,
amount: u64,
) -> Result<usize> {
let tx = networks
.bitcoin_node
.rpc_client()
.get_raw_transaction(txid)
.and_then(|r| r.transaction().map_err(Into::into))?;
let vout = tx
.output
.iter()
.position(|output| {
output.value == Amount::from_sat(amount)
&& output.script_pubkey == address.script_pubkey()
})
.ok_or_else(|| {
anyhow!(
"Could not find output with amount {} and deposit address",
amount
)
})?;
debug!("Found deposit in tx output {}", vout);
Ok(vout)
}

async fn create_deposit_and_wait(
networks: &mut TestNetworks,
amount_sats: u64,
) -> Result<Address> {
let user_key = networks.sui_network.user_keys.first().unwrap();
let hbtc_recipient = user_key.public_key().derive_address();
let hashi = networks.hashi_network.nodes()[0].hashi().clone();
// Use the on-chain MPC key rather than the local key-ready channel.
// The on-chain key is set during end_reconfig and is guaranteed
// available once HashiNetworkBuilder::build() returns.
let deposit_address =
hashi.get_deposit_address(&hashi.get_onchain_mpc_pubkey()?, Some(&hbtc_recipient))?;

info!("Sending Bitcoin to deposit address...");
let txid = networks
.bitcoin_node
.send_to_address(&deposit_address, Amount::from_sat(amount_sats))?;
info!("Transaction sent: {}", txid);

info!("Mining blocks for confirmation...");
let blocks_to_mine = 10;
networks.bitcoin_node.generate_blocks(blocks_to_mine)?;
info!("{blocks_to_mine} blocks mined");

info!("Creating deposit request on Sui...");
let vout = lookup_vout(networks, txid, deposit_address, amount_sats)?;
let mut executor = SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state())?
.with_signer(user_key.clone());
let request_id = executor
.execute_create_deposit_request(
txid_to_address(&txid),
vout as u32,
amount_sats,
Some(hbtc_recipient),
)
.await?;
info!("Deposit request created: {}", request_id);

// Mine blocks in the background so the leader's BTC-block-driven
// deposit processing loop fires.
let _miner = BackgroundMiner::start(&networks.bitcoin_node);
wait_for_deposit_confirmation(
&mut networks.sui_network.client,
request_id,
Duration::from_secs(300),
)
.await?;
info!("Deposit confirmed on Sui");

Ok(hbtc_recipient)
}

/// Mines one block per second on Bitcoin regtest until stopped.
/// Stops automatically when dropped.
struct BackgroundMiner {
stop_flag: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}

impl BackgroundMiner {
fn start(bitcoin_node: &crate::BitcoinNodeHandle) -> Self {
let stop_flag = Arc::new(AtomicBool::new(false));
let stop_clone = stop_flag.clone();
let rpc_url = bitcoin_node.rpc_url().to_string();
let handle = std::thread::spawn(move || {
let rpc = corepc_client::client_sync::v29::Client::new_with_auth(
&rpc_url,
corepc_client::client_sync::Auth::UserPass(
crate::bitcoin_node::RPC_USER.to_string(),
crate::bitcoin_node::RPC_PASSWORD.to_string(),
),
)
.expect("failed to create mining RPC client");
let addr = rpc.new_address().expect("failed to get mining address");
while !stop_clone.load(Ordering::Relaxed) {
let _ = rpc.generate_to_address(1, &addr);
std::thread::sleep(Duration::from_secs(1));
}
});
Self {
stop_flag,
handle: Some(handle),
}
}
}

impl Drop for BackgroundMiner {
fn drop(&mut self) {
self.stop_flag.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}

fn extract_witness_program(address: &bitcoin::Address) -> Result<Vec<u8>> {
let script = address.script_pubkey();
let bytes = script.as_bytes();
Expand Down
51 changes: 28 additions & 23 deletions crates/e2e-tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub mod e2e_flow;
pub mod hashi_network;
mod publish;
pub mod sui_network;
pub mod test_helpers;
pub mod upgrade_flow;

pub use bitcoin_node::BitcoinNodeBuilder;
pub use bitcoin_node::BitcoinNodeHandle;
Expand Down Expand Up @@ -69,6 +71,10 @@ impl TestNetworks {
&self.bitcoin_node
}

pub fn dir(&self) -> &Path {
self.dir.path()
}

pub async fn restart(&mut self) -> Result<()> {
self.hashi_network.restart().await
}
Expand Down Expand Up @@ -271,9 +277,13 @@ async fn apply_onchain_config_overrides(
) -> Result<()> {
use hashi::cli::client::CreateProposalParams;
use hashi::cli::client::build_create_proposal_transaction;
use hashi::cli::client::build_execute_update_config_transaction;
use hashi::cli::client::build_vote_update_config_transaction;
use hashi::cli::client::build_vote_transaction;
use hashi::cli::upgrade::build_execute_proposal_transaction;
use hashi::cli::upgrade::extract_proposal_id_from_response;
use hashi::sui_tx_executor::SuiTxExecutor;
use sui_sdk_types::Identifier;
use sui_sdk_types::StructTag;
use sui_sdk_types::TypeTag;

let nodes = networks.hashi_network.nodes();

Expand All @@ -298,6 +308,13 @@ async fn apply_onchain_config_overrides(
// to wait for all nodes to catch up to the last applied override.
let mut exec_checkpoint: u64 = 0;

let update_config_type_tag = TypeTag::Struct(Box::new(StructTag::new(
hashi_ids.package_id,
Identifier::from_static("update_config"),
Identifier::from_static("UpdateConfig"),
vec![],
)));

//TODO could we build the proposals and vote/execute on them all at the same time vs doing them
//one at a time?
for (key, value) in overrides {
Expand All @@ -318,31 +335,14 @@ async fn apply_onchain_config_overrides(
"create UpdateConfig proposal for '{key}' failed"
);

// Extract the proposal ID from the ProposalCreatedEvent. The event BCS
// layout is (Address, u64) — proposal_id followed by timestamp_ms.
let proposal_id = response
.transaction()
.events()
.events()
.iter()
.find(|e| e.contents().name().contains("ProposalCreatedEvent"))
.ok_or_else(|| {
anyhow::anyhow!(
"ProposalCreatedEvent not found after creating proposal for '{key}'"
)
})
.and_then(|e| {
let (id, _ts): (sui_sdk_types::Address, u64) =
bcs::from_bytes(e.contents().value())?;
Ok(id)
})?;

let proposal_id = extract_proposal_id_from_response(&response)?;
tracing::info!("proposal {proposal_id} created for '{key}'; collecting votes");

// 2. All remaining nodes vote. This gives 100% of total weight,
// guaranteeing the 66.67% quorum threshold is met.
for executor in &mut executors[1..] {
let vote_tx = build_vote_update_config_transaction(hashi_ids, proposal_id);
let vote_tx =
build_vote_transaction(hashi_ids, proposal_id, update_config_type_tag.clone());
let vote_resp = executor.execute(vote_tx).await?;
anyhow::ensure!(
vote_resp.transaction().effects().status().success(),
Expand All @@ -351,7 +351,12 @@ async fn apply_onchain_config_overrides(
}

// 3. Node 0 executes the proposal now that quorum is reached.
let execute_tx = build_execute_update_config_transaction(hashi_ids, proposal_id);
let execute_tx = build_execute_proposal_transaction(
hashi_ids,
proposal_id,
hashi_ids.package_id,
"update_config",
)?;
let exec_resp = executors[0].execute(execute_tx).await?;
anyhow::ensure!(
exec_resp.transaction().effects().status().success(),
Expand Down
Loading
Loading