Skip to main content

reddb_server/runtime/
blockchain_kind.rs

1//! Foundation for `KIND blockchain` collections (issue #523).
2//!
3//! Persists kind in `red_config`, exposes the reserved-column set used by
4//! every block row, scans the collection to derive the current chain tip,
5//! and wraps the hash helpers from [`crate::storage::blockchain`].
6//!
7//! This iteration does NOT validate user-supplied prev_hash/height on INSERT
8//! (the engine computes them) and does NOT enforce conflict-retry semantics
9//! — those land alongside chain-tip RPC and `verify_chain` in a later slice.
10
11use 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
20/// Value stored under `red.collection.{name}.kind` for blockchain collections.
21pub 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
28/// Reserved column names auto-filled by the engine; user INSERTs that supply
29/// them are silently overwritten so the chain remains engine-controlled.
30pub const RESERVED_COLUMNS: &[&str] = &[COL_BLOCK_HEIGHT, COL_PREV_HASH, COL_TIMESTAMP, COL_HASH];
31
32/// Chain tip derived by scanning the collection.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ChainTip {
35    /// `block_height` of the highest block. `None` if the collection has no
36    /// rows yet (pre-genesis; callers should use [`GENESIS_PREV_HASH`] for
37    /// `prev_hash` and `0` for `block_height`).
38    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    /// Returns `(prev_hash, next_height)` to use when appending a new block.
51    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
61/// Legacy best-effort adapter. Authoritative creation uses [`try_mark_as_chain`].
62pub 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
74/// Compatibility probe fails closed; authoritative callers propagate errors.
75pub 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/// Full chain tip including timestamp. Returned by `chain_tip_full` and
90/// `GET /collections/:name/chain-tip`.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ChainTipFull {
93    pub height: u64,
94    pub hash: [u8; 32],
95    pub timestamp_ms: u64,
96}
97
98/// Legacy optional read. Authoritative append/HTTP paths use the fallible API.
99pub 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
124/// Legacy convenience adapter; creation uses [`try_chain_tip`].
125pub 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
206/// Build the `BlockchainConflict:<json>` error payload mapped to HTTP 409
207/// (#524). The JSON body carries the current tip so the caller can retry
208/// with the right `prev_hash` / `block_height`.
209pub 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
234/// Canonicalize user-supplied fields for inclusion in the block hash. Sorted
235/// by key, `<key>=<plain_text>;` joined — stable across reorderings so the
236/// recomputed hash matches regardless of column order at INSERT-time.
237pub 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
254/// Build the reserved-column key/value pairs for a new block. Caller appends
255/// these to the row's `fields` AFTER stripping any user-supplied reserved
256/// columns. The returned `hash` is also returned so callers can advance the
257/// tip without recomputing.
258pub 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
277/// Issue #525 — walk a chain collection in `block_height` order and rebuild the
278/// `Block` list expected by [`verify_chain`]. Genesis is identified by
279/// `block_height == 0`.  Returns `None` when the collection is absent or not a
280/// chain.  Reserved-column extraction and canonical payload encoding mirror
281/// what the engine writes at INSERT time — this is the alignment of the two
282/// encoders referenced in #524.
283pub 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/// Outcome of `POST /collections/:name/verify-chain`.
321#[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
328/// Walk the chain end-to-end. Returns `(outcome, _)`.  Mismatches are detected
329/// via [`verify_chain`].  Callers persist the integrity flag on `ok == false`.
330pub 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
366/// Legacy best-effort adapter; runtime verification uses the fallible API.
367pub 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
408/// Convenience: produce the genesis row's full field list. Genesis carries
409/// an empty user payload — extra metadata is recorded in subsequent blocks.
410pub 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}