diff --git a/Cargo.lock b/Cargo.lock index df53c4e0242..5695f933442 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14434,10 +14434,11 @@ checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" [[package]] name = "native-tls" -version = "0.2.12" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" dependencies = [ + "lazy_static", "libc", "log", "openssl", diff --git a/rs/rosetta-api/icrc1/rosetta/CHANGELOG.md b/rs/rosetta-api/icrc1/rosetta/CHANGELOG.md index 07b9dde43d8..85e0bd30701 100644 --- a/rs/rosetta-api/icrc1/rosetta/CHANGELOG.md +++ b/rs/rosetta-api/icrc1/rosetta/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added - Added /ready endpoint which indicates whether Rosetta is finished with its initial block synch +- /call endpoint with the method 'query_block_range' to fetch multiple blocks at once ### Fixes - Changed default database path to match /data/db.sqlite diff --git a/rs/rosetta-api/icrc1/rosetta/client/src/lib.rs b/rs/rosetta-api/icrc1/rosetta/client/src/lib.rs index f81a76f15a0..7a65c688211 100644 --- a/rs/rosetta-api/icrc1/rosetta/client/src/lib.rs +++ b/rs/rosetta-api/icrc1/rosetta/client/src/lib.rs @@ -12,6 +12,7 @@ use num_bigint::BigInt; use reqwest::{Client, Url}; use rosetta_core::identifiers::*; use rosetta_core::models::RosettaSupportedKeyPair; +use rosetta_core::objects::ObjectMap; use rosetta_core::objects::Operation; use rosetta_core::objects::PublicKey; use rosetta_core::objects::Signature; @@ -693,4 +694,21 @@ impl RosettaClient { ) .await } + + pub async fn call( + &self, + network_identifier: NetworkIdentifier, + method_name: String, + parameters: ObjectMap, + ) -> Result { + self.call_endpoint( + "/call", + &CallRequest { + network_identifier, + method_name, + parameters, + }, + ) + .await + } } diff --git a/rs/rosetta-api/icrc1/rosetta/src/common/constants.rs b/rs/rosetta-api/icrc1/rosetta/src/common/constants.rs index 0eb6344a13b..2083cca1299 100644 --- a/rs/rosetta-api/icrc1/rosetta/src/common/constants.rs +++ b/rs/rosetta-api/icrc1/rosetta/src/common/constants.rs @@ -18,3 +18,4 @@ pub const FEE_COLLECTOR_OPERATION_IDENTIFIER: u64 = 8; pub const MAX_TRANSACTIONS_PER_SEARCH_TRANSACTIONS_REQUEST: u64 = 10000; pub const INGRESS_INTERVAL_OVERLAP: Duration = Duration::from_secs(120); pub const STATUS_COMPLETED: &str = "COMPLETED"; +pub const MAX_BLOCKS_PER_QUERY_BLOCK_RANGE_REQUEST: u64 = 10000; diff --git a/rs/rosetta-api/icrc1/rosetta/src/data_api/endpoints.rs b/rs/rosetta-api/icrc1/rosetta/src/data_api/endpoints.rs index 0675158c451..74f64c3dc35 100644 --- a/rs/rosetta-api/icrc1/rosetta/src/data_api/endpoints.rs +++ b/rs/rosetta-api/icrc1/rosetta/src/data_api/endpoints.rs @@ -127,3 +127,20 @@ pub async fn search_transactions( state.metadata.decimals, )?)) } + +pub async fn call( + State(state): State>, + Json(request): Json, +) -> Result> { + verify_network_id(&request.network_identifier, &state) + .map_err(|err| Error::invalid_network_id(&format!("{:?}", err)))?; + Ok(Json(services::call( + &state.storage, + &request.method_name, + request.parameters, + rosetta_core::objects::Currency::new( + state.metadata.symbol.clone(), + state.metadata.decimals.into(), + ), + )?)) +} diff --git a/rs/rosetta-api/icrc1/rosetta/src/data_api/mod.rs b/rs/rosetta-api/icrc1/rosetta/src/data_api/mod.rs index a6148eb205a..032f34d7b65 100644 --- a/rs/rosetta-api/icrc1/rosetta/src/data_api/mod.rs +++ b/rs/rosetta-api/icrc1/rosetta/src/data_api/mod.rs @@ -1,2 +1,3 @@ pub mod endpoints; pub mod services; +pub mod types; diff --git a/rs/rosetta-api/icrc1/rosetta/src/data_api/services.rs b/rs/rosetta-api/icrc1/rosetta/src/data_api/services.rs index fb005ea8bd0..fe5c96923f7 100644 --- a/rs/rosetta-api/icrc1/rosetta/src/data_api/services.rs +++ b/rs/rosetta-api/icrc1/rosetta/src/data_api/services.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::sync::Mutex; use crate::common::constants::DEFAULT_BLOCKCHAIN; +use crate::common::constants::MAX_BLOCKS_PER_QUERY_BLOCK_RANGE_REQUEST; use crate::common::constants::MAX_TRANSACTIONS_PER_SEARCH_TRANSACTIONS_REQUEST; use crate::common::constants::STATUS_COMPLETED; use crate::common::types::OperationType; @@ -15,6 +16,8 @@ use crate::common::{ icrc1_rosetta_block_to_rosetta_core_transaction, }, }; +use crate::data_api::types::QueryBlockRangeRequest; +use crate::data_api::types::QueryBlockRangeResponse; use candid::Nat; use candid::Principal; use ic_ledger_core::tokens::Zero; @@ -460,6 +463,60 @@ pub fn initial_sync_is_completed( } } +pub fn call( + storage_client: &StorageClient, + method_name: &str, + parameters: ObjectMap, + currency: Currency, +) -> Result { + match method_name { + "query_block_range" => { + let query_block_range = QueryBlockRangeRequest::try_from(parameters) + .map_err(|err| Error::parsing_unsuccessful(&err))?; + let mut blocks = vec![]; + if query_block_range.number_of_blocks > 0 { + let highest_index = query_block_range.highest_block_index; + let lowest_index = query_block_range.highest_block_index.saturating_sub( + std::cmp::min( + query_block_range.number_of_blocks, + MAX_BLOCKS_PER_QUERY_BLOCK_RANGE_REQUEST, + ) + .saturating_sub(1), + ); + blocks.extend( + storage_client + .get_blocks_by_index_range(lowest_index, highest_index) + .map_err(|err| Error::unable_to_find_block(&err))? + .into_iter() + .map(|block| { + icrc1_rosetta_block_to_rosetta_core_block(block, currency.clone()) + }) + .collect::>>() + .map_err(|err| Error::parsing_unsuccessful(&err))?, + ) + }; + let idempotent = match blocks.last() { + // If the last block in the database has the same index as the highest block index in the query we return true + Some(last_block) => { + last_block.block_identifier.index == query_block_range.highest_block_index + } + // If the database is empty or the requested block range does not exist we return false + None => false, + }; + let block_range_response = QueryBlockRangeResponse { blocks }; + Ok(CallResponse::new( + ObjectMap::try_from(block_range_response) + .map_err(|err| Error::parsing_unsuccessful(&err))?, + idempotent, + )) + } + _ => Err(Error::processing_construction_failed(&format!( + "Method {} not supported", + method_name + ))), + } +} + #[cfg(test)] mod test { use super::*; @@ -1186,4 +1243,179 @@ mod test { ); assert!(block_res.is_err()); } + + #[test] + fn test_call_query_blocks() { + let mut runner = TestRunner::new(TestRunnerConfig { + max_shrink_iters: 0, + cases: 1, + ..Default::default() + }); + + runner + .run( + &(valid_blockchain_strategy::(BLOCKCHAIN_LENGTH * 25).no_shrink()), + |blockchain| { + let storage_client_memory = StorageClient::new_in_memory().unwrap(); + let mut rosetta_blocks = vec![]; + + let currency = Currency::new("ICP".to_string(), 8); + + // Call on an empty database + let response: QueryBlockRangeResponse = call( + &storage_client_memory, + "query_block_range", + ObjectMap::try_from(QueryBlockRangeRequest { + highest_block_index: 100, + number_of_blocks: 10, + }) + .unwrap(), + currency.clone(), + ) + .unwrap() + .result + .try_into() + .unwrap(); + assert!(response.blocks.is_empty()); + + for (index, block) in blockchain.clone().into_iter().enumerate() { + rosetta_blocks.push( + RosettaBlock::from_generic_block( + encoded_block_to_generic_block(&block.encode()), + index as u64, + ) + .unwrap(), + ); + } + + storage_client_memory + .store_blocks(rosetta_blocks.clone()) + .unwrap(); + let highest_block_index = rosetta_blocks.len().saturating_sub(1) as u64; + // Call with 0 numbers of blocks + let response: QueryBlockRangeResponse = call( + &storage_client_memory, + "query_block_range", + ObjectMap::try_from(QueryBlockRangeRequest { + highest_block_index, + number_of_blocks: 0, + }) + .unwrap(), + currency.clone(), + ) + .unwrap() + .result + .try_into() + .unwrap(); + assert!(response.blocks.is_empty()); + + // Call with higher index than there are blocks in the database + let response = call( + &storage_client_memory, + "query_block_range", + ObjectMap::try_from(QueryBlockRangeRequest { + highest_block_index: (rosetta_blocks.len() * 2) as u64, + number_of_blocks: std::cmp::max( + rosetta_blocks.len() as u64, + MAX_BLOCKS_PER_QUERY_BLOCK_RANGE_REQUEST, + ), + }) + .unwrap(), + currency.clone(), + ) + .unwrap(); + let query_block_response: QueryBlockRangeResponse = + response.result.try_into().unwrap(); + // If the blocks measured from the highest block index asked for are not in the database the service should return an empty array of blocks + if rosetta_blocks.len() >= MAX_BLOCKS_PER_QUERY_BLOCK_RANGE_REQUEST as usize { + assert_eq!(query_block_response.blocks.len(), 0); + assert!(!response.idempotent); + } + // If some of the blocks measured from the highest block index asked for are in the database the service should return the blocks that are in the database + else { + if rosetta_blocks.len() * 2 + > MAX_BLOCKS_PER_QUERY_BLOCK_RANGE_REQUEST as usize + { + assert_eq!( + query_block_response.blocks.len(), + rosetta_blocks + .len() + .saturating_sub((rosetta_blocks.len() * 2).saturating_sub( + MAX_BLOCKS_PER_QUERY_BLOCK_RANGE_REQUEST as usize + )) + .saturating_sub(1) + ); + } else { + assert_eq!(query_block_response.blocks.len(), rosetta_blocks.len()); + } + assert!(!response.idempotent); + } + + let number_of_blocks = (rosetta_blocks.len() / 2) as u64; + let query_blocks_request = QueryBlockRangeRequest { + highest_block_index, + number_of_blocks, + }; + + let query_blocks_response = call( + &storage_client_memory, + "query_block_range", + ObjectMap::try_from(query_blocks_request).unwrap(), + currency.clone(), + ) + .unwrap(); + + assert!(query_blocks_response.idempotent); + let response: QueryBlockRangeResponse = + query_blocks_response.result.try_into().unwrap(); + let querried_blocks = response.blocks; + assert_eq!( + querried_blocks.len(), + std::cmp::min(number_of_blocks, MAX_BLOCKS_PER_QUERY_BLOCK_RANGE_REQUEST) + as usize + ); + if !querried_blocks.is_empty() { + assert_eq!( + querried_blocks.first().unwrap().block_identifier.index, + highest_block_index + .saturating_sub(std::cmp::min( + number_of_blocks, + MAX_BLOCKS_PER_QUERY_BLOCK_RANGE_REQUEST + )) + .saturating_add(1) + ); + assert_eq!( + querried_blocks.last().unwrap().block_identifier.index, + highest_block_index + ); + } + + let query_blocks_request = QueryBlockRangeRequest { + highest_block_index, + number_of_blocks: MAX_BLOCKS_PER_QUERY_BLOCK_RANGE_REQUEST + 1, + }; + + let query_blocks_response: QueryBlockRangeResponse = call( + &storage_client_memory, + "query_block_range", + ObjectMap::try_from(query_blocks_request).unwrap(), + currency.clone(), + ) + .unwrap() + .result + .try_into() + .unwrap(); + assert_eq!( + query_blocks_response.blocks.len(), + std::cmp::min( + MAX_BLOCKS_PER_QUERY_BLOCK_RANGE_REQUEST as usize, + rosetta_blocks.len() + ) + ); + + Ok(()) + }, + ) + .unwrap(); + } } diff --git a/rs/rosetta-api/icrc1/rosetta/src/data_api/types.rs b/rs/rosetta-api/icrc1/rosetta/src/data_api/types.rs new file mode 100644 index 00000000000..a656a7d9568 --- /dev/null +++ b/rs/rosetta-api/icrc1/rosetta/src/data_api/types.rs @@ -0,0 +1,62 @@ +use rosetta_core::objects::ObjectMap; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct QueryBlockRangeRequest { + pub highest_block_index: u64, + pub number_of_blocks: u64, +} + +impl TryFrom for ObjectMap { + type Error = anyhow::Error; + fn try_from(d: QueryBlockRangeRequest) -> Result { + match serde_json::to_value(d) { + Ok(v) => match v { + serde_json::Value::Object(ob) => Ok(ob), + _ => anyhow::bail!("Could not convert QueryBlockRangeRequest to ObjectMap. Expected type Object but received: {:?}",v) + },Err(err) => anyhow::bail!("Could not convert QueryBlockRangeRequest to ObjectMap: {:?}",err), + } + } +} + +impl TryFrom for QueryBlockRangeRequest { + type Error = String; + fn try_from(o: ObjectMap) -> Result { + serde_json::from_value(serde_json::Value::Object(o)).map_err(|e| { + format!( + "Could not parse QueryBlockRangeRequest from JSON object: {}", + e + ) + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct QueryBlockRangeResponse { + pub blocks: Vec, +} + +impl TryFrom for ObjectMap { + type Error = anyhow::Error; + fn try_from(d: QueryBlockRangeResponse) -> Result { + match serde_json::to_value(d) { + Ok(v) => match v { + serde_json::Value::Object(ob) => Ok(ob), + _ => anyhow::bail!("Could not convert QueryBlockRangeResponse to ObjectMap. Expected type Object but received: {:?}",v) + },Err(err) => anyhow::bail!("Could not convert QueryBlockRangeResponse to ObjectMap: {:?}",err), + } + } +} + +impl TryFrom for QueryBlockRangeResponse { + type Error = String; + fn try_from(o: ObjectMap) -> Result { + serde_json::from_value(serde_json::Value::Object(o)).map_err(|e| { + format!( + "Could not parse QueryBlockRangeResponse from JSON object: {}", + e + ) + }) + } +} diff --git a/rs/rosetta-api/icrc1/rosetta/src/main.rs b/rs/rosetta-api/icrc1/rosetta/src/main.rs index b03c5901a0a..a94a9df181a 100644 --- a/rs/rosetta-api/icrc1/rosetta/src/main.rs +++ b/rs/rosetta-api/icrc1/rosetta/src/main.rs @@ -346,6 +346,7 @@ async fn main() -> Result<()> { let app = Router::new() .route("/ready", get(ready)) .route("/health", get(health)) + .route("/call", post(call)) .route("/network/list", post(network_list)) .route("/network/options", post(network_options)) .route("/network/status", post(network_status)) diff --git a/rs/rosetta-api/icrc1/rosetta/tests/system_tests.rs b/rs/rosetta-api/icrc1/rosetta/tests/system_tests.rs index 043e0231bce..385579cf645 100644 --- a/rs/rosetta-api/icrc1/rosetta/tests/system_tests.rs +++ b/rs/rosetta-api/icrc1/rosetta/tests/system_tests.rs @@ -23,6 +23,7 @@ use ic_icrc_rosetta::common::utils::utils::{ icrc1_operation_to_rosetta_core_operations, icrc1_rosetta_block_to_rosetta_core_block, }; use ic_icrc_rosetta::construction_api::types::ConstructionMetadataRequestOptions; +use ic_icrc_rosetta::data_api::types::{QueryBlockRangeRequest, QueryBlockRangeResponse}; use ic_icrc_rosetta_client::RosettaClient; use ic_icrc_rosetta_runner::RosettaClientArgsBuilder; use ic_icrc_rosetta_runner::{make_transaction_with_rosetta_client_binary, DEFAULT_TOKEN_SYMBOL}; @@ -1642,3 +1643,72 @@ fn test_cli_construction() { ) .unwrap(); } + +#[test] +fn test_query_blocks_range() { + let mut runner = TestRunner::new(TestRunnerConfig { + max_shrink_iters: 0, + cases: *NUM_TEST_CASES, + ..Default::default() + }); + + runner + .run( + &(valid_transactions_strategy( + (*MINTING_IDENTITY).clone(), + DEFAULT_TRANSFER_FEE, + 50, + SystemTime::now(), + ) + .no_shrink()), + |args_with_caller| { + let rt = Runtime::new().unwrap(); + let setup = Setup::builder().build(); + + rt.block_on(async { + let env = RosettaTestingEnvironmentBuilder::new(&setup) + .with_args_with_caller(args_with_caller.clone()) + .build() + .await; + wait_for_rosetta_block(&env.rosetta_client, env.network_identifier.clone(), 0) + .await; + + if !args_with_caller.is_empty() { + let rosetta_blocks = get_rosetta_blocks_from_icrc1_ledger( + env.icrc1_agent, + 0, + *MAX_BLOCKS_PER_REQUEST, + ) + .await; + + let highest_block_index = rosetta_blocks.last().unwrap().index; + let num_blocks = rosetta_blocks.len(); + let query_blocks_request = QueryBlockRangeRequest { + highest_block_index, + number_of_blocks: num_blocks as u64, + }; + let query_block_range_response: QueryBlockRangeResponse = env + .rosetta_client + .call( + env.network_identifier.clone(), + "query_block_range".to_owned(), + query_blocks_request.try_into().unwrap(), + ) + .await + .unwrap() + .result + .try_into() + .unwrap(); + assert!(query_block_range_response.blocks.len() == num_blocks); + assert!(query_block_range_response + .blocks + .iter() + .all(|block| block.block_identifier.index <= highest_block_index)); + } + }); + + Ok(()) + }, + ) + .unwrap() +} diff --git a/rs/rosetta-api/rosetta_core/src/request_types.rs b/rs/rosetta-api/rosetta_core/src/request_types.rs index 19bc8deb1d6..6cfe866556d 100644 --- a/rs/rosetta-api/rosetta_core/src/request_types.rs +++ b/rs/rosetta-api/rosetta_core/src/request_types.rs @@ -398,3 +398,34 @@ impl SearchTransactionsRequest { } } } + +/// CallRequest is the input to the `/call` +/// endpoint. It contains the method name the user wants to call and some parameters specific for the method call. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "conversion", derive(LabelledGeneric))] +pub struct CallRequest { + #[serde(rename = "network_identifier")] + pub network_identifier: NetworkIdentifier, + + /// Method is some network-specific procedure call. This method could map to a network-specific RPC endpoint, a method in an SDK generated from a smart contract, or some hybrid of the two. The implementation must define all available methods in the Allow object. However, it is up to the caller to determine which parameters to provide when invoking /call. + #[serde(rename = "method_name")] + pub method_name: String, + + /// Parameters is some network-specific argument for a method. It is up to the caller to determine which parameters to provide when invoking /call. + #[serde(rename = "parameters")] + pub parameters: ObjectMap, +} + +impl CallRequest { + pub fn new( + network_identifier: NetworkIdentifier, + method_name: String, + parameters: ObjectMap, + ) -> CallRequest { + CallRequest { + network_identifier, + method_name, + parameters, + } + } +} diff --git a/rs/rosetta-api/rosetta_core/src/response_types.rs b/rs/rosetta-api/rosetta_core/src/response_types.rs index 7437a53beda..158910931ca 100644 --- a/rs/rosetta-api/rosetta_core/src/response_types.rs +++ b/rs/rosetta-api/rosetta_core/src/response_types.rs @@ -368,3 +368,20 @@ pub struct SearchTransactionsResponse { #[serde(skip_serializing_if = "Option::is_none")] pub next_offset: Option, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "conversion", derive(LabelledGeneric))] +pub struct CallResponse { + /// Result contains the result of the `/call` invocation. This result will not be inspected or interpreted by Rosetta tooling and is left to the caller to decode. + #[serde(rename = "result")] + pub result: ObjectMap, + + /// Idempotent indicates that if `/call` is invoked with the same CallRequest again, at any point in time, it will return the same CallResponse. Integrators may cache the CallResponse if this is set to true to avoid making unnecessary calls to the Rosetta implementation. For this reason, implementers should be very conservative about returning true here or they could cause issues for the caller. + pub idempotent: bool, +} + +impl CallResponse { + pub fn new(result: ObjectMap, idempotent: bool) -> CallResponse { + CallResponse { result, idempotent } + } +} diff --git a/rs/rosetta-api/src/models.rs b/rs/rosetta-api/src/models.rs index 5e8fe73cbf4..0a7d1d7cd51 100644 --- a/rs/rosetta-api/src/models.rs +++ b/rs/rosetta-api/src/models.rs @@ -24,48 +24,6 @@ pub struct ConstructionHashResponse { pub metadata: ObjectMap, } -/// CallRequest is the input to the `/call` -/// endpoint. It contains the method name the user wants to call and some parameters specific for the method call. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "conversion", derive(LabelledGeneric))] -pub struct CallRequest { - #[serde(rename = "network_identifier")] - pub network_identifier: NetworkIdentifier, - - #[serde(rename = "method_name")] - pub method_name: String, - - #[serde(rename = "parameters")] - pub parameters: ObjectMap, -} - -impl CallRequest { - pub fn new( - network_identifier: NetworkIdentifier, - method_name: String, - parameters: ObjectMap, - ) -> CallRequest { - CallRequest { - network_identifier, - method_name, - parameters, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "conversion", derive(LabelledGeneric))] -pub struct CallResponse { - #[serde(rename = "result")] - pub result: ObjectMap, -} - -impl CallResponse { - pub fn new(result: ObjectMap) -> CallResponse { - CallResponse { result } - } -} - /// The type (encoded as CBOR) returned by /construction/combine, containing the /// IC calls to submit the transaction and to check the result. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] diff --git a/rs/rosetta-api/src/request_handler.rs b/rs/rosetta-api/src/request_handler.rs index 716eb9282e0..037df0359cc 100644 --- a/rs/rosetta-api/src/request_handler.rs +++ b/rs/rosetta-api/src/request_handler.rs @@ -177,23 +177,26 @@ impl RosettaRequestHandler { .proposal_info(get_proposal_info_object.proposal_id) .await?; let proposal_info_response = ProposalInfoResponse::from(proposal_info); - Ok(CallResponse::new(ObjectMap::try_from( - proposal_info_response, - )?)) + Ok(CallResponse::new( + ObjectMap::try_from(proposal_info_response)?, + true, + )) } "get_pending_proposals" => { let pending_proposals = self.ledger.pending_proposals().await?; let pending_proposals_response = PendingProposalsResponse::from(pending_proposals); - Ok(CallResponse::new(ObjectMap::try_from( - pending_proposals_response, - )?)) + Ok(CallResponse::new( + ObjectMap::try_from(pending_proposals_response)?, + false, + )) } "list_known_neurons" => { let known_neurons = self.ledger.list_known_neurons().await?; let list_known_neurons_response = ListKnownNeuronsResponse { known_neurons }; - Ok(CallResponse::new(ObjectMap::try_from( - list_known_neurons_response, - )?)) + Ok(CallResponse::new( + ObjectMap::try_from(list_known_neurons_response)?, + false, + )) } _ => Err(ApiError::InvalidRequest( false,