webylib 0.3.6

Webcash HD wallet library — bearer e-cash with BIP32-style key derivation, SQLite storage, AES-256-GCM encryption, and full C FFI for cross-platform SDKs
Documentation
//! Server communication for Webcash operations
//!
//! This module handles HTTP communication with the Webcash server for operations
//! like health checks, replacements, target queries, and mining report submissions.

use serde::{Deserialize, Serialize};

#[cfg(any(feature = "native", feature = "wasm"))]
use crate::error::{Error, Result};
#[cfg(any(feature = "native", feature = "wasm"))]
use crate::webcash::PublicWebcash;
#[cfg(any(feature = "native", feature = "wasm"))]
use reqwest::Client;

/// Webcash server API endpoints
pub mod endpoints {
    /// Health check endpoint — query spend status of outputs
    pub const HEALTH_CHECK: &str = "/api/v1/health_check";
    /// Replace endpoint — atomic webcash replacement (core transaction)
    pub const REPLACE: &str = "/api/v1/replace";
    /// Target endpoint — get current mining difficulty and parameters
    pub const TARGET: &str = "/api/v1/target";
    /// Mining report endpoint — submit proof-of-work solution
    pub const MINING_REPORT: &str = "/api/v1/mining_report";
}

/// Network mode selection — the single source of truth for which server
/// the wallet communicates with. Each Wallet instance owns its network mode.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum NetworkMode {
    /// Production webcash.org server.
    #[default]
    Production,
    /// Webycash testnet at weby.cash.
    Testnet,
    /// Custom server URL (e.g., localhost:8080 for local development).
    Custom(String),
}

impl NetworkMode {
    /// Base URL for the selected network.
    pub fn base_url(&self) -> &str {
        match self {
            NetworkMode::Production => "https://webcash.org",
            NetworkMode::Testnet => "https://weby.cash/api/webcash/testnet",
            NetworkMode::Custom(url) => url.as_str(),
        }
    }
}

/// Full API URL for an endpoint on a given network.
impl NetworkMode {
    pub fn endpoint_url(&self, endpoint: &str) -> String {
        format!("{}{}", self.base_url(), endpoint)
    }
}

// ── Native-only: HTTP client ────────────────────────────────────

/// Cross-platform server client trait
#[cfg(any(feature = "native", feature = "wasm"))]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
pub trait ServerClientTrait {
    async fn health_check(&self, webcash: &[PublicWebcash]) -> Result<HealthResponse>;
    async fn replace(&self, request: &ReplaceRequest) -> Result<ReplaceResponse>;
    async fn get_target(&self) -> Result<TargetResponse>;
    async fn submit_mining_report(
        &self,
        report: &MiningReportRequest,
    ) -> Result<MiningReportResponse>;
}

#[cfg(any(feature = "native", feature = "wasm"))]
/// Server configuration
#[derive(Debug, Clone)]
pub struct ServerConfig {
    /// Network mode (determines base URL).
    pub network: NetworkMode,
    /// Request timeout in seconds.
    pub timeout_seconds: u64,
}

#[cfg(any(feature = "native", feature = "wasm"))]
impl ServerConfig {
    /// Base URL derived from the network mode.
    pub fn base_url(&self) -> &str {
        self.network.base_url()
    }
}

#[cfg(any(feature = "native", feature = "wasm"))]
impl Default for ServerConfig {
    fn default() -> Self {
        ServerConfig {
            network: NetworkMode::default(),
            timeout_seconds: 30,
        }
    }
}

#[cfg(any(feature = "native", feature = "wasm"))]
/// Webcash server client (Clone shares connection pool)
#[derive(Clone)]
pub struct ServerClient {
    client: Client,
    config: ServerConfig,
}

#[cfg(any(feature = "native", feature = "wasm"))]
impl ServerClient {
    /// Create a new server client with default configuration
    pub fn new() -> Result<Self> {
        Self::with_config(ServerConfig::default())
    }

    /// Create a new server client with custom configuration
    pub fn with_config(config: ServerConfig) -> Result<Self> {
        let builder = Client::builder();
        #[cfg(not(target_arch = "wasm32"))]
        let builder = builder
            .timeout(std::time::Duration::from_secs(config.timeout_seconds))
            .pool_max_idle_per_host(10000)
            .pool_idle_timeout(std::time::Duration::from_secs(90))
            .tcp_nodelay(true);
        let client = builder.build()?;
        Ok(ServerClient { client, config })
    }

    /// POST JSON body. On WASM uses text/plain to avoid CORS preflight.
    /// The webcash server parses JSON regardless of Content-Type.
    fn post_json_body<B: serde::Serialize>(&self, url: &str, body: &B) -> Result<reqwest::RequestBuilder> {
        let json_str = serde_json::to_string(body)?;
        #[cfg(target_arch = "wasm32")]
        { Ok(self.client.post(url).body(json_str)) }
        #[cfg(not(target_arch = "wasm32"))]
        { Ok(self.client.post(url).header("Content-Type", "application/json").body(json_str)) }
    }

    /// Check the health status of webcash entries
    pub async fn health_check(&self, webcash: &[PublicWebcash]) -> Result<HealthResponse> {
        let request_data: Vec<String> = webcash.iter().map(|wc| wc.to_string()).collect();
        let url = format!("{}{}", self.config.base_url(), endpoints::HEALTH_CHECK);
        let response = self.post_json_body(&url, &request_data)?.send().await?;

        if !response.status().is_success() {
            return Err(Error::server("Health check request failed"));
        }

        let health_response: HealthResponse = response.json().await?;
        Ok(health_response)
    }

    /// Submit a replacement request to the server
    pub async fn replace(&self, request: &ReplaceRequest) -> Result<ReplaceResponse> {
        let url = format!("{}{}", self.config.base_url(), endpoints::REPLACE);
        let response = self.post_json_body(&url, request)?.send().await?;

        let status = response.status();
        let response_text = response.text().await?;

        if !status.is_success() {
            if let Ok(error_response) = serde_json::from_str::<serde_json::Value>(&response_text) {
                if let Some(error_msg) = error_response.get("error").and_then(|v| v.as_str()) {
                    return Err(Error::server(format!(
                        "Replace request failed: {}",
                        error_msg
                    )));
                }
            }
            return Err(Error::server(format!(
                "Replace request failed with status {}: {}",
                status, response_text
            )));
        }

        let replace_response: ReplaceResponse = serde_json::from_str(&response_text)?;
        Ok(replace_response)
    }

    /// Get current mining target information
    pub async fn get_target(&self) -> Result<TargetResponse> {
        let url = format!("{}{}", self.config.base_url(), endpoints::TARGET);
        let response = self.client.get(&url).send().await?;

        if !response.status().is_success() {
            return Err(Error::server("Target request failed"));
        }

        let target_response: TargetResponse = response.json().await?;
        Ok(target_response)
    }

    /// Submit a mining report
    pub async fn submit_mining_report(
        &self,
        report: &MiningReportRequest,
    ) -> Result<MiningReportResponse> {
        let url = format!("{}{}", self.config.base_url(), endpoints::MINING_REPORT);
        let response = self.post_json_body(&url, report)?.send().await?;

        if !response.status().is_success() {
            return Err(Error::server("Mining report submission failed"));
        }

        let mining_response: MiningReportResponse = response.json().await?;
        Ok(mining_response)
    }
}

#[cfg(any(feature = "native", feature = "wasm"))]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl ServerClientTrait for ServerClient {
    async fn health_check(&self, webcash: &[PublicWebcash]) -> Result<HealthResponse> {
        self.health_check(webcash).await
    }
    async fn replace(&self, request: &ReplaceRequest) -> Result<ReplaceResponse> {
        self.replace(request).await
    }
    async fn get_target(&self) -> Result<TargetResponse> {
        self.get_target().await
    }
    async fn submit_mining_report(
        &self,
        report: &MiningReportRequest,
    ) -> Result<MiningReportResponse> {
        self.submit_mining_report(report).await
    }
}

/// Health check response
#[derive(Debug, Deserialize)]
pub struct HealthResponse {
    pub status: String,
    pub results: std::collections::HashMap<String, HealthResult>,
}

/// Individual health check result
#[derive(Debug, Deserialize)]
pub struct HealthResult {
    pub spent: Option<bool>,
    pub amount: Option<String>,
}

/// Replacement request
#[derive(Debug, Serialize)]
pub struct ReplaceRequest {
    pub webcashes: Vec<String>,
    pub new_webcashes: Vec<String>,
    pub legalese: Legalese,
}

/// Terms acceptance
#[derive(Debug, Serialize)]
pub struct Legalese {
    pub terms: bool,
}

/// Replacement response
#[derive(Debug, Deserialize)]
pub struct ReplaceResponse {
    pub status: String,
}

/// Target information response
#[derive(Debug, Deserialize)]
pub struct TargetResponse {
    pub difficulty_target_bits: u32,
    pub epoch: u32,
    pub mining_amount: String,
    pub mining_subsidy_amount: String,
    pub ratio: f64,
}

/// Mining report request
#[derive(Debug, Serialize)]
pub struct MiningReportRequest {
    pub preimage: String,
    pub legalese: Legalese,
}

/// Mining report response
#[derive(Debug, Deserialize)]
pub struct MiningReportResponse {
    pub status: String,
    pub difficulty_target: Option<u32>,
}