1use 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
28pub 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
44fn 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct FileLedgerEntry {
77 pub seq: u64,
79 pub prev_entry_hash: String,
81 pub anchored_hash: String,
83 pub entry_hash: String,
85 pub anchored_at: DateTime<Utc>,
87}
88
89impl FileLedgerEntry {
90 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
105pub struct FileLedgerAnchor {
117 path: PathBuf,
118 lock: Mutex<()>,
119}
120
121impl FileLedgerAnchor {
122 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 pub fn path(&self) -> &Path {
142 &self.path
143 }
144
145 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 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 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#[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
284pub struct ExternalLedgerAnchor {
292 path: PathBuf,
293 endpoint: Option<String>,
294 lock: Mutex<()>,
295}
296
297impl ExternalLedgerAnchor {
298 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 pub fn endpoint(&self) -> Option<&str> {
320 self.endpoint.as_deref()
321 }
322
323 pub fn path(&self) -> &Path {
325 &self.path
326 }
327
328 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 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#[derive(Debug, Clone, Default)]
407struct LastAnchorState {
408 at: Option<DateTime<Utc>>,
409 chain_seq: u64,
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub struct AnchorFrequencyPolicy {
415 pub min_interval: Duration,
417 pub min_events_between: u64,
419}
420
421impl AnchorFrequencyPolicy {
422 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 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 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
455pub struct AnchoringController {
457 backend: Arc<dyn ChainAnchor>,
458 policy: AnchorFrequencyPolicy,
459 last: Mutex<LastAnchorState>,
460}
461
462impl AnchoringController {
463 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 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 pub fn backend_name(&self) -> &str {
508 self.backend.backend_name()
509 }
510
511 pub fn policy(&self) -> AnchorFrequencyPolicy {
513 self.policy
514 }
515
516 pub fn backend(&self) -> &dyn ChainAnchor {
518 self.backend.as_ref()
519 }
520
521 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 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 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 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#[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 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)); assert!(policy.allows(&last, Utc::now(), 15)); 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 assert!(ctrl.try_anchor(&h, 3).expect("a2").is_none());
745 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 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}