mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-10 13:09:24 +00:00
1090 lines
37 KiB
Rust
1090 lines
37 KiB
Rust
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
|
|
#![doc = include_str!("../README.md")]
|
|
#![deny(missing_docs)]
|
|
#![cfg_attr(not(feature = "std"), no_std)]
|
|
|
|
use core::{
|
|
fmt::Debug,
|
|
ops::{Bound, RangeBounds},
|
|
};
|
|
use std_shims::{
|
|
alloc::{boxed::Box, format},
|
|
vec,
|
|
vec::Vec,
|
|
io,
|
|
string::{String, ToString},
|
|
};
|
|
|
|
use zeroize::Zeroize;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use curve25519_dalek::edwards::{CompressedEdwardsY, EdwardsPoint};
|
|
|
|
use serde::{Serialize, Deserialize, de::DeserializeOwned};
|
|
use serde_json::{Value, json};
|
|
|
|
use monero_serai::{
|
|
io::*,
|
|
transaction::{Input, Timelock, Pruned, Transaction},
|
|
block::Block,
|
|
DEFAULT_LOCK_WINDOW,
|
|
};
|
|
use monero_address::Address;
|
|
|
|
// Number of blocks the fee estimate will be valid for
|
|
// https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c
|
|
// /src/wallet/wallet2.cpp#L121
|
|
const GRACE_BLOCKS_FOR_FEE_ESTIMATE: u64 = 10;
|
|
|
|
// Monero errors if more than 100 is requested unless using a non-restricted RPC
|
|
// https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c454
|
|
// /src/rpc/core_rpc_server.cpp#L75
|
|
const TXS_PER_REQUEST: usize = 100;
|
|
|
|
/// An error from the RPC.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
#[cfg_attr(feature = "std", derive(thiserror::Error))]
|
|
pub enum RpcError {
|
|
/// An internal error.
|
|
#[cfg_attr(feature = "std", error("internal error ({0})"))]
|
|
InternalError(String),
|
|
/// A connection error with the node.
|
|
#[cfg_attr(feature = "std", error("connection error ({0})"))]
|
|
ConnectionError(String),
|
|
/// The node is invalid per the expected protocol.
|
|
#[cfg_attr(feature = "std", error("invalid node ({0})"))]
|
|
InvalidNode(String),
|
|
/// Requested transactions weren't found.
|
|
#[cfg_attr(feature = "std", error("transactions not found"))]
|
|
TransactionsNotFound(Vec<[u8; 32]>),
|
|
/// The transaction was pruned.
|
|
///
|
|
/// Pruned transactions are not supported at this time.
|
|
#[cfg_attr(feature = "std", error("pruned transaction"))]
|
|
PrunedTransaction,
|
|
/// A transaction (sent or received) was invalid.
|
|
#[cfg_attr(feature = "std", error("invalid transaction ({0:?})"))]
|
|
InvalidTransaction([u8; 32]),
|
|
/// The returned fee was unusable.
|
|
#[cfg_attr(feature = "std", error("unexpected fee response"))]
|
|
InvalidFee,
|
|
/// The priority intended for use wasn't usable.
|
|
#[cfg_attr(feature = "std", error("invalid priority"))]
|
|
InvalidPriority,
|
|
}
|
|
|
|
/// A struct containing a fee rate.
|
|
///
|
|
/// The fee rate is defined as a per-weight cost, along with a mask for rounding purposes.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)]
|
|
pub struct FeeRate {
|
|
/// The fee per-weight of the transaction.
|
|
per_weight: u64,
|
|
/// The mask to round with.
|
|
mask: u64,
|
|
}
|
|
|
|
impl FeeRate {
|
|
/// Construct a new fee rate.
|
|
pub fn new(per_weight: u64, mask: u64) -> Result<FeeRate, RpcError> {
|
|
if (per_weight == 0) || (mask == 0) {
|
|
Err(RpcError::InvalidFee)?;
|
|
}
|
|
Ok(FeeRate { per_weight, mask })
|
|
}
|
|
|
|
/// Write the FeeRate.
|
|
///
|
|
/// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
|
|
/// defined serialization.
|
|
pub fn write(&self, w: &mut impl io::Write) -> io::Result<()> {
|
|
w.write_all(&self.per_weight.to_le_bytes())?;
|
|
w.write_all(&self.mask.to_le_bytes())
|
|
}
|
|
|
|
/// Serialize the FeeRate to a `Vec<u8>`.
|
|
///
|
|
/// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
|
|
/// defined serialization.
|
|
pub fn serialize(&self) -> Vec<u8> {
|
|
let mut res = Vec::with_capacity(16);
|
|
self.write(&mut res).unwrap();
|
|
res
|
|
}
|
|
|
|
/// Read a FeeRate.
|
|
///
|
|
/// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
|
|
/// defined serialization.
|
|
pub fn read(r: &mut impl io::Read) -> io::Result<FeeRate> {
|
|
let per_weight = read_u64(r)?;
|
|
let mask = read_u64(r)?;
|
|
FeeRate::new(per_weight, mask).map_err(io::Error::other)
|
|
}
|
|
|
|
/// Calculate the fee to use from the weight.
|
|
///
|
|
/// This function may panic upon overflow.
|
|
pub fn calculate_fee_from_weight(&self, weight: usize) -> u64 {
|
|
let fee = self.per_weight * u64::try_from(weight).unwrap();
|
|
let fee = fee.div_ceil(self.mask) * self.mask;
|
|
debug_assert_eq!(weight, self.calculate_weight_from_fee(fee), "Miscalculated weight from fee");
|
|
fee
|
|
}
|
|
|
|
/// Calculate the weight from the fee.
|
|
pub fn calculate_weight_from_fee(&self, fee: u64) -> usize {
|
|
usize::try_from(fee / self.per_weight).unwrap()
|
|
}
|
|
}
|
|
|
|
/// The priority for the fee.
|
|
///
|
|
/// Higher-priority transactions will be included in blocks earlier.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
#[allow(non_camel_case_types)]
|
|
pub enum FeePriority {
|
|
/// The `Unimportant` priority, as defined by Monero.
|
|
Unimportant,
|
|
/// The `Normal` priority, as defined by Monero.
|
|
Normal,
|
|
/// The `Elevated` priority, as defined by Monero.
|
|
Elevated,
|
|
/// The `Priority` priority, as defined by Monero.
|
|
Priority,
|
|
/// A custom priority.
|
|
Custom {
|
|
/// The numeric representation of the priority, as used within the RPC.
|
|
priority: u32,
|
|
},
|
|
}
|
|
|
|
/// https://github.com/monero-project/monero/blob/ac02af92867590ca80b2779a7bbeafa99ff94dcb/
|
|
/// src/simplewallet/simplewallet.cpp#L161
|
|
impl FeePriority {
|
|
pub(crate) fn fee_priority(&self) -> u32 {
|
|
match self {
|
|
FeePriority::Unimportant => 1,
|
|
FeePriority::Normal => 2,
|
|
FeePriority::Elevated => 3,
|
|
FeePriority::Priority => 4,
|
|
FeePriority::Custom { priority, .. } => *priority,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct JsonRpcResponse<T> {
|
|
result: T,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TransactionResponse {
|
|
tx_hash: String,
|
|
as_hex: String,
|
|
pruned_as_hex: String,
|
|
}
|
|
#[derive(Debug, Deserialize)]
|
|
struct TransactionsResponse {
|
|
#[serde(default)]
|
|
missed_tx: Vec<String>,
|
|
txs: Vec<TransactionResponse>,
|
|
}
|
|
|
|
/// The response to an query for the information of a RingCT output.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub struct OutputInformation {
|
|
/// The block number of the block this output was added to the chain in.
|
|
///
|
|
/// This is equivalent to he height of the blockchain at the time the block was added.
|
|
pub height: usize,
|
|
/// If the output is unlocked, per the node's local view.
|
|
pub unlocked: bool,
|
|
/// The output's key.
|
|
///
|
|
/// This is a CompressedEdwardsY, not an EdwardsPoint, as it may be invalid. CompressedEdwardsY
|
|
/// only asserts validity on decompression and allows representing compressed types.
|
|
pub key: CompressedEdwardsY,
|
|
/// The output's commitment.
|
|
pub commitment: EdwardsPoint,
|
|
/// The transaction which created this output.
|
|
pub transaction: [u8; 32],
|
|
}
|
|
|
|
fn rpc_hex(value: &str) -> Result<Vec<u8>, RpcError> {
|
|
hex::decode(value).map_err(|_| RpcError::InvalidNode("expected hex wasn't hex".to_string()))
|
|
}
|
|
|
|
fn hash_hex(hash: &str) -> Result<[u8; 32], RpcError> {
|
|
rpc_hex(hash)?.try_into().map_err(|_| RpcError::InvalidNode("hash wasn't 32-bytes".to_string()))
|
|
}
|
|
|
|
fn rpc_point(point: &str) -> Result<EdwardsPoint, RpcError> {
|
|
decompress_point(
|
|
rpc_hex(point)?
|
|
.try_into()
|
|
.map_err(|_| RpcError::InvalidNode(format!("invalid point: {point}")))?,
|
|
)
|
|
.ok_or_else(|| RpcError::InvalidNode(format!("invalid point: {point}")))
|
|
}
|
|
|
|
/// An RPC connection to a Monero daemon.
|
|
///
|
|
/// This is abstract such that users can use an HTTP library (which being their choice), a
|
|
/// Tor/i2p-based transport, or even a memory buffer an external service somehow routes.
|
|
///
|
|
/// While no implementors are directly provided, [monero-simple-request-rpc](
|
|
/// https://github.com/serai-dex/serai/tree/develop/networks/monero/rpc/simple-request
|
|
/// ) is recommended.
|
|
#[async_trait]
|
|
pub trait Rpc: Sync + Clone + Debug {
|
|
/// Perform a POST request to the specified route with the specified body.
|
|
///
|
|
/// The implementor is left to handle anything such as authentication.
|
|
async fn post(&self, route: &str, body: Vec<u8>) -> Result<Vec<u8>, RpcError>;
|
|
|
|
/// Perform a RPC call to the specified route with the provided parameters.
|
|
///
|
|
/// This is NOT a JSON-RPC call. They use a route of "json_rpc" and are available via
|
|
/// `json_rpc_call`.
|
|
async fn rpc_call<Params: Send + Serialize + Debug, Response: DeserializeOwned + Debug>(
|
|
&self,
|
|
route: &str,
|
|
params: Option<Params>,
|
|
) -> Result<Response, RpcError> {
|
|
let res = self
|
|
.post(
|
|
route,
|
|
if let Some(params) = params {
|
|
serde_json::to_string(¶ms).unwrap().into_bytes()
|
|
} else {
|
|
vec![]
|
|
},
|
|
)
|
|
.await?;
|
|
let res_str = std_shims::str::from_utf8(&res)
|
|
.map_err(|_| RpcError::InvalidNode("response wasn't utf-8".to_string()))?;
|
|
serde_json::from_str(res_str)
|
|
.map_err(|_| RpcError::InvalidNode(format!("response wasn't the expected json: {res_str}")))
|
|
}
|
|
|
|
/// Perform a JSON-RPC call with the specified method with the provided parameters.
|
|
async fn json_rpc_call<Response: DeserializeOwned + Debug>(
|
|
&self,
|
|
method: &str,
|
|
params: Option<Value>,
|
|
) -> Result<Response, RpcError> {
|
|
let mut req = json!({ "method": method });
|
|
if let Some(params) = params {
|
|
req.as_object_mut().unwrap().insert("params".into(), params);
|
|
}
|
|
Ok(self.rpc_call::<_, JsonRpcResponse<Response>>("json_rpc", Some(req)).await?.result)
|
|
}
|
|
|
|
/// Perform a binary call to the specified route with the provided parameters.
|
|
async fn bin_call(&self, route: &str, params: Vec<u8>) -> Result<Vec<u8>, RpcError> {
|
|
self.post(route, params).await
|
|
}
|
|
|
|
/// Get the active blockchain protocol version.
|
|
///
|
|
/// This is specifically the major version within the most recent block header.
|
|
async fn get_hardfork_version(&self) -> Result<u8, RpcError> {
|
|
#[derive(Debug, Deserialize)]
|
|
struct HeaderResponse {
|
|
major_version: u8,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LastHeaderResponse {
|
|
block_header: HeaderResponse,
|
|
}
|
|
|
|
Ok(
|
|
self
|
|
.json_rpc_call::<LastHeaderResponse>("get_last_block_header", None)
|
|
.await?
|
|
.block_header
|
|
.major_version,
|
|
)
|
|
}
|
|
|
|
/// Get the height of the Monero blockchain.
|
|
///
|
|
/// The height is defined as the amount of blocks on the blockchain. For a blockchain with only
|
|
/// its genesis block, the height will be 1.
|
|
async fn get_height(&self) -> Result<usize, RpcError> {
|
|
#[derive(Debug, Deserialize)]
|
|
struct HeightResponse {
|
|
height: usize,
|
|
}
|
|
let res = self.rpc_call::<Option<()>, HeightResponse>("get_height", None).await?.height;
|
|
if res == 0 {
|
|
Err(RpcError::InvalidNode("node responded with 0 for the height".to_string()))?;
|
|
}
|
|
Ok(res)
|
|
}
|
|
|
|
/// Get the specified transactions.
|
|
///
|
|
/// The received transactions will be hashed in order to verify the correct transactions were
|
|
/// returned.
|
|
async fn get_transactions(&self, hashes: &[[u8; 32]]) -> Result<Vec<Transaction>, RpcError> {
|
|
if hashes.is_empty() {
|
|
return Ok(vec![]);
|
|
}
|
|
|
|
let mut hashes_hex = hashes.iter().map(hex::encode).collect::<Vec<_>>();
|
|
let mut all_txs = Vec::with_capacity(hashes.len());
|
|
while !hashes_hex.is_empty() {
|
|
let this_count = TXS_PER_REQUEST.min(hashes_hex.len());
|
|
|
|
let txs: TransactionsResponse = self
|
|
.rpc_call(
|
|
"get_transactions",
|
|
Some(json!({
|
|
"txs_hashes": hashes_hex.drain(.. this_count).collect::<Vec<_>>(),
|
|
})),
|
|
)
|
|
.await?;
|
|
|
|
if !txs.missed_tx.is_empty() {
|
|
Err(RpcError::TransactionsNotFound(
|
|
txs.missed_tx.iter().map(|hash| hash_hex(hash)).collect::<Result<_, _>>()?,
|
|
))?;
|
|
}
|
|
|
|
all_txs.extend(txs.txs);
|
|
}
|
|
|
|
all_txs
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, res)| {
|
|
// https://github.com/monero-project/monero/issues/8311
|
|
let buf = rpc_hex(if !res.as_hex.is_empty() { &res.as_hex } else { &res.pruned_as_hex })?;
|
|
let mut buf = buf.as_slice();
|
|
let tx = Transaction::read(&mut buf).map_err(|_| match hash_hex(&res.tx_hash) {
|
|
Ok(hash) => RpcError::InvalidTransaction(hash),
|
|
Err(err) => err,
|
|
})?;
|
|
if !buf.is_empty() {
|
|
Err(RpcError::InvalidNode("transaction had extra bytes after it".to_string()))?;
|
|
}
|
|
|
|
// We check this to ensure we didn't read a pruned transaction when we meant to read an
|
|
// actual transaction. That shouldn't be possible, as they have different serializations,
|
|
// yet it helps to ensure that if we applied the above exception (using the pruned data),
|
|
// it was for the right reason
|
|
if res.as_hex.is_empty() {
|
|
match tx.prefix().inputs.first() {
|
|
Some(Input::Gen { .. }) => (),
|
|
_ => Err(RpcError::PrunedTransaction)?,
|
|
}
|
|
}
|
|
|
|
// This does run a few keccak256 hashes, which is pointless if the node is trusted
|
|
// In exchange, this provides resilience against invalid/malicious nodes
|
|
if tx.hash() != hashes[i] {
|
|
Err(RpcError::InvalidNode(
|
|
"replied with transaction wasn't the requested transaction".to_string(),
|
|
))?;
|
|
}
|
|
|
|
Ok(tx)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Get the specified transactions in their pruned format.
|
|
async fn get_pruned_transactions(
|
|
&self,
|
|
hashes: &[[u8; 32]],
|
|
) -> Result<Vec<Transaction<Pruned>>, RpcError> {
|
|
if hashes.is_empty() {
|
|
return Ok(vec![]);
|
|
}
|
|
|
|
let mut hashes_hex = hashes.iter().map(hex::encode).collect::<Vec<_>>();
|
|
let mut all_txs = Vec::with_capacity(hashes.len());
|
|
while !hashes_hex.is_empty() {
|
|
let this_count = TXS_PER_REQUEST.min(hashes_hex.len());
|
|
|
|
let txs: TransactionsResponse = self
|
|
.rpc_call(
|
|
"get_transactions",
|
|
Some(json!({
|
|
"txs_hashes": hashes_hex.drain(.. this_count).collect::<Vec<_>>(),
|
|
"prune": true,
|
|
})),
|
|
)
|
|
.await?;
|
|
|
|
if !txs.missed_tx.is_empty() {
|
|
Err(RpcError::TransactionsNotFound(
|
|
txs.missed_tx.iter().map(|hash| hash_hex(hash)).collect::<Result<_, _>>()?,
|
|
))?;
|
|
}
|
|
|
|
all_txs.extend(txs.txs);
|
|
}
|
|
|
|
all_txs
|
|
.iter()
|
|
.map(|res| {
|
|
let buf = rpc_hex(&res.pruned_as_hex)?;
|
|
let mut buf = buf.as_slice();
|
|
let tx =
|
|
Transaction::<Pruned>::read(&mut buf).map_err(|_| match hash_hex(&res.tx_hash) {
|
|
Ok(hash) => RpcError::InvalidTransaction(hash),
|
|
Err(err) => err,
|
|
})?;
|
|
if !buf.is_empty() {
|
|
Err(RpcError::InvalidNode("pruned transaction had extra bytes after it".to_string()))?;
|
|
}
|
|
Ok(tx)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Get the specified transaction.
|
|
///
|
|
/// The received transaction will be hashed in order to verify the correct transaction was
|
|
/// returned.
|
|
async fn get_transaction(&self, tx: [u8; 32]) -> Result<Transaction, RpcError> {
|
|
self.get_transactions(&[tx]).await.map(|mut txs| txs.swap_remove(0))
|
|
}
|
|
|
|
/// Get the specified transaction in its pruned format.
|
|
async fn get_pruned_transaction(&self, tx: [u8; 32]) -> Result<Transaction<Pruned>, RpcError> {
|
|
self.get_pruned_transactions(&[tx]).await.map(|mut txs| txs.swap_remove(0))
|
|
}
|
|
|
|
/// Get the hash of a block from the node.
|
|
///
|
|
/// `number` is the block's zero-indexed position on the blockchain (`0` for the genesis block,
|
|
/// `height - 1` for the latest block).
|
|
async fn get_block_hash(&self, number: usize) -> Result<[u8; 32], RpcError> {
|
|
#[derive(Debug, Deserialize)]
|
|
struct BlockHeaderResponse {
|
|
hash: String,
|
|
}
|
|
#[derive(Debug, Deserialize)]
|
|
struct BlockHeaderByHeightResponse {
|
|
block_header: BlockHeaderResponse,
|
|
}
|
|
|
|
let header: BlockHeaderByHeightResponse =
|
|
self.json_rpc_call("get_block_header_by_height", Some(json!({ "height": number }))).await?;
|
|
hash_hex(&header.block_header.hash)
|
|
}
|
|
|
|
/// Get a block from the node by its hash.
|
|
///
|
|
/// The received block will be hashed in order to verify the correct block was returned.
|
|
async fn get_block(&self, hash: [u8; 32]) -> Result<Block, RpcError> {
|
|
#[derive(Debug, Deserialize)]
|
|
struct BlockResponse {
|
|
blob: String,
|
|
}
|
|
|
|
let res: BlockResponse =
|
|
self.json_rpc_call("get_block", Some(json!({ "hash": hex::encode(hash) }))).await?;
|
|
|
|
let block = Block::read::<&[u8]>(&mut rpc_hex(&res.blob)?.as_ref())
|
|
.map_err(|_| RpcError::InvalidNode("invalid block".to_string()))?;
|
|
if block.hash() != hash {
|
|
Err(RpcError::InvalidNode("different block than requested (hash)".to_string()))?;
|
|
}
|
|
Ok(block)
|
|
}
|
|
|
|
/// Get a block from the node by its number.
|
|
///
|
|
/// `number` is the block's zero-indexed position on the blockchain (`0` for the genesis block,
|
|
/// `height - 1` for the latest block).
|
|
async fn get_block_by_number(&self, number: usize) -> Result<Block, RpcError> {
|
|
#[derive(Debug, Deserialize)]
|
|
struct BlockResponse {
|
|
blob: String,
|
|
}
|
|
|
|
let res: BlockResponse =
|
|
self.json_rpc_call("get_block", Some(json!({ "height": number }))).await?;
|
|
|
|
let block = Block::read::<&[u8]>(&mut rpc_hex(&res.blob)?.as_ref())
|
|
.map_err(|_| RpcError::InvalidNode("invalid block".to_string()))?;
|
|
|
|
// Make sure this is actually the block for this number
|
|
match block.miner_transaction.prefix().inputs.first() {
|
|
Some(Input::Gen(actual)) => {
|
|
if *actual == number {
|
|
Ok(block)
|
|
} else {
|
|
Err(RpcError::InvalidNode("different block than requested (number)".to_string()))
|
|
}
|
|
}
|
|
_ => Err(RpcError::InvalidNode(
|
|
"block's miner_transaction didn't have an input of kind Input::Gen".to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Get the currently estimated fee rate from the node.
|
|
///
|
|
/// This may be manipulated to unsafe levels and MUST be sanity checked.
|
|
///
|
|
/// This MUST NOT be expected to be deterministic in any way.
|
|
async fn get_fee_rate(&self, priority: FeePriority) -> Result<FeeRate, RpcError> {
|
|
#[derive(Debug, Deserialize)]
|
|
struct FeeResponse {
|
|
status: String,
|
|
fees: Option<Vec<u64>>,
|
|
fee: u64,
|
|
quantization_mask: u64,
|
|
}
|
|
|
|
let res: FeeResponse = self
|
|
.json_rpc_call(
|
|
"get_fee_estimate",
|
|
Some(json!({ "grace_blocks": GRACE_BLOCKS_FOR_FEE_ESTIMATE })),
|
|
)
|
|
.await?;
|
|
|
|
if res.status != "OK" {
|
|
Err(RpcError::InvalidFee)?;
|
|
}
|
|
|
|
if let Some(fees) = res.fees {
|
|
// https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/
|
|
// src/wallet/wallet2.cpp#L7615-L7620
|
|
let priority_idx = usize::try_from(if priority.fee_priority() >= 4 {
|
|
3
|
|
} else {
|
|
priority.fee_priority().saturating_sub(1)
|
|
})
|
|
.map_err(|_| RpcError::InvalidPriority)?;
|
|
|
|
if priority_idx >= fees.len() {
|
|
Err(RpcError::InvalidPriority)
|
|
} else {
|
|
FeeRate::new(fees[priority_idx], res.quantization_mask)
|
|
}
|
|
} else {
|
|
// https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/
|
|
// src/wallet/wallet2.cpp#L7569-L7584
|
|
// https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/
|
|
// src/wallet/wallet2.cpp#L7660-L7661
|
|
let priority_idx =
|
|
usize::try_from(if priority.fee_priority() == 0 { 1 } else { priority.fee_priority() - 1 })
|
|
.map_err(|_| RpcError::InvalidPriority)?;
|
|
let multipliers = [1, 5, 25, 1000];
|
|
if priority_idx >= multipliers.len() {
|
|
// though not an RPC error, it seems sensible to treat as such
|
|
Err(RpcError::InvalidPriority)?;
|
|
}
|
|
let fee_multiplier = multipliers[priority_idx];
|
|
|
|
FeeRate::new(res.fee * fee_multiplier, res.quantization_mask)
|
|
}
|
|
}
|
|
|
|
/// Publish a transaction.
|
|
async fn publish_transaction(&self, tx: &Transaction) -> Result<(), RpcError> {
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Deserialize)]
|
|
struct SendRawResponse {
|
|
status: String,
|
|
double_spend: bool,
|
|
fee_too_low: bool,
|
|
invalid_input: bool,
|
|
invalid_output: bool,
|
|
low_mixin: bool,
|
|
not_relayed: bool,
|
|
overspend: bool,
|
|
too_big: bool,
|
|
too_few_outputs: bool,
|
|
reason: String,
|
|
}
|
|
|
|
let res: SendRawResponse = self
|
|
.rpc_call(
|
|
"send_raw_transaction",
|
|
Some(json!({ "tx_as_hex": hex::encode(tx.serialize()), "do_sanity_checks": false })),
|
|
)
|
|
.await?;
|
|
|
|
if res.status != "OK" {
|
|
Err(RpcError::InvalidTransaction(tx.hash()))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Generate blocks, with the specified address receiving the block reward.
|
|
///
|
|
/// Returns the hashes of the generated blocks and the last block's number.
|
|
async fn generate_blocks<const ADDR_BYTES: u128>(
|
|
&self,
|
|
address: &Address<ADDR_BYTES>,
|
|
block_count: usize,
|
|
) -> Result<(Vec<[u8; 32]>, usize), RpcError> {
|
|
#[derive(Debug, Deserialize)]
|
|
struct BlocksResponse {
|
|
blocks: Vec<String>,
|
|
height: usize,
|
|
}
|
|
|
|
let res = self
|
|
.json_rpc_call::<BlocksResponse>(
|
|
"generateblocks",
|
|
Some(json!({
|
|
"wallet_address": address.to_string(),
|
|
"amount_of_blocks": block_count
|
|
})),
|
|
)
|
|
.await?;
|
|
|
|
let mut blocks = Vec::with_capacity(res.blocks.len());
|
|
for block in res.blocks {
|
|
blocks.push(hash_hex(&block)?);
|
|
}
|
|
Ok((blocks, res.height))
|
|
}
|
|
|
|
/// Get the output indexes of the specified transaction.
|
|
async fn get_o_indexes(&self, hash: [u8; 32]) -> Result<Vec<u64>, RpcError> {
|
|
// Given the immaturity of Rust epee libraries, this is a homegrown one which is only validated
|
|
// to work against this specific function
|
|
|
|
// Header for EPEE, an 8-byte magic and a version
|
|
const EPEE_HEADER: &[u8] = b"\x01\x11\x01\x01\x01\x01\x02\x01\x01";
|
|
|
|
// Read an EPEE VarInt, distinct from the VarInts used throughout the rest of the protocol
|
|
fn read_epee_vi<R: io::Read>(reader: &mut R) -> io::Result<u64> {
|
|
let vi_start = read_byte(reader)?;
|
|
let len = match vi_start & 0b11 {
|
|
0 => 1,
|
|
1 => 2,
|
|
2 => 4,
|
|
3 => 8,
|
|
_ => unreachable!(),
|
|
};
|
|
let mut vi = u64::from(vi_start >> 2);
|
|
for i in 1 .. len {
|
|
vi |= u64::from(read_byte(reader)?) << (((i - 1) * 8) + 6);
|
|
}
|
|
Ok(vi)
|
|
}
|
|
|
|
let mut request = EPEE_HEADER.to_vec();
|
|
// Number of fields (shifted over 2 bits as the 2 LSBs are reserved for metadata)
|
|
request.push(1 << 2);
|
|
// Length of field name
|
|
request.push(4);
|
|
// Field name
|
|
request.extend(b"txid");
|
|
// Type of field
|
|
request.push(10);
|
|
// Length of string, since this byte array is technically a string
|
|
request.push(32 << 2);
|
|
// The "string"
|
|
request.extend(hash);
|
|
|
|
let indexes_buf = self.bin_call("get_o_indexes.bin", request).await?;
|
|
let mut indexes: &[u8] = indexes_buf.as_ref();
|
|
|
|
(|| {
|
|
let mut res = None;
|
|
let mut has_status = false;
|
|
|
|
if read_bytes::<_, { EPEE_HEADER.len() }>(&mut indexes)? != EPEE_HEADER {
|
|
Err(io::Error::other("invalid header"))?;
|
|
}
|
|
|
|
let read_object = |reader: &mut &[u8]| -> io::Result<Vec<u64>> {
|
|
// Read the amount of fields
|
|
let fields = read_byte(reader)? >> 2;
|
|
|
|
for _ in 0 .. fields {
|
|
// Read the length of the field's name
|
|
let name_len = read_byte(reader)?;
|
|
// Read the name of the field
|
|
let name = read_raw_vec(read_byte, name_len.into(), reader)?;
|
|
|
|
let type_with_array_flag = read_byte(reader)?;
|
|
// The type of this field, without the potentially set array flag
|
|
let kind = type_with_array_flag & (!0x80);
|
|
let has_array_flag = type_with_array_flag != kind;
|
|
|
|
// Read this many instances of the field
|
|
let iters = if has_array_flag { read_epee_vi(reader)? } else { 1 };
|
|
|
|
// Check the field type
|
|
{
|
|
#[allow(clippy::match_same_arms)]
|
|
let (expected_type, expected_array_flag) = match name.as_slice() {
|
|
b"o_indexes" => (5, true),
|
|
b"status" => (10, false),
|
|
b"untrusted" => (11, false),
|
|
b"credits" => (5, false),
|
|
b"top_hash" => (10, false),
|
|
// On-purposely prints name as a byte vector to prevent printing arbitrary strings
|
|
// This is a self-describing format so we don't have to error here, yet we don't
|
|
// claim this to be a complete deserialization function
|
|
// To ensure it works for this specific use case, it's best to ensure it's limited
|
|
// to this specific use case (ensuring we have less variables to deal with)
|
|
_ => Err(io::Error::other(format!("unrecognized field in get_o_indexes: {name:?}")))?,
|
|
};
|
|
if (expected_type != kind) || (expected_array_flag != has_array_flag) {
|
|
let fmt_array_bool = |array_bool| if array_bool { "array" } else { "not array" };
|
|
Err(io::Error::other(format!(
|
|
"field {name:?} was {kind} ({}), expected {expected_type} ({})",
|
|
fmt_array_bool(has_array_flag),
|
|
fmt_array_bool(expected_array_flag)
|
|
)))?;
|
|
}
|
|
}
|
|
|
|
let read_field_as_bytes = match kind {
|
|
/*
|
|
// i64
|
|
1 => |reader: &mut &[u8]| read_raw_vec(read_byte, 8, reader),
|
|
// i32
|
|
2 => |reader: &mut &[u8]| read_raw_vec(read_byte, 4, reader),
|
|
// i16
|
|
3 => |reader: &mut &[u8]| read_raw_vec(read_byte, 2, reader),
|
|
// i8
|
|
4 => |reader: &mut &[u8]| read_raw_vec(read_byte, 1, reader),
|
|
*/
|
|
// u64
|
|
5 => |reader: &mut &[u8]| read_raw_vec(read_byte, 8, reader),
|
|
/*
|
|
// u32
|
|
6 => |reader: &mut &[u8]| read_raw_vec(read_byte, 4, reader),
|
|
// u16
|
|
7 => |reader: &mut &[u8]| read_raw_vec(read_byte, 2, reader),
|
|
// u8
|
|
8 => |reader: &mut &[u8]| read_raw_vec(read_byte, 1, reader),
|
|
// double
|
|
9 => |reader: &mut &[u8]| read_raw_vec(read_byte, 8, reader),
|
|
*/
|
|
// string, or any collection of bytes
|
|
10 => |reader: &mut &[u8]| {
|
|
let len = read_epee_vi(reader)?;
|
|
read_raw_vec(
|
|
read_byte,
|
|
len.try_into().map_err(|_| io::Error::other("u64 length exceeded usize"))?,
|
|
reader,
|
|
)
|
|
},
|
|
// bool
|
|
11 => |reader: &mut &[u8]| read_raw_vec(read_byte, 1, reader),
|
|
/*
|
|
// object, errors here as it shouldn't be used on this call
|
|
12 => {
|
|
|_: &mut &[u8]| Err(io::Error::other("node used object in reply to get_o_indexes"))
|
|
}
|
|
// array, so far unused
|
|
13 => |_: &mut &[u8]| Err(io::Error::other("node used the unused array type")),
|
|
*/
|
|
_ => |_: &mut &[u8]| Err(io::Error::other("node used an invalid type")),
|
|
};
|
|
|
|
let mut bytes_res = vec![];
|
|
for _ in 0 .. iters {
|
|
bytes_res.push(read_field_as_bytes(reader)?);
|
|
}
|
|
|
|
let mut actual_res = Vec::with_capacity(bytes_res.len());
|
|
match name.as_slice() {
|
|
b"o_indexes" => {
|
|
for o_index in bytes_res {
|
|
actual_res.push(read_u64(&mut o_index.as_slice())?);
|
|
}
|
|
res = Some(actual_res);
|
|
}
|
|
b"status" => {
|
|
if bytes_res
|
|
.first()
|
|
.ok_or_else(|| io::Error::other("status was a 0-length array"))?
|
|
.as_slice() !=
|
|
b"OK"
|
|
{
|
|
Err(io::Error::other("response wasn't OK"))?;
|
|
}
|
|
has_status = true;
|
|
}
|
|
b"untrusted" | b"credits" | b"top_hash" => continue,
|
|
_ => Err(io::Error::other("unrecognized field in get_o_indexes"))?,
|
|
}
|
|
}
|
|
|
|
if !has_status {
|
|
Err(io::Error::other("response didn't contain a status"))?;
|
|
}
|
|
|
|
// If the Vec was empty, it would've been omitted, hence the unwrap_or
|
|
Ok(res.unwrap_or(vec![]))
|
|
};
|
|
|
|
read_object(&mut indexes)
|
|
})()
|
|
.map_err(|e| RpcError::InvalidNode(format!("invalid binary response: {e:?}")))
|
|
}
|
|
}
|
|
|
|
/// A trait for any object which can be used to select RingCT decoys.
|
|
///
|
|
/// An implementation is provided for any satisfier of `Rpc`. It is not recommended to use an `Rpc`
|
|
/// object to satisfy this. This should be satisfied by a local store of the output distribution,
|
|
/// both for performance and to prevent potential attacks a remote node can perform.
|
|
#[async_trait]
|
|
pub trait DecoyRpc: Sync + Clone + Debug {
|
|
/// Get the height the output distribution ends at.
|
|
///
|
|
/// This is equivalent to the hight of the blockchain it's for. This is intended to be cheaper
|
|
/// than fetching the entire output distribution.
|
|
async fn get_output_distribution_end_height(&self) -> Result<usize, RpcError>;
|
|
|
|
/// Get the RingCT (zero-amount) output distribution.
|
|
///
|
|
/// `range` is in terms of block numbers. The result may be smaller than the requested range if
|
|
/// the range starts before RingCT outputs were created on-chain.
|
|
async fn get_output_distribution(
|
|
&self,
|
|
range: impl Send + RangeBounds<usize>,
|
|
) -> Result<Vec<u64>, RpcError>;
|
|
|
|
/// Get the specified outputs from the RingCT (zero-amount) pool.
|
|
async fn get_outs(&self, indexes: &[u64]) -> Result<Vec<OutputInformation>, RpcError>;
|
|
|
|
/// Get the specified outputs from the RingCT (zero-amount) pool, but only return them if their
|
|
/// timelock has been satisfied.
|
|
///
|
|
/// The timelock being satisfied is distinct from being free of the 10-block lock applied to all
|
|
/// Monero transactions.
|
|
///
|
|
/// The node is trusted for if the output is unlocked unless `fingerprintable_deterministic` is
|
|
/// set to true. If `fingerprintable_deterministic` is set to true, the node's local view isn't
|
|
/// used, yet the transaction's timelock is checked to be unlocked at the specified `height`.
|
|
/// This offers a deterministic decoy selection, yet is fingerprintable as time-based timelocks
|
|
/// aren't evaluated (and considered locked, preventing their selection).
|
|
async fn get_unlocked_outputs(
|
|
&self,
|
|
indexes: &[u64],
|
|
height: usize,
|
|
fingerprintable_deterministic: bool,
|
|
) -> Result<Vec<Option<[EdwardsPoint; 2]>>, RpcError>;
|
|
}
|
|
|
|
#[async_trait]
|
|
impl<R: Rpc> DecoyRpc for R {
|
|
async fn get_output_distribution_end_height(&self) -> Result<usize, RpcError> {
|
|
<Self as Rpc>::get_height(self).await
|
|
}
|
|
|
|
async fn get_output_distribution(
|
|
&self,
|
|
range: impl Send + RangeBounds<usize>,
|
|
) -> Result<Vec<u64>, RpcError> {
|
|
#[derive(Default, Debug, Deserialize)]
|
|
struct Distribution {
|
|
distribution: Vec<u64>,
|
|
// A blockchain with just its genesis block has a height of 1
|
|
start_height: usize,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Distributions {
|
|
distributions: [Distribution; 1],
|
|
status: String,
|
|
}
|
|
|
|
let from = match range.start_bound() {
|
|
Bound::Included(from) => *from,
|
|
Bound::Excluded(from) => from
|
|
.checked_add(1)
|
|
.ok_or_else(|| RpcError::InternalError("range's from wasn't representable".to_string()))?,
|
|
Bound::Unbounded => 0,
|
|
};
|
|
let to = match range.end_bound() {
|
|
Bound::Included(to) => *to,
|
|
Bound::Excluded(to) => to
|
|
.checked_sub(1)
|
|
.ok_or_else(|| RpcError::InternalError("range's to wasn't representable".to_string()))?,
|
|
Bound::Unbounded => self.get_height().await? - 1,
|
|
};
|
|
if from > to {
|
|
Err(RpcError::InternalError(format!(
|
|
"malformed range: inclusive start {from}, inclusive end {to}"
|
|
)))?;
|
|
}
|
|
|
|
let zero_zero_case = (from == 0) && (to == 0);
|
|
let distributions: Distributions = self
|
|
.json_rpc_call(
|
|
"get_output_distribution",
|
|
Some(json!({
|
|
"binary": false,
|
|
"amounts": [0],
|
|
"cumulative": true,
|
|
// These are actually block numbers, not heights
|
|
"from_height": from,
|
|
"to_height": if zero_zero_case { 1 } else { to },
|
|
})),
|
|
)
|
|
.await?;
|
|
|
|
if distributions.status != "OK" {
|
|
Err(RpcError::ConnectionError(
|
|
"node couldn't service this request for the output distribution".to_string(),
|
|
))?;
|
|
}
|
|
|
|
let mut distributions = distributions.distributions;
|
|
let Distribution { start_height, mut distribution } = core::mem::take(&mut distributions[0]);
|
|
// start_height is also actually a block number, and it should be at least `from`
|
|
// It may be after depending on when these outputs first appeared on the blockchain
|
|
// Unfortunately, we can't validate without a binary search to find the RingCT activation block
|
|
// and an iterative search from there, so we solely sanity check it
|
|
if start_height < from {
|
|
Err(RpcError::InvalidNode(format!(
|
|
"requested distribution from {from} and got from {start_height}"
|
|
)))?;
|
|
}
|
|
// It shouldn't be after `to` though
|
|
if start_height > to {
|
|
Err(RpcError::InvalidNode(format!(
|
|
"requested distribution to {to} and got from {start_height}"
|
|
)))?;
|
|
}
|
|
|
|
let expected_len = if zero_zero_case { 2 } else { (to - start_height) + 1 };
|
|
// Yet this is actually a height
|
|
if expected_len != distribution.len() {
|
|
Err(RpcError::InvalidNode(format!(
|
|
"distribution length ({}) wasn't of the requested length ({})",
|
|
distribution.len(),
|
|
expected_len
|
|
)))?;
|
|
}
|
|
// Requesting to = 0 returns the distribution for the entire chain
|
|
// We work-around this by requesting 0, 1 (yielding two blocks), then popping the second block
|
|
if zero_zero_case {
|
|
distribution.pop();
|
|
}
|
|
Ok(distribution)
|
|
}
|
|
|
|
async fn get_outs(&self, indexes: &[u64]) -> Result<Vec<OutputInformation>, RpcError> {
|
|
#[derive(Debug, Deserialize)]
|
|
struct OutputResponse {
|
|
height: usize,
|
|
unlocked: bool,
|
|
key: String,
|
|
mask: String,
|
|
txid: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct OutsResponse {
|
|
status: String,
|
|
outs: Vec<OutputResponse>,
|
|
}
|
|
|
|
// https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c454
|
|
// /src/rpc/core_rpc_server.cpp#L67
|
|
const MAX_OUTS: usize = 5000;
|
|
|
|
let mut res = Vec::with_capacity(indexes.len());
|
|
for indexes in indexes.chunks(MAX_OUTS) {
|
|
let rpc_res: OutsResponse = self
|
|
.rpc_call(
|
|
"get_outs",
|
|
Some(json!({
|
|
"get_txid": true,
|
|
"outputs": indexes.iter().map(|o| json!({
|
|
"amount": 0,
|
|
"index": o
|
|
})).collect::<Vec<_>>()
|
|
})),
|
|
)
|
|
.await?;
|
|
|
|
if rpc_res.status != "OK" {
|
|
Err(RpcError::InvalidNode("bad response to get_outs".to_string()))?;
|
|
}
|
|
|
|
res.extend(
|
|
rpc_res
|
|
.outs
|
|
.into_iter()
|
|
.map(|output| {
|
|
Ok(OutputInformation {
|
|
height: output.height,
|
|
unlocked: output.unlocked,
|
|
key: CompressedEdwardsY(
|
|
rpc_hex(&output.key)?
|
|
.try_into()
|
|
.map_err(|_| RpcError::InvalidNode("output key wasn't 32 bytes".to_string()))?,
|
|
),
|
|
commitment: rpc_point(&output.mask)?,
|
|
transaction: hash_hex(&output.txid)?,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, RpcError>>()?,
|
|
);
|
|
}
|
|
|
|
Ok(res)
|
|
}
|
|
|
|
async fn get_unlocked_outputs(
|
|
&self,
|
|
indexes: &[u64],
|
|
height: usize,
|
|
fingerprintable_deterministic: bool,
|
|
) -> Result<Vec<Option<[EdwardsPoint; 2]>>, RpcError> {
|
|
let outs = self.get_outs(indexes).await?;
|
|
|
|
// Only need to fetch txs to do deterministic check on timelock
|
|
let txs = if fingerprintable_deterministic {
|
|
self.get_transactions(&outs.iter().map(|out| out.transaction).collect::<Vec<_>>()).await?
|
|
} else {
|
|
vec![]
|
|
};
|
|
|
|
// TODO: https://github.com/serai-dex/serai/issues/104
|
|
outs
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, out)| {
|
|
// Allow keys to be invalid, though if they are, return None to trigger selection of a new
|
|
// decoy
|
|
// Only valid keys can be used in CLSAG proofs, hence the need for re-selection, yet
|
|
// invalid keys may honestly exist on the blockchain
|
|
let Some(key) = out.key.decompress() else {
|
|
return Ok(None);
|
|
};
|
|
Ok(Some([key, out.commitment]).filter(|_| {
|
|
if fingerprintable_deterministic {
|
|
// https://github.com/monero-project/monero/blob
|
|
// /cc73fe71162d564ffda8e549b79a350bca53c454/src/cryptonote_core/blockchain.cpp#L90
|
|
const ACCEPTED_TIMELOCK_DELTA: usize = 1;
|
|
|
|
// https://github.com/monero-project/monero/blob
|
|
// /cc73fe71162d564ffda8e549b79a350bca53c454/src/cryptonote_core/blockchain.cpp#L3836
|
|
((out.height + DEFAULT_LOCK_WINDOW) <= height) &&
|
|
(Timelock::Block(height - 1 + ACCEPTED_TIMELOCK_DELTA) >=
|
|
txs[i].prefix().additional_timelock)
|
|
} else {
|
|
out.unlocked
|
|
}
|
|
}))
|
|
})
|
|
.collect()
|
|
}
|
|
}
|