Skip to content

Commit 4de3350

Browse files
danwtclaude
andauthored
feat(kaspa-tools): add DRY roundtrip command (#413)
* claude: feat(kaspa-tools): add DRY roundtrip command Add `kaspa-tools sim roundtrip` command for single roundtrip testing (deposit from Kaspa to Hub, then withdraw back) that properly reuses existing infrastructure instead of duplicating code. Changes: - Extract `create_cosmos_provider` and `now_millis` to shared `util.rs` - Create `CommonBridgeArgs` for shared CLI argument configuration - Update `SimulateTrafficCli` to use `CommonBridgeArgs` via flatten - Add new `roundtrip.rs` that wraps existing `do_round_trip()` logic - Make whale pool fields public for single-use construction The new roundtrip command achieves the same functionality as PR #407 but in ~250 lines instead of ~500, by reusing the existing stress test infrastructure rather than reimplementing balance polling, withdrawal messages, and provider creation. Usage: kaspa-tools sim roundtrip \ --kaspa-wallet-secret <secret> \ --hub-priv-key <hex> \ --timeout 300 \ --domain-kas <n> --domain-hub <n> \ --token-kas-placeholder <h256> --token-hub <h256> \ --escrow-address <kaspa-addr> \ --kaspa-wrpc-url <url> --kaspa-rest-url <url> \ --hub-rpc-url <url> --hub-grpc-url <url> \ --hub-chain-id <id> --hub-prefix dym --hub-denom adym \ --hub-decimals 18 \ --deposit-amount <sompi> --withdrawal-fee-pct 0.01 \ --kaspa-network testnet 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: make roundtrip a top-level command * refactor: simplify roundtrip with progress output * claude: refactor(kaspa-tools): improve error handling and API design (#414) - Replace unwrap() panics with proper error propagation in sim command - Change do_roundtrip() return type from Result<bool> to Result<()> - Use let-else pattern instead of nested match for cleaner control flow - Reduce module visibility to minimize public API surface - Consistent error handling style across Sim and Roundtrip commands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2cca63c commit 4de3350

File tree

9 files changed

+403
-138
lines changed

9 files changed

+403
-138
lines changed

rust/main/utils/kaspa-tools/src/main.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
use clap::Parser;
2-
use x::args::{Cli, Commands, ValidatorAction, ValidatorBackend};
2+
use x::args::{Cli, Commands, SimulateTrafficCli, ValidatorAction, ValidatorBackend};
33

44
mod sim;
55
use sim::{SimulateTrafficArgs, TrafficSim};
66
mod x;
77

8+
async fn run_sim(args: SimulateTrafficCli) -> eyre::Result<()> {
9+
let sim = SimulateTrafficArgs::try_from(args)?;
10+
let sim = TrafficSim::new(sim).await?;
11+
sim.run().await
12+
}
13+
814
async fn run(cli: Cli) {
915
tracing_subscriber::fmt::init();
1016
rustls::crypto::aws_lc_rs::default_provider()
@@ -52,10 +58,17 @@ async fn run(cli: Cli) {
5258
println!("Relayer address: {}", signer.address);
5359
println!("Relayer private key: {}", signer.private_key);
5460
}
55-
Commands::SimulateTraffic(args) => {
56-
let sim = SimulateTrafficArgs::try_from(args).unwrap();
57-
let sim = TrafficSim::new(sim).await.unwrap();
58-
sim.run().await.unwrap();
61+
Commands::Sim(args) => {
62+
if let Err(e) = run_sim(args).await {
63+
eprintln!("error: {e}");
64+
std::process::exit(1);
65+
}
66+
}
67+
Commands::Roundtrip(args) => {
68+
if let Err(e) = sim::roundtrip::do_roundtrip(args).await {
69+
eprintln!("error: {e}");
70+
std::process::exit(1);
71+
}
5972
}
6073
Commands::DecodePayload(args) => {
6174
if let Err(e) = x::decode_payload::decode_payload(&args.payload) {

rust/main/utils/kaspa-tools/src/sim/hub_whale_pool.rs

Lines changed: 4 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,17 @@
11
use super::key_cosmos::EasyHubKey;
2+
use super::util::create_cosmos_provider;
23
use eyre::Result;
3-
use hyperlane_core::config::OpSubmissionConfig;
4-
use hyperlane_core::ContractLocator;
5-
use hyperlane_core::HyperlaneDomain;
6-
use hyperlane_core::KnownHyperlaneDomain;
7-
use hyperlane_core::NativeToken;
8-
use hyperlane_core::H256;
9-
use hyperlane_cosmos::ConnectionConf as CosmosConnectionConf;
10-
use hyperlane_cosmos::RawCosmosAmount;
114
use hyperlane_cosmos::{native::ModuleQueryClient, CosmosProvider};
12-
use hyperlane_metric::prometheus_metric::PrometheusClientMetrics;
135
use std::sync::{Arc, Mutex};
146
use std::time::Instant;
157
use tokio::sync::Mutex as AsyncMutex;
168
use tracing::{debug, info};
17-
use url::Url;
189

1910
pub struct HubWhale {
2011
pub provider: CosmosProvider<ModuleQueryClient>,
21-
last_used: Mutex<Instant>,
12+
pub last_used: Mutex<Instant>,
2213
pub id: usize,
23-
tx_lock: AsyncMutex<()>,
14+
pub tx_lock: AsyncMutex<()>,
2415
}
2516

2617
impl HubWhale {
@@ -62,7 +53,7 @@ impl HubWhalePool {
6253

6354
for (id, priv_key_hex) in priv_keys.into_iter().enumerate() {
6455
let key = EasyHubKey::from_hex(&priv_key_hex);
65-
let provider = Self::create_cosmos_provider(
56+
let provider = create_cosmos_provider(
6657
&key, &rpc_url, &grpc_url, &chain_id, &prefix, &denom, decimals,
6758
)
6859
.await?;
@@ -90,46 +81,6 @@ impl HubWhalePool {
9081
Ok(Self { whales })
9182
}
9283

93-
async fn create_cosmos_provider(
94-
key: &EasyHubKey,
95-
rpc_url: &str,
96-
grpc_url: &str,
97-
chain_id: &str,
98-
prefix: &str,
99-
denom: &str,
100-
decimals: u32,
101-
) -> Result<CosmosProvider<ModuleQueryClient>> {
102-
let conf = CosmosConnectionConf::new(
103-
vec![Url::parse(grpc_url).map_err(|e| eyre::eyre!("invalid gRPC URL: {}", e))?],
104-
vec![Url::parse(rpc_url).map_err(|e| eyre::eyre!("invalid RPC URL: {}", e))?],
105-
chain_id.to_string(),
106-
prefix.to_string(),
107-
denom.to_string(),
108-
RawCosmosAmount {
109-
amount: "100000000000.0".to_string(),
110-
denom: denom.to_string(),
111-
},
112-
32,
113-
OpSubmissionConfig::default(),
114-
NativeToken {
115-
decimals,
116-
denom: denom.to_string(),
117-
},
118-
1.0,
119-
None,
120-
)
121-
.map_err(|e| eyre::eyre!(e))?;
122-
123-
let d = HyperlaneDomain::Known(KnownHyperlaneDomain::Osmosis);
124-
let locator = ContractLocator::new(&d, H256::zero());
125-
let signer = Some(key.signer());
126-
let metrics = PrometheusClientMetrics::default();
127-
let chain = None;
128-
129-
CosmosProvider::<ModuleQueryClient>::new(&conf, &locator, signer, metrics, chain)
130-
.map_err(eyre::Report::from)
131-
}
132-
13384
pub fn select_whale(&self) -> Arc<HubWhale> {
13485
let selected = self
13586
.whales

rust/main/utils/kaspa-tools/src/sim/kaspa_whale_pool.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use tracing::info;
1010
pub struct KaspaWhale {
1111
pub wallet: EasyKaspaWallet,
1212
pub secret: Secret,
13-
last_used: Mutex<Instant>,
13+
pub last_used: Mutex<Instant>,
1414
pub id: usize,
1515
}
1616

rust/main/utils/kaspa-tools/src/sim/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod kaspa_whale_pool;
33
mod key_cosmos;
44
mod key_kaspa;
55
mod round_trip;
6+
pub mod roundtrip;
67
mod sim;
78
mod stats;
89
mod util;

rust/main/utils/kaspa-tools/src/sim/round_trip.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use super::kaspa_whale_pool::KaspaWhale;
33
use super::key_cosmos::EasyHubKey;
44
use super::key_kaspa::get_kaspa_keypair;
55
use super::stats::RoundTripStats;
6+
use super::util::now_millis;
67
use cometbft_rpc::endpoint::broadcast::tx_commit::Response as HubResponse;
78
use cosmos_sdk_proto::cosmos::bank::v1beta1::MsgSend;
89
use cosmos_sdk_proto::cosmos::base::v1beta1::Coin;
@@ -32,13 +33,6 @@ const MAX_RETRIES: usize = 3;
3233
const RETRY_DELAY_MS: u64 = 2000;
3334
const HUB_FUND_AMOUNT: u64 = 50_000_000_000_000_000; // 0.05 dym to pay gas
3435

35-
fn now_millis() -> u128 {
36-
std::time::SystemTime::now()
37-
.duration_since(std::time::UNIX_EPOCH)
38-
.unwrap()
39-
.as_millis()
40-
}
41-
4236
fn is_retryable(error: &eyre::Error) -> bool {
4337
let err_str = error.to_string().to_lowercase();
4438
err_str.contains("account sequence mismatch")
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
//! Single roundtrip command - deposit from Kaspa to Hub, then withdraw back
2+
3+
use super::hub_whale_pool::HubWhale;
4+
use super::kaspa_whale_pool::KaspaWhale;
5+
use super::key_cosmos::EasyHubKey;
6+
use super::round_trip::{do_round_trip, TaskArgs, TaskResources};
7+
use super::stats::RoundTripStats;
8+
use super::util::{create_cosmos_provider, SOMPI_PER_KAS};
9+
use crate::x::args::RoundtripCli;
10+
use dym_kas_core::api::base::RateLimitConfig;
11+
use dym_kas_core::api::client::HttpClient;
12+
use dym_kas_core::wallet::{EasyKaspaWallet, EasyKaspaWalletArgs};
13+
use eyre::Result;
14+
use kaspa_wallet_core::prelude::Secret;
15+
use std::sync::{Arc, Mutex};
16+
use std::time::{Duration, Instant};
17+
use tokio::sync::mpsc;
18+
use tokio::sync::Mutex as AsyncMutex;
19+
use tokio_util::sync::CancellationToken;
20+
21+
pub async fn do_roundtrip(cli: RoundtripCli) -> Result<()> {
22+
let kaspa_network = cli.bridge.parse_kaspa_network()?;
23+
let escrow_address = cli.bridge.parse_escrow_address()?;
24+
25+
// Initialize wallets
26+
let kaspa_wallet = EasyKaspaWallet::try_new(EasyKaspaWalletArgs {
27+
wallet_secret: cli.kaspa_wallet_secret.clone(),
28+
wrpc_url: cli.bridge.kaspa_wrpc_url.clone(),
29+
net: kaspa_network.clone(),
30+
storage_folder: cli.kaspa_wallet_dir.clone(),
31+
})
32+
.await?;
33+
let kaspa_secret = Secret::from(cli.kaspa_wallet_secret.clone());
34+
let kaspa_addr = kaspa_wallet.wallet.account()?.receive_address()?;
35+
36+
let hub_key = EasyHubKey::from_hex(&cli.hub_priv_key);
37+
let hub_addr = hub_key.signer().address_string.clone();
38+
39+
let hub_provider = create_cosmos_provider(
40+
&hub_key,
41+
&cli.bridge.hub_rpc_url,
42+
&cli.bridge.hub_grpc_url,
43+
&cli.bridge.hub_chain_id,
44+
&cli.bridge.hub_prefix,
45+
&cli.bridge.hub_denom,
46+
cli.bridge.hub_decimals,
47+
)
48+
.await?;
49+
50+
// Print config
51+
println!(
52+
"Roundtrip: {} sompi ({:.2} KAS)",
53+
cli.bridge.deposit_amount,
54+
cli.bridge.deposit_amount as f64 / SOMPI_PER_KAS as f64
55+
);
56+
println!(" Kaspa: {}", kaspa_addr);
57+
println!(" Hub: {}", hub_addr);
58+
println!();
59+
60+
// Build resources
61+
let task_args = TaskArgs {
62+
domain_kas: cli.bridge.domain_kas,
63+
token_kas_placeholder: cli.bridge.token_kas_placeholder,
64+
domain_hub: cli.bridge.domain_hub,
65+
token_hub: cli.bridge.token_hub,
66+
escrow_address,
67+
deposit_amount: cli.bridge.deposit_amount,
68+
withdrawal_fee_pct: cli.bridge.withdrawal_fee_pct,
69+
};
70+
71+
let task_resources = TaskResources {
72+
hub: hub_provider.clone(),
73+
args: task_args,
74+
kas_rest: HttpClient::new(
75+
cli.bridge.kaspa_rest_url.clone(),
76+
RateLimitConfig::default(),
77+
),
78+
kaspa_network,
79+
};
80+
81+
// Wrap as whales (required by do_round_trip interface)
82+
let kaspa_whale = Arc::new(KaspaWhale {
83+
wallet: kaspa_wallet,
84+
secret: kaspa_secret,
85+
last_used: Mutex::new(Instant::now()),
86+
id: 0,
87+
});
88+
let hub_whale = Arc::new(HubWhale {
89+
provider: hub_provider,
90+
last_used: Mutex::new(Instant::now()),
91+
id: 0,
92+
tx_lock: AsyncMutex::new(()),
93+
});
94+
95+
// Setup stats channel and timeout
96+
let (tx, mut rx) = mpsc::channel::<RoundTripStats>(32);
97+
let cancel_token = CancellationToken::new();
98+
let cancel_clone = cancel_token.clone();
99+
let timeout_secs = cli.timeout;
100+
tokio::spawn(async move {
101+
tokio::time::sleep(Duration::from_secs(timeout_secs)).await;
102+
cancel_clone.cancel();
103+
});
104+
105+
// Run roundtrip in background, print progress from stats
106+
let rt_handle = tokio::spawn(async move {
107+
do_round_trip(task_resources, kaspa_whale, hub_whale, &tx, 0, cancel_token).await;
108+
});
109+
110+
// Track progress
111+
let mut last_stage = String::new();
112+
let mut final_stats: Option<RoundTripStats> = None;
113+
114+
while let Some(stats) = rx.recv().await {
115+
if stats.stage != last_stage {
116+
print_stage(&stats.stage);
117+
last_stage = stats.stage.clone();
118+
}
119+
final_stats = Some(stats);
120+
}
121+
122+
rt_handle.await?;
123+
124+
// Print result
125+
println!();
126+
let Some(stats) = final_stats else {
127+
println!("FAILED: no response");
128+
return Err(eyre::eyre!("no stats received"));
129+
};
130+
print_result(&stats)
131+
}
132+
133+
fn print_stage(stage: &str) {
134+
let msg = match stage {
135+
"PreDeposit" => "[1/4] Submitting deposit...",
136+
"AwaitingDepositCredit" => "[2/4] Waiting for hub credit...",
137+
"PreWithdrawal" => "[3/4] Submitting withdrawal...",
138+
"AwaitingWithdrawalCredit" => "[4/4] Waiting for kaspa credit...",
139+
"Complete" => "Done",
140+
s if s.contains("NotCredited") => return, // error states handled in result
141+
_ => return,
142+
};
143+
println!("{}", msg);
144+
}
145+
146+
fn print_result(stats: &RoundTripStats) -> Result<()> {
147+
let deposit_ok = stats.deposit_error.is_none() && stats.deposit_credit_error.is_none();
148+
let withdraw_ok = stats.withdrawal_error.is_none() && stats.withdraw_credit_error.is_none();
149+
150+
let deposit_time = stats
151+
.deposit_credit_time_millis
152+
.zip(stats.kaspa_deposit_tx_time_millis)
153+
.map(|(end, start)| format_duration(end - start));
154+
155+
let withdraw_time = stats
156+
.withdraw_credit_time_millis
157+
.zip(stats.hub_withdraw_tx_time_millis)
158+
.map(|(end, start)| format_duration(end - start));
159+
160+
// Deposit result
161+
print!("Deposit: ");
162+
if let Some(ref e) = stats.deposit_error {
163+
println!("FAILED ({})", e);
164+
} else if let Some(ref e) = stats.deposit_credit_error {
165+
println!("FAILED ({})", e);
166+
} else if let Some(t) = deposit_time {
167+
println!("OK ({})", t);
168+
} else {
169+
println!("INCOMPLETE");
170+
}
171+
172+
// Withdrawal result
173+
print!("Withdrawal: ");
174+
if !deposit_ok {
175+
println!("SKIPPED");
176+
} else if let Some(ref e) = stats.withdrawal_error {
177+
println!("FAILED ({})", e);
178+
} else if let Some(ref e) = stats.withdraw_credit_error {
179+
println!("FAILED ({})", e);
180+
} else if let Some(t) = withdraw_time {
181+
println!("OK ({})", t);
182+
} else {
183+
println!("INCOMPLETE");
184+
}
185+
186+
if deposit_ok && withdraw_ok && stats.stage == "Complete" {
187+
Ok(())
188+
} else {
189+
Err(eyre::eyre!("roundtrip failed"))
190+
}
191+
}
192+
193+
fn format_duration(ms: u128) -> String {
194+
let secs = ms / 1000;
195+
if secs >= 60 {
196+
format!("{}m{}s", secs / 60, secs % 60)
197+
} else {
198+
format!("{}s", secs)
199+
}
200+
}

0 commit comments

Comments
 (0)