1use std::time::{SystemTime, UNIX_EPOCH};
12
13use crate::storage::blockchain::{
14 compute_block_hash, verify_chain, Block, VerifyReport, GENESIS_PREV_HASH,
15};
16use crate::storage::unified::{EntityData, RowData, SegmentManager, UnifiedStore};
17use crate::{RedDBError, RedDBResult};
18use reddb_types::Value;
19
20pub const CHAIN_KIND_TAG: &str = "chain";
22
23pub const COL_BLOCK_HEIGHT: &str = "block_height";
24pub const COL_PREV_HASH: &str = "prev_hash";
25pub const COL_TIMESTAMP: &str = "timestamp";
26pub const COL_HASH: &str = "hash";
27
28pub const RESERVED_COLUMNS: &[&str] = &[COL_BLOCK_HEIGHT, COL_PREV_HASH, COL_TIMESTAMP, COL_HASH];
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ChainTip {
35 pub height: Option<u64>,
39 pub hash: [u8; 32],
40}
41
42impl ChainTip {
43 pub fn empty() -> Self {
44 Self {
45 height: None,
46 hash: GENESIS_PREV_HASH,
47 }
48 }
49
50 pub fn next(&self) -> ([u8; 32], u64) {
52 let next_height = self.height.map(|h| h + 1).unwrap_or(0);
53 (self.hash, next_height)
54 }
55}
56
57fn kind_key(collection: &str) -> String {
58 format!("red.collection.{collection}.kind")
59}
60
61pub fn mark_as_chain(store: &UnifiedStore, collection: &str) {
63 let _ = try_mark_as_chain(store, collection);
64}
65
66pub fn try_mark_as_chain(store: &UnifiedStore, collection: &str) -> RedDBResult<()> {
67 store.try_set_config_tree(
68 &kind_key(collection),
69 &crate::serde_json::Value::String(CHAIN_KIND_TAG.to_string()),
70 )?;
71 Ok(())
72}
73
74pub fn is_chain(store: &UnifiedStore, collection: &str) -> bool {
76 try_is_chain(store, collection).unwrap_or(true)
77}
78
79pub fn try_is_chain(store: &UnifiedStore, collection: &str) -> RedDBResult<bool> {
80 match store.try_get_config(&kind_key(collection))? {
81 None => Ok(false),
82 Some(Value::Text(kind)) => Ok(kind.as_ref() == CHAIN_KIND_TAG),
83 Some(_) => Err(RedDBError::Query(
84 "invalid persisted collection kind".into(),
85 )),
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ChainTipFull {
93 pub height: u64,
94 pub hash: [u8; 32],
95 pub timestamp_ms: u64,
96}
97
98pub fn chain_tip_full(store: &UnifiedStore, collection: &str) -> Option<ChainTipFull> {
100 try_chain_tip_full(store, collection).ok().flatten()
101}
102
103pub fn try_chain_tip_full(
104 store: &UnifiedStore,
105 collection: &str,
106) -> RedDBResult<Option<ChainTipFull>> {
107 let Some(manager) = store.get_collection(collection) else {
108 return Ok(None);
109 };
110 let mut best: Option<ChainTipFull> = None;
111 visit_chain_rows(&manager, |row| {
112 let tip = chain_row_tip(row)?;
113 if best
114 .as_ref()
115 .is_none_or(|current| tip.height > current.height)
116 {
117 best = Some(tip);
118 }
119 Ok(())
120 })?;
121 Ok(best)
122}
123
124pub fn chain_tip(store: &UnifiedStore, collection: &str) -> ChainTip {
126 try_chain_tip(store, collection).unwrap_or_else(|_| ChainTip::empty())
127}
128
129pub fn try_chain_tip(store: &UnifiedStore, collection: &str) -> RedDBResult<ChainTip> {
130 Ok(match try_chain_tip_full(store, collection)? {
131 Some(tip) => ChainTip {
132 height: Some(tip.height),
133 hash: tip.hash,
134 },
135 None => ChainTip::empty(),
136 })
137}
138
139fn visit_chain_rows(
140 manager: &SegmentManager,
141 mut consume: impl FnMut(&RowData) -> RedDBResult<()>,
142) -> RedDBResult<()> {
143 let mut failure = None;
144 super::function_budget::try_scan(
145 |visit| manager.try_for_each_entity(usize::MAX, visit),
146 |entity| {
147 let result = match &entity.data {
148 EntityData::Row(row) => consume(row),
149 _ => Err(RedDBError::Query(
150 "blockchain contains a non-row record".into(),
151 )),
152 };
153 match result {
154 Ok(()) => true,
155 Err(error) => {
156 failure = Some(error);
157 false
158 }
159 }
160 },
161 )?;
162 match failure {
163 Some(error) => Err(error),
164 None => Ok(()),
165 }
166}
167
168fn chain_row_u64(row: &RowData, field: &str) -> RedDBResult<u64> {
169 match row.get_field(field) {
170 Some(Value::UnsignedInteger(value)) => Ok(*value),
171 Some(Value::Integer(value)) if *value >= 0 => Ok(*value as u64),
172 _ => Err(RedDBError::Query(format!(
173 "invalid blockchain field {field}"
174 ))),
175 }
176}
177
178fn chain_row_hash(row: &RowData, field: &str) -> RedDBResult<[u8; 32]> {
179 match row.get_field(field) {
180 Some(Value::Blob(bytes)) => bytes
181 .as_slice()
182 .try_into()
183 .map_err(|_| RedDBError::Query(format!("invalid blockchain field {field}"))),
184 _ => Err(RedDBError::Query(format!(
185 "invalid blockchain field {field}"
186 ))),
187 }
188}
189
190fn chain_row_tip(row: &RowData) -> RedDBResult<ChainTipFull> {
191 Ok(ChainTipFull {
192 height: chain_row_u64(row, COL_BLOCK_HEIGHT)?,
193 hash: chain_row_hash(row, COL_HASH)?,
194 timestamp_ms: chain_row_u64(row, COL_TIMESTAMP)?,
195 })
196}
197
198fn hex32(bytes: &[u8; 32]) -> String {
199 let mut s = String::with_capacity(64);
200 for b in bytes {
201 s.push_str(&format!("{b:02x}"));
202 }
203 s
204}
205
206pub fn chain_conflict_error(
210 tip_height: u64,
211 tip_hash: [u8; 32],
212 tip_timestamp_ms: u64,
213 server_now_ms: u64,
214 reason: &str,
215) -> crate::api::RedDBError {
216 let body = format!(
217 "{{\"block_height\":{},\"hash\":\"{}\",\"timestamp\":{},\"server_time\":{},\"reason\":\"{}\"}}",
218 tip_height,
219 hex32(&tip_hash),
220 tip_timestamp_ms,
221 server_now_ms,
222 reason.replace('"', "'")
223 );
224 crate::api::RedDBError::InvalidOperation(format!("BlockchainConflict:{body}"))
225}
226
227pub fn now_ms() -> u64 {
228 SystemTime::now()
229 .duration_since(UNIX_EPOCH)
230 .map(|d| d.as_millis() as u64)
231 .unwrap_or(0)
232}
233
234pub fn canonical_payload(fields: &[(String, Value)]) -> Vec<u8> {
238 let mut pairs: Vec<(&str, String)> = fields
239 .iter()
240 .filter(|(k, _)| !RESERVED_COLUMNS.contains(&k.as_str()))
241 .map(|(k, v)| (k.as_str(), v.plain_text()))
242 .collect();
243 pairs.sort_by(|a, b| a.0.cmp(b.0));
244 let mut out = Vec::new();
245 for (k, v) in pairs {
246 out.extend_from_slice(k.as_bytes());
247 out.push(b'=');
248 out.extend_from_slice(v.as_bytes());
249 out.push(b';');
250 }
251 out
252}
253
254pub fn make_block_reserved_fields(
259 prev_hash: [u8; 32],
260 height: u64,
261 timestamp_ms: u64,
262 payload_canonical: &[u8],
263) -> (Vec<(String, Value)>, [u8; 32]) {
264 let hash = compute_block_hash(&prev_hash, height, timestamp_ms, payload_canonical, None);
265 let fields = vec![
266 (COL_BLOCK_HEIGHT.to_string(), Value::UnsignedInteger(height)),
267 (COL_PREV_HASH.to_string(), Value::Blob(prev_hash.to_vec())),
268 (
269 COL_TIMESTAMP.to_string(),
270 Value::UnsignedInteger(timestamp_ms),
271 ),
272 (COL_HASH.to_string(), Value::Blob(hash.to_vec())),
273 ];
274 (fields, hash)
275}
276
277pub fn collect_blocks(store: &UnifiedStore, collection: &str) -> Option<Vec<Block>> {
284 try_collect_blocks(store, collection).ok().flatten()
285}
286
287pub fn try_collect_blocks(
288 store: &UnifiedStore,
289 collection: &str,
290) -> RedDBResult<Option<Vec<Block>>> {
291 if !try_is_chain(store, collection)? {
292 return Ok(None);
293 }
294 let Some(manager) = store.get_collection(collection) else {
295 return Ok(None);
296 };
297 let mut blocks = Vec::new();
298 visit_chain_rows(&manager, |row| {
299 let tip = chain_row_tip(row)?;
300 let prev_hash = chain_row_hash(row, COL_PREV_HASH)?;
301 let user_fields: Vec<_> = row
302 .iter_fields()
303 .filter(|(name, _)| !RESERVED_COLUMNS.contains(name))
304 .map(|(name, value)| (name.to_string(), value.clone()))
305 .collect();
306 blocks.push(Block {
307 block_height: tip.height,
308 prev_hash,
309 timestamp_ms: tip.timestamp_ms,
310 payload: canonical_payload(&user_fields),
311 signed: None,
312 hash: tip.hash,
313 });
314 Ok(())
315 })?;
316 blocks.sort_by_key(|block| block.block_height);
317 Ok(Some(blocks))
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct VerifyChainOutcome {
323 pub checked: u64,
324 pub ok: bool,
325 pub first_bad_height: Option<u64>,
326}
327
328pub fn verify_chain_outcome(store: &UnifiedStore, collection: &str) -> Option<VerifyChainOutcome> {
331 try_verify_chain_outcome(store, collection).ok().flatten()
332}
333
334pub fn try_verify_chain_outcome(
335 store: &UnifiedStore,
336 collection: &str,
337) -> RedDBResult<Option<VerifyChainOutcome>> {
338 let Some(blocks) = try_collect_blocks(store, collection)? else {
339 return Ok(None);
340 };
341 if blocks.is_empty() {
342 return Err(RedDBError::Query("blockchain has no genesis block".into()));
343 }
344 let checked = blocks.len() as u64;
345 Ok(Some(match verify_chain(&blocks) {
346 VerifyReport::Ok => VerifyChainOutcome {
347 checked,
348 ok: true,
349 first_bad_height: None,
350 },
351 VerifyReport::Inconsistent { block_height, .. } => VerifyChainOutcome {
352 checked,
353 ok: false,
354 first_bad_height: Some(block_height),
355 },
356 }))
357}
358
359fn integrity_key(collection: &str) -> String {
360 format!("red.collection.{collection}.integrity")
361}
362
363const INTEGRITY_BROKEN: &str = "broken";
364const INTEGRITY_OK: &str = "ok";
365
366pub fn persist_integrity_flag(store: &UnifiedStore, collection: &str, broken: bool) {
368 let _ = try_persist_integrity_flag(store, collection, broken);
369}
370
371pub fn try_persist_integrity_flag(
372 store: &UnifiedStore,
373 collection: &str,
374 broken: bool,
375) -> RedDBResult<()> {
376 let tag = if broken {
377 INTEGRITY_BROKEN
378 } else {
379 INTEGRITY_OK
380 };
381 store.try_set_config_tree(
382 &integrity_key(collection),
383 &crate::serde_json::Value::String(tag.to_string()),
384 )?;
385 Ok(())
386}
387
388pub fn is_integrity_broken_persisted(store: &UnifiedStore, collection: &str) -> Option<bool> {
389 try_is_integrity_broken_persisted(store, collection)
390 .ok()
391 .flatten()
392}
393
394pub fn try_is_integrity_broken_persisted(
395 store: &UnifiedStore,
396 collection: &str,
397) -> RedDBResult<Option<bool>> {
398 match store.try_get_config(&integrity_key(collection))? {
399 None => Ok(None),
400 Some(Value::Text(tag)) if tag.as_ref() == INTEGRITY_BROKEN => Ok(Some(true)),
401 Some(Value::Text(tag)) if tag.as_ref() == INTEGRITY_OK => Ok(Some(false)),
402 Some(_) => Err(RedDBError::Query(
403 "invalid persisted blockchain integrity flag".into(),
404 )),
405 }
406}
407
408pub fn genesis_fields(timestamp_ms: u64) -> Vec<(String, Value)> {
411 make_block_reserved_fields(GENESIS_PREV_HASH, 0, timestamp_ms, &[]).0
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn reserved_columns_complete() {
420 assert_eq!(RESERVED_COLUMNS.len(), 4);
421 assert!(RESERVED_COLUMNS.contains(&COL_BLOCK_HEIGHT));
422 assert!(RESERVED_COLUMNS.contains(&COL_PREV_HASH));
423 assert!(RESERVED_COLUMNS.contains(&COL_TIMESTAMP));
424 assert!(RESERVED_COLUMNS.contains(&COL_HASH));
425 }
426
427 #[test]
428 fn empty_tip_advances_to_genesis_height() {
429 let tip = ChainTip::empty();
430 let (prev, height) = tip.next();
431 assert_eq!(prev, GENESIS_PREV_HASH);
432 assert_eq!(height, 0);
433 }
434
435 #[test]
436 fn tip_with_height_advances_by_one() {
437 let tip = ChainTip {
438 height: Some(7),
439 hash: [0xAB; 32],
440 };
441 let (prev, height) = tip.next();
442 assert_eq!(prev, [0xAB; 32]);
443 assert_eq!(height, 8);
444 }
445
446 #[test]
447 fn genesis_fields_carry_zero_prev_hash() {
448 let fields = genesis_fields(1_700_000_000_000);
449 let prev = fields.iter().find(|(k, _)| k == COL_PREV_HASH).unwrap();
450 match &prev.1 {
451 Value::Blob(b) => assert_eq!(&b[..], &[0u8; 32]),
452 _ => panic!("prev_hash must be Blob"),
453 }
454 let height = fields.iter().find(|(k, _)| k == COL_BLOCK_HEIGHT).unwrap();
455 assert_eq!(height.1, Value::UnsignedInteger(0));
456 }
457
458 #[test]
459 fn canonical_payload_is_order_independent() {
460 let a = vec![
461 ("user".to_string(), Value::text("alice")),
462 ("amount".to_string(), Value::Integer(100)),
463 ];
464 let b = vec![
465 ("amount".to_string(), Value::Integer(100)),
466 ("user".to_string(), Value::text("alice")),
467 ];
468 assert_eq!(canonical_payload(&a), canonical_payload(&b));
469 }
470
471 #[test]
472 fn canonical_payload_skips_reserved_columns() {
473 let fields = vec![
474 ("user".to_string(), Value::text("alice")),
475 (COL_BLOCK_HEIGHT.to_string(), Value::UnsignedInteger(42)),
476 (COL_HASH.to_string(), Value::Blob(vec![0xFF; 32])),
477 ];
478 let bytes = canonical_payload(&fields);
479 let s = String::from_utf8(bytes).unwrap();
480 assert_eq!(s, "user=alice;");
481 }
482
483 #[test]
484 fn block_hash_matches_recompute() {
485 let (fields, hash) =
486 make_block_reserved_fields(GENESIS_PREV_HASH, 0, 1_700_000_000_000, b"user=alice;");
487 let recomputed = compute_block_hash(
488 &GENESIS_PREV_HASH,
489 0,
490 1_700_000_000_000,
491 b"user=alice;",
492 None,
493 );
494 assert_eq!(hash, recomputed);
495 let stored = fields.iter().find(|(k, _)| k == COL_HASH).unwrap();
496 match &stored.1 {
497 Value::Blob(b) => assert_eq!(&b[..], &hash[..]),
498 _ => panic!("hash must be Blob"),
499 }
500 }
501}