Skip to main content

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}