#![forbid(unsafe_code)]
#[macro_use]
extern crate tracing;
mod helpers;
pub use helpers::*;
mod history_compat;
use history_compat::*;
mod routes;
mod version;
use snarkos_node_cdn::CdnBlockSync;
use snarkos_node_consensus::Consensus;
use snarkos_node_router::{
Routing,
messages::{Message, UnconfirmedTransaction},
};
use snarkos_node_sync::BlockSync;
use snarkvm::{
console::{program::ProgramID, types::Field},
ledger::narwhal::Data,
prelude::{Ledger, Network, VM, cfg_into_iter, store::ConsensusStorage},
};
use anyhow::{Context, Result};
use axum::{
body::Body,
extract::{ConnectInfo, DefaultBodyLimit, Query, State},
http::{Method, Request, StatusCode, header::CONTENT_TYPE},
middleware,
response::Response,
routing::{get, post},
};
use axum_extra::response::ErasedJson;
#[cfg(feature = "locktick")]
use locktick::parking_lot::Mutex;
use lru::LruCache;
#[cfg(not(feature = "locktick"))]
use parking_lot::Mutex;
use std::{net::SocketAddr, num::NonZeroUsize, sync::Arc, time::Duration};
use tokio::{net::TcpListener, sync::Semaphore, task::JoinHandle};
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
use tower_http::{
cors::{Any, CorsLayer},
trace::TraceLayer,
};
use tracing::Span;
pub const DEFAULT_REST_PORT: u16 = 3030;
pub const API_VERSION_V1: &str = "v1";
pub const API_VERSION_V2: &str = "v2";
const BLOCK_CACHE_SIZE: usize = 128;
#[derive(Clone)]
pub struct Rest<N: Network, C: ConsensusStorage<N>, R: Routing<N>> {
cdn_sync: Option<Arc<CdnBlockSync>>,
consensus: Option<Consensus<N>>,
ledger: Ledger<N, C>,
routing: Arc<R>,
handles: Arc<Mutex<Vec<JoinHandle<()>>>>,
block_sync: Arc<BlockSync<N>>,
num_verifying_deploys: Arc<Semaphore>,
num_verifying_executions: Arc<Semaphore>,
num_verifying_solutions: Arc<Semaphore>,
block_cache: Arc<Mutex<LruCache<N::BlockHash, ErasedJson>>>,
history_compat: Option<Arc<HistoryCompat>>,
}
impl<N: Network, C: 'static + ConsensusStorage<N>, R: Routing<N>> Rest<N, C, R> {
#[allow(clippy::too_many_arguments)]
pub async fn start(
rest_ip: SocketAddr,
rest_rps: u32,
history_api_url: Option<String>,
consensus: Option<Consensus<N>>,
ledger: Ledger<N, C>,
routing: Arc<R>,
cdn_sync: Option<Arc<CdnBlockSync>>,
block_sync: Arc<BlockSync<N>>,
) -> Result<Self> {
let history_compat = match history_api_url {
Some(url) => Some(Arc::new(HistoryCompat::new(&url, N::SHORT_NAME)?)),
None => None,
};
let mut server = Self {
consensus,
ledger,
routing,
cdn_sync,
block_sync,
handles: Default::default(),
num_verifying_deploys: Arc::new(Semaphore::new(VM::<N, C>::MAX_PARALLEL_DEPLOY_VERIFICATIONS)),
num_verifying_executions: Arc::new(Semaphore::new(VM::<N, C>::MAX_PARALLEL_EXECUTE_VERIFICATIONS)),
num_verifying_solutions: Arc::new(Semaphore::new(N::MAX_SOLUTIONS)),
block_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(BLOCK_CACHE_SIZE).unwrap()))),
history_compat,
};
server.spawn_server(rest_ip, rest_rps).await?;
Ok(server)
}
}
impl<N: Network, C: ConsensusStorage<N>, R: Routing<N>> Rest<N, C, R> {
pub const fn ledger(&self) -> &Ledger<N, C> {
&self.ledger
}
pub const fn handles(&self) -> &Arc<Mutex<Vec<JoinHandle<()>>>> {
&self.handles
}
pub fn shut_down(&self) {
self.handles.lock().iter().for_each(|handle| handle.abort());
}
}
impl<N: Network, C: ConsensusStorage<N>, R: Routing<N>> Rest<N, C, R> {
fn build_routes(&self, rest_rps: u32) -> axum::Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
.allow_headers([CONTENT_TYPE]);
let governor_config = Box::new(
GovernorConfigBuilder::default()
.per_nanosecond((1_000_000_000 / rest_rps) as u64)
.burst_size(rest_rps)
.error_handler(|error| {
let error_message = error.to_string();
let mut response = Response::new(error_message.clone().into());
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
if error_message.contains("Too Many Requests") {
*response.status_mut() = StatusCode::TOO_MANY_REQUESTS;
}
response
})
.finish()
.expect("Couldn't set up rate limiting for the REST server!"),
);
let auth_routes = axum::Router::new()
.route("/node/address", get(Self::get_node_address))
.route("/program/{id}/mapping/{name}", get(Self::get_mapping_values))
.route("/db_backup", post(Self::db_backup));
#[cfg(feature = "slipstream-plugins")]
let auth_routes = auth_routes
.route("/slipstream/plugins", get(Self::slipstream_list_plugins).post(Self::slipstream_load_plugin))
.route(
"/slipstream/plugins/{name}",
axum::routing::delete(Self::slipstream_unload_plugin),
);
let routes = axum::Router::new()
.merge(auth_routes.route_layer(middleware::from_fn(auth_middleware)))
.route("/consensus_version", get(Self::get_consensus_version))
.route("/block/height/latest", get(Self::get_block_height_latest))
.route("/block/hash/latest", get(Self::get_block_hash_latest))
.route("/block/latest", get(Self::get_block_latest))
.route("/block/{height_or_hash}", get(Self::get_block))
.route("/block/{height_or_hash}/header", get(Self::get_block_header))
.route("/block/{height_or_hash}/transactions", get(Self::get_block_transactions))
.route("/transaction/{id}", get(Self::get_transaction))
.route("/transaction/confirmed/{id}", get(Self::get_confirmed_transaction))
.route("/transaction/unconfirmed/{id}", get(Self::get_unconfirmed_transaction))
.route("/transaction/rejected/{id}/reason", get(Self::get_transaction_rejection_reason))
.route("/transaction/broadcast", post(Self::transaction_broadcast))
.route("/solution/limits/{prover_address}", get(Self::get_solution_limits_for_prover))
.route("/solution/broadcast", post(Self::solution_broadcast))
.route("/find/blockHash/{tx_id}", get(Self::find_block_hash))
.route("/find/blockHeight/{state_root}", get(Self::find_block_height_from_state_root))
.route("/find/transactionID/deployment/{program_id}", get(Self::find_latest_transaction_id_from_program_id))
.route("/find/transactionID/deployment/{program_id}/{edition}", get(Self::find_latest_transaction_id_from_program_id_and_edition))
.route("/find/transactionID/deployment/{program_id}/{edition}/original", get(Self::find_original_deployment_transaction_id))
.route("/find/transactionID/deployment/{program_id}/{edition}/{amendment}", get(Self::find_transaction_id_from_program_id_edition_and_amendment))
.route("/find/transactionID/{transition_id}", get(Self::find_transaction_id_from_transition_id))
.route("/find/transitionID/{input_or_output_id}", get(Self::find_transition_id))
.route("/peers/count", get(Self::get_peers_count))
.route("/peers/all", get(Self::get_peers_all))
.route("/peers/all/metrics", get(Self::get_peers_all_metrics))
.route("/connections/p2p/count", get(Self::get_peers_count))
.route("/connections/p2p/all", get(Self::get_peers_all))
.route("/connections/p2p/all/metrics", get(Self::get_peers_all_metrics))
.route("/program/{id}", get(Self::get_program))
.route("/program/{id}/latest_edition", get(Self::get_latest_program_edition))
.route("/program/{id}/{edition}", get(Self::get_program_for_edition))
.route("/program/{id}/mappings", get(Self::get_mapping_names))
.route("/program/{id}/mapping/{name}/{key}", get(Self::get_mapping_value))
.route("/program/{id}/amendment_count", get(Self::get_program_amendment_count))
.route("/program/{id}/{edition}/amendment_count", get(Self::get_program_amendment_count_for_edition))
.route("/sync_status", get(Self::get_sync_status))
.route("/sync/status", get(Self::get_sync_status))
.route("/sync/peers", get(Self::get_sync_peers))
.route("/sync/requests", get(Self::get_sync_requests_summary))
.route("/sync/requests/list", get(Self::get_sync_requests_list))
.route("/version", get(Self::get_version))
.route("/blocks", get(Self::get_blocks))
.route("/height/{hash}", get(Self::get_height))
.route("/memoryPool/transmissions", get(Self::get_memory_pool_transmissions))
.route("/memoryPool/solutions", get(Self::get_memory_pool_solutions))
.route("/memoryPool/transactions", get(Self::get_memory_pool_transactions))
.route("/statePath/{commitment}", get(Self::get_state_path_for_commitment))
.route("/statePaths", get(Self::get_state_paths_for_commitments))
.route("/stateRoot/latest", get(Self::get_state_root_latest))
.route("/stateRoot/{height}", get(Self::get_state_root))
.route("/committee/latest", get(Self::get_committee_latest))
.route("/committee/{height}", get(Self::get_committee))
.route("/delegators/{validator}", get(Self::get_delegators_for_validator));
let routes = match self.consensus {
Some(_) => routes
.route("/connections/bft/count", get(Self::get_bft_connections_count))
.route("/connections/bft/all", get(Self::get_bft_connections_all)),
None => routes,
};
#[cfg(feature = "metrics")]
let routes = match self.consensus {
Some(_) => routes.route("/validators/participation", get(Self::get_validator_participation_scores)),
None => routes,
};
let routes = routes.route("/program/{id}/view/{function}", post(Self::evaluate_view_latest));
let routes = if self.history_compat.is_some() {
routes
.route("/program/{id}/mapping/{name}/{key}/history/{height}", get(Self::get_history_compat))
.route("/program/{id}/mapping/{name}/history/{height}", get(Self::get_history_batch_compat))
.route("/program/{id}/view/{function}/{height}", post(Self::evaluate_view_at_height_compat))
.route("/staking/rewards/{address}/{height}", get(Self::get_staking_reward_compat))
} else {
routes
};
#[cfg(feature = "history-staking-rewards")]
let routes = if self.history_compat.is_some() {
routes
} else {
routes.route("/staking/rewards/{address}/{height}", get(Self::get_staking_reward))
};
let trace_layer = TraceLayer::new_for_http()
.make_span_with(|request: &Request<_>| {
let addr = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.to_string())
.unwrap_or_else(|| "unknown".to_string());
tracing::info_span!(
"REST",
method = %request.method(),
uri = %request.uri().path(),
addr = %addr,
)
})
.on_request(|_request: &Request<_>, _span: &Span| {
info!("Received a request");
})
.on_response(|_response: &Response<_>, latency: Duration, _span: &Span| {
info!("Finished request in {:?}", latency);
});
routes
.with_state(self.clone())
.layer(DefaultBodyLimit::max(2 * 768 * 1024))
.layer(GovernorLayer {
config: governor_config.into(),
})
.layer(cors)
.layer(trace_layer)
}
async fn spawn_server(&mut self, rest_ip: SocketAddr, rest_rps: u32) -> Result<()> {
debug!("REST rate limit per IP - {rest_rps} RPS");
let default_router = axum::Router::new().nest(
&format!("/{}", N::SHORT_NAME),
self.build_routes(rest_rps).layer(middleware::map_response(v1_error_middleware)),
);
let v1_router = axum::Router::new().nest(
&format!("/{API_VERSION_V1}/{}", N::SHORT_NAME),
self.build_routes(rest_rps).layer(middleware::map_response(v1_error_middleware)),
);
let v2_router =
axum::Router::new().nest(&format!("/{API_VERSION_V2}/{}", N::SHORT_NAME), self.build_routes(rest_rps));
let router = default_router.merge(v1_router).merge(v2_router);
let rest_listener =
TcpListener::bind(rest_ip).await.with_context(|| "Failed to bind TCP port for REST endpoints")?;
let handle = tokio::spawn(async move {
axum::serve(rest_listener, router.into_make_service_with_connect_info::<SocketAddr>())
.await
.expect("couldn't start rest server");
});
self.handles.lock().push(handle);
Ok(())
}
}
async fn v1_error_middleware(response: Response) -> Response {
const V1_STATUS_CODE: StatusCode = StatusCode::INTERNAL_SERVER_ERROR;
if response.status().is_success() {
return response;
}
let fallback = || {
let mut response = Response::new(Body::from("Failed to convert error"));
*response.status_mut() = V1_STATUS_CODE;
response
};
let Ok(bytes) = axum::body::to_bytes(response.into_body(), usize::MAX).await else {
return fallback();
};
let Ok(json_err) = serde_json::from_slice::<SerializedRestError>(&bytes) else {
return fallback();
};
let mut message = json_err.message;
for next in json_err.chain.into_iter() {
message = format!("{message} — {next}");
}
let mut response = Response::new(Body::from(message));
*response.status_mut() = V1_STATUS_CODE;
response
}
pub fn fmt_id(id: impl ToString) -> String {
let id = id.to_string();
let mut formatted_id = id.chars().take(16).collect::<String>();
if id.chars().count() > 16 {
formatted_id.push_str("..");
}
formatted_id
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
middleware,
routing::get,
};
use tower::ServiceExt;
fn test_app() -> Router {
let build_routes = || {
Router::new()
.route("/not_found", get(|| async { Err::<(), RestError>(RestError::not_found(anyhow!("missing"))) }))
.route("/bad_request", get(|| async { Err::<(), RestError>(RestError::bad_request(anyhow!("bad"))) }))
.route(
"/service_unavailable",
get(|| async { Err::<(), RestError>(RestError::service_unavailable(anyhow!("gone"))) }),
)
};
let router_v1 = build_routes().route_layer(middleware::map_response(v1_error_middleware));
let router_v2 = Router::new().nest(&format!("/{API_VERSION_V2}"), build_routes());
router_v1.merge(router_v2)
}
#[tokio::test]
async fn v1_routes_force_internal_server_error() {
let app = test_app();
let res = app.clone().oneshot(Request::builder().uri("/not_found").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
let res =
app.clone().oneshot(Request::builder().uri("/bad_request").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
let res =
app.oneshot(Request::builder().uri("/service_unavailable").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn v2_routes_return_specific_errors() {
let app = test_app();
let res =
app.clone().oneshot(Request::builder().uri("/v2/not_found").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
let res =
app.clone().oneshot(Request::builder().uri("/v2/bad_request").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let res =
app.oneshot(Request::builder().uri("/v2/service_unavailable").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
}
}
#[cfg(test)]
mod route_tests {
use super::*;
use snarkos_node_bft_ledger_service::MockLedgerService;
use snarkos_node_network::ConnectionMode;
use snarkos_node_router::test_helpers::{TestRouter, client, sample_genesis_block};
use snarkvm::{
ledger::{committee::test_helpers::sample_committee, store::helpers::memory::ConsensusMemory},
prelude::MainnetV0,
utilities::TestRng,
};
use aleo_std::StorageMode;
use axum::body::to_bytes;
use tower::ServiceExt;
type CurrentNetwork = MainnetV0;
type CurrentRest = Rest<CurrentNetwork, ConsensusMemory<CurrentNetwork>, TestRouter<CurrentNetwork>>;
const TEST_RPS: u32 = 1_000;
async fn sample_rest() -> CurrentRest {
let rng = &mut TestRng::default();
let ledger = tokio::task::spawn_blocking(|| {
Ledger::<CurrentNetwork, ConsensusMemory<CurrentNetwork>>::load(
sample_genesis_block::<CurrentNetwork>(),
StorageMode::new_test(None),
)
})
.await
.expect("the ledger task panicked")
.expect("couldn't load the test ledger");
let ledger_service = Arc::new(MockLedgerService::new(sample_committee(rng)));
Rest {
cdn_sync: None,
consensus: None,
ledger,
routing: Arc::new(client(0, 10, rng).await),
handles: Default::default(),
block_sync: Arc::new(BlockSync::new(ledger_service, ConnectionMode::Router)),
num_verifying_deploys: Arc::new(Semaphore::new(1)),
num_verifying_executions: Arc::new(Semaphore::new(1)),
num_verifying_solutions: Arc::new(Semaphore::new(1)),
block_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(BLOCK_CACHE_SIZE).unwrap()))),
history_compat: None,
}
}
async fn get(rest: &CurrentRest, uri: &str) -> (StatusCode, String) {
request(rest, Method::GET, uri).await
}
async fn request(rest: &CurrentRest, method: Method, uri: &str) -> (StatusCode, String) {
let mut request = Request::builder().method(method).uri(uri).body(Body::empty()).unwrap();
request.extensions_mut().insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 4130))));
let response = rest.build_routes(TEST_RPS).oneshot(request).await.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, String::from_utf8(body.to_vec()).unwrap())
}
#[tokio::test]
async fn latest_block_hash_is_the_genesis_hash() {
let rest = sample_rest().await;
let (status, body) = get(&rest, "/block/hash/latest").await;
assert_eq!(status, StatusCode::OK);
let hash: <CurrentNetwork as Network>::BlockHash = serde_json::from_str(&body).unwrap();
assert_eq!(hash, sample_genesis_block::<CurrentNetwork>().hash());
}
mod history_compat {
use super::*;
use crate::history_compat::fixtures;
async fn spawn_upstream() -> String {
async fn snapshot(Path((_height, mapping)): Path<(u32, String)>) -> (StatusCode, &'static str) {
match mapping.as_str() {
"unbonding" => (StatusCode::OK, fixtures::UNBONDING),
"bonded" => (StatusCode::OK, fixtures::BONDED),
"stakingrewards" => (StatusCode::OK, fixtures::STAKING_REWARDS),
"metadata" => (StatusCode::OK, fixtures::METADATA),
_ => (StatusCode::INTERNAL_SERVER_ERROR, fixtures::MISSING),
}
}
let app =
axum::Router::new().route("/mainnet/block/{height}/history/{mapping}", axum::routing::get(snapshot));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{address}")
}
async fn sample_compat_rest() -> CurrentRest {
let mut rest = sample_rest().await;
rest.history_compat = Some(Arc::new(HistoryCompat::new(&spawn_upstream().await, "mainnet").unwrap()));
rest
}
const UNBONDING_STAKER: &str = "aleo1sdjqhlcm9qltpu74ek0vxewt52zsdmn6swmpjn6m0tp9xf57dvpq740r8j";
const BONDED_STAKER: &str = "aleo1qy4qufq03wcph05fdf5aj09ez67vcmmlrzqf0zza352qwaq43gyqt3wdf6";
const VALIDATOR: &str = "aleo1vfukg8ky2mhfprw63s0k0hl4vvd8573s6fkn8cv9y0ca6q27eq8qwdnxls";
#[tokio::test]
async fn history_serves_the_value_from_the_upstream_snapshot() {
let rest = sample_compat_rest().await;
let (status, body) =
get(&rest, &format!("/program/credits.aleo/mapping/unbonding/{UNBONDING_STAKER}/history/0")).await;
assert_eq!(status, StatusCode::OK, "{body}");
let value: Option<String> = serde_json::from_str(&body).unwrap();
assert_eq!(value.as_deref(), Some("{\n microcredits: 10113730488u64,\n height: 621255u32\n}"));
}
#[tokio::test]
async fn history_answers_null_for_an_absent_key() {
let rest = sample_compat_rest().await;
let (status, body) =
get(&rest, &format!("/program/credits.aleo/mapping/unbonding/{BONDED_STAKER}/history/0")).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body.trim(), "null");
}
#[tokio::test]
async fn history_is_served_regardless_of_the_node_height() {
let rest = sample_compat_rest().await;
let (status, body) =
get(&rest, &format!("/program/credits.aleo/mapping/unbonding/{UNBONDING_STAKER}/history/1000000"))
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let value: Option<String> = serde_json::from_str(&body).unwrap();
assert!(value.is_some());
let (status, body) = get(&rest, &format!("/staking/rewards/{BONDED_STAKER}/1000000")).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_ne!(body.trim(), "null");
}
#[tokio::test]
async fn history_batch_serves_every_key_from_one_snapshot() {
let rest = sample_compat_rest().await;
let (status, body) = get(
&rest,
&format!("/program/credits.aleo/mapping/unbonding/history/0?keys={UNBONDING_STAKER},{BONDED_STAKER}"),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let values: Vec<serde_json::Value> = serde_json::from_str(&body).unwrap();
assert_eq!(values.len(), 2);
assert_eq!(values[0]["key"], UNBONDING_STAKER);
assert_eq!(values[0]["value"], "{\n microcredits: 10113730488u64,\n height: 621255u32\n}");
assert_eq!(values[1]["key"], BONDED_STAKER);
assert_eq!(values[1]["value"], serde_json::Value::Null);
}
#[tokio::test]
async fn history_rejects_what_the_upstream_does_not_record() {
let rest = sample_compat_rest().await;
let (status, body) =
get(&rest, &format!("/program/credits.aleo/mapping/committee/{VALIDATOR}/history/0")).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert!(body.contains("credits.aleo/committee"), "{body}");
let (status, body) = get(&rest, "/program/other.aleo/mapping/bonded/1field/history/0").await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert!(body.contains("other.aleo/bonded"), "{body}");
let (status, body) =
get(&rest, &format!("/program/credits.aleo/mapping/withdraw/{VALIDATOR}/history/0")).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert!(body.contains("No snapshot of 'withdraw'"), "{body}");
let (status, body) = request(&rest, Method::POST, "/program/credits.aleo/view/anything/0").await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert!(body.contains("latest height"), "{body}");
}
#[tokio::test]
async fn staking_reward_joins_the_rewards_and_bonded_snapshots() {
let rest = sample_compat_rest().await;
let (status, body) = get(&rest, &format!("/staking/rewards/{BONDED_STAKER}/0")).await;
assert_eq!(status, StatusCode::OK, "{body}");
let reward: (String, u64, u64) = serde_json::from_str(&body).unwrap();
assert_eq!(reward, (VALIDATOR.to_string(), 6477, 141_347_021_440));
let (status, body) = get(&rest, &format!("/staking/rewards/{UNBONDING_STAKER}/0")).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body.trim(), "null");
}
#[tokio::test]
async fn history_routes_are_absent_without_compatibility_mode() {
let rest = sample_rest().await;
let (status, _) =
get(&rest, &format!("/program/credits.aleo/mapping/unbonding/{UNBONDING_STAKER}/history/0")).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
}
}