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
26pub 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 fn chain_peers(&self) -> Result<u64, Error> {
38 Ok(0)
39 }
40 fn chain_target(&self) -> Option<u64> {
43 None
44 }
45}
46
47pub trait CommitSource {
51 fn chain(&self) -> Chain;
52 fn fetch(&self, txid: Txid, cancelled: &AtomicBool) -> Result<Option<Vec<u8>>, Error>;
53 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
68pub 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
81pub 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 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
107pub 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 pub const PROBE_BUDGET: Duration = Duration::from_secs(3);
127 pub const FETCH_BUDGET: Duration = Duration::from_secs(15);
129 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 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 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 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
270fn 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
285pub 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
354pub 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 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 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
478pub 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 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}