Skip to main content

urma_node/
engine.rs

1use crate::{
2    Error,
3    archive::{Archive, InventoryRows},
4    model::*,
5    source::{ChainSource, CommitSource},
6};
7use std::{
8    path::Path,
9    sync::{
10        Arc, Mutex, MutexGuard,
11        atomic::{AtomicBool, Ordering},
12    },
13    time::{Duration, Instant},
14};
15
16/// Same engine for embedded consumers and the standalone service. No signer or
17/// user decryption key is present. One synchronization coordinator admits work;
18/// the archive lock is never held while waiting for a peer.
19pub struct Engine {
20    index_instance: String,
21    archive: Mutex<Archive>,
22    pub(crate) source: Option<Arc<dyn ChainSource>>,
23    synchronizing: Mutex<()>,
24    retaining: Mutex<Option<bitcoin::Txid>>,
25    projecting_names: Mutex<Option<bitcoin::Txid>>,
26    resolving_dependencies: Mutex<Option<bitcoin::Txid>>,
27}
28
29impl Engine {
30    /// Attach a verifier after archive ownership has been acquired. The CLI
31    /// uses this order before opening any mutable header-cache files.
32    pub fn with_source(mut self, source: Arc<dyn ChainSource>) -> Result<Self, Error> {
33        if source.genesis()? != self.status()?.chain_genesis {
34            return Err(Error::Invalid("source belongs to another chain".into()));
35        }
36        self.source = Some(source);
37        Ok(self)
38    }
39
40    pub fn open(
41        path: &Path,
42        config: ArchiveConfig,
43        source: Option<Arc<dyn ChainSource>>,
44    ) -> Result<Self, Error> {
45        if let Some(source) = &source {
46            let genesis = config
47                .chain
48                .genesis()
49                .map_err(|e| Error::Invalid(e.to_string()))?
50                .0;
51            if source.genesis()? != genesis {
52                return Err(Error::Invalid("source belongs to another chain".into()));
53            }
54        }
55        Ok(Self {
56            index_instance: hex::encode(rand::random::<[u8; 16]>()),
57            archive: Mutex::new(Archive::open(path, config)?),
58            source,
59            synchronizing: Mutex::new(()),
60            retaining: Mutex::new(None),
61            projecting_names: Mutex::new(None),
62            resolving_dependencies: Mutex::new(None),
63        })
64    }
65
66    pub(crate) fn archive(&self) -> Result<MutexGuard<'_, Archive>, Error> {
67        self.archive
68            .lock()
69            .map_err(|_| Error::Invalid("archive coordinator panicked".into()))
70    }
71
72    pub fn status(&self) -> Result<ArchiveStatus, Error> {
73        self.archive()?.status()
74    }
75
76    pub fn record(&self, txid: bitcoin::Txid) -> Result<Option<ArchivedRecord>, Error> {
77        self.archive()?.record(txid)
78    }
79
80    /// Retained raw bytes by TXID: record commits/reveals and cached parents.
81    /// Ignores the selected view, so bytes kept from orphaned blocks can return;
82    /// acceptable because requesters bind them to the TXID and re-verify.
83    pub(crate) fn raw_transaction(&self, txid: bitcoin::Txid) -> Result<Option<Vec<u8>>, Error> {
84        self.archive()?.transaction(txid)
85    }
86
87    /// One overlay inventory page from scanned selected blocks; read only.
88    pub(crate) fn inventory_page(
89        &self,
90        from: u64,
91        to: u64,
92        after: Option<(u64, u32)>,
93        limit: u32,
94    ) -> Result<InventoryRows, Error> {
95        self.archive()?.inventory_page(from, to, after, limit)
96    }
97
98    /// Read a bounded Wire view without network or a second scan. All fields
99    /// and coverage come from one archive critical section. Concurrent writes
100    /// invalidate continuation tokens instead of silently mixing snapshots.
101    /// Restart invalidates tokens even if the archive has not changed.
102    pub fn wire_page(
103        &self,
104        cursor: Option<&crate::projection::WireCursor>,
105        limit: u32,
106    ) -> Result<crate::projection::WirePage, Error> {
107        self.archive()?
108            .wire_page(&self.index_instance, cursor, limit)
109    }
110
111    pub fn object_status(&self, root: bitcoin::Txid) -> Result<Option<ObjectStatus>, Error> {
112        self.archive()?.object_status(root)
113    }
114
115    /// Register an explicitly selected, authenticated Names genesis in a fully
116    /// scanned block. No registry is chosen implicitly and no source is called.
117    pub fn register_names(
118        &self,
119        genesis: bitcoin::Txid,
120        block: ChainPoint,
121    ) -> Result<crate::names::NamesStatus, Error> {
122        self.archive()?.register_names(genesis, block)
123    }
124
125    /// Reconcile retained undo, then project at most one fully scanned block.
126    /// Missing scan coverage leaves progress unchanged. Missing selected commit
127    /// placement refuses the step. SQL and state changes share the archive lock.
128    pub fn names_step(&self, genesis: bitcoin::Txid) -> Result<crate::names::NamesStep, Error> {
129        self.archive()?.names_step(genesis)
130    }
131
132    /// Read normalized Names state without registration, projection or source
133    /// access. Metadata and rows share one archive lock. Any committed event or
134    /// Engine restart invalidates continuation; discard partial traversals.
135    pub fn names_view(
136        &self,
137        genesis: bitcoin::Txid,
138        cursor: Option<&crate::names::NamesCursor>,
139        limit: u32,
140    ) -> Result<crate::names::NamesPage, Error> {
141        self.archive()?
142            .names_view(&self.index_instance, genesis, cursor, limit)
143    }
144
145    /// Orphaned projection checkpoints are refused until a maintenance step
146    /// reconciles them; an orphaned genesis always requires explicit action.
147    pub fn names_status(
148        &self,
149        genesis: bitcoin::Txid,
150    ) -> Result<Option<crate::names::NamesStatus>, Error> {
151        self.archive()?.names_status(genesis)
152    }
153
154    pub fn names_resolve(
155        &self,
156        genesis: bitcoin::Txid,
157        name: &urma_names::name::Name,
158    ) -> Result<crate::names::NamesResolution, Error> {
159        self.archive()?.names_resolve(genesis, name)
160    }
161
162    /// Maintain explicitly registered Names projections without source calls.
163    /// Inspect at most 128 registrations and attempt at most one projection
164    /// step. The cursor advances before each attempt, including failures and
165    /// coverage gaps, so one unavailable registry cannot starve later entries.
166    /// Some reports an attempted step, which may have made no progress.
167    /// Both retention modes preserve individual Names records. Cancellation is
168    /// cooperative before a step; an entered SQL/state operation may finish.
169    pub fn maintain_next_names(
170        &self,
171        cancelled: &AtomicBool,
172    ) -> Result<Option<crate::names::NamesStep>, Error> {
173        let check_cancelled = || {
174            if cancelled.load(Ordering::Acquire) {
175                Err(Error::Busy("Names maintenance cancelled".into()))
176            } else {
177                Ok(())
178            }
179        };
180        check_cancelled()?;
181        let mut cursor = self
182            .projecting_names
183            .try_lock()
184            .map_err(|_| Error::Busy("Names maintenance already running".into()))?;
185        let mut registries = self.archive()?.names_page(*cursor)?;
186        if registries.is_empty() && cursor.is_some() {
187            *cursor = None;
188            registries = self.archive()?.names_page(None)?;
189        }
190        for genesis in registries {
191            check_cancelled()?;
192            *cursor = Some(genesis);
193            match self.names_status(genesis) {
194                Ok(Some(status)) if status.complete_to_tip => continue,
195                Ok(None) => continue,
196                // The existing step reconciles an orphaned checkpoint or
197                // explicitly refuses an orphaned genesis/insufficient undo.
198                Ok(Some(_)) | Err(Error::Stale) => (),
199                Err(error) => return Err(error),
200            }
201            let mut archive = self.archive()?;
202            // Recheck after waiting for the archive coordinator as well.
203            check_cancelled()?;
204            return archive.names_step(genesis).map(Some);
205        }
206        Ok(None)
207    }
208
209    /// Pin a previously reconstructed object only if all its evidence remains
210    /// local. This does not claim current chain selection or fetch missing data.
211    /// Use retrieval::recover_and_pin to acquire and pin an incomplete object.
212    pub fn set_pin(&self, root: bitcoin::Txid, pin: bool) -> Result<(), Error> {
213        self.archive()?.set_pin(root, pin)
214    }
215
216    /// Explicit cache maintenance; no-op for unpruned archives. Returns child
217    /// record count removed. Pins protect exact members even after a reorg.
218    pub fn prune_cache(&self) -> Result<u64, Error> {
219        self.archive()?.prune_cache()
220    }
221
222    /// Enforce a soft logical-byte target on unpinned multipart cache only.
223    /// Deletes at most 128 child placements and 512 currently unreferenced raw
224    /// transactions per call. SQL accounting work and elapsed time are not
225    /// bounded by these row limits. No source calls or scan progress occur.
226    /// Existing pins and individual/root evidence survive; in-flight unpinned
227    /// recovery may lose cache and must refuse incomplete completion.
228    pub fn maintain_cache(
229        &self,
230        limit_bytes: u64,
231        cancelled: &AtomicBool,
232    ) -> Result<CacheReceipt, Error> {
233        if limit_bytes > i64::MAX as u64 {
234            return Err(Error::Invalid("cache limit exceeds storage range".into()));
235        }
236        let check = || {
237            if cancelled.load(Ordering::Acquire) {
238                Err(Error::Busy("cache maintenance cancelled".into()))
239            } else {
240                Ok(())
241            }
242        };
243        check()?;
244        let mut archive = self.archive()?;
245        check()?;
246        archive.maintain_cache(limit_bytes)
247    }
248
249    /// One fair, bounded unpruned maintenance step. Scan coverage is never
250    /// advanced by object recovery. Failures advance the cursor, so the next
251    /// round can attempt another root. Pruned mode performs no acquisition.
252    /// Cancellation is cooperative between record acquisitions; in-flight
253    /// source I/O may still finish and leave independently verified cache bytes.
254    pub fn retain_next_object(
255        &self,
256        sources: &[&dyn crate::retrieval::RecordSource],
257        limits: urma_runtime::multipart::RecoveryLimits,
258        scratch: &Path,
259        cancelled: &AtomicBool,
260    ) -> Result<Option<ObjectStatus>, Error> {
261        if cancelled.load(Ordering::Acquire) {
262            return Err(Error::Busy("object retention cancelled".into()));
263        }
264        let mut cursor = self
265            .retaining
266            .try_lock()
267            .map_err(|_| Error::Busy("object retention already running".into()))?;
268        let mut roots = self.archive()?.root_page(*cursor)?;
269        if roots.is_empty() && cursor.is_some() {
270            *cursor = None;
271            roots = self.archive()?.root_page(None)?;
272        }
273        for root in roots {
274            if cancelled.load(Ordering::Acquire) {
275                return Err(Error::Busy("object retention cancelled".into()));
276            }
277            *cursor = Some(root);
278            if self
279                .object_status(root)?
280                .is_some_and(|status| status.selected_complete())
281            {
282                continue;
283            }
284            let _object = crate::retrieval::recover_inner(
285                self, root, sources, limits, scratch, false, cancelled,
286            )?;
287            return self.object_status(root);
288        }
289        Ok(None)
290    }
291
292    pub(crate) fn complete_object(
293        &self,
294        object: &urma_runtime::multipart::RecoveredObject,
295        generation: u64,
296        members: &[crate::archive::ObjectMember],
297        pin: bool,
298        deadline: Instant,
299        cancelled: &AtomicBool,
300    ) -> Result<(), Error> {
301        self.archive()?.complete_object(
302            object.root(),
303            generation,
304            object.manifest().length,
305            object.manifest().payload_hash,
306            members,
307            pin,
308            deadline,
309            cancelled,
310        )
311    }
312
313    pub(crate) fn chain(&self) -> Result<urma_chain::observation::Chain, Error> {
314        Ok(self.archive()?.config().chain)
315    }
316
317    /// Fetch only a known transaction location from the independent full-block
318    /// source. Standard P2P is not assumed to offer an arbitrary TXID lookup.
319    /// The block is verified in full, but this targeted path does not scan/index
320    /// every candidate and consequently does not advance coverage.
321    pub fn retrieve_located_record(
322        &self,
323        txid: bitcoin::Txid,
324    ) -> Result<Option<ArchivedRecord>, Error> {
325        self.retrieve_located_record_before(txid, Instant::now() + Duration::from_secs(30))
326    }
327
328    pub(crate) fn retrieve_located_record_before(
329        &self,
330        txid: bitcoin::Txid,
331        deadline: Instant,
332    ) -> Result<Option<ArchivedRecord>, Error> {
333        check_deadline(deadline)?;
334        let cached = self.record(txid)?;
335        check_deadline(deadline)?;
336        if cached.is_some() {
337            return Ok(cached);
338        }
339        let (location, chain) = {
340            let archive = self.archive()?;
341            (archive.location(txid)?, archive.config().chain)
342        };
343        let (Some(location), Some(source)) = (location, &self.source) else {
344            return Ok(None);
345        };
346        check_deadline(deadline)?;
347        let raw = source.block(location.height);
348        check_deadline(deadline)?;
349        let raw = raw?;
350        let decoded =
351            urma_chain::decode::esplora_block_with_transactions(&raw, chain, location.hash)
352                .map_err(|e| Error::Invalid(format!("located block: {e}")))?;
353        let (block, raw) = decoded.into_parts();
354        let Some(position) = block.txdata.iter().position(|t| t.compute_txid() == txid) else {
355            return Err(Error::Invalid(
356                "verified block contradicts stored locator".into(),
357            ));
358        };
359        let reveal = &block.txdata[position];
360        if reveal.input.len() != 1 {
361            return Err(Error::Invalid("located transaction is not a reveal".into()));
362        }
363        let parent = reveal.input[0].previous_output.txid;
364        let commit =
365            if let Some(index) = block.txdata.iter().position(|t| t.compute_txid() == parent) {
366                Some(raw[index].clone())
367            } else if let Some(raw) = self.archive()?.transaction(parent)? {
368                Some(raw)
369            } else {
370                let parent_location = self.archive()?.location(parent)?;
371                match parent_location {
372                    None => None,
373                    Some(parent_location) => {
374                        check_deadline(deadline)?;
375                        let bytes = source.block(parent_location.height);
376                        check_deadline(deadline)?;
377                        let bytes = bytes?;
378                        let parent_block = urma_chain::decode::esplora_block_with_transactions(
379                            &bytes,
380                            chain,
381                            parent_location.hash,
382                        )
383                        .map_err(|e| Error::Invalid(format!("located parent block: {e}")))?;
384                        parent_block
385                            .block()
386                            .txdata
387                            .iter()
388                            .position(|t| t.compute_txid() == parent)
389                            .map(|i| parent_block.raw_transactions()[i].clone())
390                    }
391                }
392            };
393        check_deadline(deadline)?;
394        let Some(commit) = commit else {
395            return Ok(None);
396        };
397        let proof = urma_chain::inclusion::BlockProofBuilder::new(&block)
398            .and_then(|builder| {
399                builder.proof(position as u32, raw[position].clone(), raw[0].clone())
400            })
401            .map_err(|e| Error::Invalid(e.to_string()))?;
402        check_deadline(deadline)?;
403        let header = source.header(location.height);
404        check_deadline(deadline)?;
405        if header?.block_hash() != location.hash {
406            return Err(Error::Stale);
407        }
408        self.admit_record_before(
409            RecordPackage {
410                version: API_VERSION,
411                chain_genesis: chain
412                    .genesis()
413                    .map_err(|e| Error::Invalid(e.to_string()))?
414                    .0,
415                block: location,
416                reveal: proof,
417                commit,
418            },
419            Some(deadline),
420        )?;
421        let record = self.record(txid)?;
422        check_deadline(deadline)?;
423        Ok(record)
424    }
425
426    /// Supply a missing commit by its computed TXID. This is auxiliary prevout
427    /// evidence, NOT a claim about the commit's chain placement or witness.
428    /// Only existing dependencies from locally scanned blocks can be resolved.
429    /// At most 128 references are processed per call; repeat for a shared parent.
430    pub fn resolve_commit(&self, raw: &[u8]) -> Result<usize, Error> {
431        self.resolve_commit_inner(raw, None, &AtomicBool::new(false))
432    }
433
434    /// Try one distinct selected dependency whose parent is not cached. The
435    /// display-hex cursor is in memory only and advances before a source miss
436    /// or error, so one unavailable parent cannot starve later parents. No
437    /// source is called when none are missing. If a retained record caches the
438    /// parent, resolve_cached_dependency can finish remaining shared references.
439    /// Invalid candidates or omitted Pruned children do not cache that parent;
440    /// remaining references can require another explicit source call.
441    ///
442    /// Fetch runs outside the archive mutex. Its own I/O bounds are the source's
443    /// responsibility; cancellation is checked between calls/proof work and
444    /// before atomic finalization, not a hard interrupt or global deadline.
445    /// The selected generation is captured before fetch and cannot be rebound
446    /// to a newer view even if the same parent remains needed after a reorg.
447    pub fn resolve_next_dependency(
448        &self,
449        source: &dyn CommitSource,
450        cancelled: &AtomicBool,
451    ) -> Result<usize, Error> {
452        check_dependency_cancelled(cancelled)?;
453        let mut cursor = self
454            .resolving_dependencies
455            .try_lock()
456            .map_err(|_| Error::Busy("dependency acquisition already running".into()))?;
457        check_dependency_cancelled(cancelled)?;
458        let chain = source.chain(); // Contract: local configuration, no I/O.
459        let selection = {
460            let archive = self.archive()?;
461            check_dependency_cancelled(cancelled)?;
462            if chain
463                .genesis()
464                .map_err(|error| Error::Invalid(error.to_string()))?
465                != archive
466                    .config()
467                    .chain
468                    .genesis()
469                    .map_err(|error| Error::Invalid(error.to_string()))?
470            {
471                return Err(Error::Invalid(
472                    "commit source belongs to another chain".into(),
473                ));
474            }
475            let mut selection = archive.next_missing_dependency(*cursor)?;
476            if selection.is_none() && cursor.is_some() {
477                selection = archive.next_missing_dependency(None)?;
478            }
479            selection
480        };
481        check_dependency_cancelled(cancelled)?;
482        let Some((requested, generation)) = selection else {
483            *cursor = None;
484            return Ok(0);
485        };
486        *cursor = Some(requested);
487        let fetched = source.fetch(requested, cancelled);
488        check_dependency_cancelled(cancelled)?;
489        {
490            let archive = self.archive()?;
491            check_dependency_cancelled(cancelled)?;
492            archive.check_generation(generation)?;
493        }
494        let Some(raw) = fetched? else {
495            return Ok(0);
496        };
497        self.resolve_commit_inner(&raw, Some((requested, generation)), cancelled)
498    }
499
500    fn resolve_commit_inner(
501        &self,
502        raw: &[u8],
503        expected: Option<(bitcoin::Txid, u64)>,
504        cancelled: &AtomicBool,
505    ) -> Result<usize, Error> {
506        check_dependency_cancelled(cancelled)?;
507        let (chain, retention) = {
508            let archive = self.archive()?;
509            check_dependency_cancelled(cancelled)?;
510            if let Some((_, generation)) = expected {
511                archive.check_generation(generation)?;
512            }
513            (archive.config().chain, archive.config().retention)
514        };
515        // The configured-chain decoder enforces its existing 8,000,000-byte
516        // bound and exact syntax before any dependency mutation.
517        let commit = urma_chain::decode::transaction(raw, chain)
518            .map_err(|e| Error::Invalid(format!("dependency commit: {e}")))?;
519        if expected.is_some_and(|(id, _)| id != commit.compute_txid()) {
520            return Err(Error::Invalid("commit source returned another TXID".into()));
521        }
522        check_dependency_cancelled(cancelled)?;
523        let pending = {
524            let archive = self.archive()?;
525            check_dependency_cancelled(cancelled)?;
526            if let Some((_, generation)) = expected {
527                archive.check_generation(generation)?;
528            }
529            archive.pending_for(commit.compute_txid())?
530        };
531        let mut completed = Vec::new();
532        for (ticket, position, proof) in pending {
533            check_dependency_cancelled(cancelled)?;
534            let header = {
535                let archive = self.archive()?;
536                check_dependency_cancelled(cancelled)?;
537                if let Some((_, generation)) = expected {
538                    archive.check_generation(generation)?;
539                }
540                archive
541                    .header_at(ticket.point().height)?
542                    .ok_or(Error::Stale)?
543            };
544            if header.block_hash() != ticket.point().hash {
545                return Err(Error::Stale);
546            }
547            let included = proof
548                .verify(chain, &header)
549                .map_err(|e| Error::Invalid(format!("stored dependency proof: {e}")))?;
550            if included.position() != position {
551                return Err(Error::Invalid("stored dependency position mismatch".into()));
552            }
553            check_dependency_cancelled(cancelled)?;
554            let reveal = included.transaction();
555            // The exact parent TXID fixes the output values/script. Failure
556            // here classifies this candidate, rather than trusting a peer's
557            // declaration that a dependency or signed object is invalid.
558            let verified = urma_core::multipart::VerifiedRecord::verify(
559                reveal.compute_txid(),
560                reveal,
561                &commit,
562            );
563            let record = match verified {
564                Ok(verified) => {
565                    let kind = urma_core::format::RecordKind::parse(verified.record_bytes())
566                        .map_err(|e| Error::Invalid(e.to_string()))?;
567                    if retention == Retention::Pruned
568                        && matches!(
569                            kind,
570                            urma_core::format::RecordKind::DataPart
571                                | urma_core::format::RecordKind::LeafManifest
572                        )
573                    {
574                        None
575                    } else {
576                        Some(crate::scan::Record {
577                            position,
578                            wtxid: reveal.compute_wtxid(),
579                            verified,
580                            commit: commit.clone(),
581                            raw_commit: raw.to_vec(),
582                            proof,
583                        })
584                    }
585                }
586                Err(_) => None,
587            };
588            check_dependency_cancelled(cancelled)?;
589            completed.push((ticket, position, record));
590        }
591        check_dependency_cancelled(cancelled)?;
592        let mut archive = self.archive()?;
593        check_dependency_cancelled(cancelled)?;
594        if let Some((_, generation)) = expected {
595            archive.check_generation(generation)?;
596        }
597        archive.finish_dependencies(completed)
598    }
599
600    /// Cache an explicitly requested record, including children in pruned mode.
601    /// This is not a pin or a statement that the multipart object is complete.
602    /// Header selection is independent of the sender; reorg invalidates the
603    /// ticket before persistence. No source call or database lock spans proof work.
604    pub fn admit_record(&self, package: RecordPackage) -> Result<RecordReceipt, Error> {
605        self.admit_record_before(package, None)
606    }
607
608    pub(crate) fn admit_record_before(
609        &self,
610        package: RecordPackage,
611        deadline: Option<Instant>,
612    ) -> Result<RecordReceipt, Error> {
613        if let Some(deadline) = deadline {
614            check_deadline(deadline)?;
615        }
616        let (ticket, chain, header) = {
617            let archive = self.archive()?;
618            let chain = archive.config().chain;
619            if package.version != API_VERSION
620                || package.chain_genesis
621                    != chain
622                        .genesis()
623                        .map_err(|e| Error::Invalid(e.to_string()))?
624                        .0
625            {
626                return Err(Error::Invalid(
627                    "record package version or network mismatch".into(),
628                ));
629            }
630            let ticket = archive.ticket(package.block.height)?;
631            if ticket.point() != package.block {
632                return Err(Error::Stale);
633            }
634            let header = archive
635                .header_at(package.block.height)?
636                .ok_or(Error::Stale)?;
637            (ticket, chain, header)
638        };
639        let included = package
640            .reveal
641            .verify(chain, &header)
642            .map_err(|e| Error::Invalid(format!("selective inclusion: {e}")))?;
643        let commit = urma_chain::decode::transaction(&package.commit, chain)
644            .map_err(|e| Error::Invalid(format!("selective commit: {e}")))?;
645        let reveal = included.transaction();
646        let verified =
647            urma_core::multipart::VerifiedRecord::verify(reveal.compute_txid(), reveal, &commit)
648                .map_err(|e| Error::Invalid(format!("selective URMA proof: {e}")))?;
649        // VerifiedRecord authenticates bytes before interpreting references.
650        // Admission additionally requires a valid V0 record, as in the scanner.
651        // Validate the structure AFTER authentication, without repeating signature verification.
652        urma_core::envelope::extract_reveal(reveal)
653            .map_err(|e| Error::Invalid(format!("selective V0 record: {e}")))?;
654        let record = crate::scan::Record {
655            position: included.position(),
656            wtxid: reveal.compute_wtxid(),
657            verified,
658            commit,
659            raw_commit: package.commit,
660            proof: package.reveal,
661        };
662        let mut archive = self.archive()?;
663        if let Some(deadline) = deadline {
664            check_deadline(deadline)?;
665        }
666        archive.admit_record(ticket, record)
667    }
668
669    /// Admit at most 2,000 headers from the selected verifier view. This makes
670    /// no block-scan or completeness claim. Concurrent callers do not duplicate
671    /// the synchronization cycle; they receive Busy.
672    pub fn synchronize_headers(&self) -> Result<ArchiveStatus, Error> {
673        self.synchronize_headers_until(&AtomicBool::new(false))
674    }
675
676    /// Cancellation/deadline is checked between source calls, not a hard I/O
677    /// interrupt. Each source call must also have its own bounded timeout.
678    pub fn synchronize_headers_until(
679        &self,
680        cancelled: &AtomicBool,
681    ) -> Result<ArchiveStatus, Error> {
682        let budget = Budget::new(cancelled);
683        budget.check()?;
684        let _sync = self
685            .synchronizing
686            .try_lock()
687            .map_err(|_| Error::Busy("synchronization already running".into()))?;
688        let source = self
689            .source
690            .as_ref()
691            .ok_or_else(|| Error::Invalid("node is offline; no chain source configured".into()))?;
692        let before = self.status()?;
693        let remote = source.tip()?;
694        budget.check()?;
695        let floor = before.application_start.height;
696        if remote.height < floor {
697            return Err(Error::Invalid(
698                "source tip precedes application start".into(),
699            ));
700        }
701        if source.header(floor)?.block_hash() != before.application_start.hash {
702            return Err(Error::Invalid(
703                "source does not contain configured application start".into(),
704            ));
705        }
706        let mut start = floor;
707        if let Some(local) = before.selected_tip {
708            let mut height = local.height.min(remote.height);
709            loop {
710                budget.check()?;
711                let chosen = source.header(height)?;
712                if self.archive()?.header_at(height)? == Some(chosen) {
713                    if height == remote.height && remote.height < local.height {
714                        return Err(Error::Invalid("verifier tip regressed on the same prefix; archived view retained, synchronization suspended".into()));
715                    }
716                    start = height + 1;
717                    break;
718                }
719                if height == floor {
720                    break;
721                }
722                height -= 1;
723            }
724        }
725        if start > remote.height {
726            return self.status();
727        }
728        let end = remote.height.min(start.saturating_add(1999));
729        let mut headers = Vec::new();
730        for height in start..=end {
731            budget.check()?;
732            headers.push(source.header(height)?);
733        }
734        // Linked prefix plus unchanged final header binds the fetched batch to
735        // one verifier branch even if the source changed during acquisition.
736        budget.check()?;
737        if source.header(end)?
738            != *headers
739                .last()
740                .ok_or_else(|| Error::Invalid("empty header batch".into()))?
741        {
742            return Err(Error::Stale);
743        }
744        budget.check()?;
745        let mut archive = self.archive()?;
746        let now = archive.status()?;
747        if now.generation != before.generation || now.selected_tip != before.selected_tip {
748            return Err(Error::Stale);
749        }
750        budget.check()?;
751        archive.select(start, &headers)?;
752        archive.status()
753    }
754
755    /// Verify a full block and atomically commit its index evidence. Selective
756    /// imports cannot call this path or manufacture a locally scanned interval.
757    pub fn scan_next(&self) -> Result<ArchiveStatus, Error> {
758        self.scan_next_until(&AtomicBool::new(false))
759    }
760
761    /// Retry one available parent per scheduler round. Missing dependencies do
762    /// not starve subsequent block scanning or acquire completeness from peers.
763    pub fn resolve_cached_dependency(&self) -> Result<usize, Error> {
764        let raw = self.archive()?.cached_dependency()?;
765        match raw {
766            Some(raw) => self.resolve_commit(&raw),
767            None => Ok(0),
768        }
769    }
770
771    pub fn scan_next_until(&self, cancelled: &AtomicBool) -> Result<ArchiveStatus, Error> {
772        let budget = Budget::new(cancelled);
773        budget.check()?;
774        let _sync = self
775            .synchronizing
776            .try_lock()
777            .map_err(|_| Error::Busy("synchronization already running".into()))?;
778        let source = self
779            .source
780            .as_ref()
781            .ok_or_else(|| Error::Invalid("node is offline; no chain source configured".into()))?;
782        let (ticket, chain, retention) = {
783            let archive = self.archive()?;
784            let Some(ticket) = archive.next_scan()? else {
785                return archive.status();
786            };
787            (ticket, archive.config().chain, archive.config().retention)
788        };
789        let raw = source.block(ticket.point().height)?;
790        budget.check()?;
791        let batch = crate::scan::ScanBatch::verify(chain, ticket, &raw, retention, |txid| {
792            budget.check()?;
793            if let Some(raw) = self.archive()?.transaction(txid)? {
794                return Ok(Some(raw));
795            }
796            let location = self.archive()?.location(txid)?;
797            let Some(location) = location else {
798                return Ok(None);
799            };
800            let raw = source.block(location.height)?;
801            budget.check()?;
802            let decoded =
803                urma_chain::decode::esplora_block_with_transactions(&raw, chain, location.hash)
804                    .map_err(|e| Error::Invalid(format!("dependency block: {e}")))?;
805            budget.check()?;
806            if source.header(location.height)?.block_hash() != location.hash {
807                return Err(Error::Stale);
808            }
809            budget.check()?;
810            Ok(decoded
811                .block()
812                .txdata
813                .iter()
814                .position(|tx| tx.compute_txid() == txid)
815                .map(|i| decoded.raw_transactions()[i].clone()))
816        })?;
817        budget.check()?;
818        if source.header(ticket.point().height)?.block_hash() != ticket.point().hash {
819            return Err(Error::Stale);
820        }
821        budget.check()?;
822        let mut archive = self.archive()?;
823        budget.check()?;
824        archive.commit_scan(batch)?;
825        archive.status()
826    }
827}
828
829fn check_dependency_cancelled(cancelled: &AtomicBool) -> Result<(), Error> {
830    if cancelled.load(Ordering::Acquire) {
831        return Err(Error::Busy("dependency acquisition cancelled".into()));
832    }
833    Ok(())
834}
835
836struct Budget<'a> {
837    deadline: Instant,
838    cancelled: &'a AtomicBool,
839}
840impl<'a> Budget<'a> {
841    fn new(cancelled: &'a AtomicBool) -> Self {
842        Self {
843            deadline: Instant::now() + Duration::from_secs(30),
844            cancelled,
845        }
846    }
847    fn check(&self) -> Result<(), Error> {
848        if self.cancelled.load(Ordering::Acquire) || Instant::now() >= self.deadline {
849            return Err(Error::Busy(
850                "synchronization cancelled or time budget exhausted".into(),
851            ));
852        }
853        Ok(())
854    }
855}
856
857pub(crate) fn check_deadline(deadline: Instant) -> Result<(), Error> {
858    if Instant::now() >= deadline {
859        Err(Error::Busy("record acquisition deadline".into()))
860    } else {
861        Ok(())
862    }
863}