Skip to content
Merged
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
184 changes: 143 additions & 41 deletions src/commands/execute.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};
use alloy::{
dyn_abi::TypedData,
network::{EthereumWallet, TransactionBuilder},
Expand All @@ -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 &quote.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" {
Expand All @@ -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())
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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 {
Expand All @@ -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()
}
_ => {
Expand All @@ -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()));
Expand All @@ -147,25 +183,90 @@ 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));
}
}
}

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<String>,
details: Option<String>,
}

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<String> {
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 <url>",
chain_id, chain_id
"no RPC configured for chain {chain_id}\n fix: relay config set-rpc --chain {chain_id} --url <rpc-url>"
))
}

Expand All @@ -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(())
}
25 changes: 23 additions & 2 deletions src/commands/quote.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) = &quote.errors {
if !errors.is_empty() {
let msg = errors.iter()
.filter_map(|e| e.message.as_deref())
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!("quote error: {}", msg);
}
}

Ok(quote)
}

Expand Down
13 changes: 9 additions & 4 deletions src/lib/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ pub fn load_signer(key_override: Option<&str>) -> Result<PrivateKeySigner> {
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::<PrivateKeySigner>().context("invalid private key")
raw.parse::<PrivateKeySigner>().context("invalid private key — check it's a valid hex string")
}