/ node / rest / src / routes.rs
routes.rs
   1  // Copyright (c) 2025-2026 ACDC Network
   2  // This file is part of the alphaos library.
   3  //
   4  // Alpha Chain | Delta Chain Protocol
   5  // International Monetary Graphite.
   6  //
   7  // Derived from Aleo (https://aleo.org) and ProvableHQ (https://provable.com).
   8  // They built world-class ZK infrastructure. We installed the EASY button.
   9  // Their cryptography: elegant. Our modifications: bureaucracy-compatible.
  10  // Original brilliance: theirs. Robert's Rules: ours. Bugs: definitely ours.
  11  //
  12  // Original Aleo/ProvableHQ code subject to Apache 2.0 https://www.apache.org/licenses/LICENSE-2.0
  13  // All modifications and new work: CC0 1.0 Universal Public Domain Dedication.
  14  // No rights reserved. No permission required. No warranty. No refunds.
  15  //
  16  // https://creativecommons.org/publicdomain/zero/1.0/
  17  // SPDX-License-Identifier: CC0-1.0
  18  
  19  use super::*;
  20  use alphaos_node_consensus::clp::ValidatorAddress;
  21  use alphaos_node_network::PeerPoolHandling;
  22  use alphaos_node_router::messages::UnconfirmedSolution;
  23  use alphavm::{
  24      ledger::puzzle::Solution,
  25      prelude::{block::Transaction, Address, Identifier, LimitedWriter, Plaintext, Program, ToBytes, VM},
  26  };
  27  
  28  use axum::{extract::rejection::JsonRejection, Json};
  29  
  30  use anyhow::{anyhow, Context};
  31  use indexmap::IndexMap;
  32  use serde::{Deserialize, Serialize};
  33  use serde_json::json;
  34  use serde_with::skip_serializing_none;
  35  use std::{collections::HashMap, sync::atomic::Ordering};
  36  
  37  #[cfg(not(feature = "serial"))]
  38  use rayon::prelude::*;
  39  
  40  use version::VersionInfo;
  41  
  42  /// Deserialize a CSV string into a vector of strings.
  43  fn de_csv<'de, D>(de: D) -> std::result::Result<Vec<String>, D::Error>
  44  where
  45      D: serde::Deserializer<'de>,
  46  {
  47      let s = String::deserialize(de)?;
  48      Ok(if s.trim().is_empty() { Vec::new() } else { s.split(',').map(|x| x.trim().to_string()).collect() })
  49  }
  50  
  51  /// The `get_blocks` query object.
  52  #[derive(Deserialize, Serialize)]
  53  pub(crate) struct BlockRange {
  54      /// The starting block height (inclusive).
  55      start: u32,
  56      /// The ending block height (exclusive).
  57      end: u32,
  58  }
  59  
  60  #[derive(Deserialize, Serialize)]
  61  pub(crate) struct BackupPath {
  62      path: std::path::PathBuf,
  63  }
  64  
  65  /// The query object for `get_mapping_value` and `get_mapping_values`.
  66  #[derive(Copy, Clone, Deserialize, Serialize)]
  67  pub(crate) struct Metadata {
  68      metadata: Option<bool>,
  69      all: Option<bool>,
  70  }
  71  
  72  /// The query object for `transaction_broadcast`.
  73  #[derive(Copy, Clone, Deserialize, Serialize)]
  74  pub(crate) struct CheckTransaction {
  75      check_transaction: Option<bool>,
  76  }
  77  
  78  /// The query object for `solution_broadcast`.
  79  #[derive(Copy, Clone, Deserialize, Serialize)]
  80  pub(crate) struct CheckSolution {
  81      check_solution: Option<bool>,
  82  }
  83  
  84  /// The query object for `get_state_paths_for_commitments`.
  85  #[derive(Clone, Deserialize, Serialize)]
  86  pub(crate) struct Commitments {
  87      #[serde(deserialize_with = "de_csv")]
  88      commitments: Vec<String>,
  89  }
  90  
  91  /// The return value for a `sync_status` query.
  92  #[skip_serializing_none]
  93  #[derive(Copy, Clone, Serialize)]
  94  struct SyncStatus<'a> {
  95      /// Is this node fully synced with the network?
  96      is_synced: bool,
  97      /// The block height of this node.
  98      ledger_height: u32,
  99      /// Which way are we sync'ing (either "cdn" or "p2p")
 100      sync_mode: &'a str,
 101      /// The block height of the CDN (if connected to a CDN).
 102      cdn_height: Option<u32>,
 103      /// The greatest known block height of a peer.
 104      /// None, if no peers are connected yet.
 105      p2p_height: Option<u32>,
 106      /// The number of outstanding p2p sync requests.
 107      outstanding_block_requests: usize,
 108      /// The current sync speed in blocks per second.
 109      sync_speed_bps: f64,
 110  }
 111  
 112  impl<N: Network, C: ConsensusStorage<N>, R: Routing<N>> Rest<N, C, R> {
 113      /// GET /<network>/version
 114      pub(crate) async fn get_version() -> ErasedJson {
 115          ErasedJson::pretty(VersionInfo::get())
 116      }
 117  
 118      /// Get /<network>/consensus_version
 119      pub(crate) async fn get_consensus_version(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 120          Ok(ErasedJson::pretty(N::CONSENSUS_VERSION(rest.ledger.latest_height())? as u16))
 121      }
 122  
 123      /// GET /<network>/block/height/latest
 124      pub(crate) async fn get_block_height_latest(State(rest): State<Self>) -> ErasedJson {
 125          ErasedJson::pretty(rest.ledger.latest_height())
 126      }
 127  
 128      /// GET /<network>/block/hash/latest
 129      pub(crate) async fn get_block_hash_latest(State(rest): State<Self>) -> ErasedJson {
 130          ErasedJson::pretty(rest.ledger.latest_hash())
 131      }
 132  
 133      /// GET /<network>/block/latest
 134      pub(crate) async fn get_block_latest(State(rest): State<Self>) -> ErasedJson {
 135          ErasedJson::pretty(rest.ledger.latest_block())
 136      }
 137  
 138      /// GET /<network>/block/{height}
 139      /// GET /<network>/block/{blockHash}
 140      pub(crate) async fn get_block(
 141          State(rest): State<Self>,
 142          Path(height_or_hash): Path<String>,
 143      ) -> Result<ErasedJson, RestError> {
 144          // Manually parse the height or the height of the hash, axum doesn't support different types
 145          // for the same path param.
 146          let id_name;
 147          let block = if let Ok(height) = height_or_hash.parse::<u32>() {
 148              id_name = "hash";
 149              rest.ledger.try_get_block(height).with_context(|| "Failed to get block by height")?
 150          } else if let Ok(hash) = height_or_hash.parse::<N::BlockHash>() {
 151              id_name = "height";
 152              rest.ledger.try_get_block_by_hash(&hash).with_context(|| "Failed to get block by hash")?
 153          } else {
 154              return Err(RestError::bad_request(anyhow!(
 155                  "invalid input, it is neither a block height nor a block hash"
 156              )));
 157          };
 158  
 159          match block {
 160              Some(block) => Ok(ErasedJson::pretty(block)),
 161              None => Err(RestError::not_found(anyhow!("No block with {id_name} {height_or_hash} found"))),
 162          }
 163      }
 164  
 165      /// GET /<network>/blocks?start={start_height}&end={end_height}
 166      pub(crate) async fn get_blocks(
 167          State(rest): State<Self>,
 168          Query(block_range): Query<BlockRange>,
 169      ) -> Result<ErasedJson, RestError> {
 170          let start_height = block_range.start;
 171          let end_height = block_range.end;
 172  
 173          const MAX_BLOCK_RANGE: u32 = 50;
 174  
 175          // Ensure the end height is greater than the start height.
 176          if start_height > end_height {
 177              return Err(RestError::bad_request(anyhow!("Invalid block range")));
 178          }
 179  
 180          // Ensure the block range is bounded.
 181          if end_height - start_height > MAX_BLOCK_RANGE {
 182              return Err(RestError::bad_request(anyhow!(
 183                  "Cannot request more than {MAX_BLOCK_RANGE} blocks per call (requested {})",
 184                  end_height - start_height
 185              )));
 186          }
 187  
 188          // Prepare a closure for the blocking work.
 189          let get_json_blocks = move || -> Result<ErasedJson, RestError> {
 190              let blocks = cfg_into_iter!(start_height..end_height)
 191                  .map(|height| rest.ledger.get_block(height))
 192                  .collect::<Result<Vec<_>, _>>()?;
 193  
 194              Ok(ErasedJson::pretty(blocks))
 195          };
 196  
 197          // Fetch the blocks from ledger and serialize to json.
 198          match tokio::task::spawn_blocking(get_json_blocks).await {
 199              Ok(json) => json,
 200              Err(err) => {
 201                  let err: anyhow::Error = err.into();
 202  
 203                  Err(RestError::internal_server_error(
 204                      err.context(format!("Failed to get blocks '{start_height}..{end_height}'")),
 205                  ))
 206              }
 207          }
 208      }
 209  
 210      /// GET /<network>/sync/status
 211      pub(crate) async fn get_sync_status(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 212          // Get the CDN height (if we are syncing from a CDN)
 213          let (cdn_sync, cdn_height) = if let Some(cdn_sync) = &rest.cdn_sync {
 214              let done = cdn_sync.is_done();
 215  
 216              // Do not show CDN height if we are already done syncing from the CDN.
 217              let cdn_height = if done { None } else { Some(cdn_sync.get_cdn_height().await?) };
 218  
 219              // Report CDN sync until it is finished.
 220              (!done, cdn_height)
 221          } else {
 222              (false, None)
 223          };
 224  
 225          // Generate a string representing the current sync mode.
 226          let sync_mode = if cdn_sync { "cdn" } else { "p2p" };
 227  
 228          Ok(ErasedJson::pretty(SyncStatus {
 229              sync_mode,
 230              cdn_height,
 231              is_synced: !cdn_sync && rest.routing.is_block_synced(),
 232              ledger_height: rest.ledger.latest_height(),
 233              p2p_height: rest.block_sync.greatest_peer_block_height(),
 234              outstanding_block_requests: rest.block_sync.num_outstanding_block_requests(),
 235              sync_speed_bps: rest.block_sync.get_sync_speed(),
 236          }))
 237      }
 238  
 239      /// GET /<network>/sync/peers
 240      pub(crate) async fn get_sync_peers(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 241          let peers: HashMap<String, u32> =
 242              rest.block_sync.get_peer_heights().into_iter().map(|(addr, height)| (addr.to_string(), height)).collect();
 243          Ok(ErasedJson::pretty(peers))
 244      }
 245  
 246      /// GET /<network>/sync/requests
 247      pub(crate) async fn get_sync_requests_summary(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 248          let summary = rest.block_sync.get_block_requests_summary();
 249          Ok(ErasedJson::pretty(summary))
 250      }
 251  
 252      /// GET /<network>/sync/requests/list
 253      pub(crate) async fn get_sync_requests_list(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 254          let requests = rest.block_sync.get_block_requests_info();
 255          Ok(ErasedJson::pretty(requests))
 256      }
 257  
 258      /// GET /<network>/height/{blockHash}
 259      pub(crate) async fn get_height(
 260          State(rest): State<Self>,
 261          Path(hash): Path<N::BlockHash>,
 262      ) -> Result<ErasedJson, RestError> {
 263          Ok(ErasedJson::pretty(rest.ledger.get_height(&hash)?))
 264      }
 265  
 266      /// GET /<network>/block/{height}/header
 267      pub(crate) async fn get_block_header(
 268          State(rest): State<Self>,
 269          Path(height): Path<u32>,
 270      ) -> Result<ErasedJson, RestError> {
 271          Ok(ErasedJson::pretty(rest.ledger.get_header(height)?))
 272      }
 273  
 274      /// GET /<network>/block/{height}/transactions
 275      pub(crate) async fn get_block_transactions(
 276          State(rest): State<Self>,
 277          Path(height): Path<u32>,
 278      ) -> Result<ErasedJson, RestError> {
 279          Ok(ErasedJson::pretty(rest.ledger.get_transactions(height)?))
 280      }
 281  
 282      /// GET /<network>/genesis
 283      /// Returns the full genesis block (block at height 0).
 284      /// This endpoint is used by late-joining validators to fetch genesis automatically.
 285      pub(crate) async fn get_genesis(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 286          let genesis = rest
 287              .ledger
 288              .try_get_block(0)
 289              .with_context(|| "Failed to get genesis block")?
 290              .ok_or_else(|| RestError::not_found(anyhow!("Genesis block not found at height 0")))?;
 291          Ok(ErasedJson::pretty(genesis))
 292      }
 293  
 294      /// GET /<network>/genesis/hash
 295      /// Returns only the genesis block hash (lightweight check).
 296      /// This endpoint is used for BFT consensus verification of genesis across governors.
 297      pub(crate) async fn get_genesis_hash(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 298          let genesis = rest
 299              .ledger
 300              .try_get_block(0)
 301              .with_context(|| "Failed to get genesis block")?
 302              .ok_or_else(|| RestError::not_found(anyhow!("Genesis block not found at height 0")))?;
 303          Ok(ErasedJson::pretty(genesis.hash()))
 304      }
 305  
 306      /// GET /<network>/governors
 307      /// Returns the list of current governors for genesis verification.
 308      /// This endpoint is used by late-joining validators to discover the governor set for BFT consensus.
 309      pub(crate) async fn get_governors(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 310          // Get the latest committee
 311          let committee = rest.ledger.latest_committee()?;
 312  
 313          // Extract governor information
 314          let governors: Vec<serde_json::Value> = committee
 315              .members()
 316              .iter()
 317              .map(|(address, (stake, is_open, _))| {
 318                  json!({
 319                      "address": address.to_string(),
 320                      "stake": stake,
 321                      "is_open": is_open,
 322                  })
 323              })
 324              .collect();
 325  
 326          Ok(ErasedJson::pretty(json!({
 327              "round": committee.starting_round(),
 328              "total_stake": committee.total_stake(),
 329              "governors": governors,
 330          })))
 331      }
 332  
 333      /// GET /<network>/transaction/{transactionID}
 334      pub(crate) async fn get_transaction(
 335          State(rest): State<Self>,
 336          Path(tx_id): Path<N::TransactionID>,
 337      ) -> Result<ErasedJson, RestError> {
 338          // Ledger returns a generic anyhow::Error, so checking the message is the only way to parse it.
 339          Ok(ErasedJson::pretty(rest.ledger.get_transaction(tx_id).map_err(|err| {
 340              if err.to_string().contains("Missing") {
 341                  RestError::not_found(err)
 342              } else {
 343                  RestError::from(err)
 344              }
 345          })?))
 346      }
 347  
 348      /// GET /<network>/transaction/confirmed/{transactionID}
 349      pub(crate) async fn get_confirmed_transaction(
 350          State(rest): State<Self>,
 351          Path(tx_id): Path<N::TransactionID>,
 352      ) -> Result<ErasedJson, RestError> {
 353          // Ledger returns a generic anyhow::Error, so checking the message is the only way to parse it.
 354          Ok(ErasedJson::pretty(rest.ledger.get_confirmed_transaction(tx_id).map_err(|err| {
 355              if err.to_string().contains("Missing") {
 356                  RestError::not_found(err)
 357              } else {
 358                  RestError::from(err)
 359              }
 360          })?))
 361      }
 362  
 363      /// GET /<network>/transaction/unconfirmed/{transactionID}
 364      pub(crate) async fn get_unconfirmed_transaction(
 365          State(rest): State<Self>,
 366          Path(tx_id): Path<N::TransactionID>,
 367      ) -> Result<ErasedJson, RestError> {
 368          // Ledger returns a generic anyhow::Error, so checking the message is the only way to parse it.
 369          Ok(ErasedJson::pretty(rest.ledger.get_unconfirmed_transaction(&tx_id).map_err(|err| {
 370              if err.to_string().contains("Missing") {
 371                  RestError::not_found(err)
 372              } else {
 373                  RestError::from(err)
 374              }
 375          })?))
 376      }
 377  
 378      /// GET /<network>/memoryPool/transmissions
 379      pub(crate) async fn get_memory_pool_transmissions(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 380          match rest.consensus {
 381              Some(consensus) => {
 382                  Ok(ErasedJson::pretty(consensus.unconfirmed_transmissions().collect::<IndexMap<_, _>>()))
 383              }
 384              None => Err(RestError::service_unavailable(anyhow!("Route isn't available for this node type"))),
 385          }
 386      }
 387  
 388      /// GET /<network>/memoryPool/solutions
 389      pub(crate) async fn get_memory_pool_solutions(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 390          match rest.consensus {
 391              Some(consensus) => Ok(ErasedJson::pretty(consensus.unconfirmed_solutions().collect::<IndexMap<_, _>>())),
 392              None => Err(RestError::service_unavailable(anyhow!("Route isn't available for this node type"))),
 393          }
 394      }
 395  
 396      /// GET /<network>/memoryPool/transactions
 397      pub(crate) async fn get_memory_pool_transactions(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 398          match rest.consensus {
 399              Some(consensus) => Ok(ErasedJson::pretty(consensus.unconfirmed_transactions().collect::<IndexMap<_, _>>())),
 400              None => Err(RestError::service_unavailable(anyhow!("Route isn't available for this node type"))),
 401          }
 402      }
 403  
 404      /// GET /<network>/program/{programID}
 405      /// GET /<network>/program/{programID}?metadata={true}
 406      pub(crate) async fn get_program(
 407          State(rest): State<Self>,
 408          Path(id): Path<ProgramID<N>>,
 409          metadata: Query<Metadata>,
 410      ) -> Result<ErasedJson, RestError> {
 411          // Get the program from the ledger.
 412          let program = rest.ledger.get_program(id).with_context(|| format!("Failed to find program `{id}`"))?;
 413          // Check if metadata is requested and return the program with metadata if so.
 414          if metadata.metadata.unwrap_or(false) {
 415              // Get the edition of the program.
 416              let edition = rest.ledger.get_latest_edition_for_program(&id)?;
 417              return rest.return_program_with_metadata(program, edition);
 418          }
 419          // Return the program without metadata.
 420          Ok(ErasedJson::pretty(program))
 421      }
 422  
 423      /// GET /<network>/program/{programID}/{edition}
 424      /// GET /<network>/program/{programID}/{edition}?metadata={true}
 425      pub(crate) async fn get_program_for_edition(
 426          State(rest): State<Self>,
 427          Path((id, edition)): Path<(ProgramID<N>, u16)>,
 428          metadata: Query<Metadata>,
 429      ) -> Result<ErasedJson, RestError> {
 430          // Get the program from the ledger.
 431          match rest
 432              .ledger
 433              .try_get_program_for_edition(&id, edition)
 434              .with_context(|| format!("Failed get program `{id}` for edition {edition}"))?
 435          {
 436              Some(program) => {
 437                  // Check if metadata is requested and return the program with metadata if so.
 438                  if metadata.metadata.unwrap_or(false) {
 439                      rest.return_program_with_metadata(program, edition)
 440                  } else {
 441                      Ok(ErasedJson::pretty(program))
 442                  }
 443              }
 444              None => Err(RestError::not_found(anyhow!("No program `{id}` exists for edition {edition}"))),
 445          }
 446      }
 447  
 448      /// A helper function to return the program and its metadata.
 449      /// This function is used in the `get_program` and `get_program_for_edition` functions.
 450      fn return_program_with_metadata(&self, program: Program<N>, edition: u16) -> Result<ErasedJson, RestError> {
 451          let id = program.id();
 452          // Get the transaction ID associated with the program and edition.
 453          let tx_id = self.ledger.find_transaction_id_from_program_id_and_edition(id, edition)?;
 454          // Get the optional program owner associated with the program.
 455          // Note: The owner is only available after `ConsensusVersion::V9`.
 456          let program_owner = match &tx_id {
 457              Some(tid) => self
 458                  .ledger
 459                  .vm()
 460                  .block_store()
 461                  .transaction_store()
 462                  .deployment_store()
 463                  .get_deployment(tid)?
 464                  .and_then(|deployment| deployment.program_owner()),
 465              None => None,
 466          };
 467          Ok(ErasedJson::pretty(json!({
 468              "program": program,
 469              "edition": edition,
 470              "transaction_id": tx_id,
 471              "program_owner": program_owner,
 472          })))
 473      }
 474  
 475      /// GET /<network>/program/{programID}/latest_edition
 476      pub(crate) async fn get_latest_program_edition(
 477          State(rest): State<Self>,
 478          Path(id): Path<ProgramID<N>>,
 479      ) -> Result<ErasedJson, RestError> {
 480          Ok(ErasedJson::pretty(rest.ledger.get_latest_edition_for_program(&id)?))
 481      }
 482  
 483      /// GET /<network>/program/{programID}/mappings
 484      pub(crate) async fn get_mapping_names(
 485          State(rest): State<Self>,
 486          Path(id): Path<ProgramID<N>>,
 487      ) -> Result<ErasedJson, RestError> {
 488          Ok(ErasedJson::pretty(rest.ledger.vm().finalize_store().get_mapping_names_confirmed(&id)?))
 489      }
 490  
 491      /// GET /<network>/program/{programID}/mapping/{mappingName}/{mappingKey}
 492      /// GET /<network>/program/{programID}/mapping/{mappingName}/{mappingKey}?metadata={true}
 493      pub(crate) async fn get_mapping_value(
 494          State(rest): State<Self>,
 495          Path((id, name, key)): Path<(ProgramID<N>, Identifier<N>, Plaintext<N>)>,
 496          metadata: Query<Metadata>,
 497      ) -> Result<ErasedJson, RestError> {
 498          // Retrieve the mapping value.
 499          let mapping_value = rest.ledger.vm().finalize_store().get_value_confirmed(id, name, &key)?;
 500  
 501          // Check if metadata is requested and return the value with metadata if so.
 502          if metadata.metadata.unwrap_or(false) {
 503              return Ok(ErasedJson::pretty(json!({
 504                  "data": mapping_value,
 505                  "height": rest.ledger.latest_height(),
 506              })));
 507          }
 508  
 509          // Return the value without metadata.
 510          Ok(ErasedJson::pretty(mapping_value))
 511      }
 512  
 513      /// GET /<network>/program/{programID}/mapping/{mappingName}?all={true}&metadata={true}
 514      pub(crate) async fn get_mapping_values(
 515          State(rest): State<Self>,
 516          Path((id, name)): Path<(ProgramID<N>, Identifier<N>)>,
 517          metadata: Query<Metadata>,
 518      ) -> Result<ErasedJson, RestError> {
 519          // Return an error if the `all` query parameter is not set to `true`.
 520          if metadata.all != Some(true) {
 521              return Err(RestError::bad_request(anyhow!(
 522                  "Invalid query parameter. At this time, 'all=true' must be included"
 523              )));
 524          }
 525  
 526          // Retrieve the latest height.
 527          let height = rest.ledger.latest_height();
 528  
 529          // Retrieve all the mapping values from the mapping.
 530          match tokio::task::spawn_blocking(move || rest.ledger.vm().finalize_store().get_mapping_confirmed(id, name))
 531              .await
 532          {
 533              Ok(Ok(mapping_values)) => {
 534                  // Check if metadata is requested and return the mapping with metadata if so.
 535                  if metadata.metadata.unwrap_or(false) {
 536                      return Ok(ErasedJson::pretty(json!({
 537                          "data": mapping_values,
 538                          "height": height,
 539                      })));
 540                  }
 541  
 542                  // Return the full mapping without metadata.
 543                  Ok(ErasedJson::pretty(mapping_values))
 544              }
 545              Ok(Err(err)) => Err(RestError::internal_server_error(err.context("Unable to read mapping"))),
 546              Err(err) => Err(RestError::internal_server_error(anyhow!("Tokio error: {err}"))),
 547          }
 548      }
 549  
 550      /// GET /<network>/statePath/{commitment}
 551      pub(crate) async fn get_state_path_for_commitment(
 552          State(rest): State<Self>,
 553          Path(commitment): Path<Field<N>>,
 554      ) -> Result<ErasedJson, RestError> {
 555          Ok(ErasedJson::pretty(rest.ledger.get_state_path_for_commitment(&commitment)?))
 556      }
 557  
 558      /// GET /<network>/statePaths?commitments=cm1,cm2,...
 559      pub(crate) async fn get_state_paths_for_commitments(
 560          State(rest): State<Self>,
 561          Query(commitments): Query<Commitments>,
 562      ) -> Result<ErasedJson, RestError> {
 563          // Retrieve the number of commitments.
 564          let num_commitments = commitments.commitments.len();
 565          // Return an error if no commitments are provided.
 566          if num_commitments == 0 {
 567              return Err(RestError::unprocessable_entity(anyhow!("No commitments provided")));
 568          }
 569          // Return an error if the number of commitments exceeds the maximum allowed.
 570          if num_commitments > N::MAX_INPUTS {
 571              return Err(RestError::unprocessable_entity(anyhow!(
 572                  "Too many commitments provided (max: {}, got: {})",
 573                  N::MAX_INPUTS,
 574                  num_commitments
 575              )));
 576          }
 577  
 578          // Deserialize the commitments from the query.
 579          let commitments = commitments
 580              .commitments
 581              .iter()
 582              .map(|s| {
 583                  s.parse::<Field<N>>()
 584                      .map_err(|err| RestError::unprocessable_entity(err.context(format!("Invalid commitment: {s}"))))
 585              })
 586              .collect::<Result<Vec<_>, _>>()?;
 587  
 588          Ok(ErasedJson::pretty(rest.ledger.get_state_paths_for_commitments(&commitments)?))
 589      }
 590  
 591      /// GET /<network>/stateRoot/latest
 592      pub(crate) async fn get_state_root_latest(State(rest): State<Self>) -> ErasedJson {
 593          ErasedJson::pretty(rest.ledger.latest_state_root())
 594      }
 595  
 596      /// GET /<network>/stateRoot/{height}
 597      pub(crate) async fn get_state_root(
 598          State(rest): State<Self>,
 599          Path(height): Path<u32>,
 600      ) -> Result<ErasedJson, RestError> {
 601          Ok(ErasedJson::pretty(rest.ledger.get_state_root(height)?))
 602      }
 603  
 604      /// GET /<network>/committee/latest
 605      pub(crate) async fn get_committee_latest(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
 606          Ok(ErasedJson::pretty(rest.ledger.latest_committee()?))
 607      }
 608  
 609      /// GET /<network>/committee/{height}
 610      pub(crate) async fn get_committee(
 611          State(rest): State<Self>,
 612          Path(height): Path<u32>,
 613      ) -> Result<ErasedJson, RestError> {
 614          Ok(ErasedJson::pretty(rest.ledger.get_committee(height)?))
 615      }
 616  
 617      /// GET /<network>/delegators/{validator}
 618      pub(crate) async fn get_delegators_for_validator(
 619          State(rest): State<Self>,
 620          Path(validator): Path<Address<N>>,
 621      ) -> Result<ErasedJson, RestError> {
 622          // Do not process the request if the node is too far behind to avoid sending outdated data.
 623          if !rest.routing.is_within_sync_leniency() {
 624              return Err(RestError::service_unavailable(anyhow!("Unable to request delegators (node is syncing)")));
 625          }
 626  
 627          // Return the delegators for the given validator.
 628          match tokio::task::spawn_blocking(move || rest.ledger.get_delegators_for_validator(&validator)).await {
 629              Ok(Ok(delegators)) => Ok(ErasedJson::pretty(delegators)),
 630              Ok(Err(err)) => Err(RestError::internal_server_error(err.context("Unable to request delegators"))),
 631              Err(err) => Err(RestError::internal_server_error(anyhow!(err).context("Tokio error"))),
 632          }
 633      }
 634  
 635      /// GET /<network>/peers/count
 636      pub(crate) async fn get_peers_count(State(rest): State<Self>) -> ErasedJson {
 637          ErasedJson::pretty(rest.routing.router().number_of_connected_peers())
 638      }
 639  
 640      /// GET /<network>/peers/all
 641      pub(crate) async fn get_peers_all(State(rest): State<Self>) -> ErasedJson {
 642          ErasedJson::pretty(rest.routing.router().connected_peers())
 643      }
 644  
 645      /// GET /<network>/peers/all/metrics
 646      pub(crate) async fn get_peers_all_metrics(State(rest): State<Self>) -> ErasedJson {
 647          ErasedJson::pretty(rest.routing.router().connected_metrics())
 648      }
 649  
 650      /// GET /<network>/node/address
 651      pub(crate) async fn get_node_address(State(rest): State<Self>) -> ErasedJson {
 652          ErasedJson::pretty(rest.routing.router().address())
 653      }
 654  
 655      /// GET /<network>/find/blockHash/{transactionID}
 656      pub(crate) async fn find_block_hash(
 657          State(rest): State<Self>,
 658          Path(tx_id): Path<N::TransactionID>,
 659      ) -> Result<ErasedJson, RestError> {
 660          Ok(ErasedJson::pretty(rest.ledger.find_block_hash(&tx_id)?))
 661      }
 662  
 663      /// GET /<network>/find/blockHeight/{stateRoot}
 664      pub(crate) async fn find_block_height_from_state_root(
 665          State(rest): State<Self>,
 666          Path(state_root): Path<N::StateRoot>,
 667      ) -> Result<ErasedJson, RestError> {
 668          Ok(ErasedJson::pretty(rest.ledger.find_block_height_from_state_root(state_root)?))
 669      }
 670  
 671      /// GET /<network>/find/transactionID/deployment/{programID}
 672      pub(crate) async fn find_latest_transaction_id_from_program_id(
 673          State(rest): State<Self>,
 674          Path(program_id): Path<ProgramID<N>>,
 675      ) -> Result<ErasedJson, RestError> {
 676          Ok(ErasedJson::pretty(rest.ledger.find_latest_transaction_id_from_program_id(&program_id)?))
 677      }
 678  
 679      /// GET /<network>/find/transactionID/deployment/{programID}/{edition}
 680      pub(crate) async fn find_transaction_id_from_program_id_and_edition(
 681          State(rest): State<Self>,
 682          Path((program_id, edition)): Path<(ProgramID<N>, u16)>,
 683      ) -> Result<ErasedJson, RestError> {
 684          Ok(ErasedJson::pretty(rest.ledger.find_transaction_id_from_program_id_and_edition(&program_id, edition)?))
 685      }
 686  
 687      /// GET /<network>/find/transactionID/{transitionID}
 688      pub(crate) async fn find_transaction_id_from_transition_id(
 689          State(rest): State<Self>,
 690          Path(transition_id): Path<N::TransitionID>,
 691      ) -> Result<ErasedJson, RestError> {
 692          Ok(ErasedJson::pretty(rest.ledger.find_transaction_id_from_transition_id(&transition_id)?))
 693      }
 694  
 695      /// GET /<network>/find/transitionID/{inputOrOutputID}
 696      pub(crate) async fn find_transition_id(
 697          State(rest): State<Self>,
 698          Path(input_or_output_id): Path<Field<N>>,
 699      ) -> Result<ErasedJson, RestError> {
 700          Ok(ErasedJson::pretty(rest.ledger.find_transition_id(&input_or_output_id)?))
 701      }
 702  
 703      /// POST /<network>/transaction/broadcast
 704      /// POST /<network>/transaction/broadcast?check_transaction={true}
 705      ///
 706      /// Transaction Broadcast Flow
 707      ///
 708      /// /transaction/broadcast
 709      ///         |
 710      ///    +----+---------------------------+
 711      ///    |                               |
 712      ///    v                               v
 713      /// Without Query Params        With Query Param
 714      ///                                check_transaction=true
 715      ///    |                               |
 716      ///    +---------+                     +---------+
 717      ///    |         |                     |         |
 718      ///    v         v                     v         v
 719      /// Synced   Not Synced            Synced   Not Synced
 720      ///    |         |                     |         |
 721      ///    v         v                     v         v
 722      ///   200       200        check_transaction  check_transaction
 723      ///                           +---------+        +---------+
 724      ///                           |         |        |         |
 725      ///                           v         v        v         v
 726      ///                          200       422      203       503
 727      pub(crate) async fn transaction_broadcast(
 728          State(rest): State<Self>,
 729          check_transaction: Query<CheckTransaction>,
 730          json_result: Result<Json<Transaction<N>>, JsonRejection>,
 731      ) -> Result<impl axum::response::IntoResponse, RestError> {
 732          let Json(tx) = match json_result {
 733              Ok(json) => json,
 734              Err(JsonRejection::JsonDataError(err)) => {
 735                  // For JsonDataError, return 422 to let transaction validation handle it
 736                  return Err(RestError::unprocessable_entity(anyhow!("Invalid transaction data: {err}")));
 737              }
 738              Err(other_rejection) => return Err(other_rejection.into()),
 739          };
 740  
 741          // If the transaction exceeds the transaction size limit, return an error.
 742          // The buffer is initially roughly sized to hold a `transfer_public`,
 743          // most transactions will be smaller and this reduces unnecessary allocations.
 744          // TODO: Should this be a blocking task?
 745          let buffer = Vec::with_capacity(3000);
 746          if tx.write_le(LimitedWriter::new(buffer, N::MAX_TRANSACTION_SIZE)).is_err() {
 747              return Err(RestError::bad_request(anyhow!("Transaction size exceeds the byte limit")));
 748          }
 749  
 750          // Prepare the unconfirmed transaction message.
 751          let tx_id = tx.id();
 752          let message = Message::UnconfirmedTransaction(UnconfirmedTransaction {
 753              transaction_id: tx_id,
 754              transaction: Data::Object(tx.clone()),
 755          });
 756  
 757          // Check if the node is within sync leniency.
 758          let is_within_sync_leniency = rest.routing.is_within_sync_leniency();
 759  
 760          // Determine if we need to check the transaction.
 761          let check_transaction = check_transaction.check_transaction.unwrap_or(false);
 762  
 763          if check_transaction {
 764              // Select counter and limit based on transaction type.
 765              let (counter, limit, err_msg) = if tx.is_execute() {
 766                  (
 767                      &rest.num_verifying_executions,
 768                      VM::<N, C>::MAX_PARALLEL_EXECUTE_VERIFICATIONS,
 769                      "Too many execution verifications in progress",
 770                  )
 771              } else {
 772                  (
 773                      &rest.num_verifying_deploys,
 774                      VM::<N, C>::MAX_PARALLEL_DEPLOY_VERIFICATIONS,
 775                      "Too many deploy verifications in progress",
 776                  )
 777              };
 778  
 779              // Try to acquire a slot.
 780              if counter
 781                  .fetch_update(
 782                      Ordering::Relaxed,
 783                      Ordering::Relaxed,
 784                      |val| {
 785                          if val < limit {
 786                              Some(val + 1)
 787                          } else {
 788                              None
 789                          }
 790                      },
 791                  )
 792                  .is_err()
 793              {
 794                  return Err(RestError::too_many_requests(anyhow!("{err_msg}")));
 795              }
 796  
 797              // Perform the check.
 798              let res = rest.ledger.check_transaction_basic(&tx, None, &mut rand::thread_rng()).map_err(|err| {
 799                  match is_within_sync_leniency {
 800                      // The transaction failed to verify.
 801                      true => RestError::unprocessable_entity(err.context("Invalid transaction")),
 802                      // The node is out of sync and may not be able to properly validate the transaction.
 803                      false => {
 804                          RestError::service_unavailable(err.context("Unable to validate transaction (node is syncing)"))
 805                      }
 806                  }
 807              });
 808              // Release the slot.
 809              counter.fetch_sub(1, Ordering::Relaxed);
 810              // Propagate error if any.
 811              res?;
 812          }
 813  
 814          // If the consensus module is enabled, add the unconfirmed transaction to the memory pool.
 815          if let Some(consensus) = rest.consensus {
 816              // Add the unconfirmed transaction to the memory pool.
 817              consensus.add_unconfirmed_transaction(tx.clone()).await?;
 818          }
 819  
 820          // Broadcast the transaction.
 821          rest.routing.propagate(message, &[]);
 822  
 823          // Determine if the node is synced and if the transaction was checked.
 824          match !is_within_sync_leniency && check_transaction {
 825              // If the node is not synced and we validated the transaction, return a 203.
 826              true => Ok((StatusCode::NON_AUTHORITATIVE_INFORMATION, ErasedJson::pretty(tx_id))),
 827              // Otherwise, return a 200.
 828              false => Ok((StatusCode::OK, ErasedJson::pretty(tx_id))),
 829          }
 830      }
 831  
 832      /// POST /<network>/solution/broadcast
 833      /// POST /<network>/solution/broadcast?check_solution={true}
 834      ///
 835      /// Solution Broadcast Flow
 836      ///
 837      /// /solution/broadcast
 838      ///         |
 839      ///    +----+---------------------------+
 840      ///    |                               |
 841      ///    v                               v
 842      /// Without Query Params        With Query Param
 843      ///                                check_solution=true
 844      ///    |                               |
 845      ///    +---------+                     +---------+
 846      ///    |         |                     |         |
 847      ///    v         v                     v         v
 848      /// Synced   Not Synced            Synced   Not Synced
 849      ///    |         |                     |         |
 850      ///    v         v                     v         v
 851      ///   200       200        check_solution        check_solution
 852      ///                           +---------+        +---------+
 853      ///                           |         |        |         |
 854      ///                           v         v        v         v
 855      ///                          200       422      203       503
 856      pub(crate) async fn solution_broadcast(
 857          State(rest): State<Self>,
 858          check_solution: Query<CheckSolution>,
 859          Json(solution): Json<Solution<N>>,
 860      ) -> Result<impl axum::response::IntoResponse, RestError> {
 861          // Check if the node is within sync leniency.
 862          let is_within_sync_leniency = rest.routing.is_within_sync_leniency();
 863          // Determine if we need to check the solution.
 864          let check_solution = check_solution.check_solution.unwrap_or(false);
 865  
 866          if check_solution {
 867              // Select counter and limit.
 868              let (counter, limit, err_msg) =
 869                  (&rest.num_verifying_solutions, N::MAX_SOLUTIONS, "Too many solution verifications in progress");
 870  
 871              // Try to acquire a slot.
 872              if counter
 873                  .fetch_update(
 874                      Ordering::Relaxed,
 875                      Ordering::Relaxed,
 876                      |val| {
 877                          if val < limit {
 878                              Some(val + 1)
 879                          } else {
 880                              None
 881                          }
 882                      },
 883                  )
 884                  .is_err()
 885              {
 886                  return Err(RestError::too_many_requests(anyhow!("{err_msg}")));
 887              }
 888  
 889              // Compute the current epoch hash.
 890              let epoch_hash = rest.ledger.latest_epoch_hash()?;
 891              // Retrieve the current proof target.
 892              let proof_target = rest.ledger.latest_proof_target();
 893              // Ensure that the solution is valid for the given epoch.
 894              let puzzle = rest.ledger.puzzle().clone();
 895              // Check if the prover has reached their solution limit.
 896              // While alphavm will ultimately abort any excess solutions for safety, performing this check
 897              // here prevents the to-be aborted solutions from propagating through the network.
 898              let prover_address = solution.address();
 899              if rest.ledger.is_solution_limit_reached(&prover_address, 0) {
 900                  return Err(RestError::unprocessable_entity(anyhow!(
 901                      "Invalid solution '{}' - Prover '{prover_address}' has reached their solution limit for the current epoch",
 902                      fmt_id(solution.id())
 903                  )));
 904              }
 905              // Verify the solution in a blocking task.
 906              let res: Result<(), anyhow::Error> =
 907                  match tokio::task::spawn_blocking(move || puzzle.check_solution(&solution, epoch_hash, proof_target))
 908                      .await
 909                  {
 910                      Ok(Ok(())) => Ok(()),
 911                      Ok(Err(err)) => {
 912                          return match is_within_sync_leniency {
 913                              // The solution failed to verify.
 914                              true => Err(RestError::unprocessable_entity(
 915                                  err.context(format!("Invalid solution '{}'", fmt_id(solution.id()))),
 916                              )),
 917                              // The node is out of sync and may not be able to properly validate the solution.
 918                              false => Err(RestError::service_unavailable(anyhow!(
 919                                  "Unable to validate solution '{}' (node is syncing)",
 920                                  fmt_id(solution.id())
 921                              ))),
 922                          };
 923                      }
 924                      Err(err) => {
 925                          return Err(RestError::internal_server_error(anyhow!("Tokio error: {err}")));
 926                      }
 927                  };
 928              // Release the slot.
 929              counter.fetch_sub(1, Ordering::Relaxed);
 930              // Propagate error if any.
 931              res?;
 932          }
 933  
 934          // If the consensus module is enabled, add the unconfirmed solution to the memory pool.
 935          if let Some(consensus) = rest.consensus {
 936              // Add the unconfirmed solution to the memory pool.
 937              let _ = consensus.add_unconfirmed_solution(solution).await;
 938          }
 939  
 940          let solution_id = solution.id();
 941          // Prepare the unconfirmed solution message.
 942          let message =
 943              Message::UnconfirmedSolution(UnconfirmedSolution { solution_id, solution: Data::Object(solution) });
 944  
 945          // Broadcast the unconfirmed solution message.
 946          rest.routing.propagate(message, &[]);
 947  
 948          // Determine if the node is synced and if the solution was checked.
 949          match !is_within_sync_leniency && check_solution {
 950              // If the node is not synced and we validated the solution, return a 203.
 951              true => Ok((StatusCode::NON_AUTHORITATIVE_INFORMATION, ErasedJson::pretty(solution_id))),
 952              // Otherwise, return a 200.
 953              false => Ok((StatusCode::OK, ErasedJson::pretty(solution_id))),
 954          }
 955      }
 956  
 957      /// POST /{network}/db_backup?path=new_fs_path
 958      pub(crate) async fn db_backup(
 959          State(rest): State<Self>,
 960          backup_path: Query<BackupPath>,
 961      ) -> Result<ErasedJson, RestError> {
 962          rest.ledger.backup_database(&backup_path.path)?;
 963  
 964          Ok(ErasedJson::pretty(()))
 965      }
 966  
 967      /// GET /{network}/block/{blockHeight}/history/{mapping}
 968      #[cfg(feature = "history")]
 969      pub(crate) async fn get_history(
 970          State(rest): State<Self>,
 971          Path((height, mapping)): Path<(u32, alphavm::synthesizer::MappingName)>,
 972      ) -> Result<impl axum::response::IntoResponse, RestError> {
 973          // Retrieve the history for the given block height and variant.
 974          let history = alphavm::synthesizer::History::new(N::ID, rest.ledger.vm().finalize_store().storage_mode());
 975          let result = history.load_mapping(height, mapping).map_err(|err| {
 976              RestError::not_found(err.context(format!("Could not load mapping '{mapping}' from block '{height}'")))
 977          })?;
 978  
 979          Ok((StatusCode::OK, [(CONTENT_TYPE, "application/json")], result))
 980      }
 981  
 982      /// GET /{network}/validators/participation
 983      /// GET /{network}/validators/participation?metadata={true}
 984      #[cfg(feature = "telemetry")]
 985      pub(crate) async fn get_validator_participation_scores(
 986          State(rest): State<Self>,
 987          metadata: Query<Metadata>,
 988      ) -> Result<impl axum::response::IntoResponse, RestError> {
 989          match rest.consensus {
 990              Some(consensus) => {
 991                  // Retrieve the latest committee.
 992                  let latest_committee = rest.ledger.latest_committee()?;
 993                  // Retrieve the latest participation scores.
 994                  let participation_scores = consensus
 995                      .bft()
 996                      .primary()
 997                      .gateway()
 998                      .validator_telemetry()
 999                      .get_participation_scores(&latest_committee);
1000  
1001                  // Check if metadata is requested and return the participation scores with metadata if so.
1002                  if metadata.metadata.unwrap_or(false) {
1003                      return Ok(ErasedJson::pretty(json!({
1004                          "participation_scores": participation_scores,
1005                          "height": rest.ledger.latest_height(),
1006                      })));
1007                  }
1008  
1009                  Ok(ErasedJson::pretty(participation_scores))
1010              }
1011              None => Err(RestError::service_unavailable(anyhow!("Route isn't available for this node type"))),
1012          }
1013      }
1014  }
1015  
1016  // === CLP (Continuous Liveness Proof) Endpoints ===
1017  
1018  /// Response type for CLP status.
1019  #[derive(Serialize)]
1020  struct ClpStatusResponse {
1021      /// Whether CLP is active.
1022      is_active: bool,
1023      /// Current epoch number.
1024      current_epoch: u64,
1025      /// Total challenges issued this epoch.
1026      challenges_issued: u32,
1027      /// Number of validators being tracked.
1028      validator_count: usize,
1029      /// Per-validator status summary.
1030      validators: Vec<ClpValidatorSummary>,
1031  }
1032  
1033  /// Summary of a validator's CLP status.
1034  #[derive(Serialize)]
1035  struct ClpValidatorSummary {
1036      /// Validator address (hex encoded).
1037      address: String,
1038      /// Total challenges received.
1039      total_challenges: u32,
1040      /// Challenges responded to.
1041      responses: u32,
1042      /// Response rate as percentage.
1043      response_rate_percent: f64,
1044      /// Consecutive failing epochs.
1045      consecutive_failures: u32,
1046  }
1047  
1048  /// Response type for a single validator's CLP status.
1049  #[derive(Serialize)]
1050  struct ClpValidatorDetailResponse {
1051      /// Validator address.
1052      address: String,
1053      /// Total challenges received.
1054      total_challenges: u32,
1055      /// Challenges responded to.
1056      responses: u32,
1057      /// Challenges missed (after grace period).
1058      missed: u32,
1059      /// Response rate as percentage.
1060      response_rate_percent: f64,
1061      /// Consecutive failing epochs.
1062      consecutive_failures: u32,
1063      /// Current CLP epoch.
1064      current_epoch: u64,
1065  }
1066  
1067  impl<N: Network, C: ConsensusStorage<N>, R: Routing<N>> Rest<N, C, R> {
1068      /// GET /<network>/clp/status
1069      ///
1070      /// Returns the current CLP system status including all validator statuses.
1071      pub(crate) async fn get_clp_status(State(rest): State<Self>) -> Result<ErasedJson, RestError> {
1072          match rest.consensus {
1073              Some(consensus) => {
1074                  // Check if CLP manager is configured
1075                  match consensus.clp_manager() {
1076                      Some(clp_manager) => {
1077                          let is_active = clp_manager.is_active();
1078                          let current_epoch = clp_manager.current_epoch();
1079                          let challenges_issued = clp_manager.challenges_issued();
1080  
1081                          // Build validator summaries (we need to access the aggregator for this)
1082                          // Since we can't directly access all_statuses from ClpManager,
1083                          // we'll return basic info for now
1084                          let response = ClpStatusResponse {
1085                              is_active,
1086                              current_epoch,
1087                              challenges_issued,
1088                              validator_count: 0, // Will be populated when aggregator exposes this
1089                              validators: vec![], // Will be populated when aggregator exposes this
1090                          };
1091  
1092                          Ok(ErasedJson::pretty(response))
1093                      }
1094                      None => {
1095                          // CLP not enabled - return inactive status
1096                          Ok(ErasedJson::pretty(ClpStatusResponse {
1097                              is_active: false,
1098                              current_epoch: 0,
1099                              challenges_issued: 0,
1100                              validator_count: 0,
1101                              validators: vec![],
1102                          }))
1103                      }
1104                  }
1105              }
1106              None => Err(RestError::service_unavailable(anyhow!("CLP status is only available on validator nodes"))),
1107          }
1108      }
1109  
1110      /// GET /<network>/clp/validator/{address}
1111      ///
1112      /// Returns CLP status for a specific validator.
1113      pub(crate) async fn get_clp_validator_status(
1114          State(rest): State<Self>,
1115          Path(address): Path<String>,
1116      ) -> Result<ErasedJson, RestError> {
1117          match rest.consensus {
1118              Some(consensus) => {
1119                  // Parse the validator address from hex
1120                  let addr_bytes: [u8; 32] = hex_decode(&address)
1121                      .map_err(|_| RestError::bad_request(anyhow!("Invalid validator address format")))?
1122                      .try_into()
1123                      .map_err(|_| RestError::bad_request(anyhow!("Validator address must be 32 bytes")))?;
1124                  let validator_addr = ValidatorAddress(addr_bytes);
1125  
1126                  match consensus.clp_manager() {
1127                      Some(clp_manager) => {
1128                          let current_epoch = clp_manager.current_epoch();
1129  
1130                          // Try to get the response rate for this validator
1131                          match clp_manager.validator_response_rate(&validator_addr) {
1132                              Some(rate) => {
1133                                  let response = ClpValidatorDetailResponse {
1134                                      address,
1135                                      total_challenges: clp_manager.challenges_issued(),
1136                                      responses: (rate * clp_manager.challenges_issued() as f64) as u32,
1137                                      missed: 0, // Not directly accessible from manager
1138                                      response_rate_percent: rate * 100.0,
1139                                      consecutive_failures: 0, // Not directly accessible from manager
1140                                      current_epoch,
1141                                  };
1142                                  Ok(ErasedJson::pretty(response))
1143                              }
1144                              None => Err(RestError::not_found(anyhow!(
1145                                  "Validator {} not found in current CLP epoch",
1146                                  address
1147                              ))),
1148                          }
1149                      }
1150                      None => Err(RestError::service_unavailable(anyhow!("CLP is not enabled on this node"))),
1151                  }
1152              }
1153              None => Err(RestError::service_unavailable(anyhow!("CLP status is only available on validator nodes"))),
1154          }
1155      }
1156  }
1157  
1158  /// Helper function to decode hex strings.
1159  fn hex_decode(s: &str) -> Result<Vec<u8>, anyhow::Error> {
1160      // Strip optional "0x" or "ax1" prefix
1161      let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("ax1")).unwrap_or(s);
1162  
1163      if !s.len().is_multiple_of(2) {
1164          return Err(anyhow!("Hex string must have even length"));
1165      }
1166  
1167      (0..s.len())
1168          .step_by(2)
1169          .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow!("Invalid hex: {}", e)))
1170          .collect()
1171  }