Skip to main content

rustchain_client/
lib.rs

1//! # rustchain-client
2//!
3//! An HTTP client for the [RustChain](https://rustchain.org) Proof-of-Antiquity blockchain API.
4//!
5//! RustChain is the first blockchain that rewards vintage hardware (PowerPC G4, IBM POWER8,
6//! Pentium 4, etc.) for being old — not fast. This crate provides a typed Rust client for
7//! querying the RustChain node API.
8//!
9//! ## Quick Start
10//!
11//! ```rust,no_run
12//! use rustchain_client::RustChainClient;
13//!
14//! #[tokio::main]
15//! async fn main() -> Result<(), rustchain_client::Error> {
16//!     let client = RustChainClient::new("https://rustchain.org")?;
17//!
18//!     // Check node health
19//!     let health = client.health().await?;
20//!     println!("Node status: {}", health.status);
21//!
22//!     // Get current epoch
23//!     let epoch = client.epoch().await?;
24//!     println!("Current epoch: {}", epoch.epoch);
25//!
26//!     // List active miners
27//!     let miners = client.miners().await?;
28//!     println!("Active miners: {}", miners.len());
29//!
30//!     Ok(())
31//! }
32//! ```
33//!
34//! ## Features
35//!
36//! - Query node health, uptime, and version
37//! - Get current epoch information and reward pools
38//! - List active miners with hardware details and antiquity multipliers
39//! - Check wallet balances
40//! - Submit and query attestations
41//! - List and vote on governance proposals
42//! - Uses `rustls` for TLS (no OpenSSL dependency)
43//! - Supports self-signed certificates (common on RustChain nodes)
44
45use reqwest::Client;
46use serde::{Deserialize, Serialize};
47
48mod error;
49pub use error::Error;
50
51/// Response from the `/health` endpoint.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct HealthResponse {
54    /// Node status string (e.g., "healthy").
55    pub status: String,
56    /// Node uptime in seconds.
57    #[serde(default)]
58    pub uptime: Option<f64>,
59    /// Software version.
60    #[serde(default)]
61    pub version: Option<String>,
62    /// Number of connected peers.
63    #[serde(default)]
64    pub peers: Option<u32>,
65    /// Current block height.
66    #[serde(default)]
67    pub block_height: Option<u64>,
68}
69
70/// Response from the `/epoch` endpoint.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct EpochResponse {
73    /// Current epoch number.
74    pub epoch: u64,
75    /// Epoch duration in seconds.
76    #[serde(default)]
77    pub duration_seconds: Option<u64>,
78    /// Time remaining in current epoch.
79    #[serde(default)]
80    pub time_remaining: Option<f64>,
81    /// Base reward pool for this epoch.
82    #[serde(default)]
83    pub reward_pool: Option<f64>,
84    /// Number of active attestations this epoch.
85    #[serde(default)]
86    pub attestations: Option<u64>,
87    /// Epoch start timestamp.
88    #[serde(default)]
89    pub started_at: Option<String>,
90}
91
92/// A single miner entry from the `/api/miners` endpoint.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct Miner {
95    /// Miner's wallet identifier.
96    #[serde(alias = "miner_id")]
97    pub wallet: String,
98    /// Hardware architecture (e.g., "PowerPC G4", "x86_64").
99    #[serde(default)]
100    pub architecture: Option<String>,
101    /// Antiquity multiplier (e.g., 2.5 for PowerPC G4).
102    #[serde(default)]
103    pub multiplier: Option<f64>,
104    /// Whether this miner is currently active.
105    #[serde(default)]
106    pub active: Option<bool>,
107    /// Last attestation timestamp.
108    #[serde(default)]
109    pub last_seen: Option<String>,
110    /// Total RTC earned.
111    #[serde(default)]
112    pub total_earned: Option<f64>,
113    /// Hardware platform string.
114    #[serde(default)]
115    pub platform: Option<String>,
116}
117
118/// Wallet balance response.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct WalletBalance {
121    /// Wallet/miner identifier.
122    #[serde(alias = "miner_id")]
123    pub wallet: String,
124    /// Current balance in RTC.
125    pub balance: f64,
126    /// Total earned across all epochs.
127    #[serde(default)]
128    pub total_earned: Option<f64>,
129    /// Total number of attestations submitted.
130    #[serde(default)]
131    pub attestation_count: Option<u64>,
132}
133
134/// Attestation submission payload.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct AttestationSubmit {
137    /// Miner wallet identifier.
138    pub miner: String,
139    /// Hardware fingerprint data.
140    pub fingerprint: serde_json::Value,
141    /// Optional nonce for uniqueness.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub nonce: Option<String>,
144}
145
146/// Response after submitting an attestation.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct AttestationResponse {
149    /// Whether the attestation was accepted.
150    #[serde(default)]
151    pub accepted: Option<bool>,
152    /// Status message.
153    #[serde(default)]
154    pub status: Option<String>,
155    /// Error message if rejected.
156    #[serde(default)]
157    pub error: Option<String>,
158    /// Epoch the attestation was recorded for.
159    #[serde(default)]
160    pub epoch: Option<u64>,
161}
162
163/// A governance proposal.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct Proposal {
166    /// Proposal ID.
167    pub id: u64,
168    /// Proposal title.
169    pub title: String,
170    /// Proposal description / rationale.
171    #[serde(default)]
172    pub description: Option<String>,
173    /// Proposer wallet address.
174    #[serde(default)]
175    pub proposer: Option<String>,
176    /// Current status (Draft, Active, Passed, Failed).
177    #[serde(default)]
178    pub status: Option<String>,
179    /// Yes-vote weight.
180    #[serde(default)]
181    pub yes_weight: Option<f64>,
182    /// No-vote weight.
183    #[serde(default)]
184    pub no_weight: Option<f64>,
185    /// Creation timestamp.
186    #[serde(default)]
187    pub created_at: Option<String>,
188}
189
190/// Governance vote payload.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct Vote {
193    /// Proposal ID to vote on.
194    pub proposal_id: u64,
195    /// Voter wallet address.
196    pub wallet: String,
197    /// Vote direction ("yes" or "no").
198    pub vote: String,
199    /// Nonce for replay protection.
200    pub nonce: String,
201    /// Ed25519 public key (hex).
202    pub public_key: String,
203    /// Ed25519 signature (hex).
204    pub signature: String,
205}
206
207/// Vote submission response.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct VoteResponse {
210    /// Whether the vote was recorded.
211    #[serde(default)]
212    pub accepted: Option<bool>,
213    /// Status message.
214    #[serde(default)]
215    pub status: Option<String>,
216    /// Error details.
217    #[serde(default)]
218    pub error: Option<String>,
219}
220
221/// Agent Economy job listing.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct AgentJob {
224    /// Unique job identifier.
225    pub job_id: String,
226    /// Job title.
227    #[serde(default)]
228    pub title: Option<String>,
229    /// Job description.
230    #[serde(default)]
231    pub description: Option<String>,
232    /// RTC reward for completing this job.
233    #[serde(default)]
234    pub reward_rtc: Option<f64>,
235    /// Job status (open, claimed, delivered, accepted).
236    #[serde(default)]
237    pub status: Option<String>,
238    /// Job category.
239    #[serde(default)]
240    pub category: Option<String>,
241    /// Poster agent name.
242    #[serde(default)]
243    pub posted_by: Option<String>,
244}
245
246/// Agent Economy jobs list response.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct AgentJobsResponse {
249    /// Whether the request succeeded.
250    #[serde(default)]
251    pub ok: Option<bool>,
252    /// List of jobs.
253    #[serde(default)]
254    pub jobs: Vec<AgentJob>,
255    /// Total count of matching jobs.
256    #[serde(default)]
257    pub total: Option<u64>,
258    /// Available job categories.
259    #[serde(default)]
260    pub categories: Option<Vec<String>>,
261}
262
263/// The main RustChain API client.
264///
265/// # Example
266///
267/// ```rust,no_run
268/// use rustchain_client::RustChainClient;
269///
270/// # async fn example() -> Result<(), rustchain_client::Error> {
271/// let client = RustChainClient::new("https://rustchain.org")?;
272/// let health = client.health().await?;
273/// println!("{:?}", health);
274/// # Ok(())
275/// # }
276/// ```
277pub struct RustChainClient {
278    base_url: String,
279    http: Client,
280}
281
282impl RustChainClient {
283    /// Create a new RustChain API client.
284    ///
285    /// # Arguments
286    ///
287    /// * `base_url` - The base URL of the RustChain node (e.g., `"https://rustchain.org"` or
288    ///   `"https://50.28.86.131"`).
289    ///
290    /// Accepts self-signed certificates, which is common for RustChain nodes.
291    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    /// Create a client with a custom `reqwest::Client`.
305    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    /// Check node health.
313    ///
314    /// Calls `GET /health`.
315    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    /// Get current epoch information.
329    ///
330    /// Calls `GET /epoch`.
331    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    /// List active miners on the network.
345    ///
346    /// Calls `GET /api/miners`.
347    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        // The API may return miners as a top-level array or under a key
358        let text = resp.text().await.map_err(Error::Http)?;
359        // Try parsing as array first
360        if let Ok(miners) = serde_json::from_str::<Vec<Miner>>(&text) {
361            return Ok(miners);
362        }
363        // Try as object with "miners" key
364        #[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    /// Check a wallet's RTC balance.
373    ///
374    /// Calls `GET /wallet/balance?miner_id={wallet}`.
375    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    /// Submit a mining attestation.
389    ///
390    /// Calls `POST /attest/submit`.
391    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    /// List governance proposals.
414    ///
415    /// Calls `GET /governance/proposals`.
416    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    /// Get a single governance proposal by ID.
439    ///
440    /// Calls `GET /governance/proposal/{id}`.
441    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    /// Submit a governance vote.
455    ///
456    /// Calls `POST /governance/vote`.
457    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    /// List open Agent Economy jobs.
477    ///
478    /// Calls `GET /agent/jobs`.
479    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    /// Get the base URL of this client.
493    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}