From b7990182049771014697506fbf7e08864f6f1a3d Mon Sep 17 00:00:00 2001
From: Hugo Trentesaux <hugo.trentesaux@lilo.org>
Date: Mon, 19 Feb 2024 14:03:49 +0100
Subject: [PATCH] indexer check (clients/rust/gcli-v2s!24)

* v0.2.8

* improve current block display

* clippy fmt

* display names of migrated identities in tech members

* minor indexer improvements

* add finalized / latest info

* improve readability of indexer check
---
 Cargo.lock                  |  49 ++++++++-
 Cargo.toml                  |   3 +-
 res/indexer-queries.graphql |  18 ++++
 src/cache.rs                |   2 +-
 src/commands/blockchain.rs  |  72 ++++++++++---
 src/commands/collective.rs  |  54 +++++-----
 src/commands/vault.rs       |   2 +-
 src/data.rs                 |  20 ++--
 src/indexer.rs              | 207 +++++++++++++++++++++++++++---------
 9 files changed, 324 insertions(+), 103 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 1e61c32..7a491fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1083,6 +1083,18 @@ dependencies = [
  "unreachable",
 ]
 
+[[package]]
+name = "comfy-table"
+version = "7.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c64043d6c7b7a4c58e39e7efccfdea7b93d885a795d0c054a69dbbf4dd52686"
+dependencies = [
+ "crossterm 0.27.0",
+ "strum",
+ "strum_macros",
+ "unicode-width",
+]
+
 [[package]]
 name = "common"
 version = "0.1.0"
@@ -1264,6 +1276,19 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "crossterm"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
+dependencies = [
+ "bitflags 2.4.2",
+ "crossterm_winapi",
+ "libc",
+ "parking_lot",
+ "winapi",
+]
+
 [[package]]
 name = "crossterm_winapi"
 version = "0.9.1"
@@ -2073,13 +2098,14 @@ dependencies = [
 
 [[package]]
 name = "gcli"
-version = "0.2.7"
+version = "0.2.8"
 dependencies = [
  "age",
  "anyhow",
  "bip39",
  "bs58",
  "clap",
+ "comfy-table",
  "confy",
  "directories 5.0.1",
  "env_logger",
@@ -2620,7 +2646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c33e7c1ddeb15c9abcbfef6029d8e29f69b52b6d6c891031b88ed91b5065803b"
 dependencies = [
  "bitflags 1.3.2",
- "crossterm",
+ "crossterm 0.25.0",
  "dyn-clone",
  "lazy_static",
  "newline-converter",
@@ -5148,6 +5174,25 @@ version = "0.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01"
 
+[[package]]
+name = "strum"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
+
+[[package]]
+name = "strum_macros"
+version = "0.25.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.48",
+]
+
 [[package]]
 name = "substrate-bip39"
 version = "0.4.5"
diff --git a/Cargo.toml b/Cargo.toml
index bd3166d..70db2d6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,7 +9,7 @@ rust-version = "1.75.0"
 license = "AGPL-3.0-only"
 name = "gcli"
 repository = "https://git.duniter.org/clients/rust/gcli-v2s"
-version = "0.2.7"
+version = "0.2.8"
 
 [dependencies]
 # subxt is main dependency
@@ -41,6 +41,7 @@ confy = "^0.5.1"
 bs58 = "^0.5.0"
 inquire = "^0.6.2"
 directories = "^5.0.1"
+comfy-table = "^7.1.0"
 
 # crypto
 scrypt = { version = "^0.11", default-features = false }                      # for old-style key generation
diff --git a/res/indexer-queries.graphql b/res/indexer-queries.graphql
index bf7bb86..b02516a 100644
--- a/res/indexer-queries.graphql
+++ b/res/indexer-queries.graphql
@@ -39,9 +39,27 @@ query IdentityNameByPubkey($pubkey: String!) {
   }
 }
 
+query WasIdentityNameByPubkey($pubkey: String!) {
+  accountById(id: $pubkey) {
+    wasIdentity {
+      identity {
+        name
+      }
+    }
+  }
+}
+
 query LatestBlock {
   blocks(limit: 1, orderBy: height_DESC) {
     height
+    hash
+  }
+}
+
+query BlockByNumber($number: Int!) {
+  blocks(where: { height_eq: $number }) {
+    height
+    hash
   }
 }
 
diff --git a/src/cache.rs b/src/cache.rs
index 7f8f647..94316b5 100644
--- a/src/cache.rs
+++ b/src/cache.rs
@@ -38,7 +38,7 @@ impl IdentityCache {
 					format!(
 						"“ {} ”",
 						if let Some(indexer) = &self.indexer {
-							if let Ok(Some(username)) = indexer.username_by_pubkey(&pubkey).await {
+							if let Some(username) = indexer.username_by_pubkey(&pubkey).await {
 								username
 							} else {
 								pubkey
diff --git a/src/commands/blockchain.rs b/src/commands/blockchain.rs
index 2938229..ee46955 100644
--- a/src/commands/blockchain.rs
+++ b/src/commands/blockchain.rs
@@ -39,17 +39,11 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE
 			commands::runtime::runtime_info(data).await;
 		}
 		Subcommand::CurrentBlock => {
-			println!(
-				"current block on {}: {}",
-				data.cfg.duniter_endpoint,
-				data.client()
-					.storage()
-					.at_latest()
-					.await?
-					.fetch(&runtime::storage().system().number())
-					.await?
-					.unwrap()
-			);
+			let finalized_number = fetch_finalized_number(&data).await?;
+			let current_number = fetch_latest_number_and_hash(&data).await?.0;
+			println!("on {}", data.cfg.duniter_endpoint);
+			println!("finalized block\t{}", finalized_number);
+			println!("current block\t{}", current_number);
 		}
 		Subcommand::CreateBlock => {
 			todo!()
@@ -63,7 +57,7 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE
 }
 
 /// get genesis hash
-pub async fn fetch_genesis_hash(data: &Data) -> Result<Hash, anyhow::Error> {
+pub async fn fetch_genesis_hash(data: &Data) -> Result<Hash, subxt::Error> {
 	Ok(data
 		.client()
 		.storage()
@@ -73,3 +67,57 @@ pub async fn fetch_genesis_hash(data: &Data) -> Result<Hash, anyhow::Error> {
 		.await?
 		.unwrap())
 }
+
+/// get finalized number
+pub async fn fetch_finalized_number(data: &Data) -> Result<BlockNumber, subxt::Error> {
+	Ok(data
+		.client()
+		.storage()
+		.at_latest()
+		.await?
+		.fetch(&runtime::storage().system().number())
+		.await?
+		.unwrap())
+}
+
+/// get finalized hash and number (require legacy)
+pub async fn fetch_finalized_number_and_hash(
+	data: &Data,
+) -> Result<(BlockNumber, Hash), subxt::Error> {
+	let hash = data
+		.legacy_rpc_methods()
+		.await
+		.chain_get_finalized_head()
+		.await?;
+	let number = data
+		.legacy_rpc_methods()
+		.await
+		.chain_get_block(Some(hash))
+		.await?
+		.unwrap()
+		.block
+		.header
+		.number;
+
+	Ok((number, hash))
+}
+
+/// get latest hash and number
+pub async fn fetch_latest_number_and_hash(
+	data: &Data,
+) -> Result<(BlockNumber, Hash), subxt::Error> {
+	let number = data
+		.legacy_rpc_methods()
+		.await
+		.chain_get_header(None)
+		.await?
+		.unwrap()
+		.number;
+	let hash = data
+		.legacy_rpc_methods()
+		.await
+		.chain_get_block_hash(Some(number.into()))
+		.await?
+		.unwrap();
+	Ok((number, hash))
+}
diff --git a/src/commands/collective.rs b/src/commands/collective.rs
index 9717d66..4c77443 100644
--- a/src/commands/collective.rs
+++ b/src/commands/collective.rs
@@ -1,7 +1,5 @@
 use crate::*;
 
-use anyhow::Result;
-
 /// define technical committee subcommands
 #[derive(Clone, Default, Debug, clap::Parser)]
 pub enum Subcommand {
@@ -24,8 +22,8 @@ pub enum Subcommand {
 }
 
 /// handle technical committee commands
-pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliError> {
-	let data = data.build_client().await?;
+pub async fn handle_command(data: Data, command: Subcommand) -> anyhow::Result<(), GcliError> {
+	let data = data.build_client().await?.build_indexer().await?;
 	match command {
 		Subcommand::Members => technical_committee_members(&data).await?,
 		Subcommand::Propose { hex } => technical_committee_propose(&data, &hex).await?,
@@ -48,43 +46,42 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE
 }
 
 /// list technical committee members
-pub async fn technical_committee_members(data: &Data) -> Result<()> {
+pub async fn technical_committee_members(data: &Data) -> Result<(), anyhow::Error> {
 	let client = data.client();
-	let indexer = data.indexer.clone();
+	let indexer = &data.indexer;
 
-	let parent_hash = client
+	for account_id in client
 		.storage()
 		.at_latest()
 		.await?
-		.fetch(&runtime::storage().system().parent_hash())
-		.await?
-		.unwrap();
-
-	for account_id in client
-		.storage()
-		.at(parent_hash)
 		.fetch(&runtime::storage().technical_committee().members())
 		.await?
 		.unwrap_or_default()
 	{
 		println!(
 			"{}",
-			if let Some(indexer) = &indexer {
-				indexer
-					.username_by_pubkey(&account_id.to_string())
-					.await
-					.ok()
-					.flatten()
+			if let Some(indexer) = indexer {
+				// indexer is set, we can get the name
+				let name = indexer.username_by_pubkey(&account_id.to_string()).await;
+				if name.is_some() {
+					name
+				} else {
+					indexer
+						.wasname_by_pubkey(&account_id.to_string())
+						.await
+						.map(|name| format!("{name}\t(old account)"))
+				}
 			} else {
+				// indexer is not set, we can get the idty index by accountid
 				client
 					.storage()
-					.at(parent_hash)
+					.at_latest()
+					.await?
 					.fetch(&runtime::storage().identity().identity_index_of(&account_id))
-					.await
-					.ok()
-					.flatten()
+					.await?
 					.map(|identity_id| format!("{identity_id}"))
 			}
+			// no idty found, display account_id
 			.unwrap_or_else(|| account_id.to_string(),)
 		);
 	}
@@ -96,7 +93,7 @@ pub async fn technical_committee_members(data: &Data) -> Result<()> {
 // TODO:
 // * better formatting (format pubkeys to SS58 and add usernames)
 // * display proposals indices
-pub async fn technical_committee_proposals(client: &Client) -> Result<()> {
+pub async fn technical_committee_proposals(client: &Client) -> anyhow::Result<()> {
 	let parent_hash = client
 		.storage()
 		.at_latest()
@@ -125,7 +122,7 @@ pub async fn technical_committee_vote(
 	proposal_hash: Hash,
 	proposal_index: u32,
 	vote: bool,
-) -> Result<(), subxt::Error> {
+) -> anyhow::Result<(), subxt::Error> {
 	submit_call_and_look_event::<
 		runtime::technical_committee::events::Voted,
 		Payload<runtime::technical_committee::calls::types::Vote>,
@@ -140,7 +137,10 @@ pub async fn technical_committee_vote(
 
 /// propose call given as hexadecimal
 /// can be generated with `subxt explore` for example
-pub async fn technical_committee_propose(data: &Data, proposal: &str) -> Result<(), subxt::Error> {
+pub async fn technical_committee_propose(
+	data: &Data,
+	proposal: &str,
+) -> anyhow::Result<(), subxt::Error> {
 	let raw_call = hex::decode(proposal).expect("invalid hex");
 	let call = codec::decode_from_bytes(raw_call.into()).expect("invalid call");
 	let payload = runtime::tx().technical_committee().propose(5, call, 100);
diff --git a/src/commands/vault.rs b/src/commands/vault.rs
index 14c3766..b6e09b7 100644
--- a/src/commands/vault.rs
+++ b/src/commands/vault.rs
@@ -81,7 +81,7 @@ pub fn store_mnemonic(
 	password: String,
 ) -> Result<AccountId, GcliError> {
 	// check validity by deriving keypair
-	let keypair = pair_from_str(&mnemonic)?;
+	let keypair = pair_from_str(mnemonic)?;
 	let address = keypair.public();
 	// write encrypted mnemonic in file identified by pubkey
 	let path = data.project_dir.data_dir().join(address.to_string());
diff --git a/src/data.rs b/src/data.rs
index ce0e06c..16cb048 100644
--- a/src/data.rs
+++ b/src/data.rs
@@ -6,6 +6,7 @@ use indexer::Indexer;
 // consts
 pub const LOCAL_DUNITER_ENDPOINT: &str = "ws://localhost:9944";
 pub const LOCAL_INDEXER_ENDPOINT: &str = "http://localhost:4350/graphql";
+const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
 
 #[cfg(feature = "gdev")]
 pub const GDEV_DUNITER_ENDPOINTS: [&str; 5] = [
@@ -187,14 +188,13 @@ impl Data {
 	}
 	/// build a client from url
 	pub async fn build_client(mut self) -> Result<Self, GcliError> {
-		let duniter_endpoint = self.cfg.duniter_endpoint.clone();
-		self.client = Some(Client::from_url(&duniter_endpoint).await.map_err(|e| {
-			GcliError::Duniter(format!(
-				"could not establish connection with the server {}, due to error {}",
-				duniter_endpoint,
-				dbg!(e) // needed to get more details TODO fixme
-			))
-		})?);
+		let duniter_endpoint = &self.cfg.duniter_endpoint;
+		let client = Client::from_url(duniter_endpoint).await.map_err(|e| {
+			// to get more details TODO fixme, see issue #18
+			dbg!(e);
+			GcliError::Duniter(format!("can not connect to duniter {duniter_endpoint}",))
+		})?;
+		self.client = Some(client);
 		self.genesis_hash = commands::blockchain::fetch_genesis_hash(&self).await?;
 		Ok(self)
 	}
@@ -206,9 +206,9 @@ impl Data {
 		} else {
 			self.indexer = Some(Indexer {
 				gql_client: reqwest::Client::builder()
-					.user_agent("gcli/0.1.0")
+					.user_agent(format!("gcli/{PKG_VERSION}"))
 					.build()
-					.map_err(|e| anyhow!(e))?,
+					.unwrap(),
 				gql_url: self.cfg.indexer_endpoint.clone(),
 			});
 			self.indexer_genesis_hash = self.indexer().fetch_genesis_hash().await?;
diff --git a/src/indexer.rs b/src/indexer.rs
index 00e9959..495a99a 100644
--- a/src/indexer.rs
+++ b/src/indexer.rs
@@ -1,8 +1,9 @@
-use graphql_client::{reqwest::post_graphql, GraphQLQuery};
-use sp_core::Bytes;
-
 use crate::*;
+use comfy_table::*;
+use comfy_table::{ContentArrangement, Table};
+use graphql_client::{reqwest::post_graphql, GraphQLQuery};
 use identity_info::*;
+use sp_core::Bytes;
 
 // type used in parameters query
 // #[allow(non_camel_case_types)]
@@ -32,6 +33,14 @@ pub struct IdentityInfo;
 )]
 pub struct IdentityNameByPubkey;
 
+// pubkey → wasidentity
+#[derive(GraphQLQuery)]
+#[graphql(
+	schema_path = "res/indexer-schema.json",
+	query_path = "res/indexer-queries.graphql"
+)]
+pub struct WasIdentityNameByPubkey;
+
 #[derive(GraphQLQuery)]
 #[graphql(
 	schema_path = "res/indexer-schema.json",
@@ -39,6 +48,13 @@ pub struct IdentityNameByPubkey;
 )]
 pub struct LatestBlock;
 
+#[derive(GraphQLQuery)]
+#[graphql(
+	schema_path = "res/indexer-schema.json",
+	query_path = "res/indexer-queries.graphql"
+)]
+pub struct BlockByNumber;
+
 #[derive(GraphQLQuery, Debug)]
 #[graphql(
 	schema_path = "res/indexer-schema.json",
@@ -68,17 +84,37 @@ impl Indexer {
 	}
 
 	/// pubkey → name
-	pub async fn username_by_pubkey(&self, pubkey: &str) -> anyhow::Result<Option<String>> {
-		Ok(post_graphql::<IdentityNameByPubkey, _>(
+	pub async fn username_by_pubkey(&self, pubkey: &str) -> Option<String> {
+		post_graphql::<IdentityNameByPubkey, _>(
 			&self.gql_client,
 			&self.gql_url,
 			identity_name_by_pubkey::Variables {
 				pubkey: pubkey.to_string(),
 			},
 		)
-		.await?
+		.await
+		.expect("indexer connexion error")
 		.data
-		.and_then(move |mut data| data.identities.pop().map(|idty| idty.name)))
+		.and_then(move |mut data| data.identities.pop().map(|idty| idty.name))
+	}
+
+	/// pubkey → was name
+	pub async fn wasname_by_pubkey(&self, pubkey: &str) -> Option<String> {
+		post_graphql::<WasIdentityNameByPubkey, _>(
+			&self.gql_client,
+			&self.gql_url,
+			was_identity_name_by_pubkey::Variables {
+				pubkey: pubkey.to_string(),
+			},
+		)
+		.await
+		.expect("indexer connexion error")
+		.data
+		.and_then(move |data| {
+			data.account_by_id
+				.and_then(|mut acc| acc.was_identity.pop())
+				.map(|idty| idty.identity.name)
+		})
 	}
 
 	/// index → info
@@ -91,27 +127,40 @@ impl Indexer {
 			},
 		)
 		.await
-		.expect("problem")
+		.expect("indexer connexion error")
 		.data
 		.and_then(move |mut data| data.identities.pop())
 	}
 
-	/// fetch latest block number
-	pub async fn fetch_latest_block(&self) -> Result<u64, anyhow::Error> {
-		Ok(post_graphql::<LatestBlock, _>(
+	/// fetch latest block
+	pub async fn fetch_latest_block(&self) -> Option<latest_block::LatestBlockBlocks> {
+		post_graphql::<LatestBlock, _>(
 			&self.gql_client,
 			self.gql_url.clone(),
 			latest_block::Variables {},
 		)
-		.await?
+		.await
+		.expect("indexer connexion error")
 		.data
-		.unwrap() // must have a data field
-		.blocks
-		.first()
-		.unwrap() // must have one and only one parameter matching request
-		.height
-		.try_into()
-		.unwrap())
+		.and_then(move |mut data| data.blocks.pop())
+	}
+
+	/// fetch block by number
+	pub async fn fetch_block_by_number(
+		&self,
+		number: BlockNumber,
+	) -> Option<block_by_number::BlockByNumberBlocks> {
+		post_graphql::<BlockByNumber, _>(
+			&self.gql_client,
+			self.gql_url.clone(),
+			block_by_number::Variables {
+				number: number.into(),
+			},
+		)
+		.await
+		.expect("indexer connexion error")
+		.data
+		.and_then(move |mut data| data.blocks.pop())
 	}
 
 	/// fetch genesis hash
@@ -124,7 +173,10 @@ impl Indexer {
 			genesis_hash::Variables {},
 		)
 		.await
-		.map_err(|e| anyhow!(e))?;
+		.map_err(|_e| {
+			// dbg!(_e); // for more info
+			GcliError::Indexer(format!("can not connect to indexer {}", &self.gql_url))
+		})?;
 
 		// debug errors if any
 		response.errors.map_or_else(Vec::new, |e| dbg!(e));
@@ -137,57 +189,114 @@ impl Indexer {
 			))?
 			.blocks
 			.first()
-			.unwrap() // must have one and only one block matching request
+			.ok_or_else(|| GcliError::Indexer("genesis block not yet indexed".to_string()))?
 			.hash
 			.clone();
 
 		// convert it
-		let hash = TryInto::<[u8; 32]>::try_into(hash.as_ref()).unwrap();
-		Ok(hash.into())
+		Ok(convert_hash(hash))
 	}
 }
 
+/// convert indexer bytes into hash
+pub fn convert_hash(hash: Bytes) -> Hash {
+	let hash = TryInto::<[u8; 32]>::try_into(hash.as_ref()).unwrap();
+	hash.into()
+}
+
 /// define indexer subcommands
 #[derive(Clone, Default, Debug, clap::Parser)]
 pub enum Subcommand {
 	#[default]
-	/// Check that indexer and node are on the same network (same genesis hash)
+	/// Check that indexer and node are on the same network
+	/// (genesis hash, latest indexed block...)
 	Check,
-	/// Fetch latest indexed block
-	LatestBlock,
 }
 
 /// handle indexer commands
 pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliError> {
 	// build indexer because it is needed for all subcommands
-	let mut data = data.build_indexer().await?;
+	let data = data.build_client().await?.build_indexer().await?;
+	let indexer = data
+		.indexer
+		.clone()
+		.ok_or_else(|| GcliError::Logic("indexer needed for this command".to_string()))?;
+
 	// match subcommand
 	match command {
 		Subcommand::Check => {
-			data = data.build_client().await?;
-			if data.genesis_hash == data.indexer_genesis_hash {
-				println!(
-					"{} and {} have the same genesis hash: {}",
-					data.cfg.duniter_endpoint,
-					data.indexer().gql_url,
-					data.genesis_hash
-				);
+			let d_url = &data.cfg.duniter_endpoint;
+			let i_url = &indexer.gql_url;
+			let d_gen_hash = &data.genesis_hash.to_string();
+			let i_gen_hash = &data.indexer_genesis_hash.to_string();
+			let (d_finalized_n, d_finalized_h) =
+				commands::blockchain::fetch_finalized_number_and_hash(&data).await?;
+			let i_finalized_block = indexer.fetch_block_by_number(d_finalized_n).await;
+			let (i_finalized_h, i_finalized_n) = if let Some(block) = i_finalized_block {
+				(Some(convert_hash(block.hash)), Some(block.height))
 			} else {
-				println!(
-					"⚠️ {} ({}) and {} ({}) do not share same genesis",
-					data.cfg.duniter_endpoint,
-					data.genesis_hash,
-					data.indexer().gql_url,
-					data.indexer_genesis_hash
-				);
+				(None, None)
+			};
+
+			let (d_latest_n, d_latest_h) =
+				commands::blockchain::fetch_latest_number_and_hash(&data).await?;
+			let i_latest_block = indexer.fetch_latest_block().await.expect("no latest block");
+			let i_latest_h = convert_hash(i_latest_block.hash);
+			let i_latest_n = i_latest_block.height;
+
+			fn color(x: bool) -> Color {
+				match x {
+					true => Color::Green,
+					false => Color::Red,
+				}
 			}
-		}
-		Subcommand::LatestBlock => {
-			println!(
-				"latest block indexed by {} is: {}",
-				data.cfg.indexer_endpoint,
-				data.indexer().fetch_latest_block().await?
-			);
+
+			let mut table = Table::new();
+			table
+				.load_preset(presets::UTF8_FULL)
+				.apply_modifier(modifiers::UTF8_ROUND_CORNERS)
+				.set_content_arrangement(ContentArrangement::Dynamic)
+				.set_width(120)
+				.set_header(vec!["Variable", "Duniter", "Indexer"])
+				.add_row(vec!["URL", d_url, i_url])
+				.add_row(vec![
+					Cell::new("genesis hash"),
+					Cell::new(d_gen_hash),
+					Cell::new(i_gen_hash).fg(color(d_gen_hash == i_gen_hash)),
+				])
+				.add_row(vec![
+					Cell::new("finalized block number"),
+					Cell::new(d_finalized_n),
+					match i_finalized_n {
+						None => Cell::new("not indexed").fg(Color::Yellow),
+						Some(n) => {
+							// if it exists, it must be the same
+							assert_eq!(n, d_finalized_n as i64);
+							Cell::new("")
+						}
+					},
+				])
+				.add_row(vec![
+					Cell::new("finalized block hash"),
+					Cell::new(d_finalized_h),
+					match i_finalized_h {
+						// number already tells it is not indexed
+						None => Cell::new(""),
+						Some(h) => Cell::new(h).fg(color(h == d_finalized_h)),
+					},
+				])
+				.add_row(vec![
+					Cell::new("latest block number"),
+					Cell::new(d_latest_n),
+					Cell::new(i_latest_n).fg(color(i_latest_n == d_latest_n as i64)),
+				])
+				.add_row(vec![
+					Cell::new("latest block hash"),
+					Cell::new(d_latest_h),
+					Cell::new(i_latest_h).fg(color(i_latest_h == d_latest_h)),
+				]);
+
+			println!("{table}");
 		}
 	};
 
-- 
GitLab