use reqwest::Client;
use serde::{Deserialize, Serialize};
mod error;
pub use error::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthResponse {
pub status: String,
#[serde(default)]
pub uptime: Option<f64>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub peers: Option<u32>,
#[serde(default)]
pub block_height: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EpochResponse {
pub epoch: u64,
#[serde(default)]
pub duration_seconds: Option<u64>,
#[serde(default)]
pub time_remaining: Option<f64>,
#[serde(default)]
pub reward_pool: Option<f64>,
#[serde(default)]
pub attestations: Option<u64>,
#[serde(default)]
pub started_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Miner {
#[serde(alias = "miner_id")]
pub wallet: String,
#[serde(default)]
pub architecture: Option<String>,
#[serde(default)]
pub multiplier: Option<f64>,
#[serde(default)]
pub active: Option<bool>,
#[serde(default)]
pub last_seen: Option<String>,
#[serde(default)]
pub total_earned: Option<f64>,
#[serde(default)]
pub platform: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalletBalance {
#[serde(alias = "miner_id")]
pub wallet: String,
pub balance: f64,
#[serde(default)]
pub total_earned: Option<f64>,
#[serde(default)]
pub attestation_count: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttestationSubmit {
pub miner: String,
pub fingerprint: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nonce: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttestationResponse {
#[serde(default)]
pub accepted: Option<bool>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub epoch: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Proposal {
pub id: u64,
pub title: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub proposer: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub yes_weight: Option<f64>,
#[serde(default)]
pub no_weight: Option<f64>,
#[serde(default)]
pub created_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vote {
pub proposal_id: u64,
pub wallet: String,
pub vote: String,
pub nonce: String,
pub public_key: String,
pub signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoteResponse {
#[serde(default)]
pub accepted: Option<bool>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentJob {
pub job_id: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub reward_rtc: Option<f64>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub posted_by: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentJobsResponse {
#[serde(default)]
pub ok: Option<bool>,
#[serde(default)]
pub jobs: Vec<AgentJob>,
#[serde(default)]
pub total: Option<u64>,
#[serde(default)]
pub categories: Option<Vec<String>>,
}
pub struct RustChainClient {
base_url: String,
http: Client,
}
impl RustChainClient {
pub fn new(base_url: &str) -> Result<Self, Error> {
let http = Client::builder()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(Error::Http)?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
http,
})
}
pub fn with_client(base_url: &str, http: Client) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
http,
}
}
pub async fn health(&self) -> Result<HealthResponse, Error> {
let url = format!("{}/health", self.base_url);
let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
let status = resp.status();
if !status.is_success() {
return Err(Error::Api {
status: status.as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
resp.json().await.map_err(Error::Http)
}
pub async fn epoch(&self) -> Result<EpochResponse, Error> {
let url = format!("{}/epoch", self.base_url);
let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
let status = resp.status();
if !status.is_success() {
return Err(Error::Api {
status: status.as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
resp.json().await.map_err(Error::Http)
}
pub async fn miners(&self) -> Result<Vec<Miner>, Error> {
let url = format!("{}/api/miners", self.base_url);
let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
let status = resp.status();
if !status.is_success() {
return Err(Error::Api {
status: status.as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
let text = resp.text().await.map_err(Error::Http)?;
if let Ok(miners) = serde_json::from_str::<Vec<Miner>>(&text) {
return Ok(miners);
}
#[derive(Deserialize)]
struct Wrapper {
miners: Vec<Miner>,
}
let wrapper: Wrapper = serde_json::from_str(&text).map_err(Error::Json)?;
Ok(wrapper.miners)
}
pub async fn wallet_balance(&self, wallet: &str) -> Result<WalletBalance, Error> {
let url = format!("{}/wallet/balance?miner_id={}", self.base_url, wallet);
let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
let status = resp.status();
if !status.is_success() {
return Err(Error::Api {
status: status.as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
resp.json().await.map_err(Error::Http)
}
pub async fn submit_attestation(
&self,
attestation: &AttestationSubmit,
) -> Result<AttestationResponse, Error> {
let url = format!("{}/attest/submit", self.base_url);
let resp = self
.http
.post(&url)
.json(attestation)
.send()
.await
.map_err(Error::Http)?;
let status = resp.status();
if !status.is_success() && status.as_u16() != 429 {
return Err(Error::Api {
status: status.as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
resp.json().await.map_err(Error::Http)
}
pub async fn proposals(&self) -> Result<Vec<Proposal>, Error> {
let url = format!("{}/governance/proposals", self.base_url);
let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
let status = resp.status();
if !status.is_success() {
return Err(Error::Api {
status: status.as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
let text = resp.text().await.map_err(Error::Http)?;
if let Ok(proposals) = serde_json::from_str::<Vec<Proposal>>(&text) {
return Ok(proposals);
}
#[derive(Deserialize)]
struct Wrapper {
proposals: Vec<Proposal>,
}
let wrapper: Wrapper = serde_json::from_str(&text).map_err(Error::Json)?;
Ok(wrapper.proposals)
}
pub async fn proposal(&self, id: u64) -> Result<Proposal, Error> {
let url = format!("{}/governance/proposal/{}", self.base_url, id);
let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
let status = resp.status();
if !status.is_success() {
return Err(Error::Api {
status: status.as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
resp.json().await.map_err(Error::Http)
}
pub async fn vote(&self, vote: &Vote) -> Result<VoteResponse, Error> {
let url = format!("{}/governance/vote", self.base_url);
let resp = self
.http
.post(&url)
.json(vote)
.send()
.await
.map_err(Error::Http)?;
let status = resp.status();
if !status.is_success() {
return Err(Error::Api {
status: status.as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
resp.json().await.map_err(Error::Http)
}
pub async fn agent_jobs(&self) -> Result<AgentJobsResponse, Error> {
let url = format!("{}/agent/jobs", self.base_url);
let resp = self.http.get(&url).send().await.map_err(Error::Http)?;
let status = resp.status();
if !status.is_success() {
return Err(Error::Api {
status: status.as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
resp.json().await.map_err(Error::Http)
}
pub fn base_url(&self) -> &str {
&self.base_url
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = RustChainClient::new("https://rustchain.org").unwrap();
assert_eq!(client.base_url(), "https://rustchain.org");
}
#[test]
fn test_trailing_slash_stripped() {
let client = RustChainClient::new("https://rustchain.org/").unwrap();
assert_eq!(client.base_url(), "https://rustchain.org");
}
#[test]
fn test_health_deserialize() {
let json = r#"{"status":"healthy","uptime":3600.5,"version":"2.2.1","peers":3}"#;
let health: HealthResponse = serde_json::from_str(json).unwrap();
assert_eq!(health.status, "healthy");
assert_eq!(health.uptime, Some(3600.5));
assert_eq!(health.version.as_deref(), Some("2.2.1"));
assert_eq!(health.peers, Some(3));
}
#[test]
fn test_epoch_deserialize() {
let json = r#"{"epoch":42,"duration_seconds":600,"reward_pool":1.5}"#;
let epoch: EpochResponse = serde_json::from_str(json).unwrap();
assert_eq!(epoch.epoch, 42);
assert_eq!(epoch.duration_seconds, Some(600));
assert_eq!(epoch.reward_pool, Some(1.5));
}
#[test]
fn test_miner_deserialize() {
let json = r#"{"wallet":"nox-ventures","architecture":"x86_64","multiplier":1.0,"active":true}"#;
let miner: Miner = serde_json::from_str(json).unwrap();
assert_eq!(miner.wallet, "nox-ventures");
assert_eq!(miner.multiplier, Some(1.0));
}
#[test]
fn test_miner_alias_field() {
let json = r#"{"miner_id":"test-wallet","architecture":"PowerPC G4","multiplier":2.5}"#;
let miner: Miner = serde_json::from_str(json).unwrap();
assert_eq!(miner.wallet, "test-wallet");
assert_eq!(miner.multiplier, Some(2.5));
}
#[test]
fn test_agent_job_deserialize() {
let json = r#"{"job_id":"job_abc123","title":"Write docs","reward_rtc":5.0,"status":"open","category":"writing"}"#;
let job: AgentJob = serde_json::from_str(json).unwrap();
assert_eq!(job.job_id, "job_abc123");
assert_eq!(job.reward_rtc, Some(5.0));
assert_eq!(job.status.as_deref(), Some("open"));
}
#[test]
fn test_proposal_deserialize() {
let json = r#"{"id":1,"title":"Enable feature X","status":"Active","yes_weight":100.5,"no_weight":20.0}"#;
let prop: Proposal = serde_json::from_str(json).unwrap();
assert_eq!(prop.id, 1);
assert_eq!(prop.title, "Enable feature X");
assert_eq!(prop.yes_weight, Some(100.5));
}
}