Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions crates/cliquenet/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ 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"
# optional:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

cargo sort

hotshot-types = { workspace = true, optional = true }
nohash-hasher = { workspace = true }
parking_lot = { workspace = true }
rand = "0.9.2"
Expand All @@ -22,8 +24,6 @@ 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 }

[dev-dependencies]
criterion = "0.8.1"
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