diff --git a/Cargo.lock b/Cargo.lock index a6af4e14d5507..059028376aa93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2790,6 +2790,7 @@ dependencies = [ "rand 0.9.4", "rayon", "regex", + "reqwest 0.13.3", "revm", "rpassword", "semver 1.0.28", diff --git a/crates/cast/Cargo.toml b/crates/cast/Cargo.toml index 03be26a631a11..9648150edb128 100644 --- a/crates/cast/Cargo.toml +++ b/crates/cast/Cargo.toml @@ -72,6 +72,7 @@ revm = { workspace = true, features = ["optional_balance_check"] } rand.workspace = true rand_08.workspace = true rayon.workspace = true +reqwest.workspace = true serde_json.workspace = true serde.workspace = true diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index 421aae1f2153e..a0e2053036824 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -1,9 +1,12 @@ use std::{path::PathBuf, str::FromStr, time::Duration}; use alloy_consensus::{SignableTransaction, Signed}; +use alloy_eips::Encodable2718; use alloy_ens::NameOrAddress; -use alloy_network::{Ethereum, EthereumWallet, Network, TransactionBuilder}; -use alloy_primitives::Address; +use alloy_network::{ + Ethereum, EthereumWallet, Network, NetworkTransactionBuilder, TransactionBuilder, +}; +use alloy_primitives::{Address, B256, hex}; use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder}; use alloy_signer::{Signature, Signer}; use clap::Parser; @@ -19,6 +22,7 @@ use foundry_common::{ tempo::TEMPO_BROWSER_GAS_BUFFER, }; use foundry_wallets::{TempoAccessKeyConfig, WalletSigner}; +use serde_json::Value; use tempo_alloy::TempoNetwork; use crate::{ @@ -122,9 +126,13 @@ impl SendTxArgs { self; let print_sponsor_hash = tx.tempo.print_sponsor_hash; + let sponsor_url = tx.tempo.sponsor_url.clone(); let expires_at = tx.tempo.resolve_expires(); - let tempo_sponsor = - if print_sponsor_hash { None } else { tx.tempo.sponsor_config().await? }; + let tempo_sponsor = if print_sponsor_hash || sponsor_url.is_some() { + None + } else { + tx.tempo.sponsor_config().await? + }; let blob_data = if let Some(path) = path { Some(std::fs::read(path)?) } else { None }; @@ -238,6 +246,21 @@ impl SendTxArgs { // Launch browser signer if `--browser` flag is set let browser = send_tx.browser.run::().await?; + // --sponsor-url is only valid with a local signer (Case 4). Bail early with a clear + // error rather than silently ignoring it in the other signing paths. + if let Some(ref url) = sponsor_url { + validate_sponsor_url(url)?; + if unlocked { + eyre::bail!("--sponsor-url cannot be combined with --unlocked"); + } + if browser.is_some() { + eyre::bail!("--sponsor-url cannot be combined with --browser"); + } + if access_key.is_some() { + eyre::bail!("--sponsor-url cannot be combined with a Tempo access key"); + } + } + // Case 1: // Default to sending via eth_sendTransaction if the --unlocked flag is passed. // This should be the only way this RPC method is used as it requires a local node @@ -333,6 +356,49 @@ impl SendTxArgs { ) .await // Case 4: + // Remote sponsor URL: sign locally, get sponsor signature from the service, + // then submit the fully-sponsored tx to the regular RPC. + } else if let Some(sponsor_url) = sponsor_url { + let signer = match pre_resolved_signer { + Some(s) => s, + None => send_tx.eth.wallet.signer().await?, + }; + let from = signer.address(); + + tx::validate_from_address(send_tx.eth.wallet.from, from)?; + + let (mut tx_request, _) = builder.build(&signer).await?; + maybe_print_resolved_lane( + resolved_lane.as_ref(), + tx_request.nonce().unwrap_or_default(), + )?; + + // Set a placeholder fee_payer_signature so encode_for_signing produces the + // *sponsored* signing payload. The sender must commit to this variant; otherwise + // the sponsor can't attach its real signature later without invalidating the + // sender's hash. The sponsor service will overwrite this placeholder. + let dummy_sponsor_sig = Signature::from_scalars_and_parity( + B256::with_last_byte(1), + B256::with_last_byte(1), + false, + ); + tx_request.set_fee_payer_signature(dummy_sponsor_sig); + + // Sign the tx locally. + let wallet = EthereumWallet::from(signer); + let signed_tx = tx_request.build(&wallet).await?; + let raw_tx = hex::encode_prefixed(signed_tx.encoded_2718()); + + // Send to the sponsor service to get the fee payer signature attached. + let sponsored_raw_tx = sign_via_sponsor_url(&sponsor_url, &raw_tx).await?; + + // Submit the fully-sponsored tx via the regular RPC. + let sponsored_bytes = hex::decode(&sponsored_raw_tx)?; + let cast = CastTxSender::new(&provider); + let pending = cast.send_raw(&sponsored_bytes).await?; + let tx_hash = *pending.inner().tx_hash(); + cast.print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout).await + // Case 5: // An option to use a local signer was provided. // If we cannot successfully instantiate a local signer, then we will assume we don't have // enough information to sign and we must bail. @@ -433,3 +499,81 @@ where let tx_hash = *provider.send_raw_transaction(&raw_tx).await?.tx_hash(); CastTxSender::new(provider).print_tx_result(tx_hash, cast_async, confirmations, timeout).await } + +/// Validates that a sponsor URL uses https:// (localhost/127.0.0.1 may use http://). +fn validate_sponsor_url(url: &str) -> Result<()> { + let lower = url.to_lowercase(); + if lower.starts_with("https://") { + return Ok(()); + } + if lower.starts_with("http://") { + // Allow plain http only for local testing. + let host_part = lower.trim_start_matches("http://"); + if host_part.starts_with("localhost") || host_part.starts_with("127.0.0.1") { + return Ok(()); + } + eyre::bail!( + "--sponsor-url must use https:// for non-local endpoints (got {url}). \ + The sponsor relay is a trusted third party; use an encrypted channel." + ); + } + eyre::bail!( + "--sponsor-url must start with https:// (got {url}). \ + The sponsor relay is a trusted third party; use an encrypted channel." + ); +} + +/// Sends a user-signed raw transaction to a remote sponsor service via JSON-RPC +/// `eth_signRawTransaction`. The service adds its fee payer signature and returns the +/// fully-sponsored raw transaction bytes, which are then ready for submission via the +/// regular RPC. +async fn sign_via_sponsor_url(url: &str, raw_tx_hex: &str) -> Result { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_signRawTransaction", + "params": [raw_tx_hex] + }); + + let resp = reqwest::Client::new() + .post(url) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| eyre!("sponsor service request failed: {e}"))?; + + let status = resp.status(); + let text = + resp.text().await.map_err(|e| eyre!("failed to read sponsor service response: {e}"))?; + + if !status.is_success() { + eyre::bail!("sponsor service returned HTTP {status}: {text}"); + } + + #[derive(serde::Deserialize)] + struct JsonRpcResponse { + result: Option, + error: Option, + } + // Standard JSON-RPC error object: {code, message, data?} + #[derive(serde::Deserialize)] + struct JsonRpcError { + message: Option, + code: Option, + } + + let parsed: JsonRpcResponse = + serde_json::from_str(&text).map_err(|e| eyre!("invalid sponsor service response: {e}"))?; + + if let Some(err) = parsed.error { + let msg = err.message.unwrap_or_else(|| format!("code {}", err.code.unwrap_or(-1))); + eyre::bail!("sponsor service error: {msg}"); + } + + match parsed.result { + Some(serde_json::Value::String(s)) => Ok(s), + Some(other) => Err(eyre!("sponsor service returned unexpected result type: {other}")), + None => Err(eyre!("sponsor service returned no result")), + } +} diff --git a/crates/cli/src/opts/tempo.rs b/crates/cli/src/opts/tempo.rs index e76af262c2489..c0f7068a76b4e 100644 --- a/crates/cli/src/opts/tempo.rs +++ b/crates/cli/src/opts/tempo.rs @@ -105,13 +105,30 @@ pub struct TempoOpts { )] pub sponsor_sig: Option, + /// Remote sponsor (fee payer) service URL. + /// + /// When set, the user-signed transaction is forwarded to this URL via + /// `eth_signRawTransaction`. The service adds its fee payer signature and returns + /// the fully-sponsored transaction, which is then submitted via the regular RPC. + /// No local sponsor key is required. + /// + /// Example: `cast send 0x... --sponsor-url https://sponsor.tempo.xyz/tp_abc123` + #[arg( + long = "sponsor-url", + alias = "tempo.sponsor-url", + value_name = "URL", + conflicts_with_all = &["sponsor", "sponsor_signer", "sponsor_sig", "print_sponsor_hash"], + env = "TEMPO_SPONSOR_URL" + )] + pub sponsor_url: Option, + /// Print the sponsor signature hash and exit. /// /// Computes the `fee_payer_signature_hash` for the transaction so that a sponsor /// knows what hash to sign. The transaction is not sent. #[arg( long = "tempo.print-sponsor-hash", - conflicts_with_all = &["sponsor", "sponsor_signer", "sponsor_sig"] + conflicts_with_all = &["sponsor", "sponsor_signer", "sponsor_sig", "sponsor_url"] )] pub print_sponsor_hash: bool, @@ -154,6 +171,7 @@ impl TempoOpts { || self.sponsor.is_some() || self.sponsor_signer.is_some() || self.sponsor_sig.is_some() + || self.sponsor_url.is_some() || self.print_sponsor_hash || self.key_id.is_some() || self.expiring_nonce @@ -263,7 +281,9 @@ impl TempoOpts { // gas estimation so that `--tempo.print-sponsor-hash` and // `--tempo.sponsor-signature` produce identical gas estimates. Callers // should call `set_fee_payer_signature` on the built tx request. - if (self.has_sponsor_submission() || self.print_sponsor_hash) && tx.nonce_key().is_none() { + if (self.has_sponsor_submission() || self.sponsor_url.is_some() || self.print_sponsor_hash) + && tx.nonce_key().is_none() + { tx.set_nonce_key(U256::ZERO); } } @@ -420,4 +440,38 @@ mod tests { .is_err() ); } + + #[test] + fn parse_sponsor_url() { + let opts = + TempoOpts::try_parse_from(["", "--sponsor-url", "https://sponsor.tempo.xyz/tp_abc123"]) + .unwrap(); + assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123")); + assert!(opts.is_tempo()); + } + + #[test] + fn sponsor_url_alias() { + let opts = TempoOpts::try_parse_from([ + "", + "--tempo.sponsor-url", + "https://sponsor.tempo.xyz/tp_abc123", + ]) + .unwrap(); + assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123")); + } + + #[test] + fn sponsor_url_conflicts_with_sponsor() { + assert!( + TempoOpts::try_parse_from([ + "", + "--sponsor-url", + "https://sponsor.tempo.xyz", + "--tempo.sponsor", + "0x1111111111111111111111111111111111111111", + ]) + .is_err() + ); + } }