Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ bimap = "0.6.3"
bincode = "1.3.3"
bitvec = { version = "1", features = ["serde"] }
blake3 = "1.5"
bytes = { version = "1.11.0", features = ["serde"] }
bon = "3.8.2"
bytes = { version = "1.11.0", features = ["serde"] }
cbor4ii = { version = "1.0", features = ["serde1"] }
chrono = { version = "0.4", features = ["serde"] }
circular-buffer = "0.1.9"
Expand Down
5 changes: 3 additions & 2 deletions crates/cliquenet/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ description = "A fully connected mesh network"
metrics = ["dep:hotshot-types"]

[dependencies]
bon = { workspace = true }
bytes = { workspace = true }
bimap = "0.6.3"
bon = { workspace = true }
bs58 = "0.5.1"
bytes = { workspace = true }
ed25519-compact = "2.2.0"
nohash-hasher = { workspace = true }
parking_lot = { workspace = true }
Expand All @@ -22,6 +22,7 @@ snow = { version = "0.10.0", features = ["ring-accelerated"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }

# optional:
hotshot-types = { workspace = true, optional = true }

Expand Down
1 change: 1 addition & 0 deletions staking-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ hotshot-types = { workspace = true }
jf-merkle-tree-compat = { workspace = true }
jf-signature = { workspace = true, features = ["bls", "schnorr"] }
portpicker = { workspace = true }
prometheus-parse = "0.2"
rand = { workspace = true }
rand_chacha = { workspace = true }
reqwest = { workspace = true }
Expand Down
40 changes: 38 additions & 2 deletions staking-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,8 +605,44 @@ staking-cli --skip-metadata-validation update-metadata-uri --metadata-uri https:

Or via environment variable: `SKIP_METADATA_VALIDATION=true`

The staking UI service also supports Prometheus metrics format as an alternative to JSON (useful if you want to serve
metadata from your node's metrics endpoint). See the staking-ui-service documentation for details.
#### Using Your Node's Metrics Endpoint (Recommended)

The easiest way to provide metadata is to use your espresso node's `/status/metrics` endpoint. Your node already exposes
this endpoint with your validator's public key and identity information. Use `--node-url`:

```bash
# Register using your node's metrics endpoint
staking-cli register-validator --consensus-private-key <BLS_KEY> --state-private-key <STATE_KEY> \
--commission 4.99 --node-url https://my-validator.example.com

# Update metadata URI to use your node's metrics
staking-cli update-metadata-uri --node-url https://my-validator.example.com --consensus-public-key BLS_VER_KEY~...
```

When using `--node-url`, the CLI will:

1. Append `/status/metrics` to the provided URL
2. Fetch and validate the OpenMetrics data
3. Store the full metrics URL (e.g., `https://my-validator.example.com/status/metrics`) as the metadata URI

The metrics endpoint extracts metadata from these Prometheus metrics:

- `consensus_node{key="BLS_VER_KEY~..."}` - Your validator's public key (required)
- `consensus_node_identity_general{name, description, company_name, company_website}` - Identity fields
- `consensus_version{desc}` - Client version
- `consensus_node_identity_icon{small_1x, small_2x, ...}` - Icon URLs

#### Using a Custom JSON File (Alternative)

If you need more control over your metadata or want to host it separately from your node, use `--metadata-uri` to point
to a JSON file (see [Metadata URL Schema](#metadata-url-schema) above):

```bash
staking-cli register-validator ... --metadata-uri https://example.com/metadata.json
staking-cli update-metadata-uri --metadata-uri https://example.com/metadata.json --consensus-public-key BLS_VER_KEY~...
```

Note: `--node-url` and `--metadata-uri` are mutually exclusive.

### De-registering your validator

Expand Down
13 changes: 1 addition & 12 deletions staking-cli/src/claim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,6 @@ pub async fn fetch_claim_rewards_inputs(
mod test {
use alloy::primitives::{utils::parse_ether, U256};
use hotshot_contract_adapter::sol_types::{RewardClaim, StakeTableV2};
use warp::Filter as _;

use super::*;
use crate::{deploy::TestSystem, receipt::ReceiptExt as _, transaction::Transaction};
Expand Down Expand Up @@ -243,17 +242,7 @@ mod test {
#[tokio::test(flavor = "multi_thread")]
async fn test_unclaimed_rewards_not_found() -> Result<()> {
let system = TestSystem::deploy().await?;

let port = portpicker::pick_unused_port().expect("No ports available");

let route = warp::path!("reward-state-v2" / "reward-claim-input" / u64 / String)
.map(|_, _| warp::reply::with_status(warp::reply(), warp::http::StatusCode::NOT_FOUND));

tokio::spawn(warp::serve(route).run(([127, 0, 0, 1], port)));

tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;

let espresso_url = format!("http://localhost:{}/", port).parse()?;
let espresso_url = system.setup_reward_claim_not_found_mock().await;

let unclaimed = unclaimed_rewards(
&system.provider,
Expand Down
27 changes: 18 additions & 9 deletions staking-cli/src/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ use hotshot_state_prover::v3::mock_ledger::STAKE_TABLE_CAPACITY_FOR_TEST;
use hotshot_types::light_client::StateKeyPair;
use jf_merkle_tree_compat::{MerkleCommitment, MerkleTreeScheme, UniversalMerkleTreeScheme};
use rand::{rngs::StdRng, CryptoRng, Rng as _, RngCore, SeedableRng as _};
use tokio::net::TcpListener;
use url::Url;
use warp::{http::StatusCode, Filter};

Expand All @@ -46,6 +47,20 @@ use crate::{
signature::NodeSignatures, transaction::Transaction, BLSKeyPair, DEV_MNEMONIC,
};

/// Spawn a warp server on a random available port and return the port number.
/// Uses TcpListener::bind with port 0 to atomically acquire a free port,
/// avoiding race conditions with portpicker.
pub async fn serve_on_random_port(
filter: impl Filter<Extract = impl warp::Reply> + Clone + Send + Sync + 'static,
) -> u16 {
let listener = TcpListener::bind(std::net::SocketAddr::from(([127, 0, 0, 1], 0u16)))
.await
.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(warp::serve(filter).incoming(listener).run());
port
}

type TestProvider = FillProvider<
JoinFill<JoinedRecommendedFillers, WalletFiller<EthereumWallet>>,
AnvilProvider<RootProvider>,
Expand Down Expand Up @@ -380,25 +395,19 @@ impl TestSystem {
let claim_input = query_data.to_reward_claim_input()?;
let claim_input = std::sync::Arc::new(claim_input);

let port = portpicker::pick_unused_port().expect("No ports available");

let route = warp::path!("reward-state-v2" / "reward-claim-input" / u64 / String).map(
move |_block_height: u64, _address: String| warp::reply::json(&*claim_input.clone()),
);

tokio::spawn(warp::serve(route).run(([127, 0, 0, 1], port)));

let port = serve_on_random_port(route).await;
Ok(format!("http://localhost:{}/", port).parse()?)
}

pub fn setup_reward_claim_not_found_mock(&self) -> Url {
let port = portpicker::pick_unused_port().expect("No ports available");

pub async fn setup_reward_claim_not_found_mock(&self) -> Url {
let route = warp::path!("reward-state-v2" / "reward-claim-input" / u64 / String)
.map(|_, _| warp::reply::with_status(warp::reply(), StatusCode::NOT_FOUND));

tokio::spawn(warp::serve(route).run(([127, 0, 0, 1], port)));

let port = serve_on_random_port(route).await;
format!("http://localhost:{}/", port).parse().unwrap()
}
}
Expand Down
173 changes: 167 additions & 6 deletions staking-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ pub mod demo;
pub(crate) mod info;
pub(crate) mod l1;
pub(crate) mod metadata;

// Re-exported for integration tests (test_real_mainnet_node_metadata)
pub use metadata::fetch_metadata;
// TODO: Replace with imports from staking-ui-service once version compatibility is resolved
pub(crate) mod metadata_types;
// TODO: Replace with imports from staking-ui-service once version compatibility is resolved
pub(crate) mod openmetrics;
pub(crate) mod output;
pub(crate) mod parse;
pub(crate) mod receipt;
Expand Down Expand Up @@ -215,25 +222,179 @@ impl ValidSignerConfig {
#[derive(ClapArgs, Debug, Clone)]
#[group(required = true, multiple = false)]
pub struct MetadataUriArgs {
/// URL where validator metadata JSON is hosted.
#[clap(long, env = "METADATA_URI")]
metadata_uri: Option<String>,
pub metadata_uri: Option<String>,

/// URL of the node's API. Metadata will be fetched from /status/metrics.
#[clap(long, env = "NODE_URL")]
pub node_url: Option<String>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand the point of this option. Whenever I would do --node-url URL couldn't I just as easily do --metadata-uri URL/status/metrics?


/// Register without a metadata URI.
#[clap(long, env = "NO_METADATA_URI")]
no_metadata_uri: bool,
pub no_metadata_uri: bool,
}

impl TryFrom<MetadataUriArgs> for MetadataUri {
/// Represents the source of validator metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MetadataSource {
/// Metadata is served from a direct URI (JSON or OpenMetrics format).
Uri(Url),
/// Metadata is served from a node's /status/metrics endpoint.
NodeUrl(Url),
/// No metadata is provided.
None,
}

impl TryFrom<MetadataUriArgs> for MetadataSource {
type Error = anyhow::Error;

fn try_from(args: MetadataUriArgs) -> Result<Self> {
if args.no_metadata_uri {
Ok(MetadataUri::empty())
Ok(MetadataSource::None)
} else if let Some(uri_str) = args.metadata_uri {
uri_str.parse()
let url = Url::parse(&uri_str)?;
Ok(MetadataSource::Uri(url))
} else if let Some(node_url_str) = args.node_url {
let url = Url::parse(&node_url_str)?;
Ok(MetadataSource::NodeUrl(url))
} else {
bail!("Either --metadata-uri or --no-metadata-uri must be provided")
bail!("Either --metadata-uri, --node-url, or --no-metadata-uri must be provided")
}
}
}

impl MetadataSource {
/// Returns the URL that will be stored in the contract as the metadata URI.
pub fn metadata_uri(&self) -> Result<MetadataUri> {
match self {
MetadataSource::Uri(url) => MetadataUri::try_from(url.clone()),
MetadataSource::NodeUrl(url) => {
let metrics_url = url.join("status/metrics")?;
MetadataUri::try_from(metrics_url)
},
MetadataSource::None => Ok(MetadataUri::empty()),
}
}

/// Returns the URL to fetch metadata from for validation.
pub fn fetch_url(&self) -> Option<Url> {
match self {
MetadataSource::Uri(url) => Some(url.clone()),
MetadataSource::NodeUrl(url) => match url.join("status/metrics") {
Ok(metrics_url) => Some(metrics_url),
Err(e) => {
tracing::warn!("failed to construct metrics URL from {url}: {e}");
None
},
},
MetadataSource::None => None,
}
}
Comment on lines +282 to +293
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this seems to duplicate a lot of the logic from just above. Perhaps could be implemented as self.metadata_uri().inspect_err(|e| tracing::warn!("failed to construct metrics URL from {url}: {e})).ok()

}

#[cfg(test)]
mod metadata_source_tests {
use super::*;

#[test]
fn test_metadata_source_uri() {
let url = Url::parse("https://example.com/metadata.json").unwrap();
let source = MetadataSource::Uri(url.clone());

assert_eq!(source.fetch_url(), Some(url.clone()));
assert_eq!(
source.metadata_uri().unwrap().url().map(|u| u.as_str()),
Some("https://example.com/metadata.json")
);
}

#[test]
fn test_metadata_source_node_url() {
let url = Url::parse("https://example.com").unwrap();
let source = MetadataSource::NodeUrl(url);

assert_eq!(
source.fetch_url().map(|u| u.to_string()),
Some("https://example.com/status/metrics".to_string())
);
assert_eq!(
source.metadata_uri().unwrap().url().map(|u| u.as_str()),
Some("https://example.com/status/metrics")
);
}

#[test]
fn test_metadata_source_node_url_with_trailing_slash() {
let url = Url::parse("https://example.com/").unwrap();
let source = MetadataSource::NodeUrl(url);

assert_eq!(
source.fetch_url().map(|u| u.to_string()),
Some("https://example.com/status/metrics".to_string())
);
}

#[test]
fn test_metadata_source_node_url_with_path() {
let url = Url::parse("https://example.com/api/").unwrap();
let source = MetadataSource::NodeUrl(url);

assert_eq!(
source.fetch_url().map(|u| u.to_string()),
Some("https://example.com/api/status/metrics".to_string())
);
}

#[test]
fn test_metadata_source_none() {
let source = MetadataSource::None;

assert_eq!(source.fetch_url(), None);
assert!(source.metadata_uri().unwrap().url().is_none());
}

#[test]
fn test_metadata_uri_args_to_source_metadata_uri() {
let args = MetadataUriArgs {
metadata_uri: Some("https://example.com/metadata.json".to_string()),
node_url: None,
no_metadata_uri: false,
};
let source = MetadataSource::try_from(args).unwrap();
assert!(matches!(source, MetadataSource::Uri(_)));
}

#[test]
fn test_metadata_uri_args_to_source_node_url() {
let args = MetadataUriArgs {
metadata_uri: None,
node_url: Some("https://example.com".to_string()),
no_metadata_uri: false,
};
let source = MetadataSource::try_from(args).unwrap();
assert!(matches!(source, MetadataSource::NodeUrl(_)));
}

#[test]
fn test_metadata_uri_args_to_source_none() {
let args = MetadataUriArgs {
metadata_uri: None,
node_url: None,
no_metadata_uri: true,
};
let source = MetadataSource::try_from(args).unwrap();
assert!(matches!(source, MetadataSource::None));
}
}

impl TryFrom<MetadataUriArgs> for MetadataUri {
type Error = anyhow::Error;

fn try_from(args: MetadataUriArgs) -> Result<Self> {
let source = MetadataSource::try_from(args)?;
source.metadata_uri()
}
}

impl Default for Commands {
Expand Down
Loading
Loading