harmoniis-wallet 0.1.71

Smart-contract wallet for the Harmoniis marketplace for agents and robots (RGB contracts, Witness-backed bearer state, Webcash fees)
Documentation
//! Webcash mining server protocol: target fetching and report submission.

use num_bigint::BigUint;
use reqwest::Client;
use serde::Deserialize;
use webylib::Amount;

/// Mining target information from the server.
#[derive(Debug, Clone)]
pub struct TargetInfo {
    pub difficulty: u32,
    pub epoch: u32,
    pub mining_amount: Amount,
    pub subsidy_amount: Amount,
    pub ratio: f64,
}

/// Response from `/api/v1/target`.
#[derive(Debug, Deserialize)]
struct TargetResponse {
    difficulty_target_bits: u32,
    epoch: u32,
    mining_amount: String,
    mining_subsidy_amount: String,
    ratio: f64,
}

/// Response from `/api/v1/mining_report`.
#[derive(Debug, Deserialize)]
pub struct MiningReportResponse {
    #[serde(default)]
    pub status: Option<String>,
    #[serde(default)]
    pub difficulty_target: Option<u32>,
    #[serde(default)]
    pub error: Option<String>,
}

/// Mining protocol client.
pub struct MiningProtocol {
    server_url: String,
    http: Client,
    /// Blocking HTTP client for the dedicated submission OS thread.
    http_blocking: reqwest::blocking::Client,
}

impl MiningProtocol {
    /// The server URL this client is configured for.
    pub fn server_url(&self) -> &str {
        &self.server_url
    }
}

impl MiningProtocol {
    pub fn new(server_url: &str) -> anyhow::Result<Self> {
        let timeout = std::time::Duration::from_secs(60);
        let http = Client::builder().timeout(timeout).build()?;
        let http_blocking = reqwest::blocking::Client::builder()
            .timeout(timeout)
            .build()?;
        Ok(MiningProtocol {
            server_url: server_url.trim_end_matches('/').to_string(),
            http,
            http_blocking,
        })
    }

    /// Fetch current mining target from the server.
    pub async fn get_target(&self) -> anyhow::Result<TargetInfo> {
        let url = format!("{}/api/v1/target", self.server_url);
        let resp: TargetResponse = self.http.get(&url).send().await?.json().await?;

        // Server returns amounts as decimal strings like "195.3125", not raw wats.
        // Amount::from_str handles decimal parsing.
        let mining_amount: Amount = resp.mining_amount.parse().map_err(|e| {
            anyhow::anyhow!("invalid mining_amount '{}': {}", resp.mining_amount, e)
        })?;
        let subsidy_amount: Amount = resp.mining_subsidy_amount.parse().map_err(|e| {
            anyhow::anyhow!(
                "invalid mining_subsidy_amount '{}': {}",
                resp.mining_subsidy_amount,
                e
            )
        })?;

        Ok(TargetInfo {
            difficulty: resp.difficulty_target_bits,
            epoch: resp.epoch,
            mining_amount,
            subsidy_amount,
            ratio: resp.ratio,
        })
    }

    /// Blocking variant of `submit_report` for the dedicated OS submission thread.
    /// Pure blocking I/O — no tokio runtime involvement.
    pub fn submit_report_blocking(
        &self,
        preimage: &str,
        hash: &[u8; 32],
    ) -> anyhow::Result<MiningReportResponse> {
        let url = format!("{}/api/v1/mining_report", self.server_url);
        let hash_decimal = BigUint::from_bytes_be(hash).to_string();
        let body_str = format!(
            r#"{{"preimage": "{}", "work": {}, "legalese": {{"terms": true}}}}"#,
            preimage, hash_decimal
        );

        let resp = self
            .http_blocking
            .post(&url)
            .header("Content-Type", "application/json")
            .body(body_str)
            .send()?;

        let status_code = resp.status();
        let body_text = resp.text()?;

        let parsed: MiningReportResponse =
            serde_json::from_str(&body_text).unwrap_or(MiningReportResponse {
                status: None,
                difficulty_target: None,
                error: Some(body_text.clone()),
            });

        // Propagate "already used" as an error so callers can distinguish it.
        if let Some(ref err) = parsed.error {
            if err.contains("Didn't use a new secret") {
                anyhow::bail!("Didn't use a new secret value.");
            }
        }

        if status_code.is_success() {
            Ok(parsed)
        } else {
            anyhow::bail!(
                "mining report rejected (HTTP {}): {}",
                status_code,
                body_text
            )
        }
    }

    /// Submit a mining report using an externally-provided blocking client.
    ///
    /// Used by burst drain threads that each own a separate client (= separate
    /// TCP connection) for maximum parallel throughput.
    pub fn submit_report_with_client(
        client: &reqwest::blocking::Client,
        server_url: &str,
        preimage: &str,
        hash: &[u8; 32],
    ) -> anyhow::Result<MiningReportResponse> {
        let url = format!("{}/api/v1/mining_report", server_url);
        let hash_decimal = BigUint::from_bytes_be(hash).to_string();
        let body_str = format!(
            r#"{{"preimage": "{}", "work": {}, "legalese": {{"terms": true}}}}"#,
            preimage, hash_decimal
        );

        let resp = client
            .post(&url)
            .header("Content-Type", "application/json")
            .body(body_str)
            .send()?;

        let status_code = resp.status();
        let body_text = resp.text()?;

        let parsed: MiningReportResponse =
            serde_json::from_str(&body_text).unwrap_or(MiningReportResponse {
                status: None,
                difficulty_target: None,
                error: Some(body_text.clone()),
            });

        if let Some(ref err) = parsed.error {
            if err.contains("Didn't use a new secret") {
                anyhow::bail!("Didn't use a new secret value.");
            }
        }

        if status_code.is_success() {
            Ok(parsed)
        } else {
            anyhow::bail!(
                "mining report rejected (HTTP {}): {}",
                status_code,
                body_text
            )
        }
    }

    /// Submit a mining report to the server.
    ///
    /// The `work` field is the SHA256 hash expressed as a decimal integer (matching
    /// the C++ webminer's `BN_bn2dec` format). The webylib `MiningReportRequest`
    /// struct is missing this field, so we build the JSON manually.
    pub async fn submit_report(
        &self,
        preimage: &str,
        hash: &[u8; 32],
    ) -> anyhow::Result<MiningReportResponse> {
        let url = format!("{}/api/v1/mining_report", self.server_url);

        // Convert 32-byte hash to decimal integer string.
        // The C++ webminer uses BN_bn2dec for this — the server expects a plain decimal number.
        let hash_decimal = BigUint::from_bytes_be(hash).to_string();

        // Build raw JSON because serde_json::Number can't represent a 256-bit integer.
        // The work field must be a bare number (not a string), matching the C++ webminer.
        let body_str = format!(
            r#"{{"preimage": "{}", "work": {}, "legalese": {{"terms": true}}}}"#,
            preimage, hash_decimal
        );

        let resp = self
            .http
            .post(&url)
            .header("Content-Type", "application/json")
            .body(body_str)
            .send()
            .await?;

        let status_code = resp.status();
        let body_text = resp.text().await?;

        if status_code.is_success() {
            let parsed: MiningReportResponse = serde_json::from_str(&body_text)?;
            Ok(parsed)
        } else {
            // Try to parse error response
            if let Ok(parsed) = serde_json::from_str::<MiningReportResponse>(&body_text) {
                if parsed.error.as_deref() == Some("Didn't use a new secret value.") {
                    // This is a known benign error (duplicate secret)
                    return Ok(parsed);
                }
            }
            anyhow::bail!(
                "mining report rejected (HTTP {}): {}",
                status_code,
                body_text
            )
        }
    }
}