Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/cast/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
152 changes: 148 additions & 4 deletions crates/cast/src/cmd/send.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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::{
Expand Down Expand Up @@ -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 };

Expand Down Expand Up @@ -238,6 +246,21 @@ impl SendTxArgs {
// Launch browser signer if `--browser` flag is set
let browser = send_tx.browser.run::<N>().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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<String> {
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<Value>,
error: Option<JsonRpcError>,
}
// Standard JSON-RPC error object: {code, message, data?}
#[derive(serde::Deserialize)]
struct JsonRpcError {
message: Option<String>,
code: Option<i64>,
}

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")),
}
}
58 changes: 56 additions & 2 deletions crates/cli/src/opts/tempo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,30 @@ pub struct TempoOpts {
)]
pub sponsor_sig: Option<Signature>,

/// 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<String>,

/// 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,

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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()
);
}
}
Loading