urma-node 0.6.0

Optional URMA archive node: Litecoin follower, record archive, libp2p overlay and loopback service
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
use crate::{
    Error,
    model::{ChainPoint, CommitSourceStatus},
    recorded::RecordedError,
};
use bitcoin::{BlockHash, Txid, block::Header};
use rpc::{Answer, Deadline, Rpc};
use serde_json::{Value, json};
use std::{
    net::SocketAddr,
    path::{Path, PathBuf},
    sync::{
        Arc, Mutex, MutexGuard,
        atomic::{AtomicBool, Ordering},
    },
    time::{Duration, Instant},
};
use urma_chain::{decode, observation::Chain};
use urma_runtime::{
    node::Node,
    p2p::{Anchor, HeaderBootstrap, P2pProvider},
};

mod rpc;

/// Port implemented by a chain verifier, not by an untrusted remote JSON API.
/// The implementation owns header difficulty, PoW and branch selection. Bodies
/// remain untrusted bytes and are checked again against the selected header.
/// Provider-claimed observations used by autonomous clients are a different
/// contract and do not enter this port by relabelling them as verified.
pub trait ChainSource: Send + Sync {
    fn genesis(&self) -> Result<BlockHash, Error>;
    fn tip(&self) -> Result<ChainPoint, Error>;
    fn header(&self, height: u64) -> Result<Header, Error>;
    fn block(&self, height: u64) -> Result<Vec<u8>, Error>;
    /// Connected chain peers now; 0 for sources without a peer network.
    fn chain_peers(&self) -> Result<u64, Error> {
        Ok(0)
    }
    /// Absolute header-sync target announced by chain peers; `None` while
    /// unknown or for sources without a peer network.
    fn chain_target(&self) -> Option<u64> {
        None
    }
}

/// Untrusted auxiliary parent bytes, not a chain verifier. The chain getter
/// reads local configuration without I/O. Each fetch must bound its own I/O;
/// Engine cancellation is cooperative and cannot interrupt a running fetch.
pub trait CommitSource {
    fn chain(&self) -> Chain;
    fn fetch(&self, txid: Txid, cancelled: &AtomicBool) -> Result<Option<Vec<u8>>, Error>;
    /// A bounded liveness check, rate-limited by the source itself and run by
    /// the maintenance thread, never by status. `None` when no fresh check
    /// ran (or the source has none); otherwise its result.
    fn probe(&self) -> Option<Result<(), Error>> {
        None
    }
}

pub(crate) fn check_cancelled(cancelled: &AtomicBool) -> Result<(), Error> {
    if cancelled.load(Ordering::Acquire) {
        return Err(Error::Busy("dependency acquisition cancelled".into()));
    }
    Ok(())
}

/// Decode with the Engine's chain decoder, so Litecoin MWEB/HogEx encodings
/// are accepted, and require the computed TXID to equal the requested one.
pub fn verify_parent(chain: Chain, txid: Txid, raw: &[u8]) -> Result<(), Error> {
    let transaction = decode::transaction(raw, chain)
        .map_err(|error| Error::Invalid(format!("commit parent bytes do not decode: {error}")))?;
    if transaction.compute_txid() != txid {
        return Err(Error::Invalid(
            "commit parent bytes do not match the requested TXID".into(),
        ));
    }
    Ok(())
}

/// Share an explicitly configured, owner-provided runtime Node. Constructing
/// this wrapper or reading its chain creates no Node, connection or defaults.
/// Only fetch calls the existing Node; no header/placement authority is added.
pub struct RuntimeCommitSource {
    pub node: Arc<Node>,
}

impl CommitSource for RuntimeCommitSource {
    fn chain(&self) -> Chain {
        self.node.chain()
    }

    /// Preserve the original returned hex bytes, rather than reserializing a
    /// normalized transaction. The 16M-character/8M-byte admission cap applies
    /// after Node::call: its transport may allocate/decode first and owns its
    /// timeouts. There is no global deadline or transport-allocation promise.
    /// Absence is the runtime's explicit Missing result or a local JSON-RPC
    /// error with code -5 (no such mempool/blockchain transaction, including
    /// lookups a node without -txindex cannot answer), per is_missing_transaction.
    /// Other runtime errors become CommitSource without text matching.
    fn fetch(&self, txid: Txid, cancelled: &AtomicBool) -> Result<Option<Vec<u8>>, Error> {
        check_cancelled(cancelled)?;
        fetch_from_node(&self.node, txid)
    }
}

/// Optional local Litecoin Core: it may start, stop, restart or hang at any
/// time. Holds only the URL and cookie path; every lookup and probe reads the
/// cookie, and the chain and txindex are verified again whenever it changes.
/// Every RPC is bounded (see `rpc`), so a stopped or hung Core costs a lookup
/// at most its budget before the next source is tried.
pub struct LocalRpcCommitSource {
    chain: Chain,
    rpc_url: String,
    cookie_file: PathBuf,
    probe_interval: Duration,
    probe_budget: Duration,
    fetch_budget: Duration,
    verified: Mutex<Option<(Vec<u8>, Arc<Rpc>)>>,
    probed: Mutex<Option<Instant>>,
}

impl LocalRpcCommitSource {
    pub const PROBE_INTERVAL: Duration = Duration::from_secs(10);
    /// Total time one probe may take, cookie verification included.
    pub const PROBE_BUDGET: Duration = Duration::from_secs(3);
    /// Total time one lookup may take, cookie verification included.
    pub const FETCH_BUDGET: Duration = Duration::from_secs(15);
    /// Core's cookie is `__cookie__:` plus 64 hex characters.
    const COOKIE_LIMIT: usize = 1024;

    pub fn new(chain: Chain, rpc_url: String, cookie_file: PathBuf) -> Self {
        Self {
            chain,
            rpc_url,
            cookie_file,
            probe_interval: Self::PROBE_INTERVAL,
            probe_budget: Self::PROBE_BUDGET,
            fetch_budget: Self::FETCH_BUDGET,
            verified: Mutex::new(None),
            probed: Mutex::new(None),
        }
    }

    pub fn with_probe_interval(mut self, interval: Duration) -> Self {
        self.probe_interval = interval;
        self
    }

    pub fn with_budgets(mut self, probe: Duration, fetch: Duration) -> Self {
        self.probe_budget = probe;
        self.fetch_budget = fetch;
        self
    }

    /// The cookie as a bounded, non-blocking read of a regular file: a FIFO
    /// or device in its place is refused instead of blocking the worker.
    fn cookie(&self) -> Result<Vec<u8>, Error> {
        match urma_io::read_regular(&self.cookie_file, Self::COOKIE_LIMIT) {
            Ok(cookie) => Ok(cookie.to_vec()),
            Err(error) => match std::fs::symlink_metadata(&self.cookie_file) {
                Err(_absent) => Err(Error::CommitSource(
                    "cookie file missing: litecoind not running?".into(),
                )),
                Ok(_present) => Err(Error::CommitSource(format!(
                    "cookie file unreadable: {error}"
                ))),
            },
        }
    }

    /// The verified RPC for the current cookie. A new cookie's verification
    /// RPCs spend the caller's `deadline`, so the whole operation stays within
    /// one budget; they never run under the lock.
    fn rpc(&self, deadline: Deadline) -> Result<Arc<Rpc>, Error> {
        let cookie = self.cookie()?;
        if let Some((seen, rpc)) = self.verified()?.as_ref() {
            if *seen == cookie {
                return Ok(Arc::clone(rpc));
            }
        }
        let rpc = Arc::new(Rpc::new(&self.rpc_url, &cookie)?);
        let genesis = rpc.value("getblockhash", json!([0]), deadline)?;
        let expected = self
            .chain
            .genesis()
            .map_err(|error| Error::Invalid(error.to_string()))?
            .0
            .to_string();
        if genesis.as_str() != Some(expected.as_str()) {
            return Err(Error::CommitSource(
                "RPC genesis does not match selected chain".into(),
            ));
        }
        let indexes = rpc.value("getindexinfo", json!(["txindex"]), deadline)?;
        if indexes["txindex"]["synced"] != true {
            return Err(Error::CommitSource(
                "parent lookups require synchronized txindex=1 on the local node".into(),
            ));
        }
        *self.verified()? = Some((cookie, Arc::clone(&rpc)));
        Ok(rpc)
    }

    fn verified(&self) -> Result<MutexGuard<'_, Option<(Vec<u8>, Arc<Rpc>)>>, Error> {
        self.verified
            .lock()
            .map_err(|_| Error::Invalid("local RPC commit source poisoned".into()))
    }

    fn probe_due(&self) -> Result<bool, Error> {
        let mut probed = self
            .probed
            .lock()
            .map_err(|_| Error::Invalid("local RPC probe clock poisoned".into()))?;
        let due = probed.is_none_or(|last| last.elapsed() >= self.probe_interval);
        if due {
            *probed = Some(Instant::now());
        }
        Ok(due)
    }
}

impl CommitSource for LocalRpcCommitSource {
    fn chain(&self) -> Chain {
        self.chain
    }

    /// Code -5 is Core's "no such mempool or blockchain transaction": absent.
    fn fetch(&self, txid: Txid, cancelled: &AtomicBool) -> Result<Option<Vec<u8>>, Error> {
        check_cancelled(cancelled)?;
        let deadline = Deadline::after(self.fetch_budget);
        let rpc = self.rpc(deadline)?;
        match rpc.call("getrawtransaction", json!([txid, false]), deadline)? {
            Answer::Value(result) => transaction_hex(&result).map(Some),
            Answer::Failed { code: -5, .. } => Ok(None),
            Answer::Failed { code, message } => Err(Error::CommitSource(format!(
                "JSON-RPC getrawtransaction error {code}: {message}"
            ))),
        }
    }

    fn probe(&self) -> Option<Result<(), Error>> {
        match self.probe_due() {
            Ok(false) => None,
            Ok(true) => {
                let deadline = Deadline::after(self.probe_budget);
                Some(self.rpc(deadline).and_then(|rpc| {
                    rpc.value("getblockcount", json!([]), deadline)
                        .map(|_count| ())
                }))
            }
            Err(error) => Some(Err(error)),
        }
    }
}

fn fetch_from_node(node: &Node, txid: Txid) -> Result<Option<Vec<u8>>, Error> {
    let result = match node.call(
        "getrawtransaction",
        &[serde_json::json!(txid), serde_json::json!(false)],
    ) {
        Ok(result) => result,
        Err(error) if error.is_missing_transaction() => return Ok(None),
        Err(error) => return Err(Error::CommitSource(error.to_string())),
    };
    transaction_hex(&result).map(Some)
}

/// The 16M-character/8M-byte admission cap, on hex as the source returned it.
fn transaction_hex(result: &Value) -> Result<Vec<u8>, Error> {
    let encoded = result
        .as_str()
        .ok_or_else(|| Error::Invalid("commit source must return transaction hex".into()))?;
    if encoded.len() > 16_000_000 || encoded.len() % 2 != 0 {
        return Err(Error::Invalid(
            "commit source hex exceeds capacity or has odd length".into(),
        ));
    }
    let raw = hex::decode(encoded)
        .map_err(|error| Error::Invalid(format!("commit source hex: {error}")))?;
    Ok(raw)
}

/// Default public providers for one chain, without a light client. Creating
/// it does no I/O; the first fetch builds the Node. A failed build is not
/// rebuilt for RETRY and its error is returned meanwhile. Untrusted bytes.
pub struct PublicCommitSource {
    chain: Chain,
    cache_dir: PathBuf,
    state: Mutex<PublicState>,
}

struct PublicState {
    node: Option<Arc<Node>>,
    failure: Option<(Instant, String)>,
}

impl PublicCommitSource {
    const RETRY: Duration = Duration::from_secs(60);

    pub fn new(chain: Chain, cache_dir: PathBuf) -> Self {
        Self {
            chain,
            cache_dir,
            state: Mutex::new(PublicState {
                node: None,
                failure: None,
            }),
        }
    }

    fn node(&self) -> Result<Arc<Node>, Error> {
        let mut state = self
            .state
            .lock()
            .map_err(|_| Error::Invalid("public commit source poisoned".into()))?;
        if let Some(node) = state.node.as_ref() {
            return Ok(Arc::clone(node));
        }
        if let Some((failed, message)) = state.failure.as_ref() {
            if failed.elapsed() < Self::RETRY {
                return Err(Error::CommitSource(message.clone()));
            }
        }
        match Node::public_pinned(self.chain, &self.cache_dir) {
            Ok(node) => {
                let node = Arc::new(node);
                state.node = Some(Arc::clone(&node));
                state.failure = None;
                Ok(node)
            }
            Err(error) => {
                let message = error.to_string();
                state.failure = Some((Instant::now(), message.clone()));
                Err(Error::CommitSource(message))
            }
        }
    }
}

impl CommitSource for PublicCommitSource {
    fn chain(&self) -> Chain {
        self.chain
    }

    fn fetch(&self, txid: Txid, cancelled: &AtomicBool) -> Result<Option<Vec<u8>>, Error> {
        check_cancelled(cancelled)?;
        let node = self.node()?;
        fetch_from_node(&node, txid)
    }
}

/// Ordered labelled sources tried until one returns bytes. A miss or error
/// moves to the next source; the set errors only when every source errored.
/// The error table is locked only to read or write, never across a fetch.
pub struct CommitSources {
    chain: Chain,
    entries: Vec<(String, Box<dyn CommitSource + Send + Sync>)>,
    errors: Mutex<Vec<Option<RecordedError>>>,
}

impl CommitSources {
    pub fn new(chain: Chain) -> Self {
        Self {
            chain,
            entries: Vec::new(),
            errors: Mutex::new(Vec::new()),
        }
    }

    pub fn push(&mut self, label: String, source: Box<dyn CommitSource + Send + Sync>) {
        self.entries.push((label, source));
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Run each source's own rate-limited, bounded liveness check and record
    /// the outcome. The maintenance thread calls this; status never does.
    pub fn probe(&self) -> Result<(), Error> {
        for (index, entry) in self.entries.iter().enumerate() {
            if let Some(probed) = entry.1.probe() {
                self.record(index, probed.err().map(|error| RecordedError::now(&error)))?;
            }
        }
        Ok(())
    }

    /// Recorded outcomes of the latest lookups and probes; no I/O.
    pub fn status(&self) -> Result<Vec<CommitSourceStatus>, Error> {
        let errors = self.errors()?;
        Ok(self
            .entries
            .iter()
            .zip(errors.iter())
            .map(|(entry, error)| {
                let (last_error, last_error_at) = RecordedError::split(error.clone());
                let mut status = CommitSourceStatus::default();
                status.label = entry.0.clone();
                status.last_error = last_error;
                status.last_error_at = last_error_at;
                status
            })
            .collect())
    }

    pub(crate) fn source_chains(&self) -> Vec<Chain> {
        self.entries.iter().map(|entry| entry.1.chain()).collect()
    }

    fn errors(&self) -> Result<MutexGuard<'_, Vec<Option<RecordedError>>>, Error> {
        let mut errors = self
            .errors
            .lock()
            .map_err(|_| Error::Invalid("commit source status poisoned".into()))?;
        errors.resize(self.entries.len(), None);
        Ok(errors)
    }

    fn verified(&self, txid: Txid, raw: Option<Vec<u8>>) -> Result<Option<Vec<u8>>, Error> {
        let Some(raw) = raw else {
            return Ok(None);
        };
        verify_parent(self.chain, txid, &raw)?;
        Ok(Some(raw))
    }

    fn record(&self, index: usize, error: Option<RecordedError>) -> Result<(), Error> {
        let mut errors = self.errors()?;
        let slot = errors
            .get_mut(index)
            .ok_or_else(|| Error::Invalid("commit source status out of range".into()))?;
        *slot = error;
        Ok(())
    }
}

impl CommitSource for CommitSources {
    fn chain(&self) -> Chain {
        self.chain
    }

    fn fetch(&self, txid: Txid, cancelled: &AtomicBool) -> Result<Option<Vec<u8>>, Error> {
        let mut last = None;
        let mut failures = 0;
        for (index, entry) in self.entries.iter().enumerate() {
            check_cancelled(cancelled)?;
            let fetched = entry
                .1
                .fetch(txid, cancelled)
                .and_then(|raw| self.verified(txid, raw));
            check_cancelled(cancelled)?;
            match fetched {
                Ok(Some(raw)) => {
                    self.record(index, None)?;
                    return Ok(Some(raw));
                }
                Ok(None) => self.record(index, None)?,
                Err(error) => {
                    self.record(index, Some(RecordedError::now(&error)))?;
                    failures += 1;
                    last = Some(error);
                }
            }
        }
        let Some(error) = last else {
            return Ok(None);
        };
        if failures == self.entries.len() {
            return Err(error);
        }
        Ok(None)
    }
}

/// Checkpoint-relative Litecoin light client. Does not validate scripts, UTXOs
/// or MWEB consensus. Opening it does not start network connections; the first
/// sync attempt does. An application start before the configured anchor is an
/// explicit error, never silently replaced with the anchor's height.
pub struct LitecoinSource {
    chain: Chain,
    provider: P2pProvider,
}

impl LitecoinSource {
    pub fn open(
        chain: Chain,
        cache: &Path,
        anchor: Anchor,
        peers: Vec<SocketAddr>,
    ) -> Result<Self, Error> {
        if !matches!(chain, Chain::LitecoinTestnet | Chain::LitecoinMainnet) {
            return Err(Error::Invalid(
                "Litecoin source requires a Litecoin network".into(),
            ));
        }
        Ok(Self {
            chain,
            provider: P2pProvider::with_peers(chain, cache, anchor, peers).map_err(source_error)?,
        })
    }

    /// Open a Litecoin source with authenticated preceding header context.
    /// The operator trusts the bootstrap anchor's chain membership and height;
    /// the context does not prove pre-anchor consensus or earlier scan coverage.
    /// This source does not validate scripts, UTXOs or MWEB consensus. Opening
    /// only loads the local cache; the first sync attempt starts connections.
    pub fn open_bootstrap(
        cache: &Path,
        bootstrap: HeaderBootstrap,
        peers: Vec<SocketAddr>,
    ) -> Result<Self, Error> {
        let chain = bootstrap.chain();
        if !matches!(chain, Chain::LitecoinTestnet | Chain::LitecoinMainnet) {
            return Err(Error::Invalid(
                "Litecoin source requires a Litecoin network".into(),
            ));
        }
        Ok(Self {
            chain,
            provider: P2pProvider::with_bootstrap(cache, bootstrap, peers).map_err(source_error)?,
        })
    }
}

fn source_error(e: urma_runtime::error::Error) -> Error {
    Error::Source(Box::new(e))
}

impl ChainSource for LitecoinSource {
    fn genesis(&self) -> Result<BlockHash, Error> {
        Ok(self
            .chain
            .genesis()
            .map_err(|e| Error::Invalid(e.to_string()))?
            .0)
    }
    fn tip(&self) -> Result<ChainPoint, Error> {
        let (height, header) = self.provider.verified_tip().map_err(source_error)?;
        Ok(ChainPoint {
            height,
            hash: header.block_hash(),
        })
    }
    fn header(&self, height: u64) -> Result<Header, Error> {
        self.provider.verified_header(height).map_err(source_error)
    }
    fn block(&self, height: u64) -> Result<Vec<u8>, Error> {
        Ok(self
            .provider
            .verified_block(height)
            .map_err(source_error)?
            .raw()
            .to_vec())
    }
    fn chain_peers(&self) -> Result<u64, Error> {
        u64::try_from(self.provider.progress().peers_connected)
            .map_err(|error| Error::Invalid(format!("chain peer count: {error}")))
    }
    fn chain_target(&self) -> Option<u64> {
        self.provider.target_height()
    }
}