Skip to content

Commit ad4f045

Browse files
authored
feat: obolapi (#125)
* feat: implement obolapi * chore: refactor client code * test: more tests to verify the correct path * test: add test compute tree_hash_root * refactor: exit models * fix: better error handling * fix: better URL handling in client * fix: reuse ssz helpers from charon-cluster * fix: use bon as builder for ClientOption struct
1 parent eb7fb2f commit ad4f045

12 files changed

Lines changed: 1121 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ vise = "0.3"
7272
charon = { path = "crates/charon" }
7373
charon-core = { path = "crates/charon-core" }
7474
charon-build-proto = { path = "crates/charon-build-proto" }
75+
charon-cluster = { path = "crates/charon-cluster" }
7576
charon-crypto = { path = "crates/charon-crypto" }
7677
charon-eth2 = { path = "crates/charon-eth2" }
7778
charon-k1util = { path = "crates/charon-k1util" }

crates/charon-cluster/src/ssz.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ pub(crate) const SSZ_LEN_FORK_VERSION: usize = 4;
3232
/// Length of a K1 signature.
3333
pub(crate) const SSZ_LEN_K1_SIG: usize = 65;
3434
/// Length of a BLS signature.
35-
pub(crate) const SSZ_LEN_BLS_SIG: usize = 96;
35+
pub const SSZ_LEN_BLS_SIG: usize = 96;
3636
/// Length of a hash.
3737
pub(crate) const SSZ_LEN_HASH: usize = 32;
3838
/// Length of withdrawal credentials.
3939
pub(crate) const SSZ_LEN_WITHDRAW_CREDS: usize = 32;
4040
/// Length of a public key.
41-
pub(crate) const SSZ_LEN_PUB_KEY: usize = 48;
41+
pub const SSZ_LEN_PUB_KEY: usize = 48;
4242

4343
/// HashFunc is a function that hashes a definition
4444
pub type HashFuncWithBool<T, H> = fn(&T, &mut H, bool) -> Result<(), SSZError<H>>;

crates/charon/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ tracing.workspace = true
2020
tracing-subscriber.workspace = true
2121
alloy.workspace = true
2222
url.workspace = true
23+
reqwest.workspace = true
24+
serde.workspace = true
25+
serde_json.workspace = true
26+
hex.workspace = true
27+
k256.workspace = true
28+
bon.workspace = true
29+
charon-cluster = { workspace = true }
30+
charon-k1util = { workspace = true }
31+
charon-crypto = { workspace = true }
32+
eth2api = { workspace = true }
2333

2434
[build-dependencies]
2535
charon-build-proto.workspace = true

crates/charon/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ pub mod eth1wrap;
2424

2525
/// Featureset defines a set of global features and their rollout status.
2626
pub mod featureset;
27+
28+
/// Obol API client for interacting with the Obol network API.
29+
pub mod obolapi;
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
//! HTTP client for the Obol API.
2+
//!
3+
//! This module provides the main `Client` struct for interacting with the Obol
4+
//! API and helper functions for making HTTP requests.
5+
6+
use std::time::Duration;
7+
8+
use bon::Builder;
9+
use charon_cluster::lock::Lock;
10+
use reqwest::{Method, StatusCode};
11+
use url::Url;
12+
13+
use crate::obolapi::error::{Error, Result};
14+
15+
/// Default HTTP request timeout if not specified.
16+
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
17+
18+
/// REST client for Obol API requests.
19+
#[derive(Debug, Clone)]
20+
pub struct Client {
21+
/// Base Obol API URL.
22+
base_url: Url,
23+
24+
/// HTTP request timeout.
25+
_req_timeout: Duration,
26+
27+
/// Reqwest HTTP client.
28+
http_client: reqwest::Client,
29+
}
30+
31+
/// Options for configuring the Obol API client.
32+
#[derive(Debug, Default, Clone, Builder)]
33+
pub struct ClientOptions {
34+
/// Optional HTTP request timeout override (defaults to 10 seconds).
35+
pub timeout: Option<Duration>,
36+
}
37+
38+
impl Client {
39+
/// Creates a new Obol API client.
40+
pub fn new(url_str: &str, options: ClientOptions) -> Result<Self> {
41+
let req_timeout = options.timeout.unwrap_or(DEFAULT_TIMEOUT);
42+
43+
let http_client = reqwest::Client::builder().timeout(req_timeout).build()?;
44+
45+
// Ensure base_url ends with a trailing slash for proper URL joining
46+
let normalized_url = if url_str.ends_with('/') {
47+
url_str.to_string()
48+
} else {
49+
format!("{}/", url_str)
50+
};
51+
let base_url = Url::parse(&normalized_url)?;
52+
53+
Ok(Self {
54+
base_url,
55+
_req_timeout: req_timeout,
56+
http_client,
57+
})
58+
}
59+
60+
/// Returns the Launchpad cluster dashboard page for a
61+
/// given lock, on the given Obol API client.
62+
pub fn launchpad_url_for_lock(&self, lock: &Lock) -> Result<String> {
63+
let url = self.build_url(&launchpad_url_path(lock))?;
64+
Ok(url.to_string())
65+
}
66+
67+
/// Returns a reference to the HTTP client for making requests.
68+
pub(crate) fn http_client(&self) -> &reqwest::Client {
69+
&self.http_client
70+
}
71+
72+
/// Builds a URL by safely appending a path to the base URL.
73+
/// Strip leading '/' from path for proper URL joining
74+
pub(crate) fn build_url(&self, path: &str) -> Result<Url> {
75+
let path = path.trim_start_matches('/');
76+
Ok(self.base_url.join(path)?)
77+
}
78+
79+
/// Makes an HTTP POST request.
80+
pub(crate) async fn http_post(
81+
&self,
82+
url: Url,
83+
body: Vec<u8>,
84+
headers: Option<&[(String, String)]>,
85+
) -> Result<()> {
86+
let mut request = self
87+
.http_client()
88+
.post(url)
89+
.header("Content-Type", "application/json");
90+
91+
if let Some(headers) = headers {
92+
for (key, value) in headers {
93+
request = request.header(key, value);
94+
}
95+
}
96+
97+
let response = request.body(body).send().await?;
98+
99+
let status = response.status();
100+
if !status.is_success() {
101+
let body_text = response.text().await.unwrap_or_default();
102+
103+
return Err(Error::HttpError {
104+
method: Method::POST,
105+
status,
106+
body: body_text,
107+
});
108+
}
109+
110+
Ok(())
111+
}
112+
113+
/// Makes an HTTP GET request.
114+
pub(crate) async fn http_get(
115+
&self,
116+
url: Url,
117+
headers: Option<&[(String, String)]>,
118+
) -> Result<Vec<u8>> {
119+
let mut request = self.http_client().get(url);
120+
121+
if let Some(headers) = headers {
122+
for (key, value) in headers {
123+
request = request.header(key, value);
124+
}
125+
}
126+
127+
let response = request.send().await?;
128+
129+
let status = response.status();
130+
131+
if !status.is_success() {
132+
if status == StatusCode::NOT_FOUND {
133+
return Err(Error::NoExit);
134+
}
135+
136+
let body_text = response.text().await.unwrap_or_default();
137+
138+
return Err(Error::HttpError {
139+
method: Method::GET,
140+
status,
141+
body: body_text,
142+
});
143+
}
144+
145+
let body_bytes = response.bytes().await?.to_vec();
146+
Ok(body_bytes)
147+
}
148+
149+
/// Makes an HTTP DELETE request.
150+
pub(crate) async fn http_delete(
151+
&self,
152+
url: Url,
153+
headers: Option<&[(String, String)]>,
154+
) -> Result<()> {
155+
let mut request = self.http_client().delete(url);
156+
157+
if let Some(headers) = headers {
158+
for (key, value) in headers {
159+
request = request.header(key, value);
160+
}
161+
}
162+
163+
let response = request.send().await?;
164+
165+
let status = response.status();
166+
167+
if !status.is_success() {
168+
if status == StatusCode::NOT_FOUND {
169+
return Err(Error::NoExit);
170+
}
171+
return Err(Error::HttpError {
172+
method: Method::default(),
173+
status,
174+
body: String::new(),
175+
});
176+
}
177+
178+
Ok(())
179+
}
180+
}
181+
182+
fn launchpad_url_path(lock: &Lock) -> String {
183+
let hash_hex = hex::encode(&lock.lock_hash).to_uppercase();
184+
format!("/lock/0x{}/launchpad", &hash_hex)
185+
}
186+
187+
#[cfg(test)]
188+
mod tests {
189+
use super::*;
190+
use charon_cluster::definition::Definition;
191+
192+
fn test_lock_with_hash(hash: Vec<u8>) -> Lock {
193+
Lock {
194+
definition: Definition {
195+
uuid: "test-uuid".to_string(),
196+
name: "test".to_string(),
197+
version: "v1.0.0".to_string(),
198+
timestamp: "2024-01-01T00:00:00Z".to_string(),
199+
num_validators: 0,
200+
threshold: 0,
201+
dkg_algorithm: "".to_string(),
202+
fork_version: vec![],
203+
operators: vec![],
204+
creator: Default::default(),
205+
validator_addresses: vec![],
206+
deposit_amounts: vec![],
207+
consensus_protocol: "".to_string(),
208+
target_gas_limit: 0,
209+
compounding: false,
210+
config_hash: vec![],
211+
definition_hash: vec![],
212+
},
213+
distributed_validators: vec![],
214+
lock_hash: hash,
215+
signature_aggregate: vec![],
216+
node_signatures: vec![],
217+
}
218+
}
219+
220+
#[test]
221+
fn test_new_client_valid_url() {
222+
assert!(
223+
Client::new(
224+
"https://api.obol.tech",
225+
ClientOptions::builder()
226+
.timeout(Duration::from_secs(10))
227+
.build()
228+
)
229+
.is_ok()
230+
);
231+
}
232+
233+
#[test]
234+
fn test_new_client_invalid_url() {
235+
assert!(Client::new("not-a-url", ClientOptions::default()).is_err());
236+
}
237+
238+
#[test]
239+
fn test_base_url_normalization() {
240+
let c1 = Client::new("https://api.obol.tech", ClientOptions::default()).unwrap();
241+
assert_eq!(c1.base_url.as_str(), "https://api.obol.tech/");
242+
243+
let c2 = Client::new("https://api.obol.tech/", ClientOptions::default()).unwrap();
244+
assert_eq!(c2.base_url.as_str(), "https://api.obol.tech/");
245+
246+
let c3 = Client::new("https://api.obol.tech/v1", ClientOptions::default()).unwrap();
247+
assert_eq!(c3.base_url.as_str(), "https://api.obol.tech/v1/");
248+
249+
let c4 = Client::new("https://api.obol.tech/v1/", ClientOptions::default()).unwrap();
250+
assert_eq!(c4.base_url.as_str(), "https://api.obol.tech/v1/");
251+
}
252+
253+
#[test]
254+
fn test_build_url_root_base() {
255+
let client = Client::new("https://api.obol.tech", ClientOptions::default()).unwrap();
256+
assert_eq!(
257+
client.build_url("definition").unwrap().as_str(),
258+
"https://api.obol.tech/definition"
259+
);
260+
assert_eq!(
261+
client.build_url("/definition").unwrap().as_str(),
262+
"https://api.obol.tech/definition"
263+
);
264+
assert_eq!(
265+
client
266+
.build_url("exp/partial_exits/0xabc")
267+
.unwrap()
268+
.as_str(),
269+
"https://api.obol.tech/exp/partial_exits/0xabc"
270+
);
271+
}
272+
273+
#[test]
274+
fn test_build_url_versioned_base() {
275+
let client = Client::new("https://api.obol.tech/v1", ClientOptions::default()).unwrap();
276+
assert_eq!(
277+
client.build_url("definition").unwrap().as_str(),
278+
"https://api.obol.tech/v1/definition"
279+
);
280+
assert_eq!(
281+
client.build_url("/lock").unwrap().as_str(),
282+
"https://api.obol.tech/v1/lock"
283+
);
284+
assert_eq!(
285+
client
286+
.build_url("exp/exit/0xlock/5/0xkey")
287+
.unwrap()
288+
.as_str(),
289+
"https://api.obol.tech/v1/exp/exit/0xlock/5/0xkey"
290+
);
291+
}
292+
293+
#[test]
294+
fn test_launchpad_url_path() {
295+
let lock = test_lock_with_hash(vec![0x12, 0x34, 0xab, 0xcd]);
296+
assert_eq!(launchpad_url_path(&lock), "/lock/0x1234ABCD/launchpad");
297+
}
298+
299+
#[test]
300+
fn test_launchpad_url_for_lock() {
301+
let lock = test_lock_with_hash(vec![0x12, 0x34, 0xab, 0xcd]);
302+
303+
let c1 = Client::new("https://api.obol.tech", ClientOptions::default()).unwrap();
304+
assert_eq!(
305+
c1.launchpad_url_for_lock(&lock).unwrap(),
306+
"https://api.obol.tech/lock/0x1234ABCD/launchpad"
307+
);
308+
309+
let c2 = Client::new("https://api.obol.tech/v1", ClientOptions::default()).unwrap();
310+
assert_eq!(
311+
c2.launchpad_url_for_lock(&lock).unwrap(),
312+
"https://api.obol.tech/v1/lock/0x1234ABCD/launchpad"
313+
);
314+
}
315+
}

0 commit comments

Comments
 (0)