Skip to main content

ows_lib/
nano_rpc.rs

1//! Nano RPC helpers (account_info, work_generate, process).
2//!
3//! Uses `curl` for HTTP, consistent with the rest of ows-lib (no added HTTP deps).
4
5use crate::error::OwsLibError;
6use std::process::Command;
7
8/// Call a Nano RPC action via curl and return the parsed JSON response.
9fn nano_rpc_call(
10    rpc_url: &str,
11    body: &serde_json::Value,
12) -> Result<serde_json::Value, OwsLibError> {
13    let body_str = body.to_string();
14    let output = Command::new("curl")
15        .args([
16            "-fsSL",
17            "-X",
18            "POST",
19            "-H",
20            "Content-Type: application/json",
21            "-d",
22            &body_str,
23            rpc_url,
24        ])
25        .output()
26        .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
27
28    if !output.status.success() {
29        let stderr = String::from_utf8_lossy(&output.stderr);
30        return Err(OwsLibError::BroadcastFailed(format!(
31            "Nano RPC call failed: {stderr}"
32        )));
33    }
34
35    let resp_str = String::from_utf8_lossy(&output.stdout);
36    let parsed: serde_json::Value = serde_json::from_str(&resp_str)?;
37
38    // Check for Nano RPC error field
39    if let Some(error) = parsed.get("error") {
40        let msg = error.as_str().unwrap_or("unknown error");
41        return Err(OwsLibError::BroadcastFailed(format!(
42            "Nano RPC error: {msg}"
43        )));
44    }
45
46    Ok(parsed)
47}
48
49/// Account info from the Nano network.
50#[derive(Debug, Clone)]
51pub struct NanoAccountInfo {
52    /// Current frontier (head block hash), hex-encoded.
53    pub frontier: String,
54    /// Current balance in raw (decimal string).
55    pub balance: String,
56    /// Representative nano_ address.
57    pub representative: String,
58}
59
60/// Query `account_info` for a Nano account.
61///
62/// Returns `None` if the account is not yet opened (no blocks published).
63pub fn account_info(rpc_url: &str, account: &str) -> Result<Option<NanoAccountInfo>, OwsLibError> {
64    let body = serde_json::json!({
65        "action": "account_info",
66        "account": account,
67        "representative": "true"
68    });
69
70    match nano_rpc_call(rpc_url, &body) {
71        Ok(resp) => {
72            let frontier = resp["frontier"]
73                .as_str()
74                .ok_or_else(|| {
75                    OwsLibError::BroadcastFailed("no frontier in account_info response".into())
76                })?
77                .to_string();
78            let balance = resp["balance"]
79                .as_str()
80                .ok_or_else(|| {
81                    OwsLibError::BroadcastFailed("no balance in account_info response".into())
82                })?
83                .to_string();
84            let representative = resp["representative"]
85                .as_str()
86                .ok_or_else(|| {
87                    OwsLibError::BroadcastFailed(
88                        "no representative in account_info response".into(),
89                    )
90                })?
91                .to_string();
92
93            Ok(Some(NanoAccountInfo {
94                frontier,
95                balance,
96                representative,
97            }))
98        }
99        Err(OwsLibError::BroadcastFailed(msg)) if msg.contains("Account not found") => Ok(None),
100        Err(e) => Err(e),
101    }
102}
103
104/// Request proof-of-work from a single RPC endpoint.
105fn work_generate_single(
106    rpc_url: &str,
107    hash: &str,
108    difficulty: &str,
109) -> Result<String, OwsLibError> {
110    let body = serde_json::json!({
111        "action": "work_generate",
112        "hash": hash,
113        "difficulty": difficulty
114    });
115
116    let resp = nano_rpc_call(rpc_url, &body)?;
117
118    resp["work"]
119        .as_str()
120        .map(|s| s.to_string())
121        .ok_or_else(|| OwsLibError::BroadcastFailed("no work in work_generate response".into()))
122}
123
124/// Default PoW fallback endpoint, tried when the primary RPC fails work_generate.
125const FALLBACK_WORK_URL: &str = "https://rpc.nano.to";
126
127/// Request proof-of-work with multi-endpoint fallback.
128///
129/// Tries endpoints in order:
130/// 1. The primary `rpc_url`
131/// 2. URLs from `NANO_WORK_URL` env var (semicolon-separated URLs)
132/// 3. Built-in fallback endpoint
133///
134/// All remote errors are collected and logged to stderr. If every remote fails
135/// and `NANO_CPU_POW=1` is set, a future CPU fallback would go here.
136pub fn work_generate(rpc_url: &str, hash: &str, difficulty: &str) -> Result<String, OwsLibError> {
137    let mut endpoints: Vec<String> = vec![rpc_url.to_string()];
138
139    if let Ok(urls) = std::env::var("NANO_WORK_URL") {
140        for url in urls.split(';') {
141            let url = url.trim();
142            if !url.is_empty() && url != rpc_url {
143                endpoints.push(url.to_string());
144            }
145        }
146    }
147
148    if !endpoints.iter().any(|e| e == FALLBACK_WORK_URL) {
149        endpoints.push(FALLBACK_WORK_URL.to_string());
150    }
151
152    let mut last_error = None;
153
154    for endpoint in &endpoints {
155        match work_generate_single(endpoint, hash, difficulty) {
156            Ok(work) => return Ok(work),
157            Err(e) => {
158                eprintln!("  PoW failed on {endpoint}: {e}");
159                last_error = Some(e);
160            }
161        }
162    }
163
164    Err(last_error
165        .unwrap_or_else(|| OwsLibError::BroadcastFailed("no PoW endpoints available".into())))
166}
167
168/// Publish a block to the Nano network via `process` RPC.
169///
170/// Returns the block hash on success.
171pub fn process_block(
172    rpc_url: &str,
173    block_json: &serde_json::Value,
174    subtype: &str,
175) -> Result<String, OwsLibError> {
176    let body = serde_json::json!({
177        "action": "process",
178        "json_block": "true",
179        "subtype": subtype,
180        "block": block_json
181    });
182
183    let resp = nano_rpc_call(rpc_url, &body)?;
184
185    resp["hash"]
186        .as_str()
187        .map(|s| s.to_string())
188        .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no hash in process response: {resp}")))
189}
190
191/// PoW difficulty thresholds (as hex strings for work_generate RPC).
192pub const SEND_DIFFICULTY: &str = "fffffff800000000";
193pub const RECEIVE_DIFFICULTY: &str = "fffffe0000000000";