cortex_ledger/external_sink/ots/adapter.rs
1//! Live OpenTimestamps submit + verify adapter (Gate 4).
2//!
3//! This is the trust-path consumer of [`super::OtsParser`]. It bridges
4//! between the [`crate::external_sink::ExternalReceipt`] envelope and
5//! the typed [`super::TypedOtsProof`] output of the trait wrapper.
6//!
7//! Doctrine bindings:
8//!
9//! - **HTTP injection.** Cargo.lock mutation is only authorized for
10//! `opentimestamps` itself (operator decision #4). The adapter does
11//! not pin a specific HTTP client; the [`CalendarClient`] trait is
12//! the seam an operator's deployment plugs into. The default
13//! [`NoopCalendarClient`] is hard-coded to refuse and exists so the
14//! CLI surface compiles without an HTTP dependency — a real
15//! submission requires an operator-supplied client.
16//! - **Pending → Partial always.** [`verify_receipt`] maps Pending to
17//! [`OtsVerificationOutcome::Partial`] regardless of caller flag
18//! (hard rule). The CLI / verifier crate converts the local outcome
19//! to `cortex_verifier::state::VerifiedTrustState` so that this
20//! crate avoids depending on `cortex-verifier` (which already
21//! depends on `cortex-ledger`).
22//! - **Bitcoin cross-check.** A `BitcoinConfirmed` proof is only
23//! promoted to [`OtsVerificationOutcome::FullChainVerified`] when
24//! the adapter has a [`BitcoinHeaderSource`] that confirms the
25//! attestation's block height carries a header whose merkle root
26//! matches the recomputed commitment-op digest. Without a header
27//! source (v1 default) the adapter degrades to `Partial` with the
28//! `ots.bitcoin_confirmed.block_header_mismatch` invariant.
29//! - **N≥2 disjoint-authority calendar quorum** (council 2026-05-12
30//! Decision Q1, UNANIMOUS). A receipt history MAY produce
31//! `FullChainVerified` only when at least two calendars in the
32//! history witness the same anchor AND at least one of them belongs
33//! to a different operator. The three Todd-administered endpoints
34//! (`a.pool`, `alice`, `bob`) collapse to one authority for the
35//! disjointness check; the Eternitywall Finney endpoint is the
36//! default non-Todd witness. Failures surface the stable
37//! [`OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT`]
38//! (`ots.disjoint_authority.quorum_not_met`) token and the receipt
39//! is downgraded to `Partial`.
40//! - **Headers-only HTTPS Bitcoin transport** (council 2026-05-12
41//! Decision Q2 + Q3, UNANIMOUS). The
42//! [`HttpsHeadersBitcoinHeaderSource`] fetches block headers from
43//! N administratively-disjoint HTTPS providers and requires
44//! byte-identical responses from at least N providers (default 2).
45//! Single-provider + local PoW alone cannot detect block
46//! withholding / stale-tip attacks (Q3 doctrinaire vote), so the
47//! quorum is mandatory. Failures surface
48//! `ots.bitcoin_header_quorum.providers_disagree` or
49//! `ots.bitcoin_header_quorum.unreachable`.
50
51use chrono::{DateTime, Utc};
52use serde_json::json;
53
54use super::{
55 DefaultOtsParser, OtsError, OtsParser, TypedOtsProof,
56 OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT,
57 OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT, OTS_BITCOIN_HEADER_POW_INVALID_INVARIANT,
58 OTS_BITCOIN_HEADER_QUORUM_PROVIDERS_DISAGREE_INVARIANT,
59 OTS_BITCOIN_HEADER_QUORUM_UNREACHABLE_INVARIANT,
60 OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT,
61 OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT,
62};
63use crate::anchor::LedgerAnchor;
64use crate::external_sink::{anchor_text_sha256, ExternalReceipt, ExternalSink};
65
66/// Default public OTS calendar pool (council 2026-05-12 Q1 ratified
67/// default set, UNANIMOUS). Operators may override via
68/// `--sink-endpoint` on the CLI; this constant is the fallback used by
69/// the documented happy path in `docs/RUNBOOK.md`.
70///
71/// Back-compat alias: points at the FIRST entry of
72/// [`DEFAULT_OTS_CALENDAR_URLS`] (`a.pool.opentimestamps.org`,
73/// Peter-Todd-operated). New code SHOULD prefer
74/// [`DEFAULT_OTS_CALENDAR_URLS`] so the operator-disjoint quorum check
75/// (see [`enforce_disjoint_authority_quorum`]) has more than one
76/// authority to draw from.
77pub const DEFAULT_OTS_CALENDAR_URL: &str = "https://a.pool.opentimestamps.org";
78
79/// Default OpenTimestamps calendar list ratified by council
80/// 2026-05-12 Decision Q1 (UNANIMOUS). The first three endpoints are
81/// administered by Peter Todd and collapse to a single authority for
82/// the disjoint-authority quorum check ([`calendar_operator`]); the
83/// Eternitywall Finney endpoint is the default non-Todd witness.
84///
85/// Fan-out submission against this list is what makes a
86/// `FullChainVerified` promotion reachable under the
87/// N≥2-distinct-operators rule documented at the module level. Calling
88/// only the first entry (`a.pool`) keeps the receipt history at a
89/// single Todd-operated authority and pins promotion to `Partial` with
90/// [`OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT`].
91pub const DEFAULT_OTS_CALENDAR_URLS: &[&str] = &[
92 "https://a.pool.opentimestamps.org",
93 "https://alice.btc.calendar.opentimestamps.org",
94 "https://bob.btc.calendar.opentimestamps.org",
95 "https://finney.calendar.eternitywall.com",
96];
97
98/// Operator-of-record for each OTS calendar URL. Council 2026-05-12
99/// Decision Q1 doctrinaire position: three Todd endpoints share a
100/// single administrative authority and MUST collapse for any
101/// disjoint-authority quorum check.
102///
103/// The mapping needles are **canonical hostnames** (no scheme, no path,
104/// no port). [`calendar_operator`] parses the candidate URL, extracts
105/// the host, and matches with `host == needle || host.ends_with(".{needle}")`
106/// so that legitimate subdomains (e.g. `alice.btc.calendar.opentimestamps.org`
107/// under the `calendar.opentimestamps.org` zone) resolve to the
108/// expected operator while attacker-supplied URLs that embed a known
109/// hostname in their path or query (e.g.
110/// `https://attacker.example/?h=finney.calendar.eternitywall.com`) do
111/// **not**. See [`calendar_operator`] and Bug Hunt 2026-05-12 finding
112/// BH-1 for the substring-match regression this exact-host rule closes.
113pub const OTS_CALENDAR_OPERATORS: &[(&str, &str)] = &[
114 ("a.pool.opentimestamps.org", "peter_todd"),
115 ("alice.btc.calendar.opentimestamps.org", "peter_todd"),
116 ("bob.btc.calendar.opentimestamps.org", "peter_todd"),
117 ("finney.calendar.eternitywall.com", "eternitywall"),
118];
119
120/// Extract the host component of `url` for [`calendar_operator`].
121///
122/// Returns `None` if the input cannot be parsed as an absolute URL
123/// with a host. This is option B from Bug Hunt 2026-05-12 BH-1: avoid
124/// pulling the `url` crate into the workspace (zero `Cargo.lock`
125/// delta) and parse manually.
126///
127/// Recognized shape: `scheme://[userinfo@]host[:port][/path][?query][#frag]`.
128/// `scheme` MUST be present (`://` delimiter); anything that fails the
129/// recognized shape returns `None` and therefore matches no operator.
130/// The host is lower-cased (DNS is case-insensitive) so the
131/// exact-match comparison against the lower-case needles in
132/// [`OTS_CALENDAR_OPERATORS`] is deterministic.
133fn calendar_host_lower(url: &str) -> Option<String> {
134 // Require `scheme://` — no scheme means we cannot trust which
135 // component is the host. Refuse rather than guess.
136 let after_scheme = {
137 let idx = url.find("://")?;
138 let scheme = &url[..idx];
139 // RFC 3986 scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
140 if scheme.is_empty()
141 || !scheme
142 .chars()
143 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
144 || !scheme
145 .chars()
146 .next()
147 .is_some_and(|c| c.is_ascii_alphabetic())
148 {
149 return None;
150 }
151 &url[idx + 3..]
152 };
153
154 // Authority component ends at the first '/', '?', or '#'.
155 let authority_end = after_scheme
156 .find(['/', '?', '#'])
157 .unwrap_or(after_scheme.len());
158 let authority = &after_scheme[..authority_end];
159 if authority.is_empty() {
160 return None;
161 }
162
163 // Strip optional `userinfo@`. RFC 3986 allows '@' inside userinfo
164 // only as a percent-encoded triplet (`%40`), so a literal '@' is
165 // unambiguous. Use rfind so that a stray '@' inside userinfo (e.g.
166 // `user@:p@ss@host`) still leaves the rightmost '@' as the
167 // host-delimiter — same behavior as `url::Url`.
168 let host_port = match authority.rfind('@') {
169 Some(idx) => &authority[idx + 1..],
170 None => authority,
171 };
172
173 // Strip optional `:port`. IPv6 literals are bracketed (`[::1]:443`)
174 // — only treat the LAST `:` outside any bracket pair as the port
175 // delimiter. We do not actually support OTS calendar URLs over
176 // IPv6 today, but handle the shape correctly so a future bracket
177 // form does not slip past the host check.
178 let host = if let Some(stripped) = host_port.strip_prefix('[') {
179 let close = stripped.find(']')?;
180 &host_port[..close + 2] // include the closing ']' and bracketed host
181 } else if let Some(colon) = host_port.rfind(':') {
182 &host_port[..colon]
183 } else {
184 host_port
185 };
186
187 if host.is_empty() {
188 return None;
189 }
190
191 // Reject any host that still contains characters outside the
192 // RFC 3986 `reg-name` / IP-literal grammar. Notably refuse
193 // whitespace, slashes, '?', '#', and '@' — defense in depth in
194 // case a future refactor reorders the splits above.
195 if host
196 .chars()
197 .any(|c| c.is_whitespace() || matches!(c, '/' | '?' | '#' | '@'))
198 {
199 return None;
200 }
201
202 Some(host.to_ascii_lowercase())
203}
204
205/// Look up the operator-of-record for an OTS calendar URL by
206/// **exact-host** match against [`OTS_CALENDAR_OPERATORS`]. Returns
207/// `None` for an unknown calendar, for any input that does not parse
208/// as an absolute URL with a host, or for any host that is neither
209/// the canonical needle nor a subdomain of it.
210///
211/// Matching rule per needle `n`: `host == n || host.ends_with(".{n}")`.
212/// The subdomain clause covers the legitimate fan-out shape (e.g.
213/// `alice.btc.calendar.opentimestamps.org` under the
214/// `calendar.opentimestamps.org` zone) without admitting attacker
215/// strings that embed a known hostname in a query or path component.
216///
217/// Council Q1 hard rule: the disjoint-authority quorum check treats
218/// unknown calendars as their own (unverifiable) authority and refuses
219/// to count them toward a `FullChainVerified` promotion. See Bug Hunt
220/// 2026-05-12 BH-1 (substring-match bypass) for the regression this
221/// closes.
222#[must_use]
223pub fn calendar_operator(url: &str) -> Option<&'static str> {
224 let host = calendar_host_lower(url)?;
225 for (needle, operator) in OTS_CALENDAR_OPERATORS {
226 // Needles are stored lower-case; assert (debug only) that the
227 // invariant holds so a future maintainer cannot quietly add a
228 // mixed-case entry that bypasses the exact-host check.
229 debug_assert_eq!(
230 *needle,
231 needle.to_ascii_lowercase(),
232 "OTS_CALENDAR_OPERATORS needles MUST be lower-case to match calendar_host_lower output",
233 );
234 if host == *needle {
235 return Some(*operator);
236 }
237 // Subdomain match: `alice.btc.calendar.opentimestamps.org`
238 // matches `calendar.opentimestamps.org`. Use a leading-dot
239 // check so `evilopentimestamps.org` does NOT match
240 // `opentimestamps.org` and `xfinney.calendar.eternitywall.com`
241 // does NOT match `finney.calendar.eternitywall.com`.
242 if host.len() > needle.len() + 1
243 && host.ends_with(needle)
244 && host.as_bytes()[host.len() - needle.len() - 1] == b'.'
245 {
246 return Some(*operator);
247 }
248 }
249 None
250}
251
252/// Pluggable calendar HTTP client. The trait exists so operators (and
253/// tests) can supply their own transport without making `cortex-ledger`
254/// depend on a heavyweight HTTP crate.
255///
256/// Contract: `digest` is exactly 32 bytes (SHA-256). The client POSTs
257/// it to `<calendar_url>/digest` per the OpenTimestamps calendar API
258/// and returns the raw `.ots` Pending proof bytes.
259pub trait CalendarClient {
260 /// Submit a SHA-256 digest to the calendar and return the Pending
261 /// `.ots` bytes the server emits.
262 fn submit_digest(&self, calendar_url: &str, digest: &[u8; 32]) -> Result<Vec<u8>, OtsError>;
263}
264
265/// Fail-closed default [`CalendarClient`]. Operators MUST inject a
266/// real client to actually submit. Exists so the CLI surface compiles
267/// without binding cortex-ledger to a specific HTTP stack and so a
268/// missing-transport misconfiguration surfaces as an obvious adapter
269/// error rather than silently working against a mock.
270#[derive(Debug, Default, Clone, Copy)]
271pub struct NoopCalendarClient;
272
273impl CalendarClient for NoopCalendarClient {
274 fn submit_digest(&self, calendar_url: &str, _digest: &[u8; 32]) -> Result<Vec<u8>, OtsError> {
275 Err(OtsError::OtsCrateError(format!(
276 "no live calendar HTTP client configured for `{calendar_url}` (NoopCalendarClient); \
277 operator must inject a CalendarClient implementation"
278 )))
279 }
280}
281
282/// Default per-request HTTP timeout for the live [`UreqCalendarClient`].
283/// Matches [`HTTPS_HEADER_PROVIDER_TIMEOUT`]: short enough that a slow
284/// calendar does not strand the operator, long enough to absorb normal
285/// jitter on public OTS infra.
286const UREQ_CALENDAR_CLIENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
287
288/// Live [`CalendarClient`] backed by the workspace-authorized `ureq`
289/// HTTP stack (council Q5 / Decision #4 of
290/// `docs/decisions/COUNCIL_TIEBREAKS_2026_05_14.md`).
291///
292/// Mirrors the construction pattern already in use by
293/// [`HttpsHeadersBitcoinHeaderSource::fetch_one_provider`] (this same
294/// file) and `crates/cortex-ledger/src/external_sink/rekor.rs`. No new
295/// direct dep edges — `ureq` is already a `cortex-ledger` workspace
296/// dependency at `crates/cortex-ledger/Cargo.toml`.
297///
298/// Contract per [`CalendarClient::submit_digest`]:
299///
300/// - POSTs the 32-byte SHA-256 digest to `{calendar_url}/digest`.
301/// - Returns the raw Pending `.ots` bytes the calendar emits.
302/// - Wraps any transport error as
303/// [`OtsError::OtsCrateError`] (consistent with the existing
304/// `HttpsHeadersBitcoinHeaderSource::fetch_one_provider` convention
305/// at this file).
306/// - Retry policy: single-shot. The structural redundancy is the fan-out
307/// across [`DEFAULT_OTS_CALENDAR_URLS`] (council Q1) at the caller —
308/// per-URL retries inside this client double-count witnesses and can
309/// mask a quorum-failing outage.
310#[derive(Debug, Clone, Copy)]
311pub struct UreqCalendarClient {
312 timeout: std::time::Duration,
313}
314
315impl Default for UreqCalendarClient {
316 fn default() -> Self {
317 Self::new()
318 }
319}
320
321impl UreqCalendarClient {
322 /// Construct a new [`UreqCalendarClient`] with the default
323 /// per-request timeout ([`UREQ_CALENDAR_CLIENT_TIMEOUT`], 15 s).
324 #[must_use]
325 pub const fn new() -> Self {
326 Self {
327 timeout: UREQ_CALENDAR_CLIENT_TIMEOUT,
328 }
329 }
330
331 /// Override the per-request HTTP timeout. Mirrors
332 /// [`HttpsHeadersBitcoinHeaderSource::with_timeout`].
333 #[must_use]
334 pub const fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
335 self.timeout = timeout;
336 self
337 }
338}
339
340impl CalendarClient for UreqCalendarClient {
341 fn submit_digest(&self, calendar_url: &str, digest: &[u8; 32]) -> Result<Vec<u8>, OtsError> {
342 let agent = ureq::AgentBuilder::new().timeout(self.timeout).build();
343 let url = format!("{}/digest", calendar_url.trim_end_matches('/'));
344 let response = agent
345 .post(&url)
346 .set("Content-Type", "application/vnd.opentimestamps.v1")
347 .send_bytes(digest)
348 .map_err(|err| OtsError::OtsCrateError(format!("POST {url}: {err}")))?;
349 let mut bytes = Vec::new();
350 use std::io::Read as _;
351 response
352 .into_reader()
353 .read_to_end(&mut bytes)
354 .map_err(|err| OtsError::OtsCrateError(format!("read body {url}: {err}")))?;
355 Ok(bytes)
356 }
357}
358
359/// Submit a position-bound [`LedgerAnchor`] to an OpenTimestamps
360/// calendar via the supplied [`CalendarClient`] and return the
361/// `ExternalReceipt` sidecar.
362///
363/// The digest sent to the calendar is the SHA-256 of the canonical
364/// anchor text — same value `verify_external_receipts` recomputes from
365/// the local ledger. This keeps the round trip a closed contract:
366/// `anchor_text_sha256` is the only digest the calendar ever sees, and
367/// it is the only digest the receipt envelope carries.
368pub fn submit<C>(
369 anchor: &LedgerAnchor,
370 calendar_url: &str,
371 submitted_at: DateTime<Utc>,
372 client: &C,
373) -> Result<ExternalReceipt, OtsError>
374where
375 C: CalendarClient + ?Sized,
376{
377 let anchor_text = anchor.to_anchor_text();
378 let digest = sha256_bytes(anchor_text.as_bytes());
379 let ots_bytes = client.submit_digest(calendar_url, &digest)?;
380
381 // Sanity-check the bytes round-trip through our quarantine parser
382 // before we hand them off in a receipt. Any rejection here is a
383 // calendar misbehavior; surface it as the original `OtsError`.
384 DefaultOtsParser.parse_with_submitted_at(&ots_bytes, submitted_at)?;
385
386 let receipt_payload = json!({
387 "ots_proof_base64": base64_standard(&ots_bytes),
388 "calendar_url": calendar_url,
389 "submitted_digest_hex": hex_lower(&digest),
390 });
391
392 Ok(ExternalReceipt {
393 sink: ExternalSink::OpenTimestamps,
394 anchor_text_sha256: anchor_text_sha256(anchor),
395 anchor_event_count: anchor.event_count,
396 anchor_chain_head_hash: anchor.chain_head_hash.clone(),
397 submitted_at,
398 sink_endpoint: calendar_url.to_string(),
399 receipt: receipt_payload,
400 })
401}
402
403/// Source for Bitcoin block-header bytes used by [`verify_receipt`] to
404/// promote a `BitcoinConfirmed` proof to
405/// [`OtsVerificationOutcome::FullChainVerified`].
406///
407/// v1 default: operator-supplied file ([`StaticBitcoinHeaderSource`]).
408/// A future slice may add a Bitcoin RPC client behind this trait, but
409/// the trait is the seam that lets that landing be additive.
410pub trait BitcoinHeaderSource {
411 /// Return the 80-byte Bitcoin block header serialized in canonical
412 /// little-endian form. The adapter parses out the merkle root for
413 /// the cross-check.
414 fn header_for_height(&self, height: u64) -> Result<Vec<u8>, OtsError>;
415}
416
417/// In-memory [`BitcoinHeaderSource`] backed by an operator-supplied
418/// `(height -> header_bytes)` map. The CLI loads these from a
419/// `--bitcoin-header` file before invoking the verifier.
420#[derive(Debug, Default, Clone)]
421pub struct StaticBitcoinHeaderSource {
422 headers: Vec<(u64, Vec<u8>)>,
423}
424
425impl StaticBitcoinHeaderSource {
426 /// Construct an empty source. Use [`Self::with_header`] to add
427 /// known headers.
428 #[must_use]
429 pub const fn new() -> Self {
430 Self {
431 headers: Vec::new(),
432 }
433 }
434
435 /// Register a height-to-header pair.
436 #[must_use]
437 pub fn with_header(mut self, height: u64, header_bytes: Vec<u8>) -> Self {
438 self.headers.push((height, header_bytes));
439 self
440 }
441}
442
443impl BitcoinHeaderSource for StaticBitcoinHeaderSource {
444 fn header_for_height(&self, height: u64) -> Result<Vec<u8>, OtsError> {
445 self.headers
446 .iter()
447 .find(|(h, _)| *h == height)
448 .map(|(_, bytes)| bytes.clone())
449 .ok_or_else(|| {
450 OtsError::OtsCrateError(format!(
451 "no operator-supplied Bitcoin block header registered for height {height}"
452 ))
453 })
454 }
455}
456
457/// Default HTTPS Bitcoin header provider set ratified by council
458/// 2026-05-12 Decision Q3 (UNANIMOUS). Two administratively-disjoint
459/// public Esplora-style endpoints; both serve raw 80-byte block
460/// headers at `/block-height/<H>` → `/block/<HASH>/header`.
461///
462/// Operators MAY add a third provider for high-assurance deployments
463/// (council "N=3 opt-in" minority position from the cost-conscious
464/// pragmatist) but the default is `N = 2` (cap from the operational
465/// realist).
466pub const DEFAULT_HTTPS_HEADER_PROVIDERS: &[&str] =
467 &["https://mempool.space", "https://blockstream.info"];
468
469/// Minimum number of byte-identical HTTPS provider responses required
470/// for a Bitcoin block header to be accepted by
471/// [`HttpsHeadersBitcoinHeaderSource`]. Council 2026-05-12 Decision Q3:
472/// single-provider + local PoW alone cannot detect block withholding /
473/// stale-tip attacks.
474pub const DEFAULT_HTTPS_HEADER_QUORUM_N: usize = 2;
475
476/// Default HTTP timeout per provider request. Kept short on purpose —
477/// the adapter fans out and waits for quorum, so a slow provider must
478/// not block the receipt verifier indefinitely.
479const HTTPS_HEADER_PROVIDER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
480
481/// Headers-only HTTPS [`BitcoinHeaderSource`] implementation ratified
482/// by council 2026-05-12 Decision Q2 (pivot away from
483/// `bitcoincore-rpc`, archived 2025-11-25) and Q3 (N-provider quorum
484/// is mandatory; single-provider + local PoW cannot detect a
485/// withholding / stale-tip attack).
486///
487/// **Trust model (Q3 doctrinaire vote, UNANIMOUS):**
488///
489/// 1. Fetch the 80-byte block header from EACH provider URL in the
490/// constructor's list, using the workspace-authorized `ureq` HTTP
491/// stack (council Q5).
492/// 2. Require at least [`DEFAULT_HTTPS_HEADER_QUORUM_N`] (configurable
493/// via [`Self::with_quorum_n`]) **byte-identical** responses. Two
494/// providers serving the same 80 bytes corroborates the tip
495/// against an administratively-disjoint adversary set; one
496/// provider plus local PoW does not detect "I withheld the chain
497/// you wanted". Provider disagreement → fail closed with
498/// [`OTS_BITCOIN_HEADER_QUORUM_PROVIDERS_DISAGREE_INVARIANT`].
499/// Unreachable below `N` → fail closed with
500/// [`OTS_BITCOIN_HEADER_QUORUM_UNREACHABLE_INVARIANT`].
501/// 3. After the quorum check, verify locally:
502/// - header is exactly 80 bytes (the `BitcoinHeaderSource` contract
503/// already required this; the adapter re-checks defensively),
504/// - SHA-256d of the header is ≤ the target encoded in `nBits`
505/// (proof-of-work), and
506/// - `prev_block_hash` continuity against the previous header in
507/// the chain (see "Stub" below).
508///
509/// **Stub (documented):** the prev-hash chain continuity check
510/// requires a hardcoded recent checkpoint plus a rebuild of every
511/// header from the checkpoint to the cited height. That side-table
512/// material is out of scope for this slice (council Q3 acknowledged
513/// it as a separate piece of state: `header_quorum.rs`); this
514/// implementation enforces (1) and (2) and the per-header PoW check
515/// from (3), and explicitly defers the prev-hash chain rebuild to a
516/// follow-up slice. The deferral is intentional and documented here
517/// so the trust-model claim does not silently overpromise.
518///
519/// Operators wiring this into a CLI MUST also persist a long-term
520/// checkpoint store before promoting an OTS receipt to
521/// `FullChainVerified` on a header that this source returns alone;
522/// see `docs/RUNBOOK.md` §"OpenTimestamps anchor publication".
523#[derive(Debug, Clone)]
524pub struct HttpsHeadersBitcoinHeaderSource {
525 providers: Vec<String>,
526 quorum_n: usize,
527 timeout: std::time::Duration,
528}
529
530impl HttpsHeadersBitcoinHeaderSource {
531 /// Construct a new source from a list of provider base URLs.
532 /// Providers SHOULD be administratively disjoint (e.g.
533 /// `mempool.space` + `blockstream.info`); the council's
534 /// disjointness requirement is enforced by the operator's choice
535 /// of URL list, not by this constructor.
536 ///
537 /// Quorum defaults to [`DEFAULT_HTTPS_HEADER_QUORUM_N`]; override
538 /// with [`Self::with_quorum_n`]. A `quorum_n` greater than the
539 /// number of supplied providers is permissible — verification
540 /// will simply always fail closed with the
541 /// `unreachable` invariant.
542 #[must_use]
543 pub fn new(providers: Vec<String>) -> Self {
544 Self {
545 providers,
546 quorum_n: DEFAULT_HTTPS_HEADER_QUORUM_N,
547 timeout: HTTPS_HEADER_PROVIDER_TIMEOUT,
548 }
549 }
550
551 /// Override the quorum size. The lower bound of 1 is permitted
552 /// for offline / test wiring but the council recommendation
553 /// (Decision Q3) is `>= 2`.
554 #[must_use]
555 pub const fn with_quorum_n(mut self, quorum_n: usize) -> Self {
556 self.quorum_n = quorum_n;
557 self
558 }
559
560 /// Override the per-request HTTP timeout.
561 #[must_use]
562 pub const fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
563 self.timeout = timeout;
564 self
565 }
566
567 /// Borrow the configured provider URL list.
568 #[must_use]
569 pub fn providers(&self) -> &[String] {
570 &self.providers
571 }
572
573 /// Quorum threshold currently in effect.
574 #[must_use]
575 pub const fn quorum_n(&self) -> usize {
576 self.quorum_n
577 }
578
579 /// Fetch the raw 80-byte header from a single provider. Resolves
580 /// the Esplora-style path:
581 ///
582 /// 1. `GET <provider>/block-height/<height>` → block hash string.
583 /// 2. `GET <provider>/block/<hash>/header` → hex-encoded header.
584 ///
585 /// Returns `Err` if the provider is unreachable, returns a
586 /// non-2xx status, returns malformed bytes, or the decoded header
587 /// is not exactly 80 bytes. The caller treats every `Err` the
588 /// same way (provider counts as unreachable for quorum purposes).
589 fn fetch_one_provider(&self, base: &str, height: u64) -> Result<Vec<u8>, String> {
590 let agent = ureq::AgentBuilder::new().timeout(self.timeout).build();
591 let trimmed = base.trim_end_matches('/');
592
593 // Step 1: height → block hash.
594 let height_url = format!("{trimmed}/block-height/{height}");
595 let hash = agent
596 .get(&height_url)
597 .call()
598 .map_err(|err| format!("GET {height_url}: {err}"))?
599 .into_string()
600 .map_err(|err| format!("read body {height_url}: {err}"))?;
601 let hash = hash.trim();
602 if hash.is_empty() || hash.len() > 128 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
603 return Err(format!(
604 "provider {height_url} returned non-hex block hash `{hash}`"
605 ));
606 }
607
608 // Step 2: block hash → 80-byte header (hex-encoded).
609 let header_url = format!("{trimmed}/block/{hash}/header");
610 let header_hex = agent
611 .get(&header_url)
612 .call()
613 .map_err(|err| format!("GET {header_url}: {err}"))?
614 .into_string()
615 .map_err(|err| format!("read body {header_url}: {err}"))?;
616 let header_hex = header_hex.trim();
617 if header_hex.len() != BITCOIN_BLOCK_HEADER_LEN * 2
618 || !header_hex.chars().all(|c| c.is_ascii_hexdigit())
619 {
620 return Err(format!(
621 "provider {header_url} returned malformed header (expected {} hex chars, got {})",
622 BITCOIN_BLOCK_HEADER_LEN * 2,
623 header_hex.len()
624 ));
625 }
626 decode_hex(header_hex).map_err(|err| format!("provider {header_url} hex decode: {err}"))
627 }
628}
629
630impl BitcoinHeaderSource for HttpsHeadersBitcoinHeaderSource {
631 fn header_for_height(&self, height: u64) -> Result<Vec<u8>, OtsError> {
632 // Fetch from every provider. Reachable provider responses are
633 // collected; failures are recorded for diagnostics but do not
634 // short-circuit so that an in-quorum agreement can still
635 // emerge when one provider is flaky.
636 let mut responses: Vec<Vec<u8>> = Vec::with_capacity(self.providers.len());
637 let mut errors: Vec<String> = Vec::new();
638 for base in &self.providers {
639 match self.fetch_one_provider(base, height) {
640 Ok(bytes) => {
641 if bytes.len() == BITCOIN_BLOCK_HEADER_LEN {
642 responses.push(bytes);
643 } else {
644 errors.push(format!(
645 "provider {base} returned {} bytes, expected {BITCOIN_BLOCK_HEADER_LEN}",
646 bytes.len()
647 ));
648 }
649 }
650 Err(err) => errors.push(err),
651 }
652 }
653
654 if responses.len() < self.quorum_n {
655 return Err(OtsError::OtsCrateError(format!(
656 "{OTS_BITCOIN_HEADER_QUORUM_UNREACHABLE_INVARIANT}: only {} of {} providers \
657 reachable (need {}); errors: [{}]",
658 responses.len(),
659 self.providers.len(),
660 self.quorum_n,
661 errors.join("; "),
662 )));
663 }
664
665 // All `responses` must be byte-identical. Group by bytes; the
666 // largest group must hit quorum.
667 let pivot = responses[0].clone();
668 let matching = responses.iter().filter(|r| **r == pivot).count();
669 if matching < self.quorum_n {
670 return Err(OtsError::OtsCrateError(format!(
671 "{OTS_BITCOIN_HEADER_QUORUM_PROVIDERS_DISAGREE_INVARIANT}: only {} of {} reachable \
672 providers returned byte-identical headers for height {height}",
673 matching,
674 responses.len(),
675 )));
676 }
677
678 // Local PoW check: SHA-256d of the 80-byte header ≤ target
679 // encoded in `nBits` (bytes 72..76 of the header in
680 // little-endian compact form). This is the per-header
681 // "self-authenticating under PoW" check from council Q3.
682 if !verify_pow_target(&pivot) {
683 return Err(OtsError::OtsCrateError(format!(
684 "{OTS_BITCOIN_HEADER_POW_INVALID_INVARIANT}: SHA-256d of header for height \
685 {height} exceeds the target encoded in nBits",
686 )));
687 }
688
689 Ok(pivot)
690 }
691}
692
693/// Verify a Bitcoin block header's proof-of-work: `SHA-256d(header)`
694/// interpreted as a little-endian 256-bit integer must be ≤ the
695/// 256-bit target encoded by the `nBits` compact form at bytes
696/// `72..76` of the header.
697fn verify_pow_target(header: &[u8]) -> bool {
698 if header.len() != BITCOIN_BLOCK_HEADER_LEN {
699 return false;
700 }
701 // SHA-256d. Bitcoin reports the hash in display order (big-endian
702 // of the second SHA-256) but PoW compares against the raw little-
703 // endian 32-byte hash. We do the comparison in little-endian byte
704 // order against the expanded target.
705 let first = sha256_bytes(header);
706 let second = sha256_bytes(&first);
707 let mut nbits = [0u8; 4];
708 nbits.copy_from_slice(&header[72..76]);
709 let target = expand_nbits_compact(u32::from_le_bytes(nbits));
710 // Compare as 256-bit integers. `second` is little-endian, `target`
711 // is big-endian — bring both to big-endian for the comparison.
712 let mut hash_be = [0u8; 32];
713 for i in 0..32 {
714 hash_be[i] = second[31 - i];
715 }
716 hash_be <= target
717}
718
719/// Expand a Bitcoin `nBits` compact target representation
720/// (4-byte little-endian field at bytes 72..76 of the block header)
721/// into a 32-byte big-endian target.
722fn expand_nbits_compact(nbits: u32) -> [u8; 32] {
723 let exponent = (nbits >> 24) as u8;
724 let mantissa = nbits & 0x00ff_ffff;
725 let mut target = [0u8; 32];
726 // Below-3-byte mantissas right-shift; above shift left into the
727 // leading bytes. This mirrors the Bitcoin Core CompactSize ->
728 // ArithNum256 expansion.
729 if exponent <= 3 {
730 let m = mantissa >> (8 * (3 - exponent as u32));
731 target[29] = ((m >> 16) & 0xff) as u8;
732 target[30] = ((m >> 8) & 0xff) as u8;
733 target[31] = (m & 0xff) as u8;
734 } else {
735 let shift = exponent as usize - 3;
736 if shift < 30 {
737 target[31 - shift - 2] = ((mantissa >> 16) & 0xff) as u8;
738 target[31 - shift - 1] = ((mantissa >> 8) & 0xff) as u8;
739 target[31 - shift] = (mantissa & 0xff) as u8;
740 }
741 }
742 target
743}
744
745/// Decode a hex string into a byte vector. Local helper to keep this
746/// adapter free of a direct `hex` crate edge.
747fn decode_hex(s: &str) -> Result<Vec<u8>, String> {
748 if !s.len().is_multiple_of(2) {
749 return Err(format!("odd-length hex string ({} chars)", s.len()));
750 }
751 let bytes = s.as_bytes();
752 let mut out = Vec::with_capacity(s.len() / 2);
753 for i in (0..bytes.len()).step_by(2) {
754 let hi = match bytes[i] {
755 b'0'..=b'9' => bytes[i] - b'0',
756 b'a'..=b'f' => bytes[i] - b'a' + 10,
757 b'A'..=b'F' => bytes[i] - b'A' + 10,
758 other => return Err(format!("invalid hex byte {other:#x}")),
759 };
760 let lo = match bytes[i + 1] {
761 b'0'..=b'9' => bytes[i + 1] - b'0',
762 b'a'..=b'f' => bytes[i + 1] - b'a' + 10,
763 b'A'..=b'F' => bytes[i + 1] - b'A' + 10,
764 other => return Err(format!("invalid hex byte {other:#x}")),
765 };
766 out.push((hi << 4) | lo);
767 }
768 Ok(out)
769}
770
771/// Identity record for a witness contributing to an OTS verification.
772///
773/// Mirrors the shape of `cortex_verifier::witness::WitnessSummary`
774/// without forcing a `cortex-verifier` dependency on `cortex-ledger`
775/// (which would cycle, since `cortex-verifier` already depends on us).
776/// CLI / verifier callers convert this into `WitnessSummary` when they
777/// surface a full `VerifiedTrustState`.
778#[derive(Debug, Clone, PartialEq, Eq)]
779pub struct OtsWitness {
780 /// Wire string for the witness class (always
781 /// `"external_anchor_crossing"` for an OTS witness).
782 pub class: &'static str,
783 /// Wire string for the witness authority domain (always
784 /// `"external_anchor_sink"`).
785 pub authority_domain: &'static str,
786 /// Wire string for the witness tier (always `"third_party"` —
787 /// public Bitcoin or a public OTS calendar).
788 pub tier: &'static str,
789 /// Optional signer identity for reporting.
790 pub signer_id: Option<String>,
791 /// When the witness was asserted (operator-recorded submission
792 /// time for Pending; calendar upgrade time for BitcoinConfirmed).
793 pub asserted_at: DateTime<Utc>,
794}
795
796/// Stable invariant + detail bundle for [`OtsVerificationOutcome::Broken`].
797#[derive(Debug, Clone, PartialEq, Eq)]
798pub struct OtsBrokenEdge {
799 /// Stable invariant name (e.g.
800 /// [`OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT`]).
801 pub invariant: &'static str,
802 /// Operator-readable detail string.
803 pub detail: String,
804}
805
806/// Outcome of [`verify_receipt`]. The CLI maps this directly to the
807/// CLI exit table and to
808/// `cortex_verifier::state::VerifiedTrustState` — keeping this enum
809/// local to `cortex-ledger` avoids a circular dependency on
810/// `cortex-verifier`.
811#[derive(Debug, Clone, PartialEq, Eq)]
812pub enum OtsVerificationOutcome {
813 /// Bitcoin header cross-check succeeded — promote to fully verified.
814 FullChainVerified {
815 /// Witness summary surfaced for reporting.
816 witnesses: Vec<OtsWitness>,
817 },
818 /// Verification stayed advisory. Reasons carry the stable invariant
819 /// tokens for downstream consumers.
820 Partial {
821 /// Stable invariant strings (e.g.
822 /// `ots.pending.no_bitcoin_attestation_yet`).
823 reasons: Vec<String>,
824 /// Witness summary surfaced for reporting.
825 witnesses: Vec<OtsWitness>,
826 },
827 /// Verification failed closed (e.g. merkle root mismatch).
828 Broken {
829 /// Failing edge with stable invariant.
830 edge: OtsBrokenEdge,
831 /// Witness summary surfaced for reporting.
832 witnesses: Vec<OtsWitness>,
833 },
834}
835
836impl OtsVerificationOutcome {
837 /// True iff this is [`Self::FullChainVerified`].
838 #[must_use]
839 pub const fn is_full_chain_verified(&self) -> bool {
840 matches!(self, Self::FullChainVerified { .. })
841 }
842
843 /// True iff this is [`Self::Partial`].
844 #[must_use]
845 pub const fn is_partial(&self) -> bool {
846 matches!(self, Self::Partial { .. })
847 }
848
849 /// True iff this is [`Self::Broken`].
850 #[must_use]
851 pub const fn is_broken(&self) -> bool {
852 matches!(self, Self::Broken { .. })
853 }
854
855 /// Stable wire string for the outcome variant.
856 #[must_use]
857 pub const fn wire_str(&self) -> &'static str {
858 match self {
859 Self::FullChainVerified { .. } => "full_chain_verified",
860 Self::Partial { .. } => "partial",
861 Self::Broken { .. } => "broken",
862 }
863 }
864}
865
866/// Verify an [`ExternalReceipt`] whose payload carries an OTS proof.
867///
868/// Returns an [`OtsVerificationOutcome`] reflecting the strongest claim
869/// that holds:
870///
871/// - [`TypedOtsProof::Pending`] → always
872/// [`OtsVerificationOutcome::Partial`] with the stable
873/// `ots.pending.no_bitcoin_attestation_yet` reason. **Never**
874/// `FullChainVerified`, regardless of the caller's flags. This is
875/// the hard Pending → Partial mapping from operator decision #3.
876/// - [`TypedOtsProof::BitcoinConfirmed`] with a
877/// [`BitcoinHeaderSource`] that confirms the cited block height has
878/// a header whose merkle root equals the recomputed digest →
879/// [`OtsVerificationOutcome::FullChainVerified`].
880/// - [`TypedOtsProof::BitcoinConfirmed`] without a header source →
881/// [`OtsVerificationOutcome::Partial`] with the
882/// `ots.bitcoin_confirmed.block_header_mismatch` reason.
883/// - [`TypedOtsProof::BitcoinConfirmed`] with a header source that
884/// returns bytes whose merkle root does not match →
885/// [`OtsVerificationOutcome::Broken`] with the
886/// `ots.bitcoin_confirmed.merkle_path_invalid` invariant.
887///
888/// Any [`OtsError`] surfaced by the parser is returned verbatim so the
889/// CLI can map it to the right exit code.
890pub fn verify_receipt<P, B>(
891 receipt: &ExternalReceipt,
892 parser: &P,
893 bitcoin_source: Option<&B>,
894) -> Result<OtsVerificationOutcome, OtsError>
895where
896 P: OtsParser + ?Sized,
897 B: BitcoinHeaderSource + ?Sized,
898{
899 if receipt.sink != ExternalSink::OpenTimestamps {
900 return Err(OtsError::OtsCrateError(format!(
901 "external receipt sink `{}` is not `opentimestamps`; refusing to verify via OTS adapter",
902 receipt.sink,
903 )));
904 }
905 let ots_bytes = extract_ots_proof_bytes(receipt)?;
906 let parsed = parser.parse(&ots_bytes)?;
907
908 let witness = OtsWitness {
909 class: "external_anchor_crossing",
910 authority_domain: "external_anchor_sink",
911 tier: "third_party",
912 signer_id: Some(receipt.sink_endpoint.clone()),
913 asserted_at: receipt.submitted_at,
914 };
915
916 match parsed {
917 TypedOtsProof::Pending { .. } => Ok(OtsVerificationOutcome::Partial {
918 reasons: vec![OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT.to_string()],
919 witnesses: vec![witness],
920 }),
921 TypedOtsProof::BitcoinConfirmed {
922 block_height,
923 merkle_path_digest,
924 ..
925 } => verify_bitcoin_confirmed(block_height, &merkle_path_digest, bitcoin_source, witness),
926 }
927}
928
929/// Helper exposed so the CLI can call [`verify_receipt`] with the
930/// trait-default parser when it does not need test substitution.
931pub fn verify_receipt_with_defaults<B>(
932 receipt: &ExternalReceipt,
933 bitcoin_source: Option<&B>,
934) -> Result<OtsVerificationOutcome, OtsError>
935where
936 B: BitcoinHeaderSource + ?Sized,
937{
938 let parser = DefaultOtsParser;
939 verify_receipt(receipt, &parser, bitcoin_source)
940}
941
942/// Minimum number of administratively-disjoint operators required for
943/// a receipt history to support a `FullChainVerified` promotion.
944/// Council 2026-05-12 Decision Q1 (UNANIMOUS).
945pub const OTS_DISJOINT_AUTHORITY_MIN_OPERATORS: usize = 2;
946
947/// Apply the council-mandated N≥2 disjoint-authority quorum gate to a
948/// per-receipt verification outcome.
949///
950/// Inputs:
951///
952/// - `candidate`: the outcome produced by [`verify_receipt`] for a
953/// single receipt in the history. The function is a no-op for
954/// anything that is not already `FullChainVerified` — downgrade
955/// logic only ever moves a `FullChainVerified` outcome to `Partial`.
956/// - `history_witnesses`: the union of [`OtsWitness::signer_id`]
957/// strings across the **entire** receipt history. The endpoint URL
958/// is interpreted as the calendar identity and run through
959/// [`calendar_operator`] to extract the operator-of-record. Unknown
960/// calendars (those not in [`OTS_CALENDAR_OPERATORS`]) do NOT count
961/// toward the disjoint quorum — they are excluded from the operator
962/// set entirely. This is the conservative read of council 2026-05-12
963/// Q1 ("at least one is non-Todd"): an operator that cannot be
964/// classified cannot be asserted as non-Todd.
965///
966/// Returns:
967///
968/// - `FullChainVerified` verbatim when the history's witnesses include
969/// at least [`OTS_DISJOINT_AUTHORITY_MIN_OPERATORS`] distinct
970/// *known* operators.
971/// - A `Partial` outcome carrying the
972/// [`OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT`] token
973/// whenever the candidate was `FullChainVerified` but the
974/// disjoint-operator count fell short. Doctrine note: a one-witness
975/// history can never produce `FullChainVerified` — that is
976/// intentional per the council's "Mechanism C requires disjoint
977/// authority domains" position.
978/// - Any other input is returned verbatim. The function never
979/// *upgrades* an outcome.
980///
981/// The function is pure and can be called by the CLI verifier path
982/// after it has aggregated per-receipt outcomes from the history.
983#[must_use]
984pub fn enforce_disjoint_authority_quorum(
985 candidate: OtsVerificationOutcome,
986 history_witnesses: &[OtsWitness],
987) -> OtsVerificationOutcome {
988 if !candidate.is_full_chain_verified() {
989 return candidate;
990 }
991
992 let distinct_operators = distinct_known_operators(history_witnesses);
993
994 if distinct_operators >= OTS_DISJOINT_AUTHORITY_MIN_OPERATORS {
995 return candidate;
996 }
997
998 // Preserve the witness set of the original candidate so the
999 // operator can still see which receipts contributed. Surface the
1000 // stable invariant token verbatim.
1001 let witnesses = match &candidate {
1002 OtsVerificationOutcome::FullChainVerified { witnesses } => witnesses.clone(),
1003 OtsVerificationOutcome::Partial { witnesses, .. } => witnesses.clone(),
1004 OtsVerificationOutcome::Broken { witnesses, .. } => witnesses.clone(),
1005 };
1006
1007 OtsVerificationOutcome::Partial {
1008 reasons: vec![OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT.to_string()],
1009 witnesses,
1010 }
1011}
1012
1013/// Count distinct *known* operators across a witness set. Witnesses
1014/// whose `signer_id` does not exact-host-match (or legitimate-subdomain
1015/// match) a needle in [`OTS_CALENDAR_OPERATORS`] are excluded — see
1016/// [`enforce_disjoint_authority_quorum`] for the doctrinal rationale
1017/// and [`calendar_operator`] for the matching rule (Bug Hunt 2026-05-12
1018/// BH-1 closed the prior substring-match bypass).
1019fn distinct_known_operators(witnesses: &[OtsWitness]) -> usize {
1020 let mut seen: Vec<&'static str> = Vec::new();
1021 for witness in witnesses {
1022 let Some(signer) = witness.signer_id.as_deref() else {
1023 continue;
1024 };
1025 let Some(operator) = calendar_operator(signer) else {
1026 continue;
1027 };
1028 if !seen.contains(&operator) {
1029 seen.push(operator);
1030 }
1031 }
1032 seen.len()
1033}
1034
1035fn verify_bitcoin_confirmed<B>(
1036 block_height: u64,
1037 merkle_path_digest: &str,
1038 bitcoin_source: Option<&B>,
1039 witness: OtsWitness,
1040) -> Result<OtsVerificationOutcome, OtsError>
1041where
1042 B: BitcoinHeaderSource + ?Sized,
1043{
1044 let Some(source) = bitcoin_source else {
1045 return Ok(OtsVerificationOutcome::Partial {
1046 reasons: vec![OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT.to_string()],
1047 witnesses: vec![witness],
1048 });
1049 };
1050
1051 let header_bytes = source.header_for_height(block_height)?;
1052 if header_bytes.len() != BITCOIN_BLOCK_HEADER_LEN {
1053 return Err(OtsError::OtsCrateError(format!(
1054 "operator-supplied Bitcoin block header for height {block_height} \
1055 must be {BITCOIN_BLOCK_HEADER_LEN} bytes, got {}",
1056 header_bytes.len(),
1057 )));
1058 }
1059 let merkle_root_hex = extract_merkle_root_hex(&header_bytes);
1060 if merkle_root_hex != merkle_path_digest {
1061 return Ok(OtsVerificationOutcome::Broken {
1062 edge: OtsBrokenEdge {
1063 invariant: OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT,
1064 detail: format!(
1065 "Bitcoin attestation at height {block_height} declared merkle path digest \
1066 {merkle_path_digest} but operator-supplied block header carries merkle root \
1067 {merkle_root_hex}"
1068 ),
1069 },
1070 witnesses: vec![witness],
1071 });
1072 }
1073
1074 Ok(OtsVerificationOutcome::FullChainVerified {
1075 witnesses: vec![witness],
1076 })
1077}
1078
1079const BITCOIN_BLOCK_HEADER_LEN: usize = 80;
1080
1081/// Extract the merkle root from a Bitcoin block header. Bitcoin headers
1082/// are 80 bytes: `version(4) | prev_block(32) | merkle_root(32) |
1083/// timestamp(4) | bits(4) | nonce(4)`. The merkle root is stored in
1084/// internal byte order; we emit lowercase hex of the raw 32 bytes,
1085/// matching how the OTS commitment-op chain produces its digest.
1086fn extract_merkle_root_hex(header: &[u8]) -> String {
1087 hex_lower(&header[36..68])
1088}
1089
1090fn extract_ots_proof_bytes(receipt: &ExternalReceipt) -> Result<Vec<u8>, OtsError> {
1091 let object = receipt
1092 .receipt
1093 .as_object()
1094 .ok_or_else(|| OtsError::MalformedHeader {
1095 reason: "external receipt body must be a JSON object".to_string(),
1096 })?;
1097 let proof_field = object
1098 .get("ots_proof_base64")
1099 .ok_or_else(|| OtsError::MalformedHeader {
1100 reason: "external receipt body missing `ots_proof_base64`".to_string(),
1101 })?;
1102 let encoded = proof_field
1103 .as_str()
1104 .ok_or_else(|| OtsError::MalformedHeader {
1105 reason: "external receipt `ots_proof_base64` must be a string".to_string(),
1106 })?;
1107 base64_standard_decode(encoded).map_err(|reason| OtsError::MalformedHeader { reason })
1108}
1109
1110// -----------------------------------------------------------------------------
1111// Local helpers (kept module-private so cortex-ledger doesn't pick up a
1112// `hex` or `base64` dependency just for the adapter).
1113// -----------------------------------------------------------------------------
1114
1115fn sha256_bytes(input: &[u8]) -> [u8; 32] {
1116 let hex = crate::sha256::sha256_hex(input);
1117 let mut out = [0u8; 32];
1118 for (i, chunk) in hex.as_bytes().chunks(2).enumerate().take(32) {
1119 let high = ascii_hex_value(chunk[0]);
1120 let low = ascii_hex_value(chunk[1]);
1121 out[i] = (high << 4) | low;
1122 }
1123 out
1124}
1125
1126fn ascii_hex_value(byte: u8) -> u8 {
1127 match byte {
1128 b'0'..=b'9' => byte - b'0',
1129 b'a'..=b'f' => byte - b'a' + 10,
1130 b'A'..=b'F' => byte - b'A' + 10,
1131 _ => 0,
1132 }
1133}
1134
1135fn hex_lower(bytes: &[u8]) -> String {
1136 const HEX: &[u8; 16] = b"0123456789abcdef";
1137 let mut out = String::with_capacity(bytes.len() * 2);
1138 for byte in bytes {
1139 out.push(HEX[(byte >> 4) as usize] as char);
1140 out.push(HEX[(byte & 0x0f) as usize] as char);
1141 }
1142 out
1143}
1144
1145const BASE64_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1146
1147/// Standard-alphabet base64 encoder used to round-trip raw `.ots`
1148/// bytes inside the [`ExternalReceipt::receipt`] payload. Kept
1149/// module-private so cortex-ledger does not pick up a `base64`
1150/// dependency just for this adapter.
1151pub(crate) fn base64_standard(bytes: &[u8]) -> String {
1152 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
1153 let mut i = 0;
1154 while i + 3 <= bytes.len() {
1155 let triple = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8) | bytes[i + 2] as u32;
1156 out.push(BASE64_TABLE[((triple >> 18) & 0x3f) as usize] as char);
1157 out.push(BASE64_TABLE[((triple >> 12) & 0x3f) as usize] as char);
1158 out.push(BASE64_TABLE[((triple >> 6) & 0x3f) as usize] as char);
1159 out.push(BASE64_TABLE[(triple & 0x3f) as usize] as char);
1160 i += 3;
1161 }
1162 let remaining = bytes.len() - i;
1163 match remaining {
1164 1 => {
1165 let single = (bytes[i] as u32) << 16;
1166 out.push(BASE64_TABLE[((single >> 18) & 0x3f) as usize] as char);
1167 out.push(BASE64_TABLE[((single >> 12) & 0x3f) as usize] as char);
1168 out.push('=');
1169 out.push('=');
1170 }
1171 2 => {
1172 let pair = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8);
1173 out.push(BASE64_TABLE[((pair >> 18) & 0x3f) as usize] as char);
1174 out.push(BASE64_TABLE[((pair >> 12) & 0x3f) as usize] as char);
1175 out.push(BASE64_TABLE[((pair >> 6) & 0x3f) as usize] as char);
1176 out.push('=');
1177 }
1178 _ => {}
1179 }
1180 out
1181}
1182
1183fn base64_standard_decode(encoded: &str) -> Result<Vec<u8>, String> {
1184 if !encoded.len().is_multiple_of(4) {
1185 return Err(format!(
1186 "base64 length {} is not a multiple of 4",
1187 encoded.len()
1188 ));
1189 }
1190 let mut out = Vec::with_capacity(encoded.len() / 4 * 3);
1191 let bytes = encoded.as_bytes();
1192 let mut i = 0;
1193 while i + 4 <= bytes.len() {
1194 let v0 = decode_base64_byte(bytes[i])?;
1195 let v1 = decode_base64_byte(bytes[i + 1])?;
1196 let pad2 = bytes[i + 2] == b'=';
1197 let pad3 = bytes[i + 3] == b'=';
1198 let v2 = if pad2 {
1199 0
1200 } else {
1201 decode_base64_byte(bytes[i + 2])?
1202 };
1203 let v3 = if pad3 {
1204 0
1205 } else {
1206 decode_base64_byte(bytes[i + 3])?
1207 };
1208 let quad = ((v0 as u32) << 18) | ((v1 as u32) << 12) | ((v2 as u32) << 6) | v3 as u32;
1209 out.push(((quad >> 16) & 0xff) as u8);
1210 if !pad2 {
1211 out.push(((quad >> 8) & 0xff) as u8);
1212 }
1213 if !pad3 {
1214 out.push((quad & 0xff) as u8);
1215 }
1216 if pad2 && i + 4 != bytes.len() {
1217 return Err("base64 padding may only appear at the end".to_string());
1218 }
1219 i += 4;
1220 }
1221 Ok(out)
1222}
1223
1224fn decode_base64_byte(b: u8) -> Result<u8, String> {
1225 match b {
1226 b'A'..=b'Z' => Ok(b - b'A'),
1227 b'a'..=b'z' => Ok(b - b'a' + 26),
1228 b'0'..=b'9' => Ok(b - b'0' + 52),
1229 b'+' => Ok(62),
1230 b'/' => Ok(63),
1231 _ => Err(format!("invalid base64 byte {b:#x}")),
1232 }
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237 use super::*;
1238
1239 #[test]
1240 fn base64_round_trip_matches_standard_alphabet() {
1241 let cases: &[(&[u8], &str)] = &[
1242 (b"", ""),
1243 (b"f", "Zg=="),
1244 (b"fo", "Zm8="),
1245 (b"foo", "Zm9v"),
1246 (b"foob", "Zm9vYg=="),
1247 (b"fooba", "Zm9vYmE="),
1248 (b"foobar", "Zm9vYmFy"),
1249 ];
1250 for (raw, encoded) in cases {
1251 assert_eq!(base64_standard(raw), *encoded, "encode {raw:?}");
1252 assert_eq!(
1253 base64_standard_decode(encoded).expect("decode round-trip"),
1254 raw.to_vec(),
1255 "decode {encoded}"
1256 );
1257 }
1258 }
1259
1260 #[test]
1261 fn sha256_bytes_matches_canonical_hex() {
1262 let bytes = sha256_bytes(b"abc");
1263 let expected = [
1264 0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, 0xde, 0x5d, 0xae,
1265 0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61,
1266 0xf2, 0x00, 0x15, 0xad,
1267 ];
1268 assert_eq!(bytes, expected);
1269 }
1270
1271 #[test]
1272 fn noop_calendar_client_fails_closed_with_obvious_reason() {
1273 let client = NoopCalendarClient;
1274 let err = client
1275 .submit_digest("https://a.pool.opentimestamps.org", &[0u8; 32])
1276 .unwrap_err();
1277 match err {
1278 OtsError::OtsCrateError(reason) => {
1279 assert!(
1280 reason.contains("NoopCalendarClient"),
1281 "noop client must call itself out: {reason}"
1282 );
1283 }
1284 other => panic!("expected OtsCrateError, got {other:?}"),
1285 }
1286 }
1287
1288 fn pending_fixture_bytes() -> Vec<u8> {
1289 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1290 .join("tests")
1291 .join("fixtures")
1292 .join("ots")
1293 .join("pending.ots");
1294 std::fs::read(path).expect("pending fixture present")
1295 }
1296
1297 fn bitcoin_fixture_bytes() -> Vec<u8> {
1298 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1299 .join("tests")
1300 .join("fixtures")
1301 .join("ots")
1302 .join("bitcoin_confirmed.ots");
1303 std::fs::read(path).expect("bitcoin fixture present")
1304 }
1305
1306 fn external_receipt_with(ots_bytes: &[u8], sink: ExternalSink) -> ExternalReceipt {
1307 ExternalReceipt {
1308 sink,
1309 anchor_text_sha256: "a".repeat(64),
1310 anchor_event_count: 1,
1311 anchor_chain_head_hash: "b".repeat(64),
1312 submitted_at: chrono::Utc::now(),
1313 sink_endpoint: DEFAULT_OTS_CALENDAR_URL.to_string(),
1314 receipt: json!({
1315 "ots_proof_base64": base64_standard(ots_bytes),
1316 "calendar_url": DEFAULT_OTS_CALENDAR_URL,
1317 "submitted_digest_hex": "0".repeat(64),
1318 }),
1319 }
1320 }
1321
1322 #[test]
1323 fn pending_receipt_maps_to_partial_with_stable_invariant() {
1324 let receipt = external_receipt_with(&pending_fixture_bytes(), ExternalSink::OpenTimestamps);
1325 let outcome =
1326 verify_receipt_with_defaults(&receipt, None::<&StaticBitcoinHeaderSource>).unwrap();
1327 match outcome {
1328 OtsVerificationOutcome::Partial { reasons, .. } => {
1329 assert!(reasons
1330 .iter()
1331 .any(|r| r == OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT));
1332 }
1333 other => panic!("expected Partial, got {other:?}"),
1334 }
1335 }
1336
1337 #[test]
1338 fn bitcoin_receipt_without_header_source_degrades_to_partial() {
1339 let receipt = external_receipt_with(&bitcoin_fixture_bytes(), ExternalSink::OpenTimestamps);
1340 let outcome =
1341 verify_receipt_with_defaults(&receipt, None::<&StaticBitcoinHeaderSource>).unwrap();
1342 match outcome {
1343 OtsVerificationOutcome::Partial { reasons, .. } => {
1344 assert!(reasons
1345 .iter()
1346 .any(|r| r == OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT));
1347 }
1348 other => panic!("expected Partial, got {other:?}"),
1349 }
1350 }
1351
1352 #[test]
1353 fn bitcoin_receipt_with_mismatched_header_fails_closed_broken() {
1354 let receipt = external_receipt_with(&bitcoin_fixture_bytes(), ExternalSink::OpenTimestamps);
1355 let header = vec![0u8; 80];
1356 let source = StaticBitcoinHeaderSource::new().with_header(824_321, header);
1357 let outcome = verify_receipt_with_defaults(&receipt, Some(&source)).unwrap();
1358 match outcome {
1359 OtsVerificationOutcome::Broken { edge, .. } => {
1360 assert_eq!(
1361 edge.invariant,
1362 OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT
1363 );
1364 }
1365 other => panic!("expected Broken, got {other:?}"),
1366 }
1367 }
1368
1369 #[test]
1370 fn bitcoin_receipt_with_matching_header_full_chain_verified() {
1371 let receipt = external_receipt_with(&bitcoin_fixture_bytes(), ExternalSink::OpenTimestamps);
1372 let parsed = DefaultOtsParser
1373 .parse(&bitcoin_fixture_bytes())
1374 .expect("bitcoin fixture parses");
1375 let merkle_hex = match parsed {
1376 TypedOtsProof::BitcoinConfirmed {
1377 merkle_path_digest, ..
1378 } => merkle_path_digest,
1379 other => panic!("expected BitcoinConfirmed, got {other:?}"),
1380 };
1381 let mut merkle_bytes = Vec::with_capacity(32);
1382 for chunk in merkle_hex.as_bytes().chunks(2) {
1383 let h = ascii_hex_value(chunk[0]);
1384 let l = ascii_hex_value(chunk[1]);
1385 merkle_bytes.push((h << 4) | l);
1386 }
1387 let mut header = vec![0u8; 80];
1388 header[36..68].copy_from_slice(&merkle_bytes);
1389 let source = StaticBitcoinHeaderSource::new().with_header(824_321, header);
1390 let outcome = verify_receipt_with_defaults(&receipt, Some(&source)).unwrap();
1391 assert!(
1392 outcome.is_full_chain_verified(),
1393 "matching header must produce FullChainVerified, got {outcome:?}",
1394 );
1395 }
1396
1397 #[test]
1398 fn non_opentimestamps_sink_refused_before_parser() {
1399 let receipt = external_receipt_with(&pending_fixture_bytes(), ExternalSink::Rekor);
1400 let err =
1401 verify_receipt_with_defaults(&receipt, None::<&StaticBitcoinHeaderSource>).unwrap_err();
1402 match err {
1403 OtsError::OtsCrateError(reason) => {
1404 assert!(reason.contains("rekor"), "{reason}");
1405 }
1406 other => panic!("expected OtsCrateError, got {other:?}"),
1407 }
1408 }
1409
1410 #[test]
1411 fn submit_round_trip_uses_anchor_text_sha256_as_digest_and_round_trips_parser() {
1412 // Mock CalendarClient that returns the checked-in Pending
1413 // fixture verbatim — proves `submit` rejects a calendar that
1414 // returns malformed bytes and accepts canonical ones.
1415 struct MockClient {
1416 bytes: Vec<u8>,
1417 }
1418 impl CalendarClient for MockClient {
1419 fn submit_digest(
1420 &self,
1421 _calendar_url: &str,
1422 _digest: &[u8; 32],
1423 ) -> Result<Vec<u8>, OtsError> {
1424 Ok(self.bytes.clone())
1425 }
1426 }
1427 let client = MockClient {
1428 bytes: pending_fixture_bytes(),
1429 };
1430 let anchor =
1431 LedgerAnchor::new(chrono::Utc::now(), 42, "c".repeat(64)).expect("anchor builds");
1432 let receipt = submit(
1433 &anchor,
1434 DEFAULT_OTS_CALENDAR_URL,
1435 chrono::Utc::now(),
1436 &client,
1437 )
1438 .expect("submit accepts canonical Pending bytes");
1439 assert_eq!(receipt.sink, ExternalSink::OpenTimestamps);
1440 assert_eq!(receipt.anchor_event_count, 42);
1441 // The receipt body must carry the OTS bytes base64'd back so
1442 // a future `verify_receipt` can round-trip without contacting
1443 // the calendar again.
1444 let parsed_bytes = extract_ots_proof_bytes(&receipt).expect("receipt round-trips");
1445 assert_eq!(parsed_bytes, pending_fixture_bytes());
1446 }
1447
1448 // -------------------------------------------------------------------
1449 // N>=2 disjoint-authority quorum gate (council 2026-05-12 Decision Q1)
1450 // -------------------------------------------------------------------
1451
1452 fn make_witness(endpoint: &str) -> OtsWitness {
1453 OtsWitness {
1454 class: "external_anchor_crossing",
1455 authority_domain: "external_anchor_sink",
1456 tier: "third_party",
1457 signer_id: Some(endpoint.to_string()),
1458 asserted_at: chrono::Utc::now(),
1459 }
1460 }
1461
1462 #[test]
1463 fn calendar_operator_classifies_default_set() {
1464 assert_eq!(
1465 calendar_operator("https://a.pool.opentimestamps.org"),
1466 Some("peter_todd"),
1467 );
1468 assert_eq!(
1469 calendar_operator("https://alice.btc.calendar.opentimestamps.org"),
1470 Some("peter_todd"),
1471 );
1472 assert_eq!(
1473 calendar_operator("https://bob.btc.calendar.opentimestamps.org/"),
1474 Some("peter_todd"),
1475 );
1476 assert_eq!(
1477 calendar_operator("https://finney.calendar.eternitywall.com"),
1478 Some("eternitywall"),
1479 );
1480 assert!(calendar_operator("https://unknown.example.org").is_none());
1481 }
1482
1483 #[test]
1484 fn calendar_operator_rejects_deceptive_substring_in_path() {
1485 // Bug Hunt 2026-05-12 BH-1: attacker-controlled URL whose host
1486 // is attacker.example but whose path/query embeds a known
1487 // calendar hostname as a substring MUST NOT classify as that
1488 // operator. Substring-match would have returned Some(...) here;
1489 // exact-host match correctly returns None.
1490 assert!(
1491 calendar_operator("https://attacker.example/?h=alice.btc.calendar.opentimestamps.org")
1492 .is_none(),
1493 "deceptive query-string substring must not classify as peter_todd",
1494 );
1495 assert!(
1496 calendar_operator("https://attacker.example/?h=finney.calendar.eternitywall.com")
1497 .is_none(),
1498 "deceptive query-string substring must not classify as eternitywall",
1499 );
1500 // Path-suffix variant.
1501 assert!(
1502 calendar_operator("https://attacker.example/a.pool.opentimestamps.org/digest")
1503 .is_none(),
1504 "deceptive path-segment substring must not classify",
1505 );
1506 // Sibling-host variant: `xfinney.calendar.eternitywall.com` is
1507 // a different host that *ends with* the needle without the
1508 // required leading dot; the host-suffix check must reject it.
1509 assert!(
1510 calendar_operator("https://xfinney.calendar.eternitywall.com").is_none(),
1511 "sibling host that ends with needle but lacks leading dot must not classify",
1512 );
1513 // Unparseable inputs must return None — never match by accident.
1514 assert!(calendar_operator("not a url").is_none());
1515 assert!(calendar_operator("").is_none());
1516 assert!(calendar_operator("alice.btc.calendar.opentimestamps.org").is_none());
1517 }
1518
1519 #[test]
1520 fn calendar_operator_accepts_exact_host() {
1521 // Exact host with trailing slash + path.
1522 assert_eq!(
1523 calendar_operator("https://calendar.opentimestamps.org/digest"),
1524 None,
1525 "`calendar.opentimestamps.org` is not a listed needle; only the \
1526 three Todd subdomains are",
1527 );
1528 // The four canonical needles match themselves exactly.
1529 assert_eq!(
1530 calendar_operator("https://a.pool.opentimestamps.org/"),
1531 Some("peter_todd"),
1532 );
1533 assert_eq!(
1534 calendar_operator("https://a.pool.opentimestamps.org/digest?x=1"),
1535 Some("peter_todd"),
1536 );
1537 assert_eq!(
1538 calendar_operator("https://finney.calendar.eternitywall.com/digest"),
1539 Some("eternitywall"),
1540 );
1541 }
1542
1543 #[test]
1544 fn calendar_operator_accepts_legitimate_subdomain() {
1545 // `alice.btc.calendar.opentimestamps.org` is itself a listed
1546 // needle, so this is an exact-host hit. Use a deeper
1547 // sub-subdomain to exercise the legitimate subdomain rule.
1548 assert_eq!(
1549 calendar_operator("https://shard-1.alice.btc.calendar.opentimestamps.org/digest"),
1550 Some("peter_todd"),
1551 "deeper subdomain of a listed needle must inherit the operator",
1552 );
1553 // Sub-subdomain of the Eternitywall Finney calendar.
1554 assert_eq!(
1555 calendar_operator("https://shard-1.finney.calendar.eternitywall.com/"),
1556 Some("eternitywall"),
1557 );
1558 // Case-insensitive host match (DNS is case-insensitive).
1559 assert_eq!(
1560 calendar_operator("HTTPS://A.POOL.OPENTIMESTAMPS.ORG/digest"),
1561 Some("peter_todd"),
1562 );
1563 }
1564
1565 #[test]
1566 fn disjoint_quorum_rejects_two_attacker_urls_with_deceptive_substrings() {
1567 // Bug Hunt 2026-05-12 BH-1 end-to-end: two attacker URLs each
1568 // smuggle a known-operator hostname into the query string.
1569 // Under the substring-match regression these would have
1570 // classified as peter_todd + eternitywall and satisfied the
1571 // N>=2 quorum. With exact-host matching neither is a known
1572 // operator, so `distinct_known_operators` returns 0 and the
1573 // gate downgrades to Partial with the stable invariant.
1574 let witnesses = vec![
1575 make_witness("https://attacker.example/?h=alice.btc.calendar.opentimestamps.org"),
1576 make_witness("https://attacker.example/?h=finney.calendar.eternitywall.com"),
1577 ];
1578 assert_eq!(
1579 distinct_known_operators(&witnesses),
1580 0,
1581 "attacker URLs MUST contribute zero known operators",
1582 );
1583 let candidate = OtsVerificationOutcome::FullChainVerified {
1584 witnesses: witnesses.clone(),
1585 };
1586 let gated = enforce_disjoint_authority_quorum(candidate, &witnesses);
1587 match gated {
1588 OtsVerificationOutcome::Partial { reasons, .. } => {
1589 assert!(
1590 reasons
1591 .iter()
1592 .any(|r| r == OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT),
1593 "expected disjoint-authority quorum-not-met invariant, \
1594 got reasons {reasons:?}",
1595 );
1596 }
1597 other => {
1598 panic!("expected Partial downgrade for attacker-URL witness set, got {other:?}",)
1599 }
1600 }
1601 }
1602
1603 #[test]
1604 fn disjoint_quorum_two_todd_witnesses_holds_at_partial() {
1605 // Council Q1 doctrinaire vote: alice + bob both collapse to
1606 // peter_todd. Two witnesses but ONE authority -> quorum fails.
1607 let witnesses = vec![
1608 make_witness("https://alice.btc.calendar.opentimestamps.org"),
1609 make_witness("https://bob.btc.calendar.opentimestamps.org"),
1610 ];
1611 let candidate = OtsVerificationOutcome::FullChainVerified {
1612 witnesses: witnesses.clone(),
1613 };
1614 let gated = enforce_disjoint_authority_quorum(candidate, &witnesses);
1615 match gated {
1616 OtsVerificationOutcome::Partial { reasons, .. } => {
1617 assert!(reasons
1618 .iter()
1619 .any(|r| r == OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT));
1620 }
1621 other => panic!("expected Partial downgrade, got {other:?}"),
1622 }
1623 }
1624
1625 #[test]
1626 fn disjoint_quorum_todd_plus_eternitywall_promotes() {
1627 // Council Q1 happy path: one Todd + one Eternitywall = two
1628 // distinct authorities. Quorum met; FullChainVerified passes.
1629 let witnesses = vec![
1630 make_witness("https://a.pool.opentimestamps.org"),
1631 make_witness("https://finney.calendar.eternitywall.com"),
1632 ];
1633 let candidate = OtsVerificationOutcome::FullChainVerified {
1634 witnesses: witnesses.clone(),
1635 };
1636 let gated = enforce_disjoint_authority_quorum(candidate, &witnesses);
1637 assert!(
1638 gated.is_full_chain_verified(),
1639 "two disjoint operators must promote, got {gated:?}",
1640 );
1641 }
1642
1643 #[test]
1644 fn disjoint_quorum_single_witness_unreachable_at_full_chain_verified() {
1645 // Council Q1 doctrinaire: a one-witness history can NEVER
1646 // produce FullChainVerified, regardless of the operator. The
1647 // gate downgrades to Partial with the stable invariant.
1648 let witnesses = vec![make_witness("https://a.pool.opentimestamps.org")];
1649 let candidate = OtsVerificationOutcome::FullChainVerified {
1650 witnesses: witnesses.clone(),
1651 };
1652 let gated = enforce_disjoint_authority_quorum(candidate, &witnesses);
1653 match gated {
1654 OtsVerificationOutcome::Partial { reasons, .. } => {
1655 assert!(reasons
1656 .iter()
1657 .any(|r| r == OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT));
1658 }
1659 other => {
1660 panic!("single-witness history must downgrade to Partial, got {other:?}")
1661 }
1662 }
1663 }
1664
1665 #[test]
1666 fn disjoint_quorum_does_not_upgrade_partial() {
1667 // The gate must be one-directional: it never promotes a
1668 // Partial outcome to FullChainVerified, only downgrades.
1669 let witnesses = vec![
1670 make_witness("https://a.pool.opentimestamps.org"),
1671 make_witness("https://finney.calendar.eternitywall.com"),
1672 ];
1673 let candidate = OtsVerificationOutcome::Partial {
1674 reasons: vec![OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT.to_string()],
1675 witnesses: witnesses.clone(),
1676 };
1677 let gated = enforce_disjoint_authority_quorum(candidate.clone(), &witnesses);
1678 assert_eq!(gated, candidate);
1679 }
1680
1681 #[test]
1682 fn disjoint_quorum_unknown_operator_does_not_count_toward_quorum() {
1683 // Witnesses on unknown endpoints can't be asserted as
1684 // disjoint authorities (council Q1 "at least one is non-Todd"
1685 // implies we must KNOW the operator). Two Todd + one unknown
1686 // still fails.
1687 let witnesses = vec![
1688 make_witness("https://a.pool.opentimestamps.org"),
1689 make_witness("https://alice.btc.calendar.opentimestamps.org"),
1690 make_witness("https://unclassified.example.org"),
1691 ];
1692 let candidate = OtsVerificationOutcome::FullChainVerified {
1693 witnesses: witnesses.clone(),
1694 };
1695 let gated = enforce_disjoint_authority_quorum(candidate, &witnesses);
1696 match gated {
1697 OtsVerificationOutcome::Partial { reasons, .. } => {
1698 assert!(reasons
1699 .iter()
1700 .any(|r| r == OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT));
1701 }
1702 other => panic!("expected Partial, got {other:?}"),
1703 }
1704 }
1705
1706 // -------------------------------------------------------------------
1707 // Default calendar list + operator metadata (council 2026-05-12 Q1).
1708 // -------------------------------------------------------------------
1709
1710 #[test]
1711 fn default_calendar_url_is_first_entry_of_url_list() {
1712 // Back-compat guarantee documented on DEFAULT_OTS_CALENDAR_URL:
1713 // it points to the first entry of the full default list so
1714 // existing single-URL operator wiring keeps working.
1715 assert_eq!(DEFAULT_OTS_CALENDAR_URL, DEFAULT_OTS_CALENDAR_URLS[0]);
1716 }
1717
1718 #[test]
1719 fn default_calendar_urls_include_eternitywall_finney() {
1720 // Council Q1 hard requirement: the default list MUST include
1721 // a non-Todd member so an operator who fans out across the
1722 // full default list automatically clears the disjoint-quorum
1723 // gate.
1724 assert!(
1725 DEFAULT_OTS_CALENDAR_URLS
1726 .iter()
1727 .any(|u| u.contains("finney.calendar.eternitywall.com")),
1728 "default calendar list must contain Eternitywall Finney",
1729 );
1730 }
1731
1732 #[test]
1733 fn ots_calendar_operators_covers_default_list() {
1734 for url in DEFAULT_OTS_CALENDAR_URLS {
1735 assert!(
1736 calendar_operator(url).is_some(),
1737 "default calendar `{url}` must have an operator entry",
1738 );
1739 }
1740 }
1741
1742 // -------------------------------------------------------------------
1743 // HTTPS Bitcoin header source: PoW + hex helpers.
1744 // -------------------------------------------------------------------
1745
1746 #[test]
1747 fn decode_hex_round_trips_known_byte_string() {
1748 let raw = decode_hex("00ff10ab").expect("decode round-trip");
1749 assert_eq!(raw, vec![0x00, 0xff, 0x10, 0xab]);
1750 assert!(decode_hex("zz").is_err());
1751 assert!(decode_hex("0").is_err());
1752 }
1753
1754 #[test]
1755 fn https_headers_source_quorum_unreachable_when_zero_providers() {
1756 // No providers configured -> quorum cannot be met.
1757 let source = HttpsHeadersBitcoinHeaderSource::new(vec![]).with_quorum_n(2);
1758 let err = source.header_for_height(824_321).unwrap_err();
1759 match err {
1760 OtsError::OtsCrateError(reason) => {
1761 assert!(
1762 reason.contains(OTS_BITCOIN_HEADER_QUORUM_UNREACHABLE_INVARIANT),
1763 "expected unreachable invariant, got {reason}",
1764 );
1765 }
1766 other => panic!("expected OtsCrateError, got {other:?}"),
1767 }
1768 }
1769
1770 #[test]
1771 fn expand_nbits_compact_recovers_genesis_difficulty() {
1772 // Bitcoin genesis nBits = 0x1d00ffff -> target is
1773 // 0x00000000ffff0000...0000.
1774 let target = expand_nbits_compact(0x1d00_ffff);
1775 assert_eq!(target[0], 0x00);
1776 assert_eq!(target[1], 0x00);
1777 assert_eq!(target[2], 0x00);
1778 assert_eq!(target[3], 0x00);
1779 assert_eq!(target[4], 0xff);
1780 assert_eq!(target[5], 0xff);
1781 for byte in &target[6..] {
1782 assert_eq!(*byte, 0x00);
1783 }
1784 }
1785}