foundry_common/provider/
curl_transport.rs1use 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#[derive(Clone, Debug)]
16pub struct CurlTransport {
17 url: Url,
19 headers: Vec<String>,
21 jwt: Option<String>,
23}
24
25impl CurlTransport {
26 pub const fn new(url: Url) -> Self {
28 Self { url, headers: vec![], jwt: None }
29 }
30
31 pub fn with_headers(mut self, headers: Vec<String>) -> Self {
33 self.headers = headers;
34 self
35 }
36
37 pub fn with_jwt(mut self, jwt: Option<String>) -> Self {
39 self.jwt = jwt;
40 self
41 }
42
43 fn generate_curl_command(&self, req: &RequestPacket) -> eyre::Result<String> {
45 let payload_str = serde_json::to_string(req).unwrap_or_default();
46 let escaped_payload = shell_escape(&payload_str);
47
48 let mut cmd = String::from("curl -X POST");
49 cmd.push_str(" -H 'Content-Type: application/json'");
50
51 if let Some(jwt_secret) = &self.jwt {
52 append_jwt_auth(&mut cmd, jwt_secret)?;
53 }
54
55 for h in &self.headers {
56 cmd.push_str(&format!(" -H '{}'", shell_escape(h)));
57 }
58
59 cmd.push_str(&format!(" --data-raw '{escaped_payload}'"));
60 cmd.push_str(&format!(" '{}'", shell_escape(self.url.as_str())));
61
62 Ok(cmd)
63 }
64
65 pub fn request(&self, req: RequestPacket) -> TransportFut<'static> {
67 let curl_cmd_result = self.generate_curl_command(&req);
68
69 Box::pin(async move {
70 match curl_cmd_result {
71 Ok(curl_cmd) => {
72 let _ = crate::sh_println!("{curl_cmd}");
73 std::process::exit(0);
74 }
75 Err(e) => {
76 let _ = crate::sh_eprintln!("Error: {e:?}");
77 std::process::exit(1);
78 }
79 }
80 })
81 }
82}
83
84impl Service<RequestPacket> for CurlTransport {
85 type Response = ResponsePacket;
86 type Error = TransportError;
87 type Future = TransportFut<'static>;
88
89 #[inline]
90 fn poll_ready(
91 &mut self,
92 _cx: &mut std::task::Context<'_>,
93 ) -> std::task::Poll<Result<(), Self::Error>> {
94 std::task::Poll::Ready(Ok(()))
95 }
96
97 #[inline]
98 fn call(&mut self, req: RequestPacket) -> Self::Future {
99 self.request(req)
100 }
101}
102
103impl Service<RequestPacket> for &CurlTransport {
104 type Response = ResponsePacket;
105 type Error = TransportError;
106 type Future = TransportFut<'static>;
107
108 #[inline]
109 fn poll_ready(
110 &mut self,
111 _cx: &mut std::task::Context<'_>,
112 ) -> std::task::Poll<Result<(), Self::Error>> {
113 std::task::Poll::Ready(Ok(()))
114 }
115
116 #[inline]
117 fn call(&mut self, req: RequestPacket) -> Self::Future {
118 self.request(req)
119 }
120}
121
122fn shell_escape(s: &str) -> String {
124 s.replace('\'', "'\"'\"'")
125}
126
127fn build_jwt(jwt_secret: &str) -> eyre::Result<String> {
129 let secret = JwtSecret::from_hex(jwt_secret)?;
131 let claims = Claims::default();
132 let token = secret.encode(&claims)?;
133 Ok(token)
134}
135
136fn append_jwt_auth(cmd: &mut String, jwt_secret: &str) -> eyre::Result<()> {
138 let jwt = build_jwt(jwt_secret).wrap_err("Invalid --jwt-secret provided")?;
139
140 cmd.push_str(&format!(" -H 'Authorization: Bearer {}'", shell_escape(jwt.as_str())));
141
142 Ok(())
143}
144
145pub fn generate_curl_command(
150 url: &str,
151 method: &str,
152 params: Value,
153 headers: Option<&[String]>,
154 jwt_secret: Option<&str>,
155) -> eyre::Result<String> {
156 let payload = serde_json::json!({
157 "jsonrpc": "2.0",
158 "method": method,
159 "params": params,
160 "id": 1
161 });
162 let payload_str = serde_json::to_string(&payload).unwrap_or_default();
163 let escaped_payload = shell_escape(&payload_str);
164
165 let mut cmd = String::from("curl -X POST");
166 cmd.push_str(" -H 'Content-Type: application/json'");
167
168 if let Some(secret) = jwt_secret {
169 append_jwt_auth(&mut cmd, secret)?;
170 }
171
172 if let Some(hdrs) = headers {
173 for h in hdrs {
174 cmd.push_str(&format!(" -H '{}'", shell_escape(h)));
175 }
176 }
177
178 cmd.push_str(&format!(" --data-raw '{escaped_payload}'"));
179 cmd.push_str(&format!(" '{}'", shell_escape(url)));
180
181 Ok(cmd)
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}