diff --git a/src/grin.rs b/src/grin.rs index 6a5cf58..80b7a10 100644 --- a/src/grin.rs +++ b/src/grin.rs @@ -1,33 +1,46 @@ //! Grin node access — the chain data the scanner and broadcast path need. //! -//! Talks to a grin node's Foreign API v2 (JSON-RPC): read the tip, page through -//! block outputs (commit + rangeproof), and relay a finalized transaction. This -//! is the ONLY component that reaches the chain; everything else reads the DB. +//! Talks to a grin node's Foreign API v2 (JSON-RPC). Grin's chain state is a +//! set of unspent outputs in a Merkle Mountain Range; a light wallet scans by +//! walking that output set in **PMMR insertion-index order** (NOT block by +//! block — `get_block` doesn't even return rangeproofs). So the surface here is: +//! - `get_unspent_outputs(start, end, max)` — page the UTXO set WITH proofs +//! (the discovery call the scanner rewinds); returns outputs in ascending +//! `mmr_index`, only currently-unspent ones. +//! - `get_outputs(commits)` — which of our tracked commits are still unspent +//! (spend detection is by ABSENCE from the set). +//! - `get_pmmr_indices(height)` — map a wallet birthday height → a start index. +//! - `get_header(height)` / `get_tip` — reorg detection + chain tip. +//! - `push_transaction(tx)` — relay a finalized tx. //! -//! Hostile-upstream hygiene: per-request + connect timeouts, a streaming -//! body-size cap, and errors that never interpolate an untrusted node body. -//! -//! REWIND (the hard, deferred part): recognizing which of a block's outputs -//! belong to a registered account means rewinding each output's Bulletproof -//! rangeproof against that account's `rewind_hash`-derived nonce, then parsing -//! the proof message to recover the derivation path — exactly what -//! `grin_recover_output` does client-side, done here server-side. That requires -//! the grin crypto stack (secp256k1 / bulletproofs) and is stubbed below. +//! This is the ONLY component that reaches the chain; everything else reads the +//! DB. Hostile-upstream hygiene: per-request + connect timeouts, a streaming +//! body-size cap, generic errors that never interpolate an untrusted node body, +//! and permissive (never `deny_unknown_fields`) deserialization so a node +//! version bump can't break parsing. Per-output decode failures skip that output +//! rather than aborting a page. +use std::collections::HashSet; use std::time::Duration; use serde::Deserialize; +use serde_json::{json, Value}; use crate::config::Config; use crate::error::{Error, Result}; const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); -/// Hard cap on any node response body (a full block page is the largest). +/// Hard cap on any node response body (a full output page is the largest). const MAX_BODY_BYTES: usize = 32 * 1024 * 1024; +/// Chunk size when re-checking tracked commits (grin-wallet's default batch). +const OUTPUTS_CHUNK: usize = 500; -/// A raw output as read from a block: the commitment and its rangeproof. The -/// rangeproof is what the scanner rewinds to test account ownership. +/// A single unspent output from the UTXO listing: the commitment + its +/// rangeproof (both hex), plus the classification the scanner needs. The +/// rangeproof is what [`rewind_output`] rewinds to test account ownership. +/// (`lock_height` is NOT carried — the node does not send it; the scanner +/// derives it from `is_coinbase` + `height`.) #[derive(Debug, Clone)] pub struct ChainOutput { pub commit: String, @@ -35,19 +48,16 @@ pub struct ChainOutput { pub is_coinbase: bool, pub height: u64, pub mmr_index: u64, - pub lock_height: u64, } -/// A block's spend/create surface: inputs consume outputs (by commitment), -/// outputs create them. +/// One page of the UTXO traversal (`get_unspent_outputs`). #[derive(Debug, Clone)] -pub struct BlockView { - pub height: u64, - pub hash: String, - pub prev_hash: String, - /// Input commitments spent in this block (for spend detection). - pub input_commits: Vec, - /// Outputs created in this block (candidates for rewind). +#[allow(dead_code)] +pub struct OutputPage { + /// Highest insertion index available in the output PMMR (the scan target). + pub highest_index: u64, + /// Index of the last output on THIS page (advance the cursor to here). + pub last_retrieved_index: u64, pub outputs: Vec, } @@ -78,24 +88,101 @@ impl GrinNode { struct Tip { height: u64, } - let tip: Tip = self.rpc("get_tip", serde_json::json!([])).await?; + let tip: Tip = self.rpc("get_tip", json!([])).await?; Ok(tip.height) } - /// Read a block at `height` as a [`BlockView`] (Foreign API `get_block`). - /// - /// STUB: real impl parses the node's block JSON into inputs + outputs - /// (commit + rangeproof + features). Left unimplemented pending the scanner. - pub async fn get_block(&self, _height: u64) -> Result { - Err(Error::NotImplemented("grin get_block")) + /// Chain tip height + its block hash (`last_block_pushed`) — the hash is the + /// reorg-detection checkpoint the scanner stores in its cursor. + #[allow(dead_code)] + pub async fn get_tip(&self) -> Result<(u64, String)> { + #[derive(Deserialize)] + struct Tip { + height: u64, + last_block_pushed: String, + } + let tip: Tip = self.rpc("get_tip", json!([])).await?; + Ok((tip.height, tip.last_block_pushed)) } - /// Relay a finalized transaction to the node (Foreign API `push_transaction`). - /// - /// STUB: real impl posts `push_transaction(tx, fluff=false)` and treats any - /// JSON-RPC error as [`Error::Node`]. - pub async fn push_transaction(&self, _tx: &serde_json::Value) -> Result<()> { - Err(Error::NotImplemented("grin push_transaction")) + /// Page the unspent-output set by PMMR index, WITH rangeproofs. The trailing + /// `include_proof = true` is mandatory — the proof is absent otherwise, which + /// would silently yield zero rewind matches. Outputs come back only if + /// currently unspent, in ascending `mmr_index`; ones missing a proof or + /// creation height are skipped (never usable for a rewind). + #[allow(dead_code)] + pub async fn get_unspent_outputs( + &self, + start_index: u64, + end_index: Option, + max: u64, + ) -> Result { + let params = json!([start_index, end_index, max, true]); + let listing: OutputListingResp = self.rpc("get_unspent_outputs", params).await?; + let outputs = listing + .outputs + .into_iter() + .filter_map(ChainOutput::from_printable) + .collect(); + Ok(OutputPage { + highest_index: listing.highest_index, + last_retrieved_index: listing.last_retrieved_index, + outputs, + }) + } + + /// Of `commits`, return the set the node still lists as UNSPENT. Any tracked + /// commit NOT in the returned set has left the UTXO set = spent/pruned. The + /// node includes no proofs here (`include_proof = false`). Chunked to stay + /// under request limits. + #[allow(dead_code)] + pub async fn outputs_present(&self, commits: &[String]) -> Result> { + #[derive(Deserialize)] + struct OutResp { + commit: String, + } + let mut present = HashSet::with_capacity(commits.len()); + for chunk in commits.chunks(OUTPUTS_CHUNK) { + // [commits, start_height=null, end_height=null, include_proof=false, + // include_merkle_proof=false] + let params = json!([chunk, Value::Null, Value::Null, false, false]); + let outs: Vec = self.rpc("get_outputs", params).await?; + for o in outs { + present.insert(o.commit); + } + } + Ok(present) + } + + /// The first PMMR output index at or after block `height` — used to start a + /// new account's backfill from its birthday instead of index 0. + #[allow(dead_code)] + pub async fn start_index_for_height(&self, height: u64) -> Result { + let params = json!([height, Value::Null]); + let listing: OutputListingResp = self.rpc("get_pmmr_indices", params).await?; + Ok(listing.last_retrieved_index) + } + + /// The canonical main-chain block hash at `height` — the reorg probe: if it + /// differs from the hash the cursor recorded for that height, the chain + /// reorganized. Propagates an error (rather than a sentinel) on node failure + /// so a transient hiccup aborts the tick instead of faking a reorg. + #[allow(dead_code)] + pub async fn header_hash_at(&self, height: u64) -> Result { + let params = json!([height, Value::Null, Value::Null]); + #[derive(Deserialize)] + struct Header { + hash: String, + } + let h: Header = self.rpc("get_header", params).await?; + Ok(h.hash) + } + + /// Relay a finalized transaction to the node (Foreign API `push_transaction`, + /// `fluff = false`). Success is `{"result":{"Ok":null}}`; the null payload is + /// why this can't go through the value-returning [`Self::rpc`] helper. + pub async fn push_transaction(&self, tx: &Value) -> Result<()> { + self.rpc_ok_unit("push_transaction", json!([tx, false])).await } /// Liveness probe for `/ready` — a cheap tip read. @@ -103,13 +190,30 @@ impl GrinNode { self.get_tip_height().await.map(|_| ()) } - /// Minimal JSON-RPC helper with a size-capped response read. Non-2xx and - /// JSON-RPC `error` payloads map to a generic [`Error::Node`] (no body - /// interpolation). + // ── JSON-RPC plumbing ─────────────────────────────────────────────────── + + /// POST a JSON-RPC call and return the size-capped raw response bytes. + /// Non-2xx maps to a generic [`Error::Node`] (no body interpolation). + async fn rpc_raw(&self, method: &'static str, params: Value) -> Result> { + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }); + let mut req = self.http.post(&self.foreign_api_url).json(&body); + if !self.api_secret.is_empty() { + req = req.basic_auth("grin", Some(&self.api_secret)); + } + let resp = req.send().await.map_err(|_| Error::Node(method_label(method)))?; + if !resp.status().is_success() { + return Err(Error::Node(method_label(method))); + } + read_capped(resp, MAX_BODY_BYTES).await + } + + /// JSON-RPC call returning a deserialized `result.Ok` payload. A `result.Err` + /// or a JSON `"Ok": null` both map to [`Error::Node`] (use [`Self::rpc_ok_unit`] + /// for calls whose success payload is null). async fn rpc( &self, method: &'static str, - params: serde_json::Value, + params: Value, ) -> Result { #[derive(Deserialize)] struct RpcResp { @@ -120,36 +224,98 @@ impl GrinNode { #[serde(rename = "Ok")] ok: Option, } - - let body = serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": method, - "params": params, - }); - let mut req = self.http.post(&self.foreign_api_url).json(&body); - if !self.api_secret.is_empty() { - req = req.basic_auth("grin", Some(&self.api_secret)); - } - let resp = req.send().await.map_err(|_| Error::Node(method_label(method)))?; - if !resp.status().is_success() { - return Err(Error::Node(method_label(method))); - } - let bytes = read_capped(resp, MAX_BODY_BYTES).await?; - let parsed: RpcResp = serde_json::from_slice(&bytes) - .map_err(|_| Error::Node(method_label(method)))?; + let bytes = self.rpc_raw(method, params).await?; + let parsed: RpcResp = + serde_json::from_slice(&bytes).map_err(|_| Error::Node(method_label(method)))?; parsed .result .and_then(|r| r.ok) .ok_or(Error::Node(method_label(method))) } + + /// JSON-RPC call whose success payload is `Ok` with any value (including + /// `null`, as `push_transaction` returns). Success = a `result.Ok` key is + /// present and there is no `result.Err` / top-level `error`. + async fn rpc_ok_unit(&self, method: &'static str, params: Value) -> Result<()> { + #[derive(Deserialize)] + struct RawResp { + #[serde(default)] + result: Option>, + #[serde(default)] + error: Option, + } + let bytes = self.rpc_raw(method, params).await?; + let parsed: RawResp = + serde_json::from_slice(&bytes).map_err(|_| Error::Node(method_label(method)))?; + if parsed.error.is_some() { + return Err(Error::Node(method_label(method))); + } + match parsed.result { + // "Ok" present (value may be null) and no "Err" → success. + Some(map) if map.contains_key("Ok") && !map.contains_key("Err") => Ok(()), + _ => Err(Error::Node(method_label(method))), + } + } } -/// Map a method to a static label so the untrusted node body is never surfaced. +impl ChainOutput { + /// Map a node `OutputPrintable` to a scanner-usable output. Returns `None` + /// (skip) for a spent output, or one missing the proof / creation height — + /// neither can be rewound or aged, and neither aborts the page. + fn from_printable(p: OutputPrintableResp) -> Option { + if p.spent { + return None; + } + Some(ChainOutput { + commit: p.commit, + proof: p.proof?, + is_coinbase: matches!(p.output_type, OutputTypeResp::Coinbase), + height: p.block_height?, + mmr_index: p.mmr_index, + }) + } +} + +// ── permissive node deserialization (the "finicky Foreign API") ────────────── +// Our OWN structs, not grin's api::OutputPrintable (whose hand-written Deserialize +// hard-errors on missing fields and pulls in the secp stack). `#[serde(default)]` +// on optional-ish fields; never `deny_unknown_fields`. + +#[derive(Deserialize)] +struct OutputListingResp { + highest_index: u64, + last_retrieved_index: u64, + #[serde(default)] + outputs: Vec, +} + +#[derive(Deserialize)] +struct OutputPrintableResp { + output_type: OutputTypeResp, + commit: String, + #[serde(default)] + spent: bool, + #[serde(default)] + proof: Option, + #[serde(default)] + block_height: Option, + mmr_index: u64, +} + +#[derive(Deserialize)] +enum OutputTypeResp { + Coinbase, + Transaction, +} + +/// Map a method to a static label so an untrusted node body is never surfaced. fn method_label(method: &str) -> &'static str { match method { "get_tip" => "grin get_tip", - "get_block" => "grin get_block", + "get_unspent_outputs" => "grin get_unspent_outputs", + "get_outputs" => "grin get_outputs", + "get_pmmr_indices" => "grin get_pmmr_indices", + "get_header" => "grin get_header", "push_transaction" => "grin push_transaction", _ => "grin rpc", } @@ -181,9 +347,10 @@ pub struct RewoundOutput { pub n_child: u32, } -/// Rewind a single block output against an account's `rewind_hash` (VIEW-ONLY). -/// Returns the recovered value + advisory derivation path on a match, or `None` -/// if the output does not belong to the account (or the node data is malformed). +/// Rewind a single UTXO-set output against an account's `rewind_hash` +/// (VIEW-ONLY). Returns the recovered value + advisory derivation path on a +/// match, or `None` if the output does not belong to the account (or the node +/// data is malformed). /// /// `rewind_hash` is 64 hex chars (a 32-byte view credential); `out.commit` is a /// 33-byte Pedersen commitment and `out.proof` the Bulletproof, both hex. Every @@ -216,3 +383,50 @@ pub fn rewind_output(rewind_hash: &str, out: &ChainOutput) -> Option(s: &str) -> Option<[u8; N]> { hex::decode(s).ok()?.try_into().ok() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_output_listing_and_filters_spent_and_proofless() { + // A representative `get_unspent_outputs` result.Ok (OutputListing). + let json = r#"{ + "highest_index": 1000, + "last_retrieved_index": 6, + "outputs": [ + {"output_type":"Transaction","commit":"09aa","spent":false,"proof":"dead","proof_hash":"x","block_height":100,"merkle_proof":null,"mmr_index":3}, + {"output_type":"Coinbase","commit":"09bb","spent":false,"proof":"beef","block_height":50,"mmr_index":4}, + {"output_type":"Transaction","commit":"09cc","spent":true,"proof":"9999","block_height":90,"mmr_index":5}, + {"output_type":"Transaction","commit":"09dd","spent":false,"block_height":95,"mmr_index":6} + ] + }"#; + let listing: OutputListingResp = serde_json::from_str(json).expect("listing parses"); + assert_eq!(listing.highest_index, 1000); + assert_eq!(listing.last_retrieved_index, 6); + + let outs: Vec = listing + .outputs + .into_iter() + .filter_map(ChainOutput::from_printable) + .collect(); + // 09cc is spent, 09dd has no proof → both filtered; 09aa + 09bb remain. + assert_eq!(outs.len(), 2, "spent + proofless outputs must be dropped"); + assert_eq!(outs[0].commit, "09aa"); + assert!(!outs[0].is_coinbase); + assert_eq!(outs[0].height, 100); + assert_eq!(outs[1].commit, "09bb"); + assert!(outs[1].is_coinbase, "Coinbase output_type → is_coinbase"); + } + + #[test] + fn tolerates_unknown_fields_and_missing_optionals() { + // A future node version adds a field and omits `spent` — must still parse. + let j = r#"{"output_type":"Transaction","commit":"09ff","proof":"aa","block_height":1,"mmr_index":9,"future_field":true}"#; + let p: OutputPrintableResp = serde_json::from_str(j).expect("tolerant parse"); + let out = ChainOutput::from_printable(p).expect("kept"); + assert_eq!(out.commit, "09ff"); + assert!(!out.is_coinbase); + assert_eq!(out.mmr_index, 9); + } +}