openmina_archive_breadcrumb_compare/
main.rs

1use mina_p2p_messages::v2::ArchiveTransitionFrontierDiff;
2use std::{collections::HashSet, path::PathBuf};
3
4use anyhow::Result;
5use clap::Parser;
6use serde::{Deserialize, Serialize};
7use tokio::time::{interval, timeout, Duration};
8
9#[derive(Parser, Debug)]
10#[command(author, version, about, long_about = None)]
11struct Args {
12    /// OCaml Node GraphQL endpoint
13    #[arg(env = "OCAML_NODE_GRAPHQL")]
14    ocaml_node_graphql: Option<String>,
15
16    /// OCaml Node directory path
17    #[arg(env = "OCAML_NODE_DIR", required = true)]
18    ocaml_node_dir: PathBuf,
19
20    /// Openmina Node GraphQL endpoint
21    #[arg(env = "OPENMINA_NODE_GRAPHQL")]
22    openmina_node_graphql: Option<String>,
23
24    /// Openmina Node directory path
25    #[arg(env = "OPENMINA_NODE_DIR", required = true)]
26    openmina_node_dir: PathBuf,
27
28    /// Check for missing breadcrumbs
29    #[arg(long)]
30    check_missing: bool,
31}
32
33#[derive(Serialize)]
34struct GraphQLQuery {
35    query: String,
36}
37
38#[derive(Deserialize, Debug)]
39struct SyncStatusResponse {
40    data: SyncStatusData,
41}
42
43#[derive(Deserialize, Debug)]
44#[serde(rename_all = "camelCase")]
45struct SyncStatusData {
46    sync_status: String,
47}
48
49#[derive(Deserialize, Debug)]
50#[serde(rename_all = "camelCase")]
51struct BlockInfo {
52    state_hash: String,
53}
54
55#[derive(Deserialize, Debug)]
56struct BestChainResponse {
57    data: BestChainData,
58}
59
60#[derive(Deserialize, Debug)]
61#[serde(rename_all = "camelCase")]
62struct BestChainData {
63    best_chain: Vec<BlockInfo>,
64}
65
66async fn check_sync_status(endpoint: &str) -> Result<String> {
67    let client = reqwest::Client::new();
68
69    let query = GraphQLQuery {
70        query: "query MyQuery { syncStatus }".to_string(),
71    };
72
73    let response = client
74        .post(endpoint)
75        .json(&query)
76        .send()
77        .await?
78        .json::<SyncStatusResponse>()
79        .await?;
80
81    Ok(response.data.sync_status)
82}
83
84async fn get_best_chain(endpoint: &str) -> Result<Vec<String>> {
85    let client = reqwest::Client::new();
86
87    let query = GraphQLQuery {
88        query: "query MyQuery { bestChain(maxLength: 290) { stateHash } }".to_string(),
89    };
90
91    let response = client
92        .post(endpoint)
93        .json(&query)
94        .send()
95        .await?
96        .json::<BestChainResponse>()
97        .await?;
98
99    Ok(response
100        .data
101        .best_chain
102        .into_iter()
103        .map(|block| block.state_hash)
104        .collect())
105}
106
107async fn wait_for_sync(endpoint: &str, node_name: &str) -> Result<()> {
108    const TIMEOUT_DURATION: Duration = Duration::from_secs(300); // 5 minutes timeout
109    const CHECK_INTERVAL: Duration = Duration::from_secs(5);
110
111    let sync_check = async {
112        let mut interval = interval(CHECK_INTERVAL);
113
114        loop {
115            interval.tick().await;
116
117            let status = check_sync_status(endpoint).await?;
118            println!("{} sync status: {}", node_name, status);
119
120            if status == "SYNCED" {
121                return Ok(());
122            }
123
124            println!("Waiting for {} to sync...", node_name);
125        }
126    };
127
128    timeout(TIMEOUT_DURATION, sync_check).await.map_err(|_| {
129        anyhow::anyhow!(
130            "Timeout waiting for {} to sync after {:?}",
131            node_name,
132            TIMEOUT_DURATION
133        )
134    })?
135}
136
137async fn compare_chains(ocaml_endpoint: &str, openmina_endpoint: &str) -> Result<Vec<String>> {
138    const MAX_RETRIES: u32 = 3;
139    const RETRY_INTERVAL: Duration = Duration::from_secs(5);
140    let mut interval = interval(RETRY_INTERVAL);
141
142    for attempt in 1..=MAX_RETRIES {
143        println!(
144            "\nAttempting chain comparison (attempt {}/{})",
145            attempt, MAX_RETRIES
146        );
147
148        let ocaml_chain = get_best_chain(ocaml_endpoint).await?;
149        let openmina_chain = get_best_chain(openmina_endpoint).await?;
150
151        println!("Chain comparison:");
152        println!("OCaml chain length: {}", ocaml_chain.len());
153        println!("Openmina chain length: {}", openmina_chain.len());
154
155        // Try to compare chains
156        if let Err(e) = compare_chain_data(&ocaml_chain, &openmina_chain) {
157            if attempt == MAX_RETRIES {
158                return Err(e);
159            }
160            println!("Comparison failed: {}. Retrying in 5s...", e);
161            interval.tick().await;
162            continue;
163        }
164
165        println!("✅ Chains match perfectly!");
166        return Ok(ocaml_chain);
167    }
168
169    unreachable!()
170}
171
172fn compare_chain_data(ocaml_chain: &[String], openmina_chain: &[String]) -> Result<()> {
173    if ocaml_chain.len() != openmina_chain.len() {
174        anyhow::bail!(
175            "Chain lengths don't match! OCaml: {}, Openmina: {}",
176            ocaml_chain.len(),
177            openmina_chain.len()
178        );
179    }
180
181    for (i, (ocaml_hash, openmina_hash)) in
182        ocaml_chain.iter().zip(openmina_chain.iter()).enumerate()
183    {
184        if ocaml_hash != openmina_hash {
185            anyhow::bail!(
186                "Chain mismatch at position {}: \nOCaml: {}\nOpenmina: {}",
187                i,
188                ocaml_hash,
189                openmina_hash
190            );
191        }
192    }
193
194    Ok(())
195}
196
197#[derive(Debug)]
198struct DiffMismatch {
199    state_hash: String,
200    reason: String,
201}
202
203async fn compare_binary_diffs(
204    ocaml_dir: PathBuf,
205    openmina_dir: PathBuf,
206    state_hashes: &[String],
207) -> Result<Vec<DiffMismatch>> {
208    let mut mismatches = Vec::new();
209
210    if state_hashes.is_empty() {
211        println!("No state hashes provided, comparing all diffs");
212        let files = openmina_dir.read_dir()?;
213        files.for_each(|file| {
214            let file = file.unwrap();
215            let file_name = file.file_name();
216            let file_name_str = file_name.to_str().unwrap();
217            let ocaml_path = ocaml_dir.join(file_name_str);
218            let openmina_path = openmina_dir.join(file_name_str);
219
220            // Load and deserialize both files
221            let ocaml_diff = match load_and_deserialize(&ocaml_path) {
222                Ok(diff) => diff,
223                Err(e) => {
224                    mismatches.push(DiffMismatch {
225                        state_hash: file_name_str.to_string(),
226                        reason: format!("Failed to load OCaml diff: {}", e),
227                    });
228                    return;
229                }
230            };
231
232            let openmina_diff = match load_and_deserialize(&openmina_path) {
233                Ok(diff) => diff,
234                Err(e) => {
235                    mismatches.push(DiffMismatch {
236                        state_hash: file_name_str.to_string(),
237                        reason: format!("Failed to load Openmina diff: {}", e),
238                    });
239                    return;
240                }
241            };
242
243            // Compare the diffs
244            if let Some(reason) = compare_diffs(&ocaml_diff, &openmina_diff) {
245                mismatches.push(DiffMismatch {
246                    state_hash: file_name_str.to_string(),
247                    reason,
248                });
249            }
250        });
251        Ok(mismatches)
252    } else {
253        for state_hash in state_hashes {
254            let ocaml_path = ocaml_dir.join(format!("{}.bin", state_hash));
255            let openmina_path = openmina_dir.join(format!("{}.bin", state_hash));
256
257            // Load and deserialize both files
258            let ocaml_diff = match load_and_deserialize(&ocaml_path) {
259                Ok(diff) => diff,
260                Err(e) => {
261                    mismatches.push(DiffMismatch {
262                        state_hash: state_hash.clone(),
263                        reason: format!("Failed to load OCaml diff: {}", e),
264                    });
265                    continue;
266                }
267            };
268
269            let openmina_diff = match load_and_deserialize(&openmina_path) {
270                Ok(diff) => diff,
271                Err(e) => {
272                    mismatches.push(DiffMismatch {
273                        state_hash: state_hash.clone(),
274                        reason: format!("Failed to load Openmina diff: {}", e),
275                    });
276                    continue;
277                }
278            };
279
280            // Compare the diffs
281            if let Some(reason) = compare_diffs(&ocaml_diff, &openmina_diff) {
282                mismatches.push(DiffMismatch {
283                    state_hash: state_hash.clone(),
284                    reason,
285                });
286            }
287        }
288        Ok(mismatches)
289    }
290}
291
292fn load_and_deserialize(path: &PathBuf) -> Result<ArchiveTransitionFrontierDiff> {
293    let data = std::fs::read(path)?;
294    let diff = binprot::BinProtRead::binprot_read(&mut data.as_slice())?;
295    Ok(diff)
296}
297
298fn compare_diffs(
299    ocaml: &ArchiveTransitionFrontierDiff,
300    openmina: &ArchiveTransitionFrontierDiff,
301) -> Option<String> {
302    match (ocaml, openmina) {
303        (
304            ArchiveTransitionFrontierDiff::BreadcrumbAdded {
305                block: (b1, (body_hash1, state_hash1)),
306                accounts_accessed: a1,
307                accounts_created: c1,
308                tokens_used: t1,
309                sender_receipt_chains_from_parent_ledger: s1,
310            },
311            ArchiveTransitionFrontierDiff::BreadcrumbAdded {
312                block: (b2, (body_hash2, state_hash2)),
313                accounts_accessed: a2,
314                accounts_created: c2,
315                tokens_used: t2,
316                sender_receipt_chains_from_parent_ledger: s2,
317            },
318        ) => {
319            let mut mismatches = Vec::new();
320
321            if body_hash1 != body_hash2 {
322                if body_hash1.is_some() {
323                    mismatches.push(format!(
324                        "Body hash mismatch:\nOCaml: {:?}\nOpenmina: {:?}",
325                        body_hash1, body_hash2
326                    ));
327                }
328            } else if state_hash1 != state_hash2 {
329                mismatches.push(format!(
330                    "State hash mismatch:\nOCaml: {}\nOpenmina: {}",
331                    state_hash1, state_hash2
332                ));
333            } else if b1.header.protocol_state_proof != b2.header.protocol_state_proof {
334                // Note this is not a real mismatch, we can have different protocol state proofs for the same block.
335                // If both proofs are valid, we can ignore the mismatch.
336                // Create a temporary copy of b1 with b2's proof for comparison
337                let mut b1_with_b2_proof = b1.clone();
338                b1_with_b2_proof.header.protocol_state_proof =
339                    b2.header.protocol_state_proof.clone();
340
341                if &b1_with_b2_proof != b2 {
342                    let ocaml_json =
343                        serde_json::to_string_pretty(&serde_json::to_value(b1).unwrap()).unwrap();
344                    let openmina_json =
345                        serde_json::to_string_pretty(&serde_json::to_value(b2).unwrap()).unwrap();
346                    mismatches.push(format!(
347                        "Block data mismatch:\nOCaml:\n{}\nOpenmina:\n{}",
348                        ocaml_json, openmina_json
349                    ));
350                }
351            } else if b1 != b2 {
352                let ocaml_json =
353                    serde_json::to_string_pretty(&serde_json::to_value(b1).unwrap()).unwrap();
354                let openmina_json =
355                    serde_json::to_string_pretty(&serde_json::to_value(b2).unwrap()).unwrap();
356                mismatches.push(format!(
357                    "Block data mismatch:\nOCaml:\n{}\nOpenmina:\n{}",
358                    ocaml_json, openmina_json
359                ));
360            }
361
362            if a1 != a2 {
363                let ids_ocaml = a1.iter().map(|(id, _)| id.as_u64()).collect::<HashSet<_>>();
364                let ids_openmina = a2.iter().map(|(id, _)| id.as_u64()).collect::<HashSet<_>>();
365
366                // Find missing IDs in openmina (present in ocaml but not in openmina)
367                let missing_in_openmina: Vec<_> = ids_ocaml.difference(&ids_openmina).collect();
368                // Find extra IDs in openmina (present in openmina but not in ocaml)
369                let extra_in_openmina: Vec<_> = ids_openmina.difference(&ids_ocaml).collect();
370
371                if !missing_in_openmina.is_empty() {
372                    println!("Missing in Openmina: {:?}", missing_in_openmina);
373                }
374                if !extra_in_openmina.is_empty() {
375                    println!("Extra in Openmina: {:?}", extra_in_openmina);
376                }
377
378                let ocaml_json =
379                    serde_json::to_string_pretty(&serde_json::to_value(a1).unwrap()).unwrap();
380                let openmina_json =
381                    serde_json::to_string_pretty(&serde_json::to_value(a2).unwrap()).unwrap();
382                mismatches.push(format!(
383                    "Accounts accessed mismatch:\nOCaml:\n{}\nOpenmina:\n{}",
384                    ocaml_json, openmina_json
385                ));
386            }
387            if c1 != c2 {
388                let ocaml_json =
389                    serde_json::to_string_pretty(&serde_json::to_value(c1).unwrap()).unwrap();
390                let openmina_json =
391                    serde_json::to_string_pretty(&serde_json::to_value(c2).unwrap()).unwrap();
392                mismatches.push(format!(
393                    "Accounts created mismatch:\nOCaml:\n{}\nOpenmina:\n{}",
394                    ocaml_json, openmina_json
395                ));
396            }
397            if t1 != t2 {
398                let ocaml_json =
399                    serde_json::to_string_pretty(&serde_json::to_value(t1).unwrap()).unwrap();
400                let openmina_json =
401                    serde_json::to_string_pretty(&serde_json::to_value(t2).unwrap()).unwrap();
402                mismatches.push(format!(
403                    "Tokens used mismatch:\nOCaml:\n{}\nOpenmina:\n{}",
404                    ocaml_json, openmina_json
405                ));
406            }
407            if s1 != s2 {
408                let ocaml_json =
409                    serde_json::to_string_pretty(&serde_json::to_value(s1).unwrap()).unwrap();
410                let openmina_json =
411                    serde_json::to_string_pretty(&serde_json::to_value(s2).unwrap()).unwrap();
412                mismatches.push(format!(
413                    "Sender receipt chains mismatch:\nOCaml:\n{}\nOpenmina:\n{}",
414                    ocaml_json, openmina_json
415                ));
416            }
417
418            if mismatches.is_empty() {
419                None
420            } else {
421                Some(mismatches.join("\n\n"))
422            }
423        }
424        _ => {
425            let ocaml_json =
426                serde_json::to_string_pretty(&serde_json::to_value(ocaml).unwrap()).unwrap();
427            let openmina_json =
428                serde_json::to_string_pretty(&serde_json::to_value(openmina).unwrap()).unwrap();
429            Some(format!(
430                "Different diff types:\nOCaml:\n{}\nOpenmina:\n{}",
431                ocaml_json, openmina_json
432            ))
433        }
434    }
435}
436
437async fn check_missing_breadcrumbs(
438    openmina_node_dir: PathBuf,
439    openmina_endpoint: &str,
440) -> Result<()> {
441    let files = openmina_node_dir.read_dir()?;
442    let best_chain = get_best_chain(openmina_endpoint).await?;
443    let mut missing_breadcrumbs = Vec::new();
444
445    let file_names = files
446        .map(|file| {
447            file.unwrap()
448                .file_name()
449                .to_str()
450                .unwrap()
451                .to_string()
452                .strip_suffix(".bin")
453                .unwrap()
454                .to_owned()
455        })
456        .collect::<HashSet<String>>();
457
458    for best_chain_hash in best_chain {
459        if !file_names.contains(&best_chain_hash.to_string()) {
460            missing_breadcrumbs.push(best_chain_hash.to_string());
461        }
462    }
463
464    if !missing_breadcrumbs.is_empty() {
465        println!(
466            "❌ Found {} missing breadcrumbs:",
467            missing_breadcrumbs.len()
468        );
469        for missing_breadcrumb in missing_breadcrumbs {
470            println!("{}", missing_breadcrumb);
471        }
472    } else {
473        println!("✅ All breadcrumbs present!");
474    }
475
476    Ok(())
477}
478
479#[tokio::main]
480async fn main() -> Result<()> {
481    let args = Args::parse();
482
483    let mut best_chain = Vec::new();
484
485    println!("Checking for missing breadcrumbs...");
486
487    if args.check_missing {
488        check_missing_breadcrumbs(
489            args.openmina_node_dir,
490            args.openmina_node_graphql.as_deref().unwrap(),
491        )
492        .await?;
493        return Ok(());
494    }
495
496    if let (Some(ocaml_graphql), Some(openmina_graphql)) =
497        (args.ocaml_node_graphql, args.openmina_node_graphql)
498    {
499        // Wait for both nodes to be synced
500        println!("Waiting for nodes to sync...");
501        wait_for_sync(&ocaml_graphql, "OCaml Node").await?;
502        wait_for_sync(&openmina_graphql, "Openmina Node").await?;
503        println!("Both nodes are synced! ✅\n");
504        // Compare chains with retry logic
505        let bc = compare_chains(&ocaml_graphql, &openmina_graphql).await?;
506        println!("Comparing binary diffs for {} blocks...", bc.len());
507        best_chain.extend_from_slice(&bc);
508    } else {
509        println!("No graphql endpoints provided, skipping chain comparison");
510    }
511
512    let mismatches =
513        compare_binary_diffs(args.ocaml_node_dir, args.openmina_node_dir, &best_chain).await?;
514
515    if mismatches.is_empty() {
516        println!("✅ All binary diffs match perfectly!");
517    } else {
518        println!("\n❌ Found {} mismatches:", mismatches.len());
519
520        // let first_mismatch = mismatches.first().unwrap();
521        // println!(
522        //     "\nMismatch #{}: \nState Hash: {}\nReason: {}",
523        //     1, first_mismatch.state_hash, first_mismatch.reason
524        // );
525        // println!("Another {} missmatches are pending", mismatches.len() - 1);
526        for (i, mismatch) in mismatches.iter().enumerate() {
527            println!(
528                "\nMismatch #{}: \nState Hash: {}\nReason: {}",
529                i + 1,
530                mismatch.state_hash,
531                mismatch.reason
532            );
533        }
534        anyhow::bail!("Binary diff comparison failed");
535    }
536
537    Ok(())
538}