From 93e89b7df9c1bc5a59cea2e28adeeaa11df70c9d Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Fri, 15 May 2026 22:15:01 +0000 Subject: [PATCH 1/7] feat(cast): add --sponsor-url for remote Tempo fee payer sponsorship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds --sponsor-url (alias --tempo.sponsor-url) to cast send. When set, the transaction is signed locally and forwarded to the remote sponsor service via eth_sendRawTransaction. The service adds its fee payer signature and broadcasts the sponsored transaction. Usage: cast send 0x... --sponsor-url https://sponsor.tempo.xyz/tp_abc123 This works with the existing fee-payer service in tempo-apps without any changes — no new RPC methods required. The sponsor URL can also be set via TEMPO_SPONSOR_URL env var. Amp-Thread-ID: https://ampcode.com/threads/T-019e2d8c-7750-71a5-924c-2394c0c4e070 --- crates/cast/Cargo.toml | 1 + crates/cast/src/cmd/send.rs | 93 ++++++++++++++++++++++++++++++++++-- crates/cli/src/opts/tempo.rs | 61 +++++++++++++++++++++-- 3 files changed, 149 insertions(+), 6 deletions(-) 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..56e865ebc713f 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; @@ -122,9 +125,14 @@ impl SendTxArgs { self; let print_sponsor_hash = tx.tempo.print_sponsor_hash; + let sponsor_url = tx.tempo.sponsor_url.take(); let expires_at = tx.tempo.resolve_expires(); let tempo_sponsor = - if print_sponsor_hash { None } else { tx.tempo.sponsor_config().await? }; + 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 }; @@ -333,6 +341,33 @@ impl SendTxArgs { ) .await // Case 4: + // Remote sponsor URL: sign locally, forward raw tx to the sponsor service. + } 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 (tx_request, _) = builder.build(&signer).await?; + maybe_print_resolved_lane( + resolved_lane.as_ref(), + tx_request.nonce().unwrap_or_default(), + )?; + + let wallet = EthereumWallet::from(signer); + let signed_tx = tx_request.build(&wallet).await?; + let raw_tx = hex::encode_prefixed(signed_tx.encoded_2718()); + + let tx_hash = + send_via_sponsor_url(&sponsor_url, &raw_tx).await?; + + let cast = CastTxSender::new(&provider); + 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 +468,55 @@ 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 } + +/// Sends a user-signed raw transaction to a remote sponsor service via JSON-RPC +/// `eth_sendRawTransaction`. The service adds its fee payer signature and broadcasts +/// the fully-sponsored transaction. +async fn send_via_sponsor_url(url: &str, raw_tx_hex: &str) -> Result { + let client = reqwest::Client::new(); + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_sendRawTransaction", + "params": [raw_tx_hex] + }); + + let resp = client + .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, + } + #[derive(serde::Deserialize)] + struct JsonRpcError { + message: String, + } + + let parsed: JsonRpcResponse = + serde_json::from_str(&text).map_err(|e| eyre!("invalid sponsor service response: {e}"))?; + + if let Some(err) = parsed.error { + eyre::bail!("sponsor service error: {}", err.message); + } + + parsed + .result + .ok_or_else(|| eyre!("sponsor service returned no transaction hash")) +} diff --git a/crates/cli/src/opts/tempo.rs b/crates/cli/src/opts/tempo.rs index e76af262c2489..2c0a6108956db 100644 --- a/crates/cli/src/opts/tempo.rs +++ b/crates/cli/src/opts/tempo.rs @@ -105,13 +105,29 @@ 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_sendRawTransaction`. The service adds its fee payer signature and + /// broadcasts the transaction to the chain. 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, @@ -146,7 +162,7 @@ pub struct TempoOpts { impl TempoOpts { /// Returns `true` if any Tempo-specific option is set. - pub const fn is_tempo(&self) -> bool { + pub fn is_tempo(&self) -> bool { self.fee_token.is_some() || self.expires.is_some() || self.nonce_key.is_some() @@ -154,6 +170,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 +280,11 @@ 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 +441,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() + ); + } } From 5b0717a92af9a859c2b7cb8304b54b5a5587fe9a Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Fri, 15 May 2026 22:29:02 +0000 Subject: [PATCH 2/7] fix: use eth_signRawTransaction + send_raw, clone instead of take sponsor_url - Use eth_signRawTransaction to get the sponsored tx from the service, then submit via the regular RPC's send_raw_transaction. This matches the viem withFeePayer sign-only protocol. - Clone sponsor_url instead of take() so it's still present when TempoOpts::apply() runs, ensuring nonce_key=0 is set and the tx builds as a Tempo AA (0x76) type instead of EIP-1559 (0x02). - Handle sponsor service error responses that have name/code but no message field. Amp-Thread-ID: https://ampcode.com/threads/T-019e2d8c-7750-71a5-924c-2394c0c4e070 --- crates/cast/src/cmd/send.rs | 42 ++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index 56e865ebc713f..f625adbe967bf 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -6,7 +6,7 @@ use alloy_ens::NameOrAddress; use alloy_network::{ Ethereum, EthereumWallet, Network, NetworkTransactionBuilder, TransactionBuilder, }; -use alloy_primitives::{Address, B256, hex}; +use alloy_primitives::{Address, hex}; use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder}; use alloy_signer::{Signature, Signer}; use clap::Parser; @@ -125,7 +125,7 @@ impl SendTxArgs { self; let print_sponsor_hash = tx.tempo.print_sponsor_hash; - let sponsor_url = tx.tempo.sponsor_url.take(); + let sponsor_url = tx.tempo.sponsor_url.clone(); let expires_at = tx.tempo.resolve_expires(); let tempo_sponsor = if print_sponsor_hash || sponsor_url.is_some() { @@ -341,7 +341,8 @@ impl SendTxArgs { ) .await // Case 4: - // Remote sponsor URL: sign locally, forward raw tx to the sponsor service. + // 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, @@ -357,14 +358,19 @@ impl SendTxArgs { tx_request.nonce().unwrap_or_default(), )?; + // 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()); - let tx_hash = - send_via_sponsor_url(&sponsor_url, &raw_tx).await?; + // 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: @@ -470,18 +476,18 @@ where } /// Sends a user-signed raw transaction to a remote sponsor service via JSON-RPC -/// `eth_sendRawTransaction`. The service adds its fee payer signature and broadcasts -/// the fully-sponsored transaction. -async fn send_via_sponsor_url(url: &str, raw_tx_hex: &str) -> Result { - let client = reqwest::Client::new(); +/// `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_sendRawTransaction", + "method": "eth_signRawTransaction", "params": [raw_tx_hex] }); - let resp = client + let resp = reqwest::Client::new() .post(url) .header("content-type", "application/json") .json(&body) @@ -501,22 +507,28 @@ async fn send_via_sponsor_url(url: &str, raw_tx_hex: &str) -> Result { #[derive(serde::Deserialize)] struct JsonRpcResponse { - result: Option, + result: Option, error: Option, } #[derive(serde::Deserialize)] struct JsonRpcError { - message: String, + message: Option, + name: 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 { - eyre::bail!("sponsor service error: {}", err.message); + let msg = err + .message + .or(err.name) + .unwrap_or_else(|| format!("code {}", err.code.unwrap_or(-1))); + eyre::bail!("sponsor service error: {msg}"); } parsed .result - .ok_or_else(|| eyre!("sponsor service returned no transaction hash")) + .ok_or_else(|| eyre!("sponsor service returned no signed transaction")) } From a7d53fa1832c741a95c028ded5abbc37c5a4fc4e Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Mon, 18 May 2026 12:45:06 +0200 Subject: [PATCH 3/7] fix: stale doc --- crates/cli/src/opts/tempo.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/opts/tempo.rs b/crates/cli/src/opts/tempo.rs index 2c0a6108956db..d39bfdc9910e7 100644 --- a/crates/cli/src/opts/tempo.rs +++ b/crates/cli/src/opts/tempo.rs @@ -108,8 +108,9 @@ pub struct TempoOpts { /// Remote sponsor (fee payer) service URL. /// /// When set, the user-signed transaction is forwarded to this URL via - /// `eth_sendRawTransaction`. The service adds its fee payer signature and - /// broadcasts the transaction to the chain. No local sponsor key is required. + /// `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( From 0bda4661d9aabda9e791bbb297d18dc41dca3d18 Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Mon, 18 May 2026 12:47:59 +0200 Subject: [PATCH 4/7] Update Cargo.lock --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) 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", From fc3a6b2d96bc46e1990d333bc20f19b417e6d22f Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Mon, 18 May 2026 12:49:16 +0200 Subject: [PATCH 5/7] fix: make clippy happy --- crates/cli/src/opts/tempo.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/opts/tempo.rs b/crates/cli/src/opts/tempo.rs index d39bfdc9910e7..9c8736780024b 100644 --- a/crates/cli/src/opts/tempo.rs +++ b/crates/cli/src/opts/tempo.rs @@ -163,7 +163,7 @@ pub struct TempoOpts { impl TempoOpts { /// Returns `true` if any Tempo-specific option is set. - pub fn is_tempo(&self) -> bool { + pub const fn is_tempo(&self) -> bool { self.fee_token.is_some() || self.expires.is_some() || self.nonce_key.is_some() From 9ecde0bfbb33e3b1e4d4b5f63638ad15839def2b Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Mon, 18 May 2026 12:54:33 +0200 Subject: [PATCH 6/7] fix: fmt --- crates/cast/src/cmd/send.rs | 30 +++++++++++------------------- crates/cli/src/opts/tempo.rs | 4 +--- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index f625adbe967bf..a214759740b78 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -127,12 +127,11 @@ impl SendTxArgs { 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 || sponsor_url.is_some() { - 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 }; @@ -371,8 +370,7 @@ impl SendTxArgs { 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 + 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 @@ -496,10 +494,8 @@ async fn sign_via_sponsor_url(url: &str, raw_tx_hex: &str) -> Result { .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}"))?; + 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}"); @@ -521,14 +517,10 @@ async fn sign_via_sponsor_url(url: &str, raw_tx_hex: &str) -> Result { serde_json::from_str(&text).map_err(|e| eyre!("invalid sponsor service response: {e}"))?; if let Some(err) = parsed.error { - let msg = err - .message - .or(err.name) - .unwrap_or_else(|| format!("code {}", err.code.unwrap_or(-1))); + let msg = + err.message.or(err.name).unwrap_or_else(|| format!("code {}", err.code.unwrap_or(-1))); eyre::bail!("sponsor service error: {msg}"); } - parsed - .result - .ok_or_else(|| eyre!("sponsor service returned no signed transaction")) + parsed.result.ok_or_else(|| eyre!("sponsor service returned no signed transaction")) } diff --git a/crates/cli/src/opts/tempo.rs b/crates/cli/src/opts/tempo.rs index 9c8736780024b..c0f7068a76b4e 100644 --- a/crates/cli/src/opts/tempo.rs +++ b/crates/cli/src/opts/tempo.rs @@ -281,9 +281,7 @@ 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.sponsor_url.is_some() - || self.print_sponsor_hash) + 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); From 3257e9f0777e3becdefb952269088e80ad397e40 Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Mon, 18 May 2026 15:19:12 +0200 Subject: [PATCH 7/7] fix(cast): address sponsor-url discrepancies + better errors - Sign sponsored Tempo payload by setting a placeholder fee_payer_signature before local signing, so the sender commits to the sponsored variant - Bail out when --sponsor-url is combined with --unlocked, --browser, or a Tempo access key instead of silently ignoring it - Validate sponsor URL scheme (require https://, allow http only for localhost/127.0.0.1) - Tighten JSON-RPC response parsing: accept arbitrary result types, drop non-standard 'name' error field Co-authored-by: Amp --- crates/cast/src/cmd/send.rs | 67 +++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index a214759740b78..a0e2053036824 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -6,7 +6,7 @@ use alloy_ens::NameOrAddress; use alloy_network::{ Ethereum, EthereumWallet, Network, NetworkTransactionBuilder, TransactionBuilder, }; -use alloy_primitives::{Address, hex}; +use alloy_primitives::{Address, B256, hex}; use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder}; use alloy_signer::{Signature, Signer}; use clap::Parser; @@ -22,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::{ @@ -245,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 @@ -351,12 +367,23 @@ impl SendTxArgs { tx::validate_from_address(send_tx.eth.wallet.from, from)?; - let (tx_request, _) = builder.build(&signer).await?; + 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?; @@ -473,6 +500,29 @@ where 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 @@ -503,13 +553,13 @@ async fn sign_via_sponsor_url(url: &str, raw_tx_hex: &str) -> Result { #[derive(serde::Deserialize)] struct JsonRpcResponse { - result: Option, + result: Option, error: Option, } + // Standard JSON-RPC error object: {code, message, data?} #[derive(serde::Deserialize)] struct JsonRpcError { message: Option, - name: Option, code: Option, } @@ -517,10 +567,13 @@ async fn sign_via_sponsor_url(url: &str, raw_tx_hex: &str) -> Result { serde_json::from_str(&text).map_err(|e| eyre!("invalid sponsor service response: {e}"))?; if let Some(err) = parsed.error { - let msg = - err.message.or(err.name).unwrap_or_else(|| format!("code {}", err.code.unwrap_or(-1))); + let msg = err.message.unwrap_or_else(|| format!("code {}", err.code.unwrap_or(-1))); eyre::bail!("sponsor service error: {msg}"); } - parsed.result.ok_or_else(|| eyre!("sponsor service returned no signed transaction")) + 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")), + } }