1use super::bootstrap::{HeaderBootstrap, context_headers};
2use crate::config::{
3 Checkpoint, LITECOIN_MAINNET_CHECKPOINT, LITECOIN_TESTNET_CHECKPOINT, P2P_HEADER_CACHE_MAX,
4 P2P_HEADER_CACHE_REVALIDATE, P2P_LOCATOR_LINEAR, P2P_MAX_FUTURE_SECS, P2P_MEDIAN_TIME_SPAN,
5 P2P_POW_WORKERS,
6};
7use crate::error::{Context, Error, ensure};
8use bitcoin::BlockHash;
9use bitcoin::block::Header;
10use bitcoin::consensus::encode::{deserialize, serialize};
11use std::borrow::Cow;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::time::Instant;
15use urma_chain::observation::Chain;
16use urma_chain::pow::{PowError, chain_work, check_header_pow, expected_bits};
17
18pub struct HeaderChain {
19 chain: Chain,
20 start_height: u64,
21 trusted_next: Option<BlockHash>,
22 ancestors: Option<Vec<Header>>,
23 headers: Vec<Header>,
24 index: HashMap<BlockHash, usize>,
25 path: PathBuf,
26}
27
28#[derive(Debug, PartialEq, Eq)]
29pub enum Extension {
30 Appended(usize),
31 Reorganized { fork_height: u64, appended: usize },
32 Ignored,
33}
34
35pub fn checkpoint(chain: Chain) -> Result<&'static Checkpoint, Error> {
36 match chain {
37 Chain::LitecoinMainnet => Ok(&LITECOIN_MAINNET_CHECKPOINT),
38 Chain::LitecoinTestnet => Ok(&LITECOIN_TESTNET_CHECKPOINT),
39 Chain::BitcoinMainnet | Chain::BitcoinRegtest | Chain::BitcoinTestnet4 => {
40 Err(Error::Unsupported(
41 "the peer-to-peer light client serves Litecoin networks only".into(),
42 ))
43 }
44 }
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct Anchor {
53 pub height: u64,
54 pub header: Header,
55 pub next_hash: BlockHash,
56}
57
58impl Anchor {
59 pub fn embedded(chain: Chain) -> Result<Self, Error> {
60 let checkpoint = checkpoint(chain)?;
61 Ok(Self {
62 height: checkpoint.height,
63 header: deserialize(&hex::decode(checkpoint.header)?)?,
64 next_hash: checkpoint.next_hash.parse()?,
65 })
66 }
67}
68
69impl HeaderChain {
70 pub fn open_bootstrap(cache_dir: &Path, bootstrap: HeaderBootstrap) -> Result<Self, Error> {
75 let (chain, height, header, ancestors) = bootstrap.into_parts();
76 let path = cache_dir.join(format!(
77 "strict-v1-{}-{height}-{}-headers.bin",
78 chain.label().replace(' ', "-"),
79 header.block_hash()
80 ));
81 Self {
82 chain,
83 start_height: height,
84 trusted_next: None,
85 ancestors: Some(ancestors),
86 headers: vec![header],
87 index: HashMap::from([(header.block_hash(), 0)]),
88 path,
89 }
90 .restore()
91 }
92
93 pub fn open(chain: Chain, cache_dir: &Path) -> Result<Self, Error> {
94 Self::open_at(chain, cache_dir, Anchor::embedded(chain)?)
95 }
96
97 pub fn open_at(chain: Chain, cache_dir: &Path, anchor: Anchor) -> Result<Self, Error> {
98 let path = cache_dir.join(format!("{}-headers.bin", chain.label().replace(' ', "-")));
99 Self::anchored(chain, path, anchor.height, anchor.header, anchor.next_hash)
100 }
101
102 pub fn anchored(
103 chain: Chain,
104 path: PathBuf,
105 start_height: u64,
106 anchor: Header,
107 trusted_next: BlockHash,
108 ) -> Result<Self, Error> {
109 check_header_pow(&anchor, chain).map_err(|cause| Error::Invalid(cause.to_string()))?;
110 Self {
111 chain,
112 start_height,
113 trusted_next: Some(trusted_next),
114 ancestors: None,
115 headers: vec![anchor],
116 index: HashMap::from([(anchor.block_hash(), 0)]),
117 path,
118 }
119 .restore()
120 }
121
122 fn restore(mut self) -> Result<Self, Error> {
123 if self.path.try_exists()? {
124 match self.load() {
125 Ok(()) => (),
126 Err(error) if error.is_header_context() => return Err(error),
127 Err(error) => {
128 tracing::warn!(%error, path = %self.path.display(), "header cache rejected; restarting from the checkpoint");
129 self.reset()?;
130 }
131 }
132 }
133 Ok(self)
134 }
135
136 fn reset(&mut self) -> Result<(), Error> {
137 self.headers.truncate(1);
138 self.index = HashMap::from([(self.headers[0].block_hash(), 0)]);
139 std::fs::remove_file(&self.path)?;
140 Ok(())
141 }
142
143 fn load(&mut self) -> Result<(), Error> {
144 let started = Instant::now();
145 let raw = urma_io::read_bounded(&self.path, P2P_HEADER_CACHE_MAX * Header::SIZE)?;
146 tracing::debug!(target: "urma_startup", chain = self.chain.label(), stage = "cache_read", elapsed_ms = started.elapsed().as_secs_f64() * 1000.0, headers = raw.len() / Header::SIZE, "light client stage completed");
147 let started = Instant::now();
148 ensure!(
149 raw.len() % Header::SIZE == 0 && raw.len() >= Header::SIZE,
150 "header cache {} is not a whole number of headers",
151 self.path.display()
152 );
153 let stored: Header = deserialize(&raw[..Header::SIZE])?;
154 ensure!(
155 stored == self.headers[0],
156 "header cache {} starts from a different checkpoint",
157 self.path.display()
158 );
159 let count = raw.len() / Header::SIZE;
160 let mut headers = Vec::new();
161 headers.try_reserve_exact(count)?;
162 for slice in raw.chunks_exact(Header::SIZE) {
163 headers.push(deserialize::<Header>(slice)?);
164 }
165 tracing::debug!(target: "urma_startup", chain = self.chain.label(), stage = "cache_decode", elapsed_ms = started.elapsed().as_secs_f64() * 1000.0, headers = count, "light client stage completed");
166 let revalidate_from = count.max(P2P_HEADER_CACHE_REVALIDATE) - P2P_HEADER_CACHE_REVALIDATE;
167 let started = Instant::now();
168 for (offset, header) in headers.iter().enumerate().skip(1) {
169 self.validate_context(&headers[..offset], header)
170 .with_context(|| {
171 format!("header cache {} at offset {offset}", self.path.display())
172 })?;
173 }
174 tracing::debug!(target: "urma_startup", chain = self.chain.label(), stage = "cache_context", elapsed_ms = started.elapsed().as_secs_f64() * 1000.0, headers = count, "light client stage completed");
175 let tail = &headers[revalidate_from.max(1)..];
176 let started = Instant::now();
177 prove_batch(self.chain, tail)?;
178 tracing::debug!(target: "urma_startup", chain = self.chain.label(), stage = "cache_pow", elapsed_ms = started.elapsed().as_secs_f64() * 1000.0, headers = count, pow_headers = tail.len(), "light client stage completed");
179 let started = Instant::now();
180 self.index = headers
181 .iter()
182 .enumerate()
183 .map(|(offset, header)| (header.block_hash(), offset))
184 .collect();
185 self.headers = headers;
186 tracing::debug!(target: "urma_startup", chain = self.chain.label(), stage = "cache_index", elapsed_ms = started.elapsed().as_secs_f64() * 1000.0, headers = count, "light client stage completed");
187 Ok(())
188 }
189
190 pub fn persist(&self) -> Result<(), Error> {
191 let mut raw = Vec::new();
192 raw.try_reserve_exact(self.headers.len() * Header::SIZE)?;
193 for header in &self.headers {
194 raw.extend_from_slice(&serialize(header));
195 }
196 urma_io::write_replace(&self.path, &raw)?;
197 Ok(())
198 }
199
200 pub fn chain(&self) -> Chain {
201 self.chain
202 }
203
204 pub fn start_height(&self) -> u64 {
205 self.start_height
206 }
207
208 pub fn tip_height(&self) -> u64 {
209 self.start_height + self.offset_count()
210 }
211
212 pub fn synced_count(&self) -> u64 {
213 self.offset_count()
214 }
215
216 fn offset_count(&self) -> u64 {
217 match u64::try_from(self.headers.len() - 1) {
218 Ok(count) => count,
219 Err(error) => {
220 tracing::error!(%error, "header count exceeds u64");
221 u64::MAX
222 }
223 }
224 }
225
226 pub fn tip_hash(&self) -> BlockHash {
227 self.headers[self.headers.len() - 1].block_hash()
228 }
229
230 pub fn height_of(&self, hash: BlockHash) -> Result<u64, Error> {
231 let offset = *self
232 .index
233 .get(&hash)
234 .with_context(|| format!("block {hash} is not in the verified header chain"))?;
235 Ok(self.start_height + u64::try_from(offset)?)
236 }
237
238 pub fn header_at(&self, height: u64) -> Result<Header, Error> {
239 let offset = height
240 .checked_sub(self.start_height)
241 .with_context(|| format!("height {height} precedes the light client checkpoint"))?;
242 let offset = usize::try_from(offset)?;
243 self.headers
244 .get(offset)
245 .copied()
246 .with_context(|| format!("height {height} is above the verified header tip"))
247 }
248
249 pub fn hash_at(&self, height: u64) -> Result<BlockHash, Error> {
250 Ok(self.header_at(height)?.block_hash())
251 }
252
253 pub fn locator(&self) -> Vec<BlockHash> {
254 let mut hashes = Vec::new();
255 let mut back = self.headers.len() - 1;
256 let mut step = 1;
257 loop {
258 hashes.push(self.headers[back].block_hash());
259 if back == 0 {
260 break;
261 }
262 if hashes.len() > P2P_LOCATOR_LINEAR {
263 step *= 2;
264 }
265 back = back.max(step) - step;
266 }
267 hashes
268 }
269
270 pub fn extend(&mut self, batch: &[Header], now: u32) -> Result<Extension, Error> {
271 let Some(first) = batch.first() else {
272 return Ok(Extension::Ignored);
273 };
274 let fork = *self
275 .index
276 .get(&first.prev_blockhash)
277 .context("peer headers do not connect to the verified header chain")?;
278 let known = batch
279 .iter()
280 .zip(self.headers.iter().skip(fork + 1))
281 .take_while(|(incoming, held)| incoming.block_hash() == held.block_hash())
282 .count();
283 let fresh = &batch[known..];
284 if fresh.is_empty() {
285 return Ok(Extension::Ignored);
286 }
287 ensure!(
288 self.headers.len() + fresh.len() <= P2P_HEADER_CACHE_MAX,
289 "header chain would exceed the client capacity"
290 );
291 let base = fork + known;
292 prove_batch(self.chain, fresh)?;
293 if base == self.headers.len() - 1 {
294 self.append(fresh, now)?;
295 return Ok(Extension::Appended(fresh.len()));
296 }
297 let mut candidate = self.headers[..=base].to_vec();
298 for header in fresh {
299 self.validate_next(&candidate, header, now)?;
300 candidate.push(*header);
301 }
302 let challenger = chain_work(&candidate[base + 1..])
303 .map_err(|cause| Error::Invalid(cause.to_string()))?;
304 let incumbent = chain_work(&self.headers[base + 1..])
305 .map_err(|cause| Error::Invalid(cause.to_string()))?;
306 if challenger <= incumbent {
307 tracing::warn!(
308 fork_offset = base,
309 "peer branch carries no more work than the held chain"
310 );
311 return Ok(Extension::Ignored);
312 }
313 for dropped in &self.headers[base + 1..] {
314 self.index.remove(&dropped.block_hash());
315 }
316 self.headers = candidate;
317 for (offset, header) in self.headers.iter().enumerate().skip(base + 1) {
318 self.index.insert(header.block_hash(), offset);
319 }
320 Ok(Extension::Reorganized {
321 fork_height: self.start_height + u64::try_from(base)?,
322 appended: fresh.len(),
323 })
324 }
325
326 fn append(&mut self, fresh: &[Header], now: u32) -> Result<(), Error> {
327 let original = self.headers.len();
328 for header in fresh {
329 match self.validate_next(&self.headers, header, now) {
330 Ok(()) => {
331 self.index.insert(header.block_hash(), self.headers.len());
332 self.headers.push(*header);
333 }
334 Err(error) => {
335 for rejected in &self.headers[original..] {
336 self.index.remove(&rejected.block_hash());
337 }
338 self.headers.truncate(original);
339 return Err(error);
340 }
341 }
342 }
343 Ok(())
344 }
345
346 fn validate_next(&self, window: &[Header], header: &Header, now: u32) -> Result<(), Error> {
347 self.validate_context(window, header)?;
348 let horizon = now
349 .checked_add(P2P_MAX_FUTURE_SECS)
350 .context("clock horizon overflow")?;
351 ensure!(
352 header.time <= horizon,
353 "peer header time is too far in the future"
354 );
355 Ok(())
356 }
357
358 fn validate_context(&self, window: &[Header], header: &Header) -> Result<(), Error> {
359 let last = window.last().context("header window is empty")?;
360 ensure!(
361 header.prev_blockhash == last.block_hash(),
362 "peer header does not extend its predecessor"
363 );
364 let (first_height, context) = self.preceding_context(window)?;
365 if window.len() == 1 && self.trusted_next.is_some() {
366 ensure!(
367 Some(header.block_hash()) == self.trusted_next,
368 "peer header after the checkpoint is not the checkpointed block"
369 );
370 } else {
371 let required = expected_bits(self.chain, &context, first_height, header.time).map_err(
372 |cause| match cause {
373 cause @ (PowError::ShortWindow { .. }
374 | PowError::MissingDifficultyContext { .. }) => Error::HeaderContext(cause),
375 cause => Error::Invalid(cause.to_string()),
376 },
377 )?;
378 ensure!(
379 header.bits == required,
380 "peer header bits {:08x} differ from the required {:08x}",
381 header.bits.to_consensus(),
382 required.to_consensus()
383 );
384 }
385 ensure!(
386 header.time > median_time(&context),
387 "peer header time is not after the median of the previous blocks"
388 );
389 Ok(())
390 }
391
392 fn preceding_context<'a>(
393 &self,
394 window: &'a [Header],
395 ) -> Result<(u64, Cow<'a, [Header]>), Error> {
396 let Some(ancestors) = &self.ancestors else {
397 return Ok((self.start_height, Cow::Borrowed(window)));
398 };
399 let bound = context_headers(self.chain) + 1;
400 let next_height = self
401 .start_height
402 .checked_add(u64::try_from(window.len())?)
403 .context("header context height overflow")?;
404 let tail = if window.len() >= bound {
405 Cow::Borrowed(&window[window.len() - bound..])
406 } else {
407 let count = (bound - window.len()).min(ancestors.len());
408 let mut tail = Vec::with_capacity(count + window.len());
409 tail.extend_from_slice(&ancestors[ancestors.len() - count..]);
410 tail.extend_from_slice(window);
411 Cow::Owned(tail)
412 };
413 let first_height = next_height
414 .checked_sub(u64::try_from(tail.len())?)
415 .context("header context precedes genesis")?;
416 Ok((first_height, tail))
417 }
418}
419
420pub fn prove_batch(chain: Chain, headers: &[Header]) -> Result<(), Error> {
421 if headers.is_empty() {
422 return Ok(());
423 }
424 let workers = std::thread::available_parallelism()?
425 .get()
426 .min(P2P_POW_WORKERS)
427 .min(headers.len());
428 let chunk = headers.len().div_ceil(workers);
429 let verdicts: Vec<Result<(), (usize, String)>> = std::thread::scope(|scope| {
430 let mut handles = Vec::new();
431 for (slot, part) in headers.chunks(chunk).enumerate() {
432 handles.push(scope.spawn(move || {
433 for (offset, header) in part.iter().enumerate() {
434 check_header_pow(header, chain)
435 .map_err(|cause| (slot * chunk + offset, cause.to_string()))?;
436 }
437 Ok(())
438 }));
439 }
440 let mut verdicts = Vec::new();
441 for handle in handles {
442 match handle.join() {
443 Ok(verdict) => verdicts.push(verdict),
444 Err(panic) => {
445 tracing::error!(?panic, "header proof worker panicked");
446 verdicts.push(Err((usize::MAX, "header proof worker panicked".into())));
447 }
448 }
449 }
450 verdicts
451 });
452 for verdict in verdicts {
453 match verdict {
454 Ok(()) => (),
455 Err((offset, cause)) => {
456 return Err(Error::Invalid(format!(
457 "header at batch offset {offset} fails proof of work: {cause}"
458 )));
459 }
460 }
461 }
462 Ok(())
463}
464
465fn median_time(window: &[Header]) -> u32 {
466 let start = window.len().max(P2P_MEDIAN_TIME_SPAN) - P2P_MEDIAN_TIME_SPAN;
467 let mut times: Vec<u32> = window[start..].iter().map(|header| header.time).collect();
468 times.sort_unstable();
469 times[times.len() / 2]
470}