Skip to main content

clawft_kernel/
chain_anchor.rs

1//! Chain-head external anchoring beyond [`MockAnchor`](crate::chain::MockAnchor).
2//!
3//! Implements ADR-041 / WEFT-137:
4//! - [`FileLedgerAnchor`] — durable append-only hash-linked local ledger
5//!   (useful production default until a public ledger is chosen)
6//! - [`ExternalLedgerAnchor`] — wired external-ledger stub (intent log +
7//!   optional endpoint binding; network POST deferred until a target is
8//!   selected)
9//! - [`AnchoringController`] — frequency policy (`min_interval_secs`,
10//!   `min_events_between`) over any [`ChainAnchor`](crate::chain::ChainAnchor)
11//!
12//! Config surface: [`clawft_types::config::ChainExternalAnchorConfig`] on
13//! `kernel.chain.external_anchor`.
14
15use std::fs::{self, File, OpenOptions};
16use std::io::{BufRead, BufReader, Write};
17use std::path::{Path, PathBuf};
18use std::sync::{Arc, Mutex};
19use std::time::Duration;
20
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use weftos_rvf_crypto::hash::shake256_256;
24
25use crate::chain::{AnchorReceipt, ChainAnchor, ChainManager, MockAnchor};
26use clawft_types::config::{ChainAnchorBackend, ChainExternalAnchorConfig};
27
28/// Chain event source of project anchors: `project.anchor` on the user chain
29/// (the daemon's record of an accepted statement) and `project.anchored` on
30/// the project chain (the acknowledgement). Reserved in
31/// `project_identity::RESERVED_SOURCES` so a `chain.append` caller cannot
32/// forge either.
33pub const ANCHOR_SOURCE: &str = "project.anchor";
34
35#[cfg(feature = "native")]
36#[path = "chain_anchor_parent.rs"]
37mod parent;
38#[cfg(feature = "native")]
39pub use parent::{
40    AnchorAck, AnchorSubmitError, ParentAnchor, ParentAnchorConfig, ParentTransport,
41    KIND_ANCHORED, KIND_ANCHOR,
42};
43
44// ── Helpers ──────────────────────────────────────────────────────────────
45
46fn hex_hash(h: &[u8; 32]) -> String {
47    h.iter().map(|b| format!("{b:02x}")).collect()
48}
49
50fn parse_hex_hash(s: &str) -> Result<[u8; 32], String> {
51    if s.len() != 64 {
52        return Err(format!("expected 64 hex chars, got {}", s.len()));
53    }
54    let mut out = [0u8; 32];
55    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
56        let hi = hex_nibble(chunk[0])?;
57        let lo = hex_nibble(chunk[1])?;
58        out[i] = (hi << 4) | lo;
59    }
60    Ok(out)
61}
62
63fn hex_nibble(c: u8) -> Result<u8, String> {
64    match c {
65        b'0'..=b'9' => Ok(c - b'0'),
66        b'a'..=b'f' => Ok(c - b'a' + 10),
67        b'A'..=b'F' => Ok(c - b'A' + 10),
68        _ => Err(format!("invalid hex digit: {}", c as char)),
69    }
70}
71
72// ── File ledger entry ────────────────────────────────────────────────────
73
74/// One line of the append-only file ledger (JSONL).
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct FileLedgerEntry {
77    /// Monotonic ledger sequence (1-based).
78    pub seq: u64,
79    /// Hex-encoded SHAKE-256 of the previous entry (zeros for genesis).
80    pub prev_entry_hash: String,
81    /// Hex-encoded 32-byte hash that was anchored (ExoChain head).
82    pub anchored_hash: String,
83    /// Hex-encoded SHAKE-256 of this entry's commitment bytes.
84    pub entry_hash: String,
85    /// RFC3339 timestamp of the anchor operation.
86    pub anchored_at: DateTime<Utc>,
87}
88
89impl FileLedgerEntry {
90    /// Commitment bytes: `seq_be ‖ prev_entry_hash ‖ anchored_hash ‖ ts_secs_be`.
91    fn commitment(seq: u64, prev: &[u8; 32], anchored: &[u8; 32], ts_secs: i64) -> [u8; 32] {
92        let mut buf = Vec::with_capacity(8 + 32 + 32 + 8);
93        buf.extend_from_slice(&seq.to_be_bytes());
94        buf.extend_from_slice(prev);
95        buf.extend_from_slice(anchored);
96        buf.extend_from_slice(&ts_secs.to_be_bytes());
97        shake256_256(&buf)
98    }
99
100    fn tx_id(seq: u64) -> String {
101        format!("file-ledger-{seq}")
102    }
103}
104
105// ── FileLedgerAnchor ─────────────────────────────────────────────────────
106
107/// Append-only, hash-linked local ledger of chain-head anchors.
108///
109/// Each successful [`ChainAnchor::anchor`] appends one JSONL line whose
110/// `entry_hash` commits to `(seq, prev_entry_hash, anchored_hash, timestamp)`.
111/// [`ChainAnchor::verify`] reloads the ledger, finds the receipt's `tx_id`,
112/// and checks both the stored hash and the entry commitment.
113///
114/// This is the production-useful default backend until a public external
115/// ledger (OpenTimestamps, Ethereum, …) is selected per deployment.
116pub struct FileLedgerAnchor {
117    path: PathBuf,
118    lock: Mutex<()>,
119}
120
121impl FileLedgerAnchor {
122    /// Open (or create) a ledger at `path`. Parent directories are created.
123    pub fn open(path: impl Into<PathBuf>) -> Result<Self, String> {
124        let path = path.into();
125        if let Some(parent) = path.parent()
126            && !parent.as_os_str().is_empty()
127        {
128            fs::create_dir_all(parent)
129                .map_err(|e| format!("create ledger dir {}: {e}", parent.display()))?;
130        }
131        if !path.exists() {
132            File::create(&path).map_err(|e| format!("create ledger {}: {e}", path.display()))?;
133        }
134        Ok(Self {
135            path,
136            lock: Mutex::new(()),
137        })
138    }
139
140    /// Path of the underlying JSONL ledger.
141    pub fn path(&self) -> &Path {
142        &self.path
143    }
144
145    /// Load all ledger entries (best-effort parse; skips blank lines).
146    pub fn load_entries(&self) -> Result<Vec<FileLedgerEntry>, String> {
147        let file = File::open(&self.path)
148            .map_err(|e| format!("open ledger {}: {e}", self.path.display()))?;
149        let reader = BufReader::new(file);
150        let mut out = Vec::new();
151        for (lineno, line) in reader.lines().enumerate() {
152            let line = line.map_err(|e| format!("read ledger line {}: {e}", lineno + 1))?;
153            let trimmed = line.trim();
154            if trimmed.is_empty() {
155                continue;
156            }
157            let entry: FileLedgerEntry = serde_json::from_str(trimmed)
158                .map_err(|e| format!("parse ledger line {}: {e}", lineno + 1))?;
159            out.push(entry);
160        }
161        Ok(out)
162    }
163
164    /// Verify the entire ledger's hash-link integrity.
165    pub fn verify_chain(&self) -> Result<bool, String> {
166        let entries = self.load_entries()?;
167        let mut prev = [0u8; 32];
168        for entry in &entries {
169            let anchored = parse_hex_hash(&entry.anchored_hash)?;
170            let prev_stored = parse_hex_hash(&entry.prev_entry_hash)?;
171            if prev_stored != prev {
172                return Ok(false);
173            }
174            let expected = FileLedgerEntry::commitment(
175                entry.seq,
176                &prev,
177                &anchored,
178                entry.anchored_at.timestamp(),
179            );
180            let entry_hash = parse_hex_hash(&entry.entry_hash)?;
181            if entry_hash != expected {
182                return Ok(false);
183            }
184            prev = entry_hash;
185        }
186        Ok(true)
187    }
188
189    fn append_entry(&self, hash: &[u8; 32]) -> Result<AnchorReceipt, String> {
190        let _guard = self
191            .lock
192            .lock()
193            .map_err(|_| "file ledger lock poisoned".to_string())?;
194
195        let entries = self.load_entries()?;
196        let (seq, prev) = if let Some(last) = entries.last() {
197            (last.seq + 1, parse_hex_hash(&last.entry_hash)?)
198        } else {
199            (1u64, [0u8; 32])
200        };
201
202        let anchored_at = Utc::now();
203        let entry_hash = FileLedgerEntry::commitment(seq, &prev, hash, anchored_at.timestamp());
204        let entry = FileLedgerEntry {
205            seq,
206            prev_entry_hash: hex_hash(&prev),
207            anchored_hash: hex_hash(hash),
208            entry_hash: hex_hash(&entry_hash),
209            anchored_at,
210        };
211
212        let mut file = OpenOptions::new()
213            .create(true)
214            .append(true)
215            .open(&self.path)
216            .map_err(|e| format!("append ledger {}: {e}", self.path.display()))?;
217        let line = serde_json::to_string(&entry)
218            .map_err(|e| format!("serialize ledger entry: {e}"))?;
219        writeln!(file, "{line}").map_err(|e| format!("write ledger entry: {e}"))?;
220        file.sync_all()
221            .map_err(|e| format!("sync ledger {}: {e}", self.path.display()))?;
222
223        Ok(AnchorReceipt {
224            hash: *hash,
225            tx_id: FileLedgerEntry::tx_id(seq),
226            anchored_at,
227        })
228    }
229}
230
231impl ChainAnchor for FileLedgerAnchor {
232    fn anchor(&self, hash: &[u8; 32]) -> Result<AnchorReceipt, String> {
233        self.append_entry(hash)
234    }
235
236    fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, String> {
237        let entries = self.load_entries()?;
238        let Some(entry) = entries
239            .iter()
240            .find(|e| e.tx_id_matches(&receipt.tx_id) || FileLedgerEntry::tx_id(e.seq) == receipt.tx_id)
241        else {
242            return Ok(false);
243        };
244        let stored = parse_hex_hash(&entry.anchored_hash)?;
245        if stored != receipt.hash {
246            return Ok(false);
247        }
248        // Recompute commitment for the found entry in ledger context.
249        let prev = parse_hex_hash(&entry.prev_entry_hash)?;
250        let expected = FileLedgerEntry::commitment(
251            entry.seq,
252            &prev,
253            &receipt.hash,
254            entry.anchored_at.timestamp(),
255        );
256        let entry_hash = parse_hex_hash(&entry.entry_hash)?;
257        Ok(entry_hash == expected)
258    }
259
260    fn backend_name(&self) -> &str {
261        "file-ledger"
262    }
263}
264
265impl FileLedgerEntry {
266    fn tx_id_matches(&self, tx_id: &str) -> bool {
267        FileLedgerEntry::tx_id(self.seq) == tx_id
268    }
269}
270
271// ── ExternalLedgerAnchor (stub with real wiring) ─────────────────────────
272
273/// Intent-log entry for the external-ledger stub.
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
275pub struct ExternalIntentEntry {
276    pub seq: u64,
277    pub anchored_hash: String,
278    pub tx_id: String,
279    pub endpoint: Option<String>,
280    pub submitted: bool,
281    pub anchored_at: DateTime<Utc>,
282}
283
284/// Wired external-ledger stub.
285///
286/// Always persists an intent to a local JSONL log. When `endpoint` is set,
287/// the receipt records the binding (`external-stub:{endpoint}:{seq}`) so a
288/// future HTTP/gRPC transport can submit without changing the trait surface.
289/// Network POST is intentionally not performed until a ledger target is
290/// chosen (ADR-041 defers chain selection).
291pub struct ExternalLedgerAnchor {
292    path: PathBuf,
293    endpoint: Option<String>,
294    lock: Mutex<()>,
295}
296
297impl ExternalLedgerAnchor {
298    /// Create a stub bound to `path` and optional `endpoint`.
299    pub fn open(path: impl Into<PathBuf>, endpoint: Option<String>) -> Result<Self, String> {
300        let path = path.into();
301        if let Some(parent) = path.parent()
302            && !parent.as_os_str().is_empty()
303        {
304            fs::create_dir_all(parent)
305                .map_err(|e| format!("create external intent dir {}: {e}", parent.display()))?;
306        }
307        if !path.exists() {
308            File::create(&path)
309                .map_err(|e| format!("create external intent log {}: {e}", path.display()))?;
310        }
311        Ok(Self {
312            path,
313            endpoint,
314            lock: Mutex::new(()),
315        })
316    }
317
318    /// Configured external endpoint (if any).
319    pub fn endpoint(&self) -> Option<&str> {
320        self.endpoint.as_deref()
321    }
322
323    /// Intent log path.
324    pub fn path(&self) -> &Path {
325        &self.path
326    }
327
328    /// Load all recorded intents.
329    pub fn load_intents(&self) -> Result<Vec<ExternalIntentEntry>, String> {
330        let file = File::open(&self.path)
331            .map_err(|e| format!("open external intent log {}: {e}", self.path.display()))?;
332        let reader = BufReader::new(file);
333        let mut out = Vec::new();
334        for (lineno, line) in reader.lines().enumerate() {
335            let line = line.map_err(|e| format!("read intent line {}: {e}", lineno + 1))?;
336            let trimmed = line.trim();
337            if trimmed.is_empty() {
338                continue;
339            }
340            let entry: ExternalIntentEntry = serde_json::from_str(trimmed)
341                .map_err(|e| format!("parse intent line {}: {e}", lineno + 1))?;
342            out.push(entry);
343        }
344        Ok(out)
345    }
346}
347
348impl ChainAnchor for ExternalLedgerAnchor {
349    fn anchor(&self, hash: &[u8; 32]) -> Result<AnchorReceipt, String> {
350        let _guard = self
351            .lock
352            .lock()
353            .map_err(|_| "external ledger lock poisoned".to_string())?;
354
355        let intents = self.load_intents()?;
356        let seq = intents.last().map(|e| e.seq + 1).unwrap_or(1);
357        let anchored_at = Utc::now();
358        let tx_id = match &self.endpoint {
359            Some(ep) => format!("external-stub:{ep}:{seq}"),
360            None => format!("external-stub:pending:{seq}"),
361        };
362        // `submitted` is false until a real transport is wired; endpoint
363        // presence only means the binding is configured.
364        let entry = ExternalIntentEntry {
365            seq,
366            anchored_hash: hex_hash(hash),
367            tx_id: tx_id.clone(),
368            endpoint: self.endpoint.clone(),
369            submitted: false,
370            anchored_at,
371        };
372
373        let mut file = OpenOptions::new()
374            .create(true)
375            .append(true)
376            .open(&self.path)
377            .map_err(|e| format!("append external intent {}: {e}", self.path.display()))?;
378        let line = serde_json::to_string(&entry)
379            .map_err(|e| format!("serialize external intent: {e}"))?;
380        writeln!(file, "{line}").map_err(|e| format!("write external intent: {e}"))?;
381        file.sync_all()
382            .map_err(|e| format!("sync external intent {}: {e}", self.path.display()))?;
383
384        Ok(AnchorReceipt {
385            hash: *hash,
386            tx_id,
387            anchored_at,
388        })
389    }
390
391    fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, String> {
392        let intents = self.load_intents()?;
393        Ok(intents.iter().any(|e| {
394            e.tx_id == receipt.tx_id && parse_hex_hash(&e.anchored_hash).ok() == Some(receipt.hash)
395        }))
396    }
397
398    fn backend_name(&self) -> &str {
399        "external-stub"
400    }
401}
402
403// ── Frequency policy + controller ────────────────────────────────────────
404
405/// Last successful anchor observed by the controller.
406#[derive(Debug, Clone, Default)]
407struct LastAnchorState {
408    at: Option<DateTime<Utc>>,
409    chain_seq: u64,
410}
411
412/// Frequency policy for external anchoring.
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub struct AnchorFrequencyPolicy {
415    /// Minimum wall-clock duration between anchors (`0` = no time gate).
416    pub min_interval: Duration,
417    /// Minimum ExoChain sequence delta between anchors (`0` = no event gate).
418    pub min_events_between: u64,
419}
420
421impl AnchorFrequencyPolicy {
422    /// Build from config fields.
423    pub fn from_config(cfg: &ChainExternalAnchorConfig) -> Self {
424        Self {
425            min_interval: Duration::from_secs(cfg.min_interval_secs),
426            min_events_between: cfg.min_events_between,
427        }
428    }
429
430    /// Whether both gates permit an anchor at `now` for `chain_seq`.
431    fn allows(&self, last: &LastAnchorState, now: DateTime<Utc>, chain_seq: u64) -> bool {
432        if self.min_events_between > 0 {
433            let delta = chain_seq.saturating_sub(last.chain_seq);
434            // First anchor (last.chain_seq == 0 and never anchored) always allowed
435            // on the event gate only when we have never anchored, or delta is enough.
436            if last.at.is_some() && delta < self.min_events_between {
437                return false;
438            }
439        }
440        if !self.min_interval.is_zero()
441            && let Some(at) = last.at
442        {
443            let elapsed = now
444                .signed_duration_since(at)
445                .to_std()
446                .unwrap_or(Duration::ZERO);
447            if elapsed < self.min_interval {
448                return false;
449            }
450        }
451        true
452    }
453}
454
455/// Policy-gated wrapper around a [`ChainAnchor`] backend.
456pub struct AnchoringController {
457    backend: Arc<dyn ChainAnchor>,
458    policy: AnchorFrequencyPolicy,
459    last: Mutex<LastAnchorState>,
460}
461
462impl AnchoringController {
463    /// Wrap an existing backend with a frequency policy.
464    pub fn new(backend: Arc<dyn ChainAnchor>, policy: AnchorFrequencyPolicy) -> Self {
465        Self {
466            backend,
467            policy,
468            last: Mutex::new(LastAnchorState::default()),
469        }
470    }
471
472    /// Build from kernel config. Returns `None` when backend is `None`.
473    pub fn from_config(cfg: &ChainExternalAnchorConfig) -> Result<Option<Self>, String> {
474        let backend: Arc<dyn ChainAnchor> = match cfg.backend {
475            ChainAnchorBackend::None => return Ok(None),
476            ChainAnchorBackend::Mock => Arc::new(MockAnchor),
477            ChainAnchorBackend::File => {
478                let path = cfg
479                    .effective_ledger_path()
480                    .ok_or_else(|| {
481                        "file-ledger: ledger_path required (no default available)".to_string()
482                    })?;
483                Arc::new(FileLedgerAnchor::open(path)?)
484            }
485            ChainAnchorBackend::External => {
486                let path = cfg.effective_ledger_path().ok_or_else(|| {
487                    "external-stub: ledger_path required for intent log (no default available)"
488                        .to_string()
489                })?;
490                Arc::new(ExternalLedgerAnchor::open(path, cfg.endpoint.clone())?)
491            }
492            ChainAnchorBackend::Parent => {
493                return Err(
494                    "parent anchor: needs the project key and a parent link; \
495                     project boot builds ParentAnchor and wraps it with AnchoringController::new"
496                        .to_string(),
497                );
498            }
499        };
500        Ok(Some(Self::new(
501            backend,
502            AnchorFrequencyPolicy::from_config(cfg),
503        )))
504    }
505
506    /// Backend name for logging/display.
507    pub fn backend_name(&self) -> &str {
508        self.backend.backend_name()
509    }
510
511    /// Frequency policy in force.
512    pub fn policy(&self) -> AnchorFrequencyPolicy {
513        self.policy
514    }
515
516    /// Underlying backend (for direct verify / tests).
517    pub fn backend(&self) -> &dyn ChainAnchor {
518        self.backend.as_ref()
519    }
520
521    /// Anchor only when the frequency policy allows it.
522    ///
523    /// Returns `Ok(None)` when the policy suppresses the call (not an error).
524    pub fn try_anchor(
525        &self,
526        hash: &[u8; 32],
527        chain_seq: u64,
528    ) -> Result<Option<AnchorReceipt>, String> {
529        let now = Utc::now();
530        {
531            let last = self
532                .last
533                .lock()
534                .map_err(|_| "anchor policy lock poisoned".to_string())?;
535            if !self.policy.allows(&last, now, chain_seq) {
536                return Ok(None);
537            }
538        }
539        let receipt = self.backend.anchor(hash)?;
540        let mut last = self
541            .last
542            .lock()
543            .map_err(|_| "anchor policy lock poisoned".to_string())?;
544        last.at = Some(receipt.anchored_at);
545        last.chain_seq = chain_seq;
546        Ok(Some(receipt))
547    }
548
549    /// Force an anchor, bypassing the frequency policy (still updates state).
550    pub fn force_anchor(
551        &self,
552        hash: &[u8; 32],
553        chain_seq: u64,
554    ) -> Result<AnchorReceipt, String> {
555        let receipt = self.backend.anchor(hash)?;
556        let mut last = self
557            .last
558            .lock()
559            .map_err(|_| "anchor policy lock poisoned".to_string())?;
560        last.at = Some(receipt.anchored_at);
561        last.chain_seq = chain_seq;
562        Ok(receipt)
563    }
564
565    /// Anchor the current ExoChain head when policy allows.
566    ///
567    /// On success, also appends a `chain.external_anchor` audit event to
568    /// the local chain so operators can see the receipt on the ExoChain.
569    pub fn try_anchor_chain_head(
570        &self,
571        chain: &ChainManager,
572    ) -> Result<Option<AnchorReceipt>, String> {
573        let hash = chain.head_hash();
574        let seq = chain.sequence();
575        let Some(receipt) = self.try_anchor(&hash, seq)? else {
576            return Ok(None);
577        };
578        // Best-effort audit event; ignore if chain is locked somehow.
579        let _ = chain.append(
580            "chain",
581            "external_anchor",
582            Some(serde_json::json!({
583                "backend": self.backend_name(),
584                "tx_id": receipt.tx_id,
585                "hash": hex_hash(&receipt.hash),
586                "anchored_at": receipt.anchored_at.to_rfc3339(),
587                "chain_seq": seq,
588            })),
589        );
590        Ok(Some(receipt))
591    }
592}
593
594// ── Tests ────────────────────────────────────────────────────────────────
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use std::time::Duration;
600
601    fn temp_path(name: &str) -> PathBuf {
602        let dir = std::env::temp_dir().join("clawft-weft-137-tests");
603        let _ = fs::create_dir_all(&dir);
604        dir.join(format!(
605            "{name}-{}-{}.jsonl",
606            std::process::id(),
607            Utc::now().timestamp_nanos_opt().unwrap_or(0)
608        ))
609    }
610
611    #[test]
612    fn file_ledger_anchor_roundtrip_and_chain() {
613        let path = temp_path("file-roundtrip");
614        let _ = fs::remove_file(&path);
615        let anchor = FileLedgerAnchor::open(&path).expect("open");
616        let h1 = [0x11u8; 32];
617        let h2 = [0x22u8; 32];
618
619        let r1 = anchor.anchor(&h1).expect("anchor1");
620        assert_eq!(r1.hash, h1);
621        assert!(r1.tx_id.starts_with("file-ledger-"));
622        assert!(anchor.verify(&r1).expect("verify1"));
623
624        let r2 = anchor.anchor(&h2).expect("anchor2");
625        assert!(anchor.verify(&r2).expect("verify2"));
626        assert!(anchor.verify_chain().expect("chain ok"));
627
628        let entries = anchor.load_entries().expect("load");
629        assert_eq!(entries.len(), 2);
630        assert_eq!(entries[0].seq, 1);
631        assert_eq!(entries[1].seq, 2);
632        assert_ne!(entries[0].entry_hash, entries[1].entry_hash);
633
634        let _ = fs::remove_file(&path);
635    }
636
637    #[test]
638    fn file_ledger_verify_rejects_unknown_tx() {
639        let path = temp_path("file-unknown");
640        let _ = fs::remove_file(&path);
641        let anchor = FileLedgerAnchor::open(&path).expect("open");
642        let hash = [7u8; 32];
643        let _ = anchor.anchor(&hash).expect("anchor");
644        let bogus = AnchorReceipt {
645            hash,
646            tx_id: "file-ledger-999".into(),
647            anchored_at: Utc::now(),
648        };
649        assert!(!anchor.verify(&bogus).expect("verify"));
650        let _ = fs::remove_file(&path);
651    }
652
653    #[test]
654    fn file_ledger_detects_tamper() {
655        let path = temp_path("file-tamper");
656        let _ = fs::remove_file(&path);
657        let anchor = FileLedgerAnchor::open(&path).expect("open");
658        let _ = anchor.anchor(&[9u8; 32]).expect("anchor");
659        // Corrupt the file.
660        fs::write(&path, "{\"seq\":1,\"prev_entry_hash\":\"00\",\"anchored_hash\":\"xx\",\"entry_hash\":\"yy\",\"anchored_at\":\"2020-01-01T00:00:00Z\"}\n")
661            .expect("corrupt");
662        assert!(anchor.verify_chain().is_err() || !anchor.verify_chain().unwrap_or(true));
663        let _ = fs::remove_file(&path);
664    }
665
666    #[test]
667    fn external_stub_records_intent_with_endpoint() {
668        let path = temp_path("ext-intent");
669        let _ = fs::remove_file(&path);
670        let anchor = ExternalLedgerAnchor::open(
671            &path,
672            Some("https://ledger.example/anchor".into()),
673        )
674        .expect("open");
675        assert_eq!(anchor.backend_name(), "external-stub");
676        let hash = [0xABu8; 32];
677        let receipt = anchor.anchor(&hash).expect("anchor");
678        assert!(receipt.tx_id.contains("ledger.example"));
679        assert!(anchor.verify(&receipt).expect("verify"));
680        let intents = anchor.load_intents().expect("load");
681        assert_eq!(intents.len(), 1);
682        assert_eq!(intents[0].endpoint.as_deref(), Some("https://ledger.example/anchor"));
683        assert!(!intents[0].submitted);
684        let _ = fs::remove_file(&path);
685    }
686
687    #[test]
688    fn external_stub_pending_without_endpoint() {
689        let path = temp_path("ext-pending");
690        let _ = fs::remove_file(&path);
691        let anchor = ExternalLedgerAnchor::open(&path, None).expect("open");
692        let receipt = anchor.anchor(&[1u8; 32]).expect("anchor");
693        assert!(receipt.tx_id.contains("pending"));
694        let _ = fs::remove_file(&path);
695    }
696
697    #[test]
698    fn frequency_policy_event_gate() {
699        let policy = AnchorFrequencyPolicy {
700            min_interval: Duration::ZERO,
701            min_events_between: 10,
702        };
703        let last = LastAnchorState {
704            at: Some(Utc::now()),
705            chain_seq: 5,
706        };
707        assert!(!policy.allows(&last, Utc::now(), 10)); // delta 5 < 10
708        assert!(policy.allows(&last, Utc::now(), 15)); // delta 10
709        // Never anchored: always allow
710        assert!(policy.allows(&LastAnchorState::default(), Utc::now(), 1));
711    }
712
713    #[test]
714    fn frequency_policy_time_gate() {
715        let policy = AnchorFrequencyPolicy {
716            min_interval: Duration::from_secs(60),
717            min_events_between: 0,
718        };
719        let last = LastAnchorState {
720            at: Some(Utc::now()),
721            chain_seq: 1,
722        };
723        assert!(!policy.allows(&last, Utc::now(), 100));
724        let later = Utc::now() + chrono::Duration::seconds(61);
725        assert!(policy.allows(&last, later, 100));
726    }
727
728    #[test]
729    fn controller_try_anchor_respects_policy() {
730        let path = temp_path("ctrl-policy");
731        let _ = fs::remove_file(&path);
732        let backend = Arc::new(FileLedgerAnchor::open(&path).expect("open"));
733        let ctrl = AnchoringController::new(
734            backend,
735            AnchorFrequencyPolicy {
736                min_interval: Duration::ZERO,
737                min_events_between: 5,
738            },
739        );
740        let h = [3u8; 32];
741        let r1 = ctrl.try_anchor(&h, 1).expect("a1").expect("first allowed");
742        assert!(ctrl.backend().verify(&r1).expect("v"));
743        // Same sequence delta too small
744        assert!(ctrl.try_anchor(&h, 3).expect("a2").is_none());
745        // Enough events
746        let r3 = ctrl.try_anchor(&[4u8; 32], 10).expect("a3");
747        assert!(r3.is_some());
748        let _ = fs::remove_file(&path);
749    }
750
751    #[test]
752    fn controller_from_config_file() {
753        let path = temp_path("cfg-file");
754        let _ = fs::remove_file(&path);
755        let cfg = ChainExternalAnchorConfig {
756            backend: ChainAnchorBackend::File,
757            ledger_path: Some(path.to_string_lossy().into_owned()),
758            endpoint: None,
759            min_interval_secs: 0,
760            min_events_between: 0,
761        };
762        let ctrl = AnchoringController::from_config(&cfg)
763            .expect("build")
764            .expect("some");
765        assert_eq!(ctrl.backend_name(), "file-ledger");
766        let receipt = ctrl
767            .force_anchor(&[0xFFu8; 32], 42)
768            .expect("force");
769        assert!(ctrl.backend().verify(&receipt).expect("verify"));
770        let _ = fs::remove_file(&path);
771    }
772
773    #[test]
774    fn controller_from_config_none_returns_none() {
775        let cfg = ChainExternalAnchorConfig {
776            backend: ChainAnchorBackend::None,
777            ..Default::default()
778        };
779        assert!(AnchoringController::from_config(&cfg)
780            .expect("ok")
781            .is_none());
782    }
783
784    #[test]
785    fn controller_from_config_mock() {
786        let cfg = ChainExternalAnchorConfig {
787            backend: ChainAnchorBackend::Mock,
788            ledger_path: None,
789            endpoint: None,
790            min_interval_secs: 0,
791            min_events_between: 0,
792        };
793        let ctrl = AnchoringController::from_config(&cfg)
794            .expect("build")
795            .expect("some");
796        assert_eq!(ctrl.backend_name(), "mock");
797        let r = ctrl.force_anchor(&[0u8; 32], 1).expect("anchor");
798        assert!(ctrl.backend().verify(&r).expect("v"));
799    }
800
801    #[test]
802    fn controller_from_config_external() {
803        let path = temp_path("cfg-ext");
804        let _ = fs::remove_file(&path);
805        let cfg = ChainExternalAnchorConfig {
806            backend: ChainAnchorBackend::External,
807            ledger_path: Some(path.to_string_lossy().into_owned()),
808            endpoint: Some("https://ots.example".into()),
809            min_interval_secs: 0,
810            min_events_between: 0,
811        };
812        let ctrl = AnchoringController::from_config(&cfg)
813            .expect("build")
814            .expect("some");
815        assert_eq!(ctrl.backend_name(), "external-stub");
816        let r = ctrl.try_anchor(&[5u8; 32], 1).expect("a").expect("some");
817        assert!(r.tx_id.contains("ots.example"));
818        let _ = fs::remove_file(&path);
819    }
820
821    #[test]
822    fn try_anchor_chain_head_emits_audit_event() {
823        let path = temp_path("head-audit");
824        let _ = fs::remove_file(&path);
825        let cm = ChainManager::new(0, 1000);
826        cm.append("kernel", "boot.init", None);
827        let head = cm.head_hash();
828
829        let backend = Arc::new(FileLedgerAnchor::open(&path).expect("open"));
830        let ctrl = AnchoringController::new(
831            backend,
832            AnchorFrequencyPolicy {
833                min_interval: Duration::ZERO,
834                min_events_between: 0,
835            },
836        );
837        let receipt = ctrl
838            .try_anchor_chain_head(&cm)
839            .expect("anchor")
840            .expect("some");
841        assert_eq!(receipt.hash, head);
842
843        // Audit event should be on the chain.
844        let events = cm.tail(0);
845        let found = events.iter().any(|e| e.kind == "external_anchor");
846        assert!(found, "expected chain.external_anchor event");
847        let _ = fs::remove_file(&path);
848    }
849
850    #[test]
851    fn mock_anchor_still_works_via_controller() {
852        let ctrl = AnchoringController::new(
853            Arc::new(MockAnchor),
854            AnchorFrequencyPolicy {
855                min_interval: Duration::ZERO,
856                min_events_between: 0,
857            },
858        );
859        let r = ctrl.force_anchor(&[42u8; 32], 0).expect("a");
860        assert!(r.tx_id.starts_with("mock-"));
861    }
862}