Skip to main content

urma_node/
engine.rs

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