mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-08 20:29:23 +00:00
Rename the coins folder to networks (#583)
* Rename the coins folder to networks Ethereum isn't a coin. It's a network. Resolves #357. * More renames of coins -> networks in orchestration * Correct paths in tests/ * cargo fmt
This commit is contained in:
31
networks/monero/rpc/simple-request/Cargo.toml
Normal file
31
networks/monero/rpc/simple-request/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "monero-simple-request-rpc"
|
||||
version = "0.1.0"
|
||||
description = "RPC connection to a Monero daemon via simple-request, built around monero-serai"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/serai-dex/serai/tree/develop/networks/monero/rpc/simple-request"
|
||||
authors = ["Luke Parker <lukeparker5132@gmail.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.79"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { version = "0.1", default-features = false }
|
||||
|
||||
hex = { version = "0.4", default-features = false, features = ["alloc"] }
|
||||
digest_auth = { version = "0.3", default-features = false }
|
||||
simple-request = { path = "../../../../common/request", version = "0.1", default-features = false, features = ["tls"] }
|
||||
tokio = { version = "1", default-features = false }
|
||||
|
||||
monero-rpc = { path = "..", default-features = false, features = ["std"] }
|
||||
|
||||
[dev-dependencies]
|
||||
monero-address = { path = "../../wallet/address", default-features = false, features = ["std"] }
|
||||
|
||||
tokio = { version = "1", default-features = false, features = ["macros"] }
|
||||
21
networks/monero/rpc/simple-request/LICENSE
Normal file
21
networks/monero/rpc/simple-request/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022-2024 Luke Parker
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
3
networks/monero/rpc/simple-request/README.md
Normal file
3
networks/monero/rpc/simple-request/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Monero simple-request RPC
|
||||
|
||||
RPC connection to a Monero daemon via simple-request, built around monero-serai.
|
||||
290
networks/monero/rpc/simple-request/src/lib.rs
Normal file
290
networks/monero/rpc/simple-request/src/lib.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
|
||||
#![doc = include_str!("../README.md")]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use std::{sync::Arc, io::Read, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use digest_auth::{WwwAuthenticateHeader, AuthContext};
|
||||
use simple_request::{
|
||||
hyper::{StatusCode, header::HeaderValue, Request},
|
||||
Response, Client,
|
||||
};
|
||||
|
||||
use monero_rpc::{RpcError, Rpc};
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Authentication {
|
||||
// If unauthenticated, use a single client
|
||||
Unauthenticated(Client),
|
||||
// If authenticated, use a single client which supports being locked and tracks its nonce
|
||||
// This ensures that if a nonce is requested, another caller doesn't make a request invalidating
|
||||
// it
|
||||
Authenticated {
|
||||
username: String,
|
||||
password: String,
|
||||
#[allow(clippy::type_complexity)]
|
||||
connection: Arc<Mutex<(Option<(WwwAuthenticateHeader, u64)>, Client)>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// An HTTP(S) transport for the RPC.
|
||||
///
|
||||
/// Requires tokio.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SimpleRequestRpc {
|
||||
authentication: Authentication,
|
||||
url: String,
|
||||
request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl SimpleRequestRpc {
|
||||
fn digest_auth_challenge(
|
||||
response: &Response,
|
||||
) -> Result<Option<(WwwAuthenticateHeader, u64)>, RpcError> {
|
||||
Ok(if let Some(header) = response.headers().get("www-authenticate") {
|
||||
Some((
|
||||
digest_auth::parse(header.to_str().map_err(|_| {
|
||||
RpcError::InvalidNode("www-authenticate header wasn't a string".to_string())
|
||||
})?)
|
||||
.map_err(|_| RpcError::InvalidNode("invalid digest-auth response".to_string()))?,
|
||||
0,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new HTTP(S) RPC connection.
|
||||
///
|
||||
/// A daemon requiring authentication can be used via including the username and password in the
|
||||
/// URL.
|
||||
pub async fn new(url: String) -> Result<SimpleRequestRpc, RpcError> {
|
||||
Self::with_custom_timeout(url, DEFAULT_TIMEOUT).await
|
||||
}
|
||||
|
||||
/// Create a new HTTP(S) RPC connection with a custom timeout.
|
||||
///
|
||||
/// A daemon requiring authentication can be used via including the username and password in the
|
||||
/// URL.
|
||||
pub async fn with_custom_timeout(
|
||||
mut url: String,
|
||||
request_timeout: Duration,
|
||||
) -> Result<SimpleRequestRpc, RpcError> {
|
||||
let authentication = if url.contains('@') {
|
||||
// Parse out the username and password
|
||||
let url_clone = url;
|
||||
let split_url = url_clone.split('@').collect::<Vec<_>>();
|
||||
if split_url.len() != 2 {
|
||||
Err(RpcError::ConnectionError("invalid amount of login specifications".to_string()))?;
|
||||
}
|
||||
let mut userpass = split_url[0];
|
||||
url = split_url[1].to_string();
|
||||
|
||||
// If there was additionally a protocol string, restore that to the daemon URL
|
||||
if userpass.contains("://") {
|
||||
let split_userpass = userpass.split("://").collect::<Vec<_>>();
|
||||
if split_userpass.len() != 2 {
|
||||
Err(RpcError::ConnectionError("invalid amount of protocol specifications".to_string()))?;
|
||||
}
|
||||
url = split_userpass[0].to_string() + "://" + &url;
|
||||
userpass = split_userpass[1];
|
||||
}
|
||||
|
||||
let split_userpass = userpass.split(':').collect::<Vec<_>>();
|
||||
if split_userpass.len() > 2 {
|
||||
Err(RpcError::ConnectionError("invalid amount of passwords".to_string()))?;
|
||||
}
|
||||
|
||||
let client = Client::without_connection_pool(&url)
|
||||
.map_err(|_| RpcError::ConnectionError("invalid URL".to_string()))?;
|
||||
// Obtain the initial challenge, which also somewhat validates this connection
|
||||
let challenge = Self::digest_auth_challenge(
|
||||
&client
|
||||
.request(
|
||||
Request::post(url.clone())
|
||||
.body(vec![].into())
|
||||
.map_err(|e| RpcError::ConnectionError(format!("couldn't make request: {e:?}")))?,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?,
|
||||
)?;
|
||||
Authentication::Authenticated {
|
||||
username: split_userpass[0].to_string(),
|
||||
password: (*split_userpass.get(1).unwrap_or(&"")).to_string(),
|
||||
connection: Arc::new(Mutex::new((challenge, client))),
|
||||
}
|
||||
} else {
|
||||
Authentication::Unauthenticated(Client::with_connection_pool())
|
||||
};
|
||||
|
||||
Ok(SimpleRequestRpc { authentication, url, request_timeout })
|
||||
}
|
||||
}
|
||||
|
||||
impl SimpleRequestRpc {
|
||||
async fn inner_post(&self, route: &str, body: Vec<u8>) -> Result<Vec<u8>, RpcError> {
|
||||
let request_fn = |uri| {
|
||||
Request::post(uri)
|
||||
.body(body.clone().into())
|
||||
.map_err(|e| RpcError::ConnectionError(format!("couldn't make request: {e:?}")))
|
||||
};
|
||||
|
||||
async fn body_from_response(response: Response<'_>) -> Result<Vec<u8>, RpcError> {
|
||||
/*
|
||||
let length = usize::try_from(
|
||||
response
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.ok_or(RpcError::InvalidNode("no content-length header"))?
|
||||
.to_str()
|
||||
.map_err(|_| RpcError::InvalidNode("non-ascii content-length value"))?
|
||||
.parse::<u32>()
|
||||
.map_err(|_| RpcError::InvalidNode("non-u32 content-length value"))?,
|
||||
)
|
||||
.unwrap();
|
||||
// Only pre-allocate 1 MB so a malicious node which claims a content-length of 1 GB actually
|
||||
// has to send 1 GB of data to cause a 1 GB allocation
|
||||
let mut res = Vec::with_capacity(length.max(1024 * 1024));
|
||||
let mut body = response.into_body();
|
||||
while res.len() < length {
|
||||
let Some(data) = body.data().await else { break };
|
||||
res.extend(data.map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?.as_ref());
|
||||
}
|
||||
*/
|
||||
|
||||
let mut res = Vec::with_capacity(128);
|
||||
response
|
||||
.body()
|
||||
.await
|
||||
.map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?
|
||||
.read_to_end(&mut res)
|
||||
.unwrap();
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
for attempt in 0 .. 2 {
|
||||
return Ok(match &self.authentication {
|
||||
Authentication::Unauthenticated(client) => {
|
||||
body_from_response(
|
||||
client
|
||||
.request(request_fn(self.url.clone() + "/" + route)?)
|
||||
.await
|
||||
.map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Authentication::Authenticated { username, password, connection } => {
|
||||
let mut connection_lock = connection.lock().await;
|
||||
|
||||
let mut request = request_fn("/".to_string() + route)?;
|
||||
|
||||
// If we don't have an auth challenge, obtain one
|
||||
if connection_lock.0.is_none() {
|
||||
connection_lock.0 = Self::digest_auth_challenge(
|
||||
&connection_lock
|
||||
.1
|
||||
.request(request)
|
||||
.await
|
||||
.map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?,
|
||||
)?;
|
||||
request = request_fn("/".to_string() + route)?;
|
||||
}
|
||||
|
||||
// Insert the challenge response, if we have a challenge
|
||||
if let Some((challenge, cnonce)) = connection_lock.0.as_mut() {
|
||||
// Update the cnonce
|
||||
// Overflow isn't a concern as this is a u64
|
||||
*cnonce += 1;
|
||||
|
||||
let mut context = AuthContext::new_post::<_, _, _, &[u8]>(
|
||||
username,
|
||||
password,
|
||||
"/".to_string() + route,
|
||||
None,
|
||||
);
|
||||
context.set_custom_cnonce(hex::encode(cnonce.to_le_bytes()));
|
||||
|
||||
request.headers_mut().insert(
|
||||
"Authorization",
|
||||
HeaderValue::from_str(
|
||||
&challenge
|
||||
.respond(&context)
|
||||
.map_err(|_| {
|
||||
RpcError::InvalidNode("couldn't respond to digest-auth challenge".to_string())
|
||||
})?
|
||||
.to_header_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let response = connection_lock
|
||||
.1
|
||||
.request(request)
|
||||
.await
|
||||
.map_err(|e| RpcError::ConnectionError(format!("{e:?}")));
|
||||
|
||||
let (error, is_stale) = match &response {
|
||||
Err(e) => (Some(e.clone()), false),
|
||||
Ok(response) => (
|
||||
None,
|
||||
if response.status() == StatusCode::UNAUTHORIZED {
|
||||
if let Some(header) = response.headers().get("www-authenticate") {
|
||||
header
|
||||
.to_str()
|
||||
.map_err(|_| {
|
||||
RpcError::InvalidNode("www-authenticate header wasn't a string".to_string())
|
||||
})?
|
||||
.contains("stale")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
// If the connection entered an error state, drop the cached challenge as challenges are
|
||||
// per-connection
|
||||
// We don't need to create a new connection as simple-request will for us
|
||||
if error.is_some() || is_stale {
|
||||
connection_lock.0 = None;
|
||||
// If we're not already on our second attempt, move to the next loop iteration
|
||||
// (retrying all of this once)
|
||||
if attempt == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = error {
|
||||
Err(e)?
|
||||
} else {
|
||||
debug_assert!(is_stale);
|
||||
Err(RpcError::InvalidNode(
|
||||
"node claimed fresh connection had stale authentication".to_string(),
|
||||
))?
|
||||
}
|
||||
} else {
|
||||
body_from_response(response.unwrap()).await?
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Rpc for SimpleRequestRpc {
|
||||
async fn post(&self, route: &str, body: Vec<u8>) -> Result<Vec<u8>, RpcError> {
|
||||
tokio::time::timeout(self.request_timeout, self.inner_post(route, body))
|
||||
.await
|
||||
.map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?
|
||||
}
|
||||
}
|
||||
144
networks/monero/rpc/simple-request/tests/tests.rs
Normal file
144
networks/monero/rpc/simple-request/tests/tests.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use monero_address::{Network, MoneroAddress};
|
||||
|
||||
// monero-rpc doesn't include a transport
|
||||
// We can't include the simple-request crate there as then we'd have a cyclical dependency
|
||||
// Accordingly, we test monero-rpc here (implicitly testing the simple-request transport)
|
||||
use monero_simple_request_rpc::*;
|
||||
|
||||
static SEQUENTIAL: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
const ADDRESS: &str =
|
||||
"4B33mFPMq6mKi7Eiyd5XuyKRVMGVZz1Rqb9ZTyGApXW5d1aT7UBDZ89ewmnWFkzJ5wPd2SFbn313vCT8a4E2Qf4KQH4pNey";
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rpc() {
|
||||
use monero_rpc::Rpc;
|
||||
|
||||
let guard = SEQUENTIAL.get_or_init(|| Mutex::new(())).lock().await;
|
||||
|
||||
let rpc =
|
||||
SimpleRequestRpc::new("http://serai:seraidex@127.0.0.1:18081".to_string()).await.unwrap();
|
||||
|
||||
{
|
||||
// Test get_height
|
||||
let height = rpc.get_height().await.unwrap();
|
||||
// The height should be the amount of blocks on chain
|
||||
// The number of a block should be its zero-indexed position
|
||||
// Accordingly, there should be no block whose number is the height
|
||||
assert!(rpc.get_block_by_number(height).await.is_err());
|
||||
let block_number = height - 1;
|
||||
// There should be a block just prior
|
||||
let block = rpc.get_block_by_number(block_number).await.unwrap();
|
||||
|
||||
// Also test the block RPC routes are consistent
|
||||
assert_eq!(block.number().unwrap(), block_number);
|
||||
assert_eq!(rpc.get_block(block.hash()).await.unwrap(), block);
|
||||
assert_eq!(rpc.get_block_hash(block_number).await.unwrap(), block.hash());
|
||||
|
||||
// And finally the hardfork version route
|
||||
assert_eq!(rpc.get_hardfork_version().await.unwrap(), block.header.hardfork_version);
|
||||
}
|
||||
|
||||
// Test generate_blocks
|
||||
for amount_of_blocks in [1, 5] {
|
||||
let (blocks, number) = rpc
|
||||
.generate_blocks(
|
||||
&MoneroAddress::from_str(Network::Mainnet, ADDRESS).unwrap(),
|
||||
amount_of_blocks,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let height = rpc.get_height().await.unwrap();
|
||||
assert_eq!(number, height - 1);
|
||||
|
||||
let mut actual_blocks = Vec::with_capacity(amount_of_blocks);
|
||||
for i in (height - amount_of_blocks) .. height {
|
||||
actual_blocks.push(rpc.get_block_by_number(i).await.unwrap().hash());
|
||||
}
|
||||
assert_eq!(blocks, actual_blocks);
|
||||
}
|
||||
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_decoy_rpc() {
|
||||
use monero_rpc::{Rpc, DecoyRpc};
|
||||
|
||||
let guard = SEQUENTIAL.get_or_init(|| Mutex::new(())).lock().await;
|
||||
|
||||
let rpc =
|
||||
SimpleRequestRpc::new("http://serai:seraidex@127.0.0.1:18081".to_string()).await.unwrap();
|
||||
|
||||
// Ensure there's blocks on-chain
|
||||
rpc
|
||||
.generate_blocks(&MoneroAddress::from_str(Network::Mainnet, ADDRESS).unwrap(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Test get_output_distribution
|
||||
// Our documentation for our Rust fn defines it as taking two block numbers
|
||||
{
|
||||
let distribution_len = rpc.get_output_distribution_end_height().await.unwrap();
|
||||
assert_eq!(distribution_len, rpc.get_height().await.unwrap());
|
||||
|
||||
rpc.get_output_distribution(0 ..= distribution_len).await.unwrap_err();
|
||||
assert_eq!(
|
||||
rpc.get_output_distribution(0 .. distribution_len).await.unwrap().len(),
|
||||
distribution_len
|
||||
);
|
||||
assert_eq!(
|
||||
rpc.get_output_distribution(.. distribution_len).await.unwrap().len(),
|
||||
distribution_len
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rpc.get_output_distribution(.. (distribution_len - 1)).await.unwrap().len(),
|
||||
distribution_len - 1
|
||||
);
|
||||
assert_eq!(
|
||||
rpc.get_output_distribution(1 .. distribution_len).await.unwrap().len(),
|
||||
distribution_len - 1
|
||||
);
|
||||
|
||||
assert_eq!(rpc.get_output_distribution(0 ..= 0).await.unwrap().len(), 1);
|
||||
assert_eq!(rpc.get_output_distribution(0 ..= 1).await.unwrap().len(), 2);
|
||||
assert_eq!(rpc.get_output_distribution(1 ..= 1).await.unwrap().len(), 1);
|
||||
|
||||
rpc.get_output_distribution(0 .. 0).await.unwrap_err();
|
||||
#[allow(clippy::reversed_empty_ranges)]
|
||||
rpc.get_output_distribution(1 .. 0).await.unwrap_err();
|
||||
}
|
||||
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
// This test passes yet requires a mainnet node, which we don't have reliable access to in CI.
|
||||
/*
|
||||
#[tokio::test]
|
||||
async fn test_zero_out_tx_o_indexes() {
|
||||
use monero_rpc::Rpc;
|
||||
|
||||
let guard = SEQUENTIAL.get_or_init(|| Mutex::new(())).lock().await;
|
||||
|
||||
let rpc = SimpleRequestRpc::new("https://node.sethforprivacy.com".to_string()).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rpc
|
||||
.get_o_indexes(
|
||||
hex::decode("17ce4c8feeb82a6d6adaa8a89724b32bf4456f6909c7f84c8ce3ee9ebba19163")
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap()
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
Vec::<u64>::new()
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
}
|
||||
*/
|
||||
Reference in New Issue
Block a user