1use std::path::PathBuf;
68use std::sync::atomic::{AtomicUsize, Ordering};
69use std::sync::{Arc, Mutex, mpsc};
70
71use chrono::{DateTime, Duration, Utc};
72use clawft_types::project::canon::{hex_decode, hex_encode};
73use clawft_types::project::cert::{ProjectAnchorStmt, key_id, ts};
74use ed25519_dalek::SigningKey;
75use serde_json::{Value, json};
76
77use super::ANCHOR_SOURCE;
78use crate::chain::{AnchorReceipt, ChainAnchor, ChainEvent, ChainManager};
79
80#[path = "chain_anchor_parent_types.rs"]
81mod types;
82pub use types::*;
83
84#[derive(Debug, Clone)]
85struct Accepted {
86 stmt: ProjectAnchorStmt,
87 ack: AnchorAck,
88}
89
90#[derive(Default)]
91struct State {
92 last: Option<Accepted>,
93 failures: u32,
94 retry_at: Option<DateTime<Utc>>,
95 last_error: Option<String>,
96}
97
98pub struct ParentAnchor {
100 chain: Arc<ChainManager>,
101 key: SigningKey,
102 project_id: String,
103 cert_serial: Mutex<u64>,
104 transport: Arc<dyn ParentTransport>,
105 pending_path: PathBuf,
106 cfg: ParentAnchorConfig,
107 op: Mutex<()>,
109 state: Mutex<State>,
111 inflight: Arc<AtomicUsize>,
113}
114
115fn receipt_of(acc: &Accepted, now: DateTime<Utc>) -> AnchorReceipt {
116 AnchorReceipt {
117 hash: hex_decode::<32>(&acc.stmt.head_hash).unwrap_or([0u8; 32]),
118 tx_id: format!("parent-{}", acc.ack.user_seq),
119 anchored_at: now,
120 }
121}
122
123impl ParentAnchor {
124 pub fn new(
128 chain: Arc<ChainManager>,
129 key: SigningKey,
130 project_id: impl Into<String>,
131 cert_serial: u64,
132 transport: Arc<dyn ParentTransport>,
133 pending_path: impl Into<PathBuf>,
134 cfg: ParentAnchorConfig,
135 ) -> Self {
136 let this = Self {
137 chain,
138 key,
139 project_id: project_id.into(),
140 cert_serial: Mutex::new(cert_serial),
141 transport,
142 pending_path: pending_path.into(),
143 cfg,
144 op: Mutex::new(()),
145 state: Mutex::new(State::default()),
146 inflight: Arc::new(AtomicUsize::new(0)),
147 };
148 {
149 let mut st = this.state.lock().unwrap();
150 st.last = this.recover();
151 if this.read_pending().is_some_and(|p| st.last.as_ref().is_some_and(|l| p.seq <= l.stmt.seq)) {
152 let _ = std::fs::remove_file(&this.pending_path);
153 }
154 }
155 this
156 }
157
158 pub fn set_cert_serial(&self, serial: u64) {
160 *self.cert_serial.lock().unwrap() = serial;
161 }
162
163 pub fn last_accepted(&self) -> Option<(u64, String)> {
165 let st = self.state.lock().unwrap();
166 st.last.as_ref().map(|a| (a.stmt.seq, a.stmt.hash()))
167 }
168
169 pub fn pending(&self) -> Option<ProjectAnchorStmt> {
171 self.read_pending()
172 }
173
174 pub fn last_error(&self) -> Option<String> {
176 self.state.lock().unwrap().last_error.clone()
177 }
178
179 fn pubkey(&self) -> [u8; 32] {
180 self.key.verifying_key().to_bytes()
181 }
182
183 fn recover(&self) -> Option<Accepted> {
185 let mut best: Option<Accepted> = None;
186 for e in self.chain.tail(0) {
187 if e.source != ANCHOR_SOURCE || e.kind != KIND_ANCHORED {
188 continue;
189 }
190 let Some(acc) = e.payload.as_ref().and_then(parse_anchored) else { continue };
191 if acc.stmt.project_id == self.project_id && best.as_ref().is_none_or(|b| acc.stmt.seq > b.stmt.seq) {
192 best = Some(acc);
193 }
194 }
195 best
196 }
197
198 fn head_event(&self) -> Result<ChainEvent, String> {
199 self.chain
200 .tail(1)
201 .into_iter()
202 .next_back()
203 .ok_or_else(|| "project chain is empty; nothing to anchor".to_string())
204 }
205
206 fn event_for(&self, hash: &[u8; 32]) -> Result<ChainEvent, String> {
207 let head = self.head_event()?;
208 if &head.hash == hash {
209 return Ok(head);
210 }
211 self.chain
212 .tail(usize::MAX)
213 .into_iter()
214 .rev()
215 .find(|e| &e.hash == hash)
216 .ok_or_else(|| "hash is not an event of the project chain".to_string())
217 }
218
219 fn has_head(&self, seq: u64, hash: &str) -> bool {
221 self.chain
222 .tail(0)
223 .iter()
224 .any(|e| e.sequence == seq && hex_encode(&e.hash) == hash)
225 }
226
227 fn covered(last: &Accepted, ev: &ChainEvent) -> bool {
230 ev.sequence <= last.stmt.head_seq
231 || (ev.sequence == last.stmt.head_seq + 1
232 && ev.source == ANCHOR_SOURCE
233 && ev.kind == KIND_ANCHORED)
234 }
235
236 fn build(&self, last: Option<&Accepted>, ev: &ChainEvent, now: DateTime<Utc>) -> ProjectAnchorStmt {
237 ProjectAnchorStmt {
238 project_id: self.project_id.clone(),
239 project_key_id: String::new(),
240 cert_serial: *self.cert_serial.lock().unwrap(),
241 seq: last.map_or(1, |l| l.stmt.seq + 1),
242 chain_id: u64::from(ev.chain_id),
243 head_hash: hex_encode(&ev.hash),
244 head_seq: ev.sequence,
245 rule_hash: hex_encode(&ev.rule_hash.unwrap_or([0u8; 32])),
246 at: ts(now),
247 prev_anchor: last.map(|l| l.stmt.hash()),
248 sig: String::new(),
249 }
250 .sign(&self.key)
251 }
252
253 fn read_pending(&self) -> Option<ProjectAnchorStmt> {
254 let text = std::fs::read_to_string(&self.pending_path).ok()?;
255 text.lines().rev().find(|l| !l.trim().is_empty()).and_then(|l| serde_json::from_str(l).ok())
256 }
257
258 fn write_pending(&self, stmt: &ProjectAnchorStmt) -> Result<(), String> {
259 let mut line = serde_json::to_vec(stmt).map_err(|e| e.to_string())?;
260 line.push(b'\n');
261 crate::project_identity::write_private_atomic(&self.pending_path, &line, false)
262 .map_err(|e| format!("write {}: {e}", self.pending_path.display()))
263 }
264
265 fn fail(&self, now: DateTime<Utc>, why: String) -> String {
266 let mut st = self.state.lock().unwrap();
267 st.failures = st.failures.saturating_add(1);
268 let shift = (st.failures - 1).min(20);
269 let secs = (self.cfg.backoff_base_secs << shift).min(self.cfg.backoff_max_secs);
270 st.retry_at = Some(now + Duration::seconds(secs));
271 st.last_error = Some(why.clone());
272 why
273 }
274
275 fn record(&self, stmt: ProjectAnchorStmt, ack: AnchorAck) -> Accepted {
279 self.chain.append(
280 ANCHOR_SOURCE,
281 KIND_ANCHORED,
282 Some(json!({
283 "statement": stmt,
284 "user_seq": ack.user_seq,
285 "user_event_hash": ack.user_event_hash,
286 })),
287 );
288 if self.read_pending().is_none_or(|p| p.seq <= stmt.seq) {
289 let _ = std::fs::remove_file(&self.pending_path);
290 }
291 let acc = Accepted { stmt, ack };
292 let mut st = self.state.lock().unwrap();
293 st.last = Some(acc.clone());
294 st.failures = 0;
295 st.retry_at = None;
296 st.last_error = None;
297 acc
298 }
299
300 fn adopt(&self, last: (ProjectAnchorStmt, AnchorAck)) -> Result<Accepted, String> {
303 let (stmt, ack) = last;
304 let ours = self.state.lock().unwrap().last.clone();
305 if stmt.project_id != self.project_id {
306 return Err("statement is for another project".into());
307 }
308 let pk = self.pubkey();
309 if key_id(&pk) != stmt.project_key_id || stmt.verify(&pk).is_err() {
310 return Err("statement does not verify under the current project key".into());
311 }
312 let extends = match &ours {
313 Some(o) => stmt.seq == o.stmt.seq + 1 && stmt.prev_anchor.as_deref() == Some(o.stmt.hash().as_str()),
314 None => stmt.seq == 1 && stmt.prev_anchor.is_none(),
315 };
316 if !extends {
317 return Err("statement is not the next one after the recorded anchor chain".into());
318 }
319 if !self.has_head(stmt.head_seq, &stmt.head_hash) {
320 return Err(format!(
321 "our chain has no event {} at seq {}; refusing to record a head we did not produce",
322 stmt.head_hash, stmt.head_seq
323 ));
324 }
325 Ok(self.record(stmt, ack))
326 }
327
328 fn submit_bounded(&self, stmt: &ProjectAnchorStmt) -> Result<AnchorAck, AnchorSubmitError> {
331 if self.inflight.load(Ordering::SeqCst) > 0 {
332 return Err(AnchorSubmitError::Unreachable("previous submit still in flight".into()));
333 }
334 self.inflight.fetch_add(1, Ordering::SeqCst);
335 let (tx, rx) = mpsc::channel();
336 let (transport, s, inflight) = (self.transport.clone(), stmt.clone(), self.inflight.clone());
337 std::thread::spawn(move || {
338 let r = transport.submit(&s);
339 inflight.fetch_sub(1, Ordering::SeqCst);
340 let _ = tx.send(r);
341 });
342 rx.recv_timeout(std::time::Duration::from_secs(self.cfg.submit_timeout_secs))
343 .unwrap_or_else(|_| {
344 Err(AnchorSubmitError::Unreachable(format!(
345 "no answer within {} s",
346 self.cfg.submit_timeout_secs
347 )))
348 })
349 }
350
351 fn deliver(&self, mut stmt: ProjectAnchorStmt, now: DateTime<Utc>) -> Result<AnchorReceipt, String> {
353 for attempt in 0..2 {
354 match self.submit_bounded(&stmt) {
355 Ok(ack) => return Ok(receipt_of(&self.record(stmt, ack), now)),
356 Err(AnchorSubmitError::Unreachable(m)) => {
357 return Err(self.fail(now, format!("parent unreachable: {m}")));
358 }
359 Err(AnchorSubmitError::Rejected { kind, message, last, .. }) => {
360 if attempt == 0 && let Some(last) = last {
361 match self.adopt(*last) {
362 Ok(acc) => {
363 let head = self.head_event()?;
364 if Self::covered(&acc, &head) {
365 return Ok(receipt_of(&acc, now));
366 }
367 stmt = self.build(Some(&acc), &head, now);
368 self.write_pending(&stmt)?;
369 continue;
370 }
371 Err(why) => {
372 return Err(self.fail(
373 now,
374 format!("parent rejected anchor ({kind}): {message}; not adopted: {why}"),
375 ));
376 }
377 }
378 }
379 return Err(self.fail(now, format!("parent rejected anchor ({kind}): {message}")));
380 }
381 }
382 }
383 Err(self.fail(now, "parent rejected the rebuilt anchor".into()))
384 }
385
386 fn anchor_event(&self, ev: ChainEvent, now: DateTime<Utc>, force: bool) -> Result<AnchorReceipt, String> {
387 let _op = self.op.lock().map_err(|_| "parent anchor lock poisoned".to_string())?;
388 let (last, retry_at) = {
389 let st = self.state.lock().unwrap();
390 (st.last.clone(), st.retry_at)
391 };
392 if let Some(acc) = &last
393 && Self::covered(acc, &ev)
394 {
395 return Ok(receipt_of(acc, now));
396 }
397 let stmt = self.build(last.as_ref(), &ev, now);
398 self.write_pending(&stmt)?;
399 if !force && let Some(t) = retry_at && now < t {
400 return Err(format!("parent unreachable; next retry after {}", ts(t)));
401 }
402 self.deliver(stmt, now)
403 }
404
405 pub fn anchor_head_at(&self, now: DateTime<Utc>, force: bool) -> Result<AnchorReceipt, String> {
408 self.anchor_event(self.head_event()?, now, force)
409 }
410
411 pub fn anchor_head(&self) -> Result<AnchorReceipt, String> {
414 self.anchor_head_at(Utc::now(), true)
415 }
416
417 pub fn retry_pending_at(&self, now: DateTime<Utc>) -> Result<Option<AnchorReceipt>, String> {
420 let _op = self.op.lock().map_err(|_| "parent anchor lock poisoned".to_string())?;
421 let Some(stmt) = self.read_pending() else { return Ok(None) };
422 let (last, retry_at) = {
423 let st = self.state.lock().unwrap();
424 (st.last.clone(), st.retry_at)
425 };
426 if last.is_some_and(|l| stmt.seq <= l.stmt.seq) {
427 let _ = std::fs::remove_file(&self.pending_path);
428 return Ok(None);
429 }
430 if retry_at.is_some_and(|t| now < t) {
431 return Ok(None);
432 }
433 self.deliver(stmt, now).map(Some)
434 }
435
436 pub fn retry_pending(&self) -> Result<Option<AnchorReceipt>, String> {
438 self.retry_pending_at(Utc::now())
439 }
440}
441
442fn parse_anchored(p: &Value) -> Option<Accepted> {
443 let stmt = serde_json::from_value(p.get("statement")?.clone()).ok()?;
444 Some(Accepted {
445 stmt,
446 ack: AnchorAck {
447 user_seq: p.get("user_seq")?.as_u64()?,
448 user_event_hash: p.get("user_event_hash")?.as_str()?.to_owned(),
449 },
450 })
451}
452
453impl ChainAnchor for ParentAnchor {
454 fn anchor(&self, hash: &[u8; 32]) -> Result<AnchorReceipt, String> {
455 let now = Utc::now();
456 self.anchor_event(self.event_for(hash)?, now, false)
457 }
458
459 fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, String> {
460 let want = hex_encode(&receipt.hash);
461 Ok(self.chain.tail(0).iter().any(|e| {
462 e.source == ANCHOR_SOURCE
463 && e.kind == KIND_ANCHORED
464 && e.payload.as_ref().and_then(parse_anchored).is_some_and(|a| {
465 a.stmt.head_hash == want && format!("parent-{}", a.ack.user_seq) == receipt.tx_id
466 })
467 }))
468 }
469
470 fn backend_name(&self) -> &str {
471 "parent"
472 }
473}
474
475#[cfg(test)]
476#[path = "chain_anchor_parent_tests.rs"]
477mod tests;