diff --git a/src/commands/execute.rs b/src/commands/execute.rs index 2792ca9..f6ccad6 100644 --- a/src/commands/execute.rs +++ b/src/commands/execute.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use alloy::{ dyn_abi::TypedData, network::{EthereumWallet, TransactionBuilder}, @@ -8,20 +8,23 @@ use alloy::{ signers::{local::PrivateKeySigner, Signer}, }; use indicatif::{ProgressBar, ProgressStyle}; +use std::time::Duration; use crate::lib::{client::RelayClient, config::Config, types::{Execute, StepKind}}; +const MAX_TX_RETRIES: u32 = 2; +const RETRY_DELAY_MS: u64 = 2000; +const STATUS_POLL_MS: u64 = 3000; +const STATUS_TIMEOUT_SECS: u64 = 120; + pub async fn run(client: &RelayClient, cfg: &Config, quote: Execute, signer: PrivateKeySigner) -> Result<()> { let wallet = EthereumWallet::from(signer.clone()); + // collect request_id for post-execution status polling + let request_id = quote.steps.iter().find_map(|s| s.request_id.clone()); + for step in "e.steps { - let spinner = ProgressBar::new_spinner(); - spinner.set_style( - ProgressStyle::default_spinner() - .template("{spinner:.cyan} {msg}") - .unwrap(), - ); - spinner.set_message(format!("{}", step.action)); - spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + let spinner = new_spinner(); + spinner.set_message(step.action.clone()); for item in &step.items { if item.status == "complete" { @@ -39,7 +42,8 @@ pub async fn run(client: &RelayClient, cfg: &Config, quote: Execute, signer: Pri let base_provider = ProviderBuilder::new() .on_builtin(&rpc_url) - .await?; + .await + .with_context(|| format!("failed to connect to RPC for chain {chain_id}: {rpc_url}"))?; let wallet_provider = ProviderBuilder::new() .wallet(wallet.clone()) @@ -51,7 +55,7 @@ pub async fn run(client: &RelayClient, cfg: &Config, quote: Execute, signer: Pri .as_deref() .unwrap_or_default() .parse() - .unwrap_or_default(); + .context("invalid destination address in step data")?; let value = data .value @@ -68,10 +72,6 @@ pub async fn run(client: &RelayClient, cfg: &Config, quote: Execute, signer: Pri .parse() .unwrap_or_default(); - let nonce = base_provider - .get_transaction_count(signer.address()) - .await?; - let max_fee_per_gas: u128 = data .max_fee_per_gas .as_deref() @@ -90,29 +90,65 @@ pub async fn run(client: &RelayClient, cfg: &Config, quote: Execute, signer: Pri .and_then(|s| s.parse().ok()) .unwrap_or(300_000); - let mut tx = TransactionRequest::default() - .to(to) - .value(value) - .input(calldata.into()) - .nonce(nonce) - .max_fee_per_gas(max_fee_per_gas) - .max_priority_fee_per_gas(max_priority_fee_per_gas) - .gas_limit(gas_limit); - tx.set_chain_id(chain_id); + // retry loop — re-fetch nonce each attempt to handle conflicts + let mut last_err = None; + let mut tx_hash = None; + + for attempt in 0..=MAX_TX_RETRIES { + if attempt > 0 { + spinner.set_message(format!( + "{} — retry {}/{}", + step.action, attempt, MAX_TX_RETRIES + )); + tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await; + } - let tx_hash = *wallet_provider.send_transaction(tx).await?.tx_hash(); + let nonce = base_provider + .get_transaction_count(signer.address()) + .await + .context("failed to fetch nonce")?; - spinner.set_message(format!( - "{} — tx: {}", - step.action, - tx_hash - )); + let mut tx = TransactionRequest::default() + .to(to) + .value(value) + .input(calldata.clone().into()) + .nonce(nonce) + .max_fee_per_gas(max_fee_per_gas) + .max_priority_fee_per_gas(max_priority_fee_per_gas) + .gas_limit(gas_limit); + tx.set_chain_id(chain_id); + match wallet_provider.send_transaction(tx).await { + Ok(pending) => { + tx_hash = Some(*pending.tx_hash()); + break; + } + Err(e) => { + let msg = e.to_string(); + // surface actionable errors immediately + if msg.contains("insufficient funds") { + bail!("insufficient funds — wallet doesn't have enough ETH to cover amount + gas"); + } + if msg.contains("rejected") || msg.contains("denied") { + bail!("transaction rejected by RPC: {}", msg); + } + last_err = Some(e); + } + } + } + + let hash = tx_hash.ok_or_else(|| { + let e = last_err.unwrap(); + anyhow::anyhow!("transaction failed after {} retries: {}", MAX_TX_RETRIES, e) + })?; + + spinner.set_message(format!("{} — tx: {}", step.action, hash)); post_step_check(client, &step.id, &item.check).await?; } + StepKind::Signature => { - let sign = data.sign.as_ref().context("signature step missing sign data")?; - let post = data.post.as_ref().context("signature step missing post data")?; + let sign = data.sign.as_ref().context("signature step missing sign field")?; + let post = data.post.as_ref().context("signature step missing post field")?; let sig_kind = sign.signature_kind.as_deref().unwrap_or("eip191"); let signature = match sig_kind { @@ -123,10 +159,10 @@ pub async fn run(client: &RelayClient, cfg: &Config, quote: Execute, signer: Pri "primaryType": sign.primary_type, "message": sign.value, })) - .context("failed to parse eip712 typed data")?; + .context("failed to parse EIP-712 typed data from step")?; let hash = typed_data .eip712_signing_hash() - .context("failed to compute eip712 hash")?; + .context("failed to compute EIP-712 signing hash")?; signer.sign_hash(&hash).await?.to_string() } _ => { @@ -135,7 +171,7 @@ pub async fn run(client: &RelayClient, cfg: &Config, quote: Execute, signer: Pri } }; - let endpoint = post.endpoint.as_deref().context("post missing endpoint")?; + let endpoint = post.endpoint.as_deref().context("signature post step missing endpoint")?; let mut body = post.body.clone().unwrap_or(serde_json::Value::Object(Default::default())); if let serde_json::Value::Object(ref mut map) = body { map.insert("signature".to_string(), serde_json::Value::String(signature.clone())); @@ -147,9 +183,10 @@ pub async fn run(client: &RelayClient, cfg: &Config, quote: Execute, signer: Pri "GET" => client.http.get(&url), _ => client.http.post(&url), }; - req.json(&body).send().await?; + req.json(&body).send().await + .context("failed to post signature to Relay API")?; - spinner.set_message(format!("{} — signed: {}…", step.action, &signature[..10])); + spinner.set_message(format!("{} — signed", step.action)); } } } @@ -157,15 +194,79 @@ pub async fn run(client: &RelayClient, cfg: &Config, quote: Execute, signer: Pri spinner.finish_with_message(format!("{} ✓", step.action)); } - println!("\nbridge complete"); + // Poll until bridge confirms or times out + if let Some(rid) = request_id { + poll_bridge_status(client, &rid).await?; + } else { + println!("\nbridge submitted — no request ID to track"); + } + Ok(()) } +async fn poll_bridge_status(client: &RelayClient, request_id: &str) -> Result<()> { + #[derive(serde::Deserialize)] + struct StatusResp { + status: Option, + details: Option, + } + + let spinner = new_spinner(); + spinner.set_message("waiting for bridge confirmation..."); + + let url = client.url(&format!("/intents/status/v2?requestId={}", request_id)); + let deadline = tokio::time::Instant::now() + Duration::from_secs(STATUS_TIMEOUT_SECS); + + loop { + let resp: StatusResp = client.http.get(&url).send().await + .context("failed to poll bridge status")? + .json().await + .context("failed to parse bridge status response")?; + + let status = resp.status.as_deref().unwrap_or("unknown"); + + match status { + "success" => { + spinner.finish_with_message("bridge confirmed ✓"); + return Ok(()); + } + "failure" | "refund" => { + spinner.finish_and_clear(); + let detail = resp.details.as_deref().unwrap_or("no details"); + bail!("bridge {}: {}", status, detail); + } + _ => { + spinner.set_message(format!("status: {} — waiting...", status)); + } + } + + if tokio::time::Instant::now() >= deadline { + spinner.finish_and_clear(); + bail!( + "bridge timed out after {}s — check status manually:\n relay status {}", + STATUS_TIMEOUT_SECS, request_id + ); + } + + tokio::time::sleep(Duration::from_millis(STATUS_POLL_MS)).await; + } +} + +fn new_spinner() -> ProgressBar { + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .unwrap(), + ); + spinner.enable_steady_tick(Duration::from_millis(80)); + spinner +} + fn rpc_url_for_chain(cfg: &Config, chain_id: u64) -> Result { crate::lib::config::rpc_for_chain(cfg, chain_id) .ok_or_else(|| anyhow::anyhow!( - "no RPC for chain {}. Run: relay config set-rpc --chain {} --url ", - chain_id, chain_id + "no RPC configured for chain {chain_id}\n fix: relay config set-rpc --chain {chain_id} --url " )) } @@ -177,6 +278,7 @@ async fn post_step_check( let Some(check) = check else { return Ok(()) }; let Some(endpoint) = &check.endpoint else { return Ok(()) }; let url = client.url(endpoint); - let _ = client.http.get(&url).send().await?; + client.http.get(&url).send().await + .context("failed to call step check endpoint")?; Ok(()) } diff --git a/src/commands/quote.rs b/src/commands/quote.rs index a6dfba3..04c9429 100644 --- a/src/commands/quote.rs +++ b/src/commands/quote.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use serde_json::json; use crate::lib::client::RelayClient; use crate::lib::types::Execute; @@ -25,7 +25,28 @@ pub async fn run( }); let url = client.url("/quote"); - let quote: Execute = client.http.post(&url).json(&body).send().await?.json().await?; + let resp = client.http.post(&url).json(&body).send().await + .context("failed to reach Relay API — check your internet connection")?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("quote failed (HTTP {}): {}", status, text.trim()); + } + + let quote: Execute = resp.json().await + .context("unexpected response format from /quote")?; + + if let Some(errors) = "e.errors { + if !errors.is_empty() { + let msg = errors.iter() + .filter_map(|e| e.message.as_deref()) + .collect::>() + .join(", "); + anyhow::bail!("quote error: {}", msg); + } + } + Ok(quote) } diff --git a/src/lib/wallet.rs b/src/lib/wallet.rs index a613b06..ebb8cbf 100644 --- a/src/lib/wallet.rs +++ b/src/lib/wallet.rs @@ -5,14 +5,19 @@ pub fn load_signer(key_override: Option<&str>) -> Result { let raw = if let Some(k) = key_override { k.to_string() } else { - std::env::var("RELAY_PRIVATE_KEY") - .context("no wallet: set RELAY_PRIVATE_KEY env or use --private-key")? + std::env::var("RELAY_PRIVATE_KEY").unwrap_or_default() }; + if raw.is_empty() { + bail!( + "no private key found\n options:\n relay config set --private-key 0x...\n export RELAY_PRIVATE_KEY=0x...\n relay bridge --private-key 0x..." + ); + } + let raw = raw.trim_start_matches("0x"); if raw.len() != 64 { - bail!("private key must be 32 bytes (64 hex chars)"); + bail!("private key must be 32 bytes (64 hex chars), got {} chars", raw.len()); } - raw.parse::().context("invalid private key") + raw.parse::().context("invalid private key — check it's a valid hex string") }