Skip to main content

urma_node/
source.rs

1use crate::{
2    Error,
3    model::{ChainPoint, CommitSourceStatus},
4    recorded::RecordedError,
5};
6use bitcoin::{BlockHash, Txid, block::Header};
7use std::{
8    net::SocketAddr,
9    path::{Path, PathBuf},
10    sync::{
11        Arc, Mutex, MutexGuard,
12        atomic::{AtomicBool, Ordering},
13    },
14    time::{Duration, Instant},
15};
16use urma_chain::{decode, observation::Chain};
17use urma_runtime::{
18    node::{Node, NodeConfig},
19    p2p::{Anchor, HeaderBootstrap, P2pProvider},
20};
21
22/// Port implemented by a chain verifier, not by an untrusted remote JSON API.
23/// The implementation owns header difficulty, PoW and branch selection. Bodies
24/// remain untrusted bytes and are checked again against the selected header.
25/// Provider-claimed observations used by autonomous clients are a different
26/// contract and do not enter this port by relabelling them as verified.
27pub trait ChainSource: Send + Sync {
28    fn genesis(&self) -> Result<BlockHash, Error>;
29    fn tip(&self) -> Result<ChainPoint, Error>;
30    fn header(&self, height: u64) -> Result<Header, Error>;
31    fn block(&self, height: u64) -> Result<Vec<u8>, Error>;
32    /// Connected chain peers now; 0 for sources without a peer network.
33    fn chain_peers(&self) -> Result<u64, Error> {
34        Ok(0)
35    }
36    /// Absolute header-sync target announced by chain peers; `None` while
37    /// unknown or for sources without a peer network.
38    fn chain_target(&self) -> Option<u64> {
39        None
40    }
41}
42
43/// Untrusted auxiliary parent bytes, not a chain verifier. The chain getter
44/// reads local configuration without I/O. Each fetch must bound its own I/O;
45/// Engine cancellation is cooperative and cannot interrupt a running fetch.
46pub trait CommitSource {
47    fn chain(&self) -> Chain;
48    fn fetch(&self, txid: Txid, cancelled: &AtomicBool) -> Result<Option<Vec<u8>>, Error>;
49    /// A liveness check for status, rate-limited by the source itself. `None`
50    /// when no fresh check ran (or the source has none); otherwise its result.
51    fn probe(&self) -> Option<Result<(), Error>> {
52        None
53    }
54}
55
56pub(crate) fn check_cancelled(cancelled: &AtomicBool) -> Result<(), Error> {
57    if cancelled.load(Ordering::Acquire) {
58        return Err(Error::Busy("dependency acquisition cancelled".into()));
59    }
60    Ok(())
61}
62
63/// Decode with the Engine's chain decoder, so Litecoin MWEB/HogEx encodings
64/// are accepted, and require the computed TXID to equal the requested one.
65pub fn verify_parent(chain: Chain, txid: Txid, raw: &[u8]) -> Result<(), Error> {
66    let transaction = decode::transaction(raw, chain)
67        .map_err(|error| Error::Invalid(format!("commit parent bytes do not decode: {error}")))?;
68    if transaction.compute_txid() != txid {
69        return Err(Error::Invalid(
70            "commit parent bytes do not match the requested TXID".into(),
71        ));
72    }
73    Ok(())
74}
75
76/// Share an explicitly configured, owner-provided runtime Node. Constructing
77/// this wrapper or reading its chain creates no Node, connection or defaults.
78/// Only fetch calls the existing Node; no header/placement authority is added.
79pub struct RuntimeCommitSource {
80    pub node: Arc<Node>,
81}
82
83impl CommitSource for RuntimeCommitSource {
84    fn chain(&self) -> Chain {
85        self.node.chain()
86    }
87
88    /// Preserve the original returned hex bytes, rather than reserializing a
89    /// normalized transaction. The 16M-character/8M-byte admission cap applies
90    /// after Node::call: its transport may allocate/decode first and owns its
91    /// timeouts. There is no global deadline or transport-allocation promise.
92    /// Absence is the runtime's explicit Missing result or a local JSON-RPC
93    /// error with code -5 (no such mempool/blockchain transaction, including
94    /// lookups a node without -txindex cannot answer), per is_missing_transaction.
95    /// Other runtime errors become CommitSource without text matching.
96    fn fetch(&self, txid: Txid, cancelled: &AtomicBool) -> Result<Option<Vec<u8>>, Error> {
97        check_cancelled(cancelled)?;
98        fetch_from_node(&self.node, txid)
99    }
100}
101
102/// Optional local Litecoin Core: it may start, stop or restart at any time.
103/// Holds only the URL and cookie path; every lookup and probe reads the cookie,
104/// and a node is rebuilt and its txindex verified whenever the cookie changes.
105pub struct LocalRpcCommitSource {
106    chain: Chain,
107    rpc_url: String,
108    cookie_file: PathBuf,
109    probe_interval: Duration,
110    verified: Mutex<Option<(Vec<u8>, Arc<Node>)>>,
111    probed: Mutex<Option<Instant>>,
112}
113
114impl LocalRpcCommitSource {
115    pub const PROBE_INTERVAL: Duration = Duration::from_secs(10);
116
117    pub fn new(chain: Chain, rpc_url: String, cookie_file: PathBuf) -> Self {
118        Self {
119            chain,
120            rpc_url,
121            cookie_file,
122            probe_interval: Self::PROBE_INTERVAL,
123            verified: Mutex::new(None),
124            probed: Mutex::new(None),
125        }
126    }
127
128    pub fn with_probe_interval(mut self, interval: Duration) -> Self {
129        self.probe_interval = interval;
130        self
131    }
132
133    fn node(&self) -> Result<Arc<Node>, Error> {
134        let cookie = std::fs::read(&self.cookie_file).map_err(|_| {
135            Error::CommitSource("cookie file missing: litecoind not running?".into())
136        })?;
137        let mut verified = self
138            .verified
139            .lock()
140            .map_err(|_| Error::Invalid("local RPC commit source poisoned".into()))?;
141        if let Some((seen, node)) = verified.as_ref() {
142            if *seen == cookie {
143                return Ok(Arc::clone(node));
144            }
145        }
146        *verified = None;
147        let node = Node::connect(NodeConfig {
148            chain: self.chain,
149            rpc_url: self.rpc_url.clone(),
150            cookie_file: self.cookie_file.clone(),
151        })
152        .and_then(|node| node.require_txindex().map(|()| Arc::new(node)))
153        .map_err(|error| Error::CommitSource(error.to_string()))?;
154        *verified = Some((cookie, Arc::clone(&node)));
155        Ok(node)
156    }
157
158    fn probe_due(&self) -> Result<bool, Error> {
159        let mut probed = self
160            .probed
161            .lock()
162            .map_err(|_| Error::Invalid("local RPC probe clock poisoned".into()))?;
163        let due = probed.is_none_or(|last| last.elapsed() >= self.probe_interval);
164        if due {
165            *probed = Some(Instant::now());
166        }
167        Ok(due)
168    }
169}
170
171impl CommitSource for LocalRpcCommitSource {
172    fn chain(&self) -> Chain {
173        self.chain
174    }
175
176    fn fetch(&self, txid: Txid, cancelled: &AtomicBool) -> Result<Option<Vec<u8>>, Error> {
177        check_cancelled(cancelled)?;
178        let node = self.node()?;
179        fetch_from_node(&node, txid)
180    }
181
182    fn probe(&self) -> Option<Result<(), Error>> {
183        match self.probe_due() {
184            Ok(false) => None,
185            Ok(true) => Some(self.node().and_then(|node| {
186                node.call("getblockcount", &[])
187                    .map(|_| ())
188                    .map_err(|error| Error::CommitSource(error.to_string()))
189            })),
190            Err(error) => Some(Err(error)),
191        }
192    }
193}
194
195fn fetch_from_node(node: &Node, txid: Txid) -> Result<Option<Vec<u8>>, Error> {
196    let result = match node.call(
197        "getrawtransaction",
198        &[serde_json::json!(txid), serde_json::json!(false)],
199    ) {
200        Ok(result) => result,
201        Err(error) if error.is_missing_transaction() => return Ok(None),
202        Err(error) => return Err(Error::CommitSource(error.to_string())),
203    };
204    let encoded = result
205        .as_str()
206        .ok_or_else(|| Error::Invalid("commit source must return transaction hex".into()))?;
207    if encoded.len() > 16_000_000 || encoded.len() % 2 != 0 {
208        return Err(Error::Invalid(
209            "commit source hex exceeds capacity or has odd length".into(),
210        ));
211    }
212    let raw = hex::decode(encoded)
213        .map_err(|error| Error::Invalid(format!("commit source hex: {error}")))?;
214    Ok(Some(raw))
215}
216
217/// Default public providers for one chain, without a light client. Creating
218/// it does no I/O; the first fetch builds the Node. A failed build is not
219/// rebuilt for RETRY and its error is returned meanwhile. Untrusted bytes.
220pub struct PublicCommitSource {
221    chain: Chain,
222    cache_dir: PathBuf,
223    state: Mutex<PublicState>,
224}
225
226struct PublicState {
227    node: Option<Arc<Node>>,
228    failure: Option<(Instant, String)>,
229}
230
231impl PublicCommitSource {
232    const RETRY: Duration = Duration::from_secs(60);
233
234    pub fn new(chain: Chain, cache_dir: PathBuf) -> Self {
235        Self {
236            chain,
237            cache_dir,
238            state: Mutex::new(PublicState {
239                node: None,
240                failure: None,
241            }),
242        }
243    }
244
245    fn node(&self) -> Result<Arc<Node>, Error> {
246        let mut state = self
247            .state
248            .lock()
249            .map_err(|_| Error::Invalid("public commit source poisoned".into()))?;
250        if let Some(node) = state.node.as_ref() {
251            return Ok(Arc::clone(node));
252        }
253        if let Some((failed, message)) = state.failure.as_ref() {
254            if failed.elapsed() < Self::RETRY {
255                return Err(Error::CommitSource(message.clone()));
256            }
257        }
258        match Node::public_pinned(self.chain, &self.cache_dir) {
259            Ok(node) => {
260                let node = Arc::new(node);
261                state.node = Some(Arc::clone(&node));
262                state.failure = None;
263                Ok(node)
264            }
265            Err(error) => {
266                let message = error.to_string();
267                state.failure = Some((Instant::now(), message.clone()));
268                Err(Error::CommitSource(message))
269            }
270        }
271    }
272}
273
274impl CommitSource for PublicCommitSource {
275    fn chain(&self) -> Chain {
276        self.chain
277    }
278
279    fn fetch(&self, txid: Txid, cancelled: &AtomicBool) -> Result<Option<Vec<u8>>, Error> {
280        check_cancelled(cancelled)?;
281        let node = self.node()?;
282        fetch_from_node(&node, txid)
283    }
284}
285
286/// Ordered labelled sources tried until one returns bytes. A miss or error
287/// moves to the next source; the set errors only when every source errored.
288/// The error table is locked only to read or write, never across a fetch.
289pub struct CommitSources {
290    chain: Chain,
291    entries: Vec<(String, Box<dyn CommitSource + Send + Sync>)>,
292    errors: Mutex<Vec<Option<RecordedError>>>,
293}
294
295impl CommitSources {
296    pub fn new(chain: Chain) -> Self {
297        Self {
298            chain,
299            entries: Vec::new(),
300            errors: Mutex::new(Vec::new()),
301        }
302    }
303
304    pub fn push(&mut self, label: String, source: Box<dyn CommitSource + Send + Sync>) {
305        self.entries.push((label, source));
306    }
307
308    pub fn is_empty(&self) -> bool {
309        self.entries.is_empty()
310    }
311
312    pub fn status(&self) -> Result<Vec<CommitSourceStatus>, Error> {
313        for (index, entry) in self.entries.iter().enumerate() {
314            if let Some(probed) = entry.1.probe() {
315                self.record(index, probed.err().map(|error| RecordedError::now(&error)))?;
316            }
317        }
318        let errors = self.errors()?;
319        Ok(self
320            .entries
321            .iter()
322            .zip(errors.iter())
323            .map(|(entry, error)| {
324                let (last_error, last_error_at) = RecordedError::split(error.clone());
325                let mut status = CommitSourceStatus::default();
326                status.label = entry.0.clone();
327                status.last_error = last_error;
328                status.last_error_at = last_error_at;
329                status
330            })
331            .collect())
332    }
333
334    pub(crate) fn source_chains(&self) -> Vec<Chain> {
335        self.entries.iter().map(|entry| entry.1.chain()).collect()
336    }
337
338    fn errors(&self) -> Result<MutexGuard<'_, Vec<Option<RecordedError>>>, Error> {
339        let mut errors = self
340            .errors
341            .lock()
342            .map_err(|_| Error::Invalid("commit source status poisoned".into()))?;
343        errors.resize(self.entries.len(), None);
344        Ok(errors)
345    }
346
347    fn verified(&self, txid: Txid, raw: Option<Vec<u8>>) -> Result<Option<Vec<u8>>, Error> {
348        let Some(raw) = raw else {
349            return Ok(None);
350        };
351        verify_parent(self.chain, txid, &raw)?;
352        Ok(Some(raw))
353    }
354
355    fn record(&self, index: usize, error: Option<RecordedError>) -> Result<(), Error> {
356        let mut errors = self.errors()?;
357        let slot = errors
358            .get_mut(index)
359            .ok_or_else(|| Error::Invalid("commit source status out of range".into()))?;
360        *slot = error;
361        Ok(())
362    }
363}
364
365impl CommitSource for CommitSources {
366    fn chain(&self) -> Chain {
367        self.chain
368    }
369
370    fn fetch(&self, txid: Txid, cancelled: &AtomicBool) -> Result<Option<Vec<u8>>, Error> {
371        let mut last = None;
372        let mut failures = 0;
373        for (index, entry) in self.entries.iter().enumerate() {
374            check_cancelled(cancelled)?;
375            let fetched = entry
376                .1
377                .fetch(txid, cancelled)
378                .and_then(|raw| self.verified(txid, raw));
379            check_cancelled(cancelled)?;
380            match fetched {
381                Ok(Some(raw)) => {
382                    self.record(index, None)?;
383                    return Ok(Some(raw));
384                }
385                Ok(None) => self.record(index, None)?,
386                Err(error) => {
387                    self.record(index, Some(RecordedError::now(&error)))?;
388                    failures += 1;
389                    last = Some(error);
390                }
391            }
392        }
393        let Some(error) = last else {
394            return Ok(None);
395        };
396        if failures == self.entries.len() {
397            return Err(error);
398        }
399        Ok(None)
400    }
401}
402
403/// Checkpoint-relative Litecoin light client. Does not validate scripts, UTXOs
404/// or MWEB consensus. Opening it does not start network connections; the first
405/// sync attempt does. An application start before the configured anchor is an
406/// explicit error, never silently replaced with the anchor's height.
407pub struct LitecoinSource {
408    chain: Chain,
409    provider: P2pProvider,
410}
411
412impl LitecoinSource {
413    pub fn open(
414        chain: Chain,
415        cache: &Path,
416        anchor: Anchor,
417        peers: Vec<SocketAddr>,
418    ) -> Result<Self, Error> {
419        if !matches!(chain, Chain::LitecoinTestnet | Chain::LitecoinMainnet) {
420            return Err(Error::Invalid(
421                "Litecoin source requires a Litecoin network".into(),
422            ));
423        }
424        Ok(Self {
425            chain,
426            provider: P2pProvider::with_peers(chain, cache, anchor, peers).map_err(source_error)?,
427        })
428    }
429
430    /// Open a Litecoin source with authenticated preceding header context.
431    /// The operator trusts the bootstrap anchor's chain membership and height;
432    /// the context does not prove pre-anchor consensus or earlier scan coverage.
433    /// This source does not validate scripts, UTXOs or MWEB consensus. Opening
434    /// only loads the local cache; the first sync attempt starts connections.
435    pub fn open_bootstrap(
436        cache: &Path,
437        bootstrap: HeaderBootstrap,
438        peers: Vec<SocketAddr>,
439    ) -> Result<Self, Error> {
440        let chain = bootstrap.chain();
441        if !matches!(chain, Chain::LitecoinTestnet | Chain::LitecoinMainnet) {
442            return Err(Error::Invalid(
443                "Litecoin source requires a Litecoin network".into(),
444            ));
445        }
446        Ok(Self {
447            chain,
448            provider: P2pProvider::with_bootstrap(cache, bootstrap, peers).map_err(source_error)?,
449        })
450    }
451}
452
453fn source_error(e: urma_runtime::error::Error) -> Error {
454    Error::Source(Box::new(e))
455}
456
457impl ChainSource for LitecoinSource {
458    fn genesis(&self) -> Result<BlockHash, Error> {
459        Ok(self
460            .chain
461            .genesis()
462            .map_err(|e| Error::Invalid(e.to_string()))?
463            .0)
464    }
465    fn tip(&self) -> Result<ChainPoint, Error> {
466        let (height, header) = self.provider.verified_tip().map_err(source_error)?;
467        Ok(ChainPoint {
468            height,
469            hash: header.block_hash(),
470        })
471    }
472    fn header(&self, height: u64) -> Result<Header, Error> {
473        self.provider.verified_header(height).map_err(source_error)
474    }
475    fn block(&self, height: u64) -> Result<Vec<u8>, Error> {
476        Ok(self
477            .provider
478            .verified_block(height)
479            .map_err(source_error)?
480            .raw()
481            .to_vec())
482    }
483    fn chain_peers(&self) -> Result<u64, Error> {
484        u64::try_from(self.provider.progress().peers_connected)
485            .map_err(|error| Error::Invalid(format!("chain peer count: {error}")))
486    }
487    fn chain_target(&self) -> Option<u64> {
488        self.provider.target_height()
489    }
490}