1use reqwest::Client;
46use serde::{Deserialize, Serialize};
47
48mod error;
49pub use error::Error;
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct HealthResponse {
54 pub status: String,
56 #[serde(default)]
58 pub uptime: Option<f64>,
59 #[serde(default)]
61 pub version: Option<String>,
62 #[serde(default)]
64 pub peers: Option<u32>,
65 #[serde(default)]
67 pub block_height: Option<u64>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct EpochResponse {
73 pub epoch: u64,
75 #[serde(default)]
77 pub duration_seconds: Option<u64>,
78 #[serde(default)]
80 pub time_remaining: Option<f64>,
81 #[serde(default)]
83 pub reward_pool: Option<f64>,
84 #[serde(default)]
86 pub attestations: Option<u64>,
87 #[serde(default)]
89 pub started_at: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct Miner {
95 #[serde(alias = "miner_id")]
97 pub wallet: String,
98 #[serde(default)]
100 pub architecture: Option<String>,
101 #[serde(default)]
103 pub multiplier: Option<f64>,
104 #[serde(default)]
106 pub active: Option<bool>,
107 #[serde(default)]
109 pub last_seen: Option<String>,
110 #[serde(default)]
112 pub total_earned: Option<f64>,
113 #[serde(default)]
115 pub platform: Option<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct WalletBalance {
121 #[serde(alias = "miner_id")]
123 pub wallet: String,
124 pub balance: f64,
126 #[serde(default)]
128 pub total_earned: Option<f64>,
129 #[serde(default)]
131 pub attestation_count: Option<u64>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct AttestationSubmit {
137 pub miner: String,
139 pub fingerprint: serde_json::Value,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub nonce: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct AttestationResponse {
149 #[serde(default)]
151 pub accepted: Option<bool>,
152 #[serde(default)]
154 pub status: Option<String>,
155 #[serde(default)]
157 pub error: Option<String>,
158 #[serde(default)]
160 pub epoch: Option<u64>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct Proposal {
166 pub id: u64,
168 pub title: String,
170 #[serde(default)]
172 pub description: Option<String>,
173 #[serde(default)]
175 pub proposer: Option<String>,
176 #[serde(default)]
178 pub status: Option<String>,
179 #[serde(default)]
181 pub yes_weight: Option<f64>,
182 #[serde(default)]
184 pub no_weight: Option<f64>,
185 #[serde(default)]
187 pub created_at: Option<String>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct Vote {
193 pub proposal_id: u64,
195 pub wallet: String,
197 pub vote: String,
199 pub nonce: String,
201 pub public_key: String,
203 pub signature: String,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct VoteResponse {
210 #[serde(default)]
212 pub accepted: Option<bool>,
213 #[serde(default)]
215 pub status: Option<String>,
216 #[serde(default)]
218 pub error: Option<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct AgentJob {
224 pub job_id: String,
226 #[serde(default)]
228 pub title: Option<String>,
229 #[serde(default)]
231 pub description: Option<String>,
232 #[serde(default)]
234 pub reward_rtc: Option<f64>,
235 #[serde(default)]
237 pub status: Option<String>,
238 #[serde(default)]
240 pub category: Option<String>,
241 #[serde(default)]
243 pub posted_by: Option<String>,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct AgentJobsResponse {
249 #[serde(default)]
251 pub ok: Option<bool>,
252 #[serde(default)]
254 pub jobs: Vec<AgentJob>,
255 #[serde(default)]
257 pub total: Option<u64>,
258 #[serde(default)]
260 pub categories: Option<Vec<String>>,
261}
262
263pub struct RustChainClient {
278 base_url: String,
279 http: Client,
280}
281
282impl RustChainClient {
283 pub fn new(base_url: &str) -> Result<Self, Error> {
292 let http = Client::builder()
293 .danger_accept_invalid_certs(true)
294 .timeout(std::time::Duration::from_secs(30))
295 .build()
296 .map_err(Error::Http)?;
297
298 Ok(Self {
299 base_url: base_url.trim_end_matches('/').to_string(),
300 http,
301 })
302 }
303
304 pub fn with_client(base_url: &str, http: Client) -> Self {
306 Self {
307 base_url: base_url.trim_end_matches('/').to_string(),
308 http,
309 }
310 }
311
312 pub async fn health(&self) -> Result<HealthResponse, Error> {
316 let url = format!("{}/health", self.base_url);
317 let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
318 let status = resp.status();
319 if !status.is_success() {
320 return Err(Error::Api {
321 status: status.as_u16(),
322 message: resp.text().await.unwrap_or_default(),
323 });
324 }
325 resp.json().await.map_err(Error::Http)
326 }
327
328 pub async fn epoch(&self) -> Result<EpochResponse, Error> {
332 let url = format!("{}/epoch", self.base_url);
333 let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
334 let status = resp.status();
335 if !status.is_success() {
336 return Err(Error::Api {
337 status: status.as_u16(),
338 message: resp.text().await.unwrap_or_default(),
339 });
340 }
341 resp.json().await.map_err(Error::Http)
342 }
343
344 pub async fn miners(&self) -> Result<Vec<Miner>, Error> {
348 let url = format!("{}/api/miners", self.base_url);
349 let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
350 let status = resp.status();
351 if !status.is_success() {
352 return Err(Error::Api {
353 status: status.as_u16(),
354 message: resp.text().await.unwrap_or_default(),
355 });
356 }
357 let text = resp.text().await.map_err(Error::Http)?;
359 if let Ok(miners) = serde_json::from_str::<Vec<Miner>>(&text) {
361 return Ok(miners);
362 }
363 #[derive(Deserialize)]
365 struct Wrapper {
366 miners: Vec<Miner>,
367 }
368 let wrapper: Wrapper = serde_json::from_str(&text).map_err(Error::Json)?;
369 Ok(wrapper.miners)
370 }
371
372 pub async fn wallet_balance(&self, wallet: &str) -> Result<WalletBalance, Error> {
376 let url = format!("{}/wallet/balance?miner_id={}", self.base_url, wallet);
377 let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
378 let status = resp.status();
379 if !status.is_success() {
380 return Err(Error::Api {
381 status: status.as_u16(),
382 message: resp.text().await.unwrap_or_default(),
383 });
384 }
385 resp.json().await.map_err(Error::Http)
386 }
387
388 pub async fn submit_attestation(
392 &self,
393 attestation: &AttestationSubmit,
394 ) -> Result<AttestationResponse, Error> {
395 let url = format!("{}/attest/submit", self.base_url);
396 let resp = self
397 .http
398 .post(&url)
399 .json(attestation)
400 .send()
401 .await
402 .map_err(Error::Http)?;
403 let status = resp.status();
404 if !status.is_success() && status.as_u16() != 429 {
405 return Err(Error::Api {
406 status: status.as_u16(),
407 message: resp.text().await.unwrap_or_default(),
408 });
409 }
410 resp.json().await.map_err(Error::Http)
411 }
412
413 pub async fn proposals(&self) -> Result<Vec<Proposal>, Error> {
417 let url = format!("{}/governance/proposals", self.base_url);
418 let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
419 let status = resp.status();
420 if !status.is_success() {
421 return Err(Error::Api {
422 status: status.as_u16(),
423 message: resp.text().await.unwrap_or_default(),
424 });
425 }
426 let text = resp.text().await.map_err(Error::Http)?;
427 if let Ok(proposals) = serde_json::from_str::<Vec<Proposal>>(&text) {
428 return Ok(proposals);
429 }
430 #[derive(Deserialize)]
431 struct Wrapper {
432 proposals: Vec<Proposal>,
433 }
434 let wrapper: Wrapper = serde_json::from_str(&text).map_err(Error::Json)?;
435 Ok(wrapper.proposals)
436 }
437
438 pub async fn proposal(&self, id: u64) -> Result<Proposal, Error> {
442 let url = format!("{}/governance/proposal/{}", self.base_url, id);
443 let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
444 let status = resp.status();
445 if !status.is_success() {
446 return Err(Error::Api {
447 status: status.as_u16(),
448 message: resp.text().await.unwrap_or_default(),
449 });
450 }
451 resp.json().await.map_err(Error::Http)
452 }
453
454 pub async fn vote(&self, vote: &Vote) -> Result<VoteResponse, Error> {
458 let url = format!("{}/governance/vote", self.base_url);
459 let resp = self
460 .http
461 .post(&url)
462 .json(vote)
463 .send()
464 .await
465 .map_err(Error::Http)?;
466 let status = resp.status();
467 if !status.is_success() {
468 return Err(Error::Api {
469 status: status.as_u16(),
470 message: resp.text().await.unwrap_or_default(),
471 });
472 }
473 resp.json().await.map_err(Error::Http)
474 }
475
476 pub async fn agent_jobs(&self) -> Result<AgentJobsResponse, Error> {
480 let url = format!("{}/agent/jobs", self.base_url);
481 let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
482 let status = resp.status();
483 if !status.is_success() {
484 return Err(Error::Api {
485 status: status.as_u16(),
486 message: resp.text().await.unwrap_or_default(),
487 });
488 }
489 resp.json().await.map_err(Error::Http)
490 }
491
492 pub fn base_url(&self) -> &str {
494 &self.base_url
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn test_client_creation() {
504 let client = RustChainClient::new("https://rustchain.org").unwrap();
505 assert_eq!(client.base_url(), "https://rustchain.org");
506 }
507
508 #[test]
509 fn test_trailing_slash_stripped() {
510 let client = RustChainClient::new("https://rustchain.org/").unwrap();
511 assert_eq!(client.base_url(), "https://rustchain.org");
512 }
513
514 #[test]
515 fn test_health_deserialize() {
516 let json = r#"{"status":"healthy","uptime":3600.5,"version":"2.2.1","peers":3}"#;
517 let health: HealthResponse = serde_json::from_str(json).unwrap();
518 assert_eq!(health.status, "healthy");
519 assert_eq!(health.uptime, Some(3600.5));
520 assert_eq!(health.version.as_deref(), Some("2.2.1"));
521 assert_eq!(health.peers, Some(3));
522 }
523
524 #[test]
525 fn test_epoch_deserialize() {
526 let json = r#"{"epoch":42,"duration_seconds":600,"reward_pool":1.5}"#;
527 let epoch: EpochResponse = serde_json::from_str(json).unwrap();
528 assert_eq!(epoch.epoch, 42);
529 assert_eq!(epoch.duration_seconds, Some(600));
530 assert_eq!(epoch.reward_pool, Some(1.5));
531 }
532
533 #[test]
534 fn test_miner_deserialize() {
535 let json = r#"{"wallet":"nox-ventures","architecture":"x86_64","multiplier":1.0,"active":true}"#;
536 let miner: Miner = serde_json::from_str(json).unwrap();
537 assert_eq!(miner.wallet, "nox-ventures");
538 assert_eq!(miner.multiplier, Some(1.0));
539 }
540
541 #[test]
542 fn test_miner_alias_field() {
543 let json = r#"{"miner_id":"test-wallet","architecture":"PowerPC G4","multiplier":2.5}"#;
544 let miner: Miner = serde_json::from_str(json).unwrap();
545 assert_eq!(miner.wallet, "test-wallet");
546 assert_eq!(miner.multiplier, Some(2.5));
547 }
548
549 #[test]
550 fn test_agent_job_deserialize() {
551 let json = r#"{"job_id":"job_abc123","title":"Write docs","reward_rtc":5.0,"status":"open","category":"writing"}"#;
552 let job: AgentJob = serde_json::from_str(json).unwrap();
553 assert_eq!(job.job_id, "job_abc123");
554 assert_eq!(job.reward_rtc, Some(5.0));
555 assert_eq!(job.status.as_deref(), Some("open"));
556 }
557
558 #[test]
559 fn test_proposal_deserialize() {
560 let json = r#"{"id":1,"title":"Enable feature X","status":"Active","yes_weight":100.5,"no_weight":20.0}"#;
561 let prop: Proposal = serde_json::from_str(json).unwrap();
562 assert_eq!(prop.id, 1);
563 assert_eq!(prop.title, "Enable feature X");
564 assert_eq!(prop.yes_weight, Some(100.5));
565 }
566}