Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion bin/gravity_cli/src/epoch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,20 @@ use clap::Parser;

use crate::command::Executable;

pub mod next;
pub mod status;
pub mod watch;

// TODO: consensus-layer queries for the remaining `/consensus/*` HTTP endpoints
// (QC at epoch/round, ledger_info by epoch, validator_count by epoch,
// block by epoch/round) are intentionally not wired into `gravity-cli`
// yet. The blocker is that `crates/api/src/consensus_api.rs` only mounts
// those routes under `#[cfg(debug_assertions)]`, so they are missing in
// release builds. Once that is lifted (or moved behind an opt-in flag),
// add subcommands like `epoch qc <epoch> <round>` that call
// `GET <server_url>/consensus/qc/:epoch/:round`. DKG coverage can
// likewise be expanded beyond the existing `dkg status` / `dkg
// randomness` commands.

#[derive(Debug, Parser)]
pub struct EpochCommand {
Expand All @@ -12,13 +25,20 @@ pub struct EpochCommand {

#[derive(Debug, Parser)]
pub enum SubCommands {
/// Show detailed current epoch timing (running/remaining/overdue).
Status(status::StatusCommand),
/// Print a one-liner predicting when the next epoch transition happens.
Next(next::NextCommand),
/// Poll until an epoch transition is observed (logs each change).
Watch(watch::WatchCommand),
}

impl Executable for EpochCommand {
fn execute(self) -> Result<(), anyhow::Error> {
match self.command {
SubCommands::Status(status_cmd) => status_cmd.execute(),
SubCommands::Status(c) => c.execute(),
SubCommands::Next(c) => c.execute(),
SubCommands::Watch(c) => c.execute(),
}
}
}
134 changes: 134 additions & 0 deletions bin/gravity_cli/src/epoch/next.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
use alloy_primitives::{Bytes, TxKind};
use alloy_provider::{Provider, ProviderBuilder};
use alloy_rpc_types::eth::{BlockNumberOrTag, TransactionInput, TransactionRequest};
use alloy_sol_types::SolCall;
use clap::Parser;
use serde::Serialize;

use crate::{
command::Executable,
contract::{EpochConfig, Reconfiguration, EPOCH_CONFIG_ADDRESS, RECONFIGURATION_ADDRESS},
output::OutputFormat,
};

#[derive(Debug, Parser)]
pub struct NextCommand {
/// RPC URL for gravity node
#[clap(long, env = "GRAVITY_RPC_URL")]
pub rpc_url: Option<String>,

#[clap(skip)]
pub output_format: OutputFormat,
}

#[derive(Serialize)]
struct NextInfo {
current_epoch: u64,
predicted_transition_unix_secs: u64,
seconds_until_transition: i64,
}

impl Executable for NextCommand {
fn execute(self) -> Result<(), anyhow::Error> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(self.execute_async())
}
}

impl NextCommand {
async fn execute_async(self) -> Result<(), anyhow::Error> {
let rpc_url = self.rpc_url.ok_or_else(|| anyhow::anyhow!("--rpc-url is required"))?;
let provider = ProviderBuilder::new().connect_http(rpc_url.parse()?);

let (current_epoch, last_time_micros, interval_micros, block_ts) =
fetch_epoch_timing(&provider).await?;

// Predicted transition (seconds since unix epoch)
let predicted = (last_time_micros + interval_micros) / 1_000_000;
let delta: i64 = predicted as i64 - block_ts as i64;

match self.output_format {
OutputFormat::Json => {
let info = NextInfo {
current_epoch,
predicted_transition_unix_secs: predicted,
seconds_until_transition: delta,
};
println!("{}", serde_json::to_string_pretty(&info)?);
}
OutputFormat::Plain => {
if delta >= 0 {
println!(
"epoch {current_epoch}: next transition in {} (≈ unix {predicted})",
format_hms(delta as u64)
);
} else {
println!(
"epoch {current_epoch}: transition overdue by {} (expected at unix {predicted})",
format_hms((-delta) as u64)
);
}
}
}
Ok(())
}
}

pub(crate) async fn fetch_epoch_timing(
provider: &impl Provider,
) -> Result<(u64, u64, u64, u64), anyhow::Error> {
// current epoch
let call = Reconfiguration::currentEpochCall {};
let result = provider
.call(TransactionRequest {
to: Some(TxKind::Call(RECONFIGURATION_ADDRESS)),
input: TransactionInput::new(Bytes::from(call.abi_encode())),
..Default::default()
})
.await?;
let current_epoch = Reconfiguration::currentEpochCall::abi_decode_returns(&result)?;

// last reconfig time (micros)
let call = Reconfiguration::lastReconfigurationTimeCall {};
let result = provider
.call(TransactionRequest {
to: Some(TxKind::Call(RECONFIGURATION_ADDRESS)),
input: TransactionInput::new(Bytes::from(call.abi_encode())),
..Default::default()
})
.await?;
let last_time = Reconfiguration::lastReconfigurationTimeCall::abi_decode_returns(&result)?;

// interval (micros)
let call = EpochConfig::epochIntervalMicrosCall {};
let result = provider
.call(TransactionRequest {
to: Some(TxKind::Call(EPOCH_CONFIG_ADDRESS)),
input: TransactionInput::new(Bytes::from(call.abi_encode())),
..Default::default()
})
.await?;
let interval = EpochConfig::epochIntervalMicrosCall::abi_decode_returns(&result)?;

// latest block timestamp (seconds)
let latest_block = provider
.get_block_by_number(BlockNumberOrTag::Latest)
.await?
.ok_or_else(|| anyhow::anyhow!("failed to fetch latest block"))?;
let block_ts = latest_block.header.timestamp;

Ok((current_epoch, last_time, interval, block_ts))
}

pub(crate) fn format_hms(secs: u64) -> String {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
if h > 0 {
format!("{h}h {m}m {s}s")
} else if m > 0 {
format!("{m}m {s}s")
} else {
format!("{s}s")
}
}
95 changes: 95 additions & 0 deletions bin/gravity_cli/src/epoch/watch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
use alloy_provider::ProviderBuilder;
use clap::Parser;
use colored::Colorize;
use std::time::Duration;

use crate::{command::Executable, epoch::next::fetch_epoch_timing};

#[derive(Debug, Parser)]
pub struct WatchCommand {
/// RPC URL for gravity node
#[clap(long, env = "GRAVITY_RPC_URL")]
pub rpc_url: Option<String>,

/// Polling interval in seconds (default 5)
#[clap(long, default_value = "5")]
pub interval_secs: u64,

/// Emit a status line every poll even when the epoch hasn't changed.
/// Default: only print transitions.
#[clap(long)]
pub verbose: bool,
}

impl Executable for WatchCommand {
fn execute(self) -> Result<(), anyhow::Error> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(self.execute_async())
}
}

impl WatchCommand {
async fn execute_async(self) -> Result<(), anyhow::Error> {
let rpc_url = self.rpc_url.ok_or_else(|| anyhow::anyhow!("--rpc-url is required"))?;
let provider = ProviderBuilder::new().connect_http(rpc_url.parse()?);

let interval = Duration::from_secs(self.interval_secs.max(1));
let mut last_seen: Option<u64> = None;

println!(
"{} watching epoch transitions every {}s (Ctrl+C to stop)",
"[epoch watch]".cyan(),
interval.as_secs()
);

loop {
match fetch_epoch_timing(&provider).await {
Ok((current_epoch, last_time_micros, interval_micros, block_ts)) => {
let predicted = (last_time_micros + interval_micros) / 1_000_000;
let delta: i64 = predicted as i64 - block_ts as i64;

match last_seen {
None => {
println!(
"{} initial: epoch {current_epoch}, next in {}",
"[epoch watch]".cyan(),
signed_hms(delta)
);
last_seen = Some(current_epoch);
}
Some(prev) if prev != current_epoch => {
println!(
"{} {} {prev} → {current_epoch}, next in {}",
"[epoch watch]".cyan(),
"transition:".green().bold(),
signed_hms(delta),
);
last_seen = Some(current_epoch);
}
_ => {
if self.verbose {
println!(
"{} epoch {current_epoch}, next in {}",
"[epoch watch]".dimmed(),
signed_hms(delta)
);
}
}
}
}
Err(e) => {
eprintln!("{} rpc error (will retry): {e}", "[epoch watch]".yellow());
}
}
tokio::time::sleep(interval).await;
}
}
}

fn signed_hms(delta: i64) -> String {
if delta >= 0 {
super::next::format_hms(delta as u64)
} else {
format!("overdue {}", super::next::format_hms((-delta) as u64))
}
}
15 changes: 15 additions & 0 deletions bin/gravity_cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ fn main() {
status_cmd.output_format = output_format;
status_cmd.execute()
}
epoch::SubCommands::Next(mut c) => {
c.output_format = output_format;
c.execute()
}
epoch::SubCommands::Watch(c) => c.execute(),
},
command::SubCommands::Status(mut status_cmd) => {
status_cmd.output_format = output_format;
Expand Down Expand Up @@ -181,6 +186,16 @@ fn apply_config_defaults(cmd: &mut Command, profile: &Option<config::ProfileConf
c.rpc_url.clone_from(&profile.rpc_url);
}
}
epoch::SubCommands::Next(ref mut c) => {
if c.rpc_url.is_none() {
c.rpc_url.clone_from(&profile.rpc_url);
}
}
epoch::SubCommands::Watch(ref mut c) => {
if c.rpc_url.is_none() {
c.rpc_url.clone_from(&profile.rpc_url);
}
}
},
command::SubCommands::Status(ref mut c) => {
if c.rpc_url.is_none() {
Expand Down
Loading