Skip to main content

foundry_common/provider/
curl_transport.rs

1//! Transport that outputs equivalent curl commands instead of making RPC requests.
2
3use alloy_json_rpc::{RequestPacket, ResponsePacket};
4use alloy_rpc_types_engine::{Claims, JwtSecret};
5use alloy_transport::{TransportError, TransportFut};
6use eyre::Context;
7use serde_json::Value;
8use tower::Service;
9use url::Url;
10
11/// Escapes a string for use in a single-quoted shell argument.
12fn shell_escape(s: &str) -> String {
13    s.replace('\'', "'\"'\"'")
14}
15
16/// Build a JWT using a secret
17fn build_jwt(jwt_secret: &str) -> eyre::Result<String> {
18    // Decode jwt from hex, then generate claims (iat with current timestamp)
19    let secret = JwtSecret::from_hex(jwt_secret)?;
20    let claims = Claims::default();
21    let token = secret.encode(&claims)?;
22    Ok(token)
23}
24
25/// Appends a JWT Authorization header to a curl command.
26fn append_jwt_auth(cmd: &mut String, jwt_secret: &str) -> eyre::Result<()> {
27    let jwt = build_jwt(jwt_secret).wrap_err("Invalid --jwt-secret provided")?;
28
29    cmd.push_str(&format!(" -H 'Authorization: Bearer {}'", shell_escape(jwt.as_str())));
30
31    Ok(())
32}
33
34/// Generates a curl command for an RPC request.
35///
36/// This is a standalone helper that can be used to generate curl commands
37/// without going through the transport layer.
38pub fn generate_curl_command(
39    url: &str,
40    method: &str,
41    params: Value,
42    headers: Option<&[String]>,
43    jwt_secret: Option<&str>,
44) -> eyre::Result<String> {
45    let payload = serde_json::json!({
46        "jsonrpc": "2.0",
47        "method": method,
48        "params": params,
49        "id": 1
50    });
51    let payload_str = serde_json::to_string(&payload).unwrap_or_default();
52    let escaped_payload = shell_escape(&payload_str);
53
54    let mut cmd = String::from("curl -X POST");
55    cmd.push_str(" -H 'Content-Type: application/json'");
56
57    if let Some(secret) = jwt_secret {
58        append_jwt_auth(&mut cmd, secret)?;
59    }
60
61    if let Some(hdrs) = headers {
62        for h in hdrs {
63            cmd.push_str(&format!(" -H '{}'", shell_escape(h)));
64        }
65    }
66
67    cmd.push_str(&format!(" --data-raw '{escaped_payload}'"));
68    cmd.push_str(&format!(" '{}'", shell_escape(url)));
69
70    Ok(cmd)
71}
72
73/// A transport that prints curl commands instead of executing RPC requests.
74///
75/// When a request is made through this transport, it will print the equivalent
76/// curl command to stderr and return a dummy successful response.
77#[derive(Clone, Debug)]
78pub struct CurlTransport {
79    /// The URL to connect to.
80    url: Url,
81    /// The headers to use for requests.
82    headers: Vec<String>,
83    /// The JWT to use for requests.
84    jwt: Option<String>,
85}
86
87impl CurlTransport {
88    /// Create a new curl transport with the given URL.
89    pub const fn new(url: Url) -> Self {
90        Self { url, headers: vec![], jwt: None }
91    }
92
93    /// Set the headers for the transport.
94    pub fn with_headers(mut self, headers: Vec<String>) -> Self {
95        self.headers = headers;
96        self
97    }
98
99    /// Set the JWT Secret for the transport.
100    pub fn with_jwt(mut self, jwt: Option<String>) -> Self {
101        self.jwt = jwt;
102        self
103    }
104
105    /// Generate a curl command for a request.
106    fn generate_curl_command(&self, req: &RequestPacket) -> eyre::Result<String> {
107        let payload_str = serde_json::to_string(req).unwrap_or_default();
108        let escaped_payload = shell_escape(&payload_str);
109
110        let mut cmd = String::from("curl -X POST");
111        cmd.push_str(" -H 'Content-Type: application/json'");
112
113        if let Some(jwt_secret) = &self.jwt {
114            append_jwt_auth(&mut cmd, jwt_secret)?;
115        }
116
117        for h in &self.headers {
118            cmd.push_str(&format!(" -H '{}'", shell_escape(h)));
119        }
120
121        cmd.push_str(&format!(" --data-raw '{escaped_payload}'"));
122        cmd.push_str(&format!(" '{}'", shell_escape(self.url.as_str())));
123
124        Ok(cmd)
125    }
126
127    /// Handle a request by printing the curl command.
128    pub fn request(&self, req: RequestPacket) -> TransportFut<'static> {
129        let curl_cmd_result = self.generate_curl_command(&req);
130
131        Box::pin(async move {
132            match curl_cmd_result {
133                Ok(curl_cmd) => {
134                    let _ = crate::sh_println!("{curl_cmd}");
135                    std::process::exit(0);
136                }
137                Err(e) => {
138                    let _ = crate::sh_eprintln!("Error: {e:?}");
139                    std::process::exit(1);
140                }
141            }
142        })
143    }
144}
145
146impl Service<RequestPacket> for CurlTransport {
147    type Response = ResponsePacket;
148    type Error = TransportError;
149    type Future = TransportFut<'static>;
150
151    #[inline]
152    fn poll_ready(
153        &mut self,
154        _cx: &mut std::task::Context<'_>,
155    ) -> std::task::Poll<Result<(), Self::Error>> {
156        std::task::Poll::Ready(Ok(()))
157    }
158
159    #[inline]
160    fn call(&mut self, req: RequestPacket) -> Self::Future {
161        self.request(req)
162    }
163}
164
165impl Service<RequestPacket> for &CurlTransport {
166    type Response = ResponsePacket;
167    type Error = TransportError;
168    type Future = TransportFut<'static>;
169
170    #[inline]
171    fn poll_ready(
172        &mut self,
173        _cx: &mut std::task::Context<'_>,
174    ) -> std::task::Poll<Result<(), Self::Error>> {
175        std::task::Poll::Ready(Ok(()))
176    }
177
178    #[inline]
179    fn call(&mut self, req: RequestPacket) -> Self::Future {
180        self.request(req)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use alloy_json_rpc::{Id, Request};
188
189    fn make_test_request() -> RequestPacket {
190        let req: Request<Vec<()>> = Request::new("eth_blockNumber", Id::Number(1), vec![]);
191        let serialized = req.serialize().unwrap();
192        RequestPacket::Single(serialized)
193    }
194
195    #[test]
196    fn test_basic_curl_command() {
197        let transport = CurlTransport::new("https://eth.example.com".parse().unwrap());
198        let req = make_test_request();
199        let cmd = transport.generate_curl_command(&req).unwrap();
200        assert!(cmd.contains("eth_blockNumber"));
201        assert!(cmd.contains("https://eth.example.com"));
202        assert!(cmd.contains("jsonrpc"));
203    }
204
205    #[test]
206    fn test_curl_with_headers() {
207        let transport = CurlTransport::new("https://eth.example.com".parse().unwrap())
208            .with_headers(vec!["X-Custom: value".to_string()]);
209        let req = make_test_request();
210        let cmd = transport.generate_curl_command(&req).unwrap();
211        assert!(cmd.contains("X-Custom: value"));
212    }
213
214    #[test]
215    fn test_curl_with_jwt() {
216        let jwt_secret = "5c43996d0d150a81f06ae452fce38120d97a4156650aec7487b3384bfe32edae";
217        let transport = CurlTransport::new("https://eth.example.com".parse().unwrap())
218            .with_jwt(Some(jwt_secret.to_string()));
219        let req = make_test_request();
220        let cmd = transport.generate_curl_command(&req).unwrap();
221
222        let jwt = cmd
223            .split("Authorization: Bearer ")
224            .nth(1)
225            .expect("missing Authorization header")
226            .split('\'')
227            .next()
228            .expect("malformed Authorization header");
229
230        let secret = JwtSecret::from_hex(jwt_secret).unwrap();
231        secret.validate(jwt).unwrap();
232    }
233
234    #[test]
235    fn test_shell_escape() {
236        let escaped = shell_escape("it's a test");
237        assert_eq!(escaped, "it'\"'\"'s a test");
238    }
239}