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