mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-08 20:29:23 +00:00
I'm really unhappy with a cfg(test) within the codebase. The double checking of it makes it tolerable though, especially when compared to dropping these tests.
521 lines
16 KiB
Rust
521 lines
16 KiB
Rust
use std::io;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use zeroize::Zeroizing;
|
|
|
|
use transcript::RecommendedTranscript;
|
|
|
|
use group::{ff::Field, Group};
|
|
use dalek_ff_group::{Scalar, EdwardsPoint};
|
|
use frost::{curve::Ed25519, ThresholdKeys};
|
|
|
|
use monero_serai::{
|
|
Protocol,
|
|
transaction::Transaction,
|
|
block::Block as MBlock,
|
|
rpc::{RpcError, Rpc},
|
|
wallet::{
|
|
ViewPair, Scanner,
|
|
address::{Network, SubaddressIndex, AddressSpec},
|
|
Fee, SpendableOutput, Change, TransactionError, SignableTransaction as MSignableTransaction,
|
|
Eventuality, TransactionMachine,
|
|
},
|
|
};
|
|
|
|
pub use serai_client::{primitives::MAX_DATA_LEN, coins::monero::Address};
|
|
|
|
use crate::{
|
|
Payment, Plan, additional_key,
|
|
coins::{
|
|
CoinError, Block as BlockTrait, OutputType, Output as OutputTrait,
|
|
Transaction as TransactionTrait, Eventuality as EventualityTrait, PostFeeBranch, Coin,
|
|
drop_branches, amortize_fee,
|
|
},
|
|
};
|
|
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct Output(SpendableOutput, Vec<u8>);
|
|
|
|
const EXTERNAL_SUBADDRESS: Option<SubaddressIndex> = SubaddressIndex::new(0, 0);
|
|
const BRANCH_SUBADDRESS: Option<SubaddressIndex> = SubaddressIndex::new(1, 0);
|
|
const CHANGE_SUBADDRESS: Option<SubaddressIndex> = SubaddressIndex::new(2, 0);
|
|
|
|
impl OutputTrait for Output {
|
|
// While we could use (tx, o), using the key ensures we won't be susceptible to the burning bug.
|
|
// While we already are immune, thanks to using featured address, this doesn't hurt and is
|
|
// technically more efficient.
|
|
type Id = [u8; 32];
|
|
|
|
fn kind(&self) -> OutputType {
|
|
match self.0.output.metadata.subaddress {
|
|
EXTERNAL_SUBADDRESS => OutputType::External,
|
|
BRANCH_SUBADDRESS => OutputType::Branch,
|
|
CHANGE_SUBADDRESS => OutputType::Change,
|
|
_ => panic!("unrecognized address was scanned for"),
|
|
}
|
|
}
|
|
|
|
fn id(&self) -> Self::Id {
|
|
self.0.output.data.key.compress().to_bytes()
|
|
}
|
|
|
|
fn amount(&self) -> u64 {
|
|
self.0.commitment().amount
|
|
}
|
|
|
|
fn data(&self) -> &[u8] {
|
|
&self.1
|
|
}
|
|
|
|
fn write<W: io::Write>(&self, writer: &mut W) -> io::Result<()> {
|
|
self.0.write(writer)?;
|
|
writer.write_all(&u16::try_from(self.1.len()).unwrap().to_le_bytes())?;
|
|
writer.write_all(&self.1)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn read<R: io::Read>(reader: &mut R) -> io::Result<Self> {
|
|
let output = SpendableOutput::read(reader)?;
|
|
|
|
let mut data_len = [0; 2];
|
|
reader.read_exact(&mut data_len)?;
|
|
|
|
let mut data = vec![0; usize::from(u16::from_le_bytes(data_len))];
|
|
reader.read_exact(&mut data)?;
|
|
|
|
Ok(Output(output, data))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl TransactionTrait<Monero> for Transaction {
|
|
type Id = [u8; 32];
|
|
fn id(&self) -> Self::Id {
|
|
self.hash()
|
|
}
|
|
fn serialize(&self) -> Vec<u8> {
|
|
self.serialize()
|
|
}
|
|
#[cfg(test)]
|
|
async fn fee(&self, _: &Monero) -> u64 {
|
|
self.rct_signatures.base.fee
|
|
}
|
|
}
|
|
|
|
impl EventualityTrait for Eventuality {
|
|
fn read<R: io::Read>(reader: &mut R) -> io::Result<Self> {
|
|
Eventuality::read(reader)
|
|
}
|
|
fn serialize(&self) -> Vec<u8> {
|
|
self.serialize()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct SignableTransaction {
|
|
keys: ThresholdKeys<Ed25519>,
|
|
transcript: RecommendedTranscript,
|
|
// Monero height, defined as the length of the chain
|
|
height: usize,
|
|
actual: MSignableTransaction,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct Block([u8; 32], MBlock);
|
|
impl BlockTrait<Monero> for Block {
|
|
type Id = [u8; 32];
|
|
fn id(&self) -> Self::Id {
|
|
self.0
|
|
}
|
|
|
|
fn median_fee(&self) -> Fee {
|
|
// TODO
|
|
Fee { per_weight: 80000, mask: 10000 }
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Monero {
|
|
pub(crate) rpc: Rpc,
|
|
}
|
|
// Shim required for testing/debugging purposes due to generic arguments also necessitating trait
|
|
// bounds
|
|
impl PartialEq for Monero {
|
|
fn eq(&self, _: &Self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
impl Eq for Monero {}
|
|
|
|
impl Monero {
|
|
pub fn new(url: String) -> Monero {
|
|
Monero { rpc: Rpc::new(url).unwrap() }
|
|
}
|
|
|
|
fn view_pair(spend: EdwardsPoint) -> ViewPair {
|
|
ViewPair::new(spend.0, Zeroizing::new(additional_key::<Monero>(0).0))
|
|
}
|
|
|
|
fn address_internal(spend: EdwardsPoint, subaddress: Option<SubaddressIndex>) -> Address {
|
|
Address::new(Self::view_pair(spend).address(
|
|
Network::Mainnet,
|
|
AddressSpec::Featured { subaddress, payment_id: None, guaranteed: true },
|
|
))
|
|
.unwrap()
|
|
}
|
|
|
|
fn scanner(spend: EdwardsPoint) -> Scanner {
|
|
let mut scanner = Scanner::from_view(Self::view_pair(spend), None);
|
|
debug_assert!(EXTERNAL_SUBADDRESS.is_none());
|
|
scanner.register_subaddress(BRANCH_SUBADDRESS.unwrap());
|
|
scanner.register_subaddress(CHANGE_SUBADDRESS.unwrap());
|
|
scanner
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn test_view_pair() -> ViewPair {
|
|
ViewPair::new(*EdwardsPoint::generator(), Zeroizing::new(Scalar::one().0))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn test_scanner() -> Scanner {
|
|
Scanner::from_view(Self::test_view_pair(), Some(std::collections::HashSet::new()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn test_address() -> Address {
|
|
Address::new(Self::test_view_pair().address(Network::Mainnet, AddressSpec::Standard)).unwrap()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Coin for Monero {
|
|
type Curve = Ed25519;
|
|
|
|
type Fee = Fee;
|
|
type Transaction = Transaction;
|
|
type Block = Block;
|
|
|
|
type Output = Output;
|
|
type SignableTransaction = SignableTransaction;
|
|
type Eventuality = Eventuality;
|
|
type TransactionMachine = TransactionMachine;
|
|
|
|
type Address = Address;
|
|
|
|
const ID: &'static str = "Monero";
|
|
const CONFIRMATIONS: usize = 10;
|
|
|
|
// wallet2 will not create a transaction larger than 100kb, and Monero won't relay a transaction
|
|
// larger than 150kb. This fits within the 100kb mark
|
|
// Technically, it can be ~124, yet a small bit of buffer is appreciated
|
|
// TODO: Test creating a TX this big
|
|
const MAX_INPUTS: usize = 120;
|
|
const MAX_OUTPUTS: usize = 16;
|
|
|
|
// 0.01 XMR
|
|
const DUST: u64 = 10000000000;
|
|
|
|
// Monero doesn't require/benefit from tweaking
|
|
fn tweak_keys(_: &mut ThresholdKeys<Self::Curve>) {}
|
|
|
|
fn address(key: EdwardsPoint) -> Self::Address {
|
|
Self::address_internal(key, EXTERNAL_SUBADDRESS)
|
|
}
|
|
|
|
fn branch_address(key: EdwardsPoint) -> Self::Address {
|
|
Self::address_internal(key, BRANCH_SUBADDRESS)
|
|
}
|
|
|
|
async fn get_latest_block_number(&self) -> Result<usize, CoinError> {
|
|
// Monero defines height as chain length, so subtract 1 for block number
|
|
Ok(self.rpc.get_height().await.map_err(|_| CoinError::ConnectionError)? - 1)
|
|
}
|
|
|
|
async fn get_block(&self, number: usize) -> Result<Self::Block, CoinError> {
|
|
let hash = self.rpc.get_block_hash(number).await.map_err(|_| CoinError::ConnectionError)?;
|
|
let block = self.rpc.get_block(hash).await.map_err(|_| CoinError::ConnectionError)?;
|
|
Ok(Block(hash, block))
|
|
}
|
|
|
|
async fn get_outputs(
|
|
&self,
|
|
block: &Self::Block,
|
|
key: EdwardsPoint,
|
|
) -> Result<Vec<Self::Output>, CoinError> {
|
|
let mut txs = Self::scanner(key)
|
|
.scan(&self.rpc, &block.1)
|
|
.await
|
|
.map_err(|_| CoinError::ConnectionError)?
|
|
.iter()
|
|
.filter_map(|outputs| Some(outputs.not_locked()).filter(|outputs| !outputs.is_empty()))
|
|
.collect::<Vec<_>>();
|
|
|
|
// This should be pointless as we shouldn't be able to scan for any other subaddress
|
|
// This just ensures nothing invalid makes it through
|
|
for tx_outputs in &txs {
|
|
for output in tx_outputs {
|
|
assert!([EXTERNAL_SUBADDRESS, BRANCH_SUBADDRESS, CHANGE_SUBADDRESS]
|
|
.contains(&output.output.metadata.subaddress));
|
|
}
|
|
}
|
|
|
|
let mut outputs = Vec::with_capacity(txs.len());
|
|
for mut tx_outputs in txs.drain(..) {
|
|
for output in tx_outputs.drain(..) {
|
|
let mut data = output.arbitrary_data().get(0).cloned().unwrap_or(vec![]);
|
|
|
|
// The Output serialization code above uses u16 to represent length
|
|
data.truncate(u16::MAX.into());
|
|
// Monero data segments should be <= 255 already, and MAX_DATA_LEN is currently 512
|
|
// This just allows either Monero to change, or MAX_DATA_LEN to change, without introducing
|
|
// complicationso
|
|
data.truncate(MAX_DATA_LEN.try_into().unwrap());
|
|
|
|
outputs.push(Output(output, data));
|
|
}
|
|
}
|
|
|
|
Ok(outputs)
|
|
}
|
|
|
|
async fn prepare_send(
|
|
&self,
|
|
keys: ThresholdKeys<Ed25519>,
|
|
block_number: usize,
|
|
mut plan: Plan<Self>,
|
|
fee: Fee,
|
|
) -> Result<(Option<(SignableTransaction, Eventuality)>, Vec<PostFeeBranch>), CoinError> {
|
|
// Sanity check this has at least one output planned
|
|
assert!((!plan.payments.is_empty()) || plan.change.is_some());
|
|
|
|
// Get the protocol for the specified block number
|
|
// For now, this should just be v16, the latest deployed protocol, since there's no upcoming
|
|
// hard fork to be mindful of
|
|
let get_protocol = || Protocol::v16;
|
|
|
|
#[cfg(not(test))]
|
|
let protocol = get_protocol();
|
|
// If this is a test, we won't be using a mainnet node and need a distinct protocol
|
|
// determination
|
|
// Just use whatever the node expects
|
|
#[cfg(test)]
|
|
let protocol = self.rpc.get_protocol().await.unwrap();
|
|
|
|
// Hedge against the above codegen failing by having an always included runtime check
|
|
if !cfg!(test) {
|
|
assert_eq!(protocol, get_protocol());
|
|
}
|
|
|
|
// Check a fork hasn't occurred which this processor hasn't been updated for
|
|
assert_eq!(protocol, self.rpc.get_protocol().await.map_err(|_| CoinError::ConnectionError)?);
|
|
|
|
let signable = |plan: &mut Plan<Self>, tx_fee: Option<_>| {
|
|
// Monero requires at least two outputs
|
|
// If we only have one output planned, add a dummy payment
|
|
let outputs = plan.payments.len() + usize::from(u8::from(plan.change.is_some()));
|
|
if outputs == 0 {
|
|
return Ok(None);
|
|
} else if outputs == 1 {
|
|
plan.payments.push(Payment {
|
|
address: Address::new(
|
|
ViewPair::new(EdwardsPoint::generator().0, Zeroizing::new(Scalar::one().0))
|
|
.address(Network::Mainnet, AddressSpec::Standard),
|
|
)
|
|
.unwrap(),
|
|
amount: 0,
|
|
data: None,
|
|
});
|
|
}
|
|
|
|
let mut payments = vec![];
|
|
for payment in &plan.payments {
|
|
// If we're solely estimating the fee, don't actually specify an amount
|
|
// This won't affect the fee calculation yet will ensure we don't hit an out of funds error
|
|
payments.push((
|
|
payment.address.clone().into(),
|
|
if tx_fee.is_none() { 0 } else { payment.amount },
|
|
));
|
|
}
|
|
|
|
match MSignableTransaction::new(
|
|
protocol,
|
|
// Use the plan ID as the r_seed
|
|
// This perfectly binds the plan while simultaneously allowing verifying the plan was
|
|
// executed with no additional communication
|
|
Some(Zeroizing::new(plan.id())),
|
|
plan.inputs.iter().cloned().map(|input| input.0).collect(),
|
|
payments,
|
|
plan.change.map(|key| {
|
|
Change::fingerprintable(Self::address_internal(key, CHANGE_SUBADDRESS).into())
|
|
}),
|
|
vec![],
|
|
fee,
|
|
) {
|
|
Ok(signable) => Ok(Some(signable)),
|
|
Err(e) => match e {
|
|
TransactionError::MultiplePaymentIds => {
|
|
panic!("multiple payment IDs despite not supporting integrated addresses");
|
|
}
|
|
TransactionError::NoInputs |
|
|
TransactionError::NoOutputs |
|
|
TransactionError::NoChange |
|
|
TransactionError::TooManyOutputs |
|
|
TransactionError::TooMuchData |
|
|
TransactionError::TooLargeTransaction |
|
|
TransactionError::WrongPrivateKey => {
|
|
panic!("created an Monero invalid transaction: {e}");
|
|
}
|
|
TransactionError::ClsagError(_) |
|
|
TransactionError::InvalidTransaction(_) |
|
|
TransactionError::FrostError(_) => {
|
|
panic!("supposedly unreachable (at this time) Monero error: {e}");
|
|
}
|
|
TransactionError::NotEnoughFunds(_, _) => {
|
|
if tx_fee.is_none() {
|
|
Ok(None)
|
|
} else {
|
|
panic!("didn't have enough funds for a Monero TX");
|
|
}
|
|
}
|
|
TransactionError::RpcError(e) => {
|
|
log::error!("RpcError when preparing transaction: {e:?}");
|
|
Err(CoinError::ConnectionError)
|
|
}
|
|
},
|
|
}
|
|
};
|
|
|
|
let tx_fee = match signable(&mut plan, None)? {
|
|
Some(tx) => tx.fee(),
|
|
None => return Ok((None, drop_branches(&plan))),
|
|
};
|
|
|
|
let branch_outputs = amortize_fee(&mut plan, tx_fee);
|
|
|
|
let signable = SignableTransaction {
|
|
keys,
|
|
transcript: plan.transcript(),
|
|
height: block_number + 1,
|
|
actual: match signable(&mut plan, Some(tx_fee))? {
|
|
Some(signable) => signable,
|
|
None => return Ok((None, branch_outputs)),
|
|
},
|
|
};
|
|
let eventuality = signable.actual.eventuality().unwrap();
|
|
Ok((Some((signable, eventuality)), branch_outputs))
|
|
}
|
|
|
|
async fn attempt_send(
|
|
&self,
|
|
transaction: SignableTransaction,
|
|
) -> Result<Self::TransactionMachine, CoinError> {
|
|
transaction
|
|
.actual
|
|
.clone()
|
|
.multisig(
|
|
&self.rpc,
|
|
transaction.keys.clone(),
|
|
transaction.transcript.clone(),
|
|
transaction.height,
|
|
)
|
|
.await
|
|
.map_err(|_| CoinError::ConnectionError)
|
|
}
|
|
|
|
async fn publish_transaction(&self, tx: &Self::Transaction) -> Result<(), CoinError> {
|
|
match self.rpc.publish_transaction(tx).await {
|
|
Ok(_) => Ok(()),
|
|
Err(RpcError::ConnectionError) => Err(CoinError::ConnectionError)?,
|
|
// TODO: Distinguish already in pool vs double spend (other signing attempt succeeded) vs
|
|
// invalid transaction
|
|
Err(e) => panic!("failed to publish TX {:?}: {e}", tx.hash()),
|
|
}
|
|
}
|
|
|
|
async fn get_transaction(&self, id: &[u8; 32]) -> Result<Transaction, CoinError> {
|
|
self.rpc.get_transaction(*id).await.map_err(|_| CoinError::ConnectionError)
|
|
}
|
|
|
|
fn confirm_completion(&self, eventuality: &Eventuality, tx: &Transaction) -> bool {
|
|
eventuality.matches(tx)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn get_block_number(&self, id: &[u8; 32]) -> usize {
|
|
self.rpc.get_block(*id).await.unwrap().number()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn get_fee(&self) -> Self::Fee {
|
|
self.rpc.get_fee().await.unwrap()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn mine_block(&self) {
|
|
// https://github.com/serai-dex/serai/issues/198
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
|
|
#[derive(serde::Deserialize, Debug)]
|
|
struct EmptyResponse {}
|
|
let _: EmptyResponse = self
|
|
.rpc
|
|
.rpc_call(
|
|
"json_rpc",
|
|
Some(serde_json::json!({
|
|
"method": "generateblocks",
|
|
"params": {
|
|
"wallet_address": Self::test_address().to_string(),
|
|
"amount_of_blocks": 1
|
|
},
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn test_send(&self, address: Self::Address) -> Block {
|
|
use zeroize::Zeroizing;
|
|
use rand_core::OsRng;
|
|
|
|
let new_block = self.get_latest_block_number().await.unwrap() + 1;
|
|
for _ in 0 .. 80 {
|
|
self.mine_block().await;
|
|
}
|
|
|
|
let outputs = Self::test_scanner()
|
|
.scan(&self.rpc, &self.rpc.get_block_by_number(new_block).await.unwrap())
|
|
.await
|
|
.unwrap()
|
|
.swap_remove(0)
|
|
.ignore_timelock();
|
|
|
|
let amount = outputs[0].commitment().amount;
|
|
// The dust should always be sufficient for the fee
|
|
let fee = Monero::DUST;
|
|
|
|
let tx = MSignableTransaction::new(
|
|
self.rpc.get_protocol().await.unwrap(),
|
|
None,
|
|
outputs,
|
|
vec![(address.into(), amount - fee)],
|
|
Some(Change::fingerprintable(Self::test_address().into())),
|
|
vec![],
|
|
self.rpc.get_fee().await.unwrap(),
|
|
)
|
|
.unwrap()
|
|
.sign(&mut OsRng, &self.rpc, &Zeroizing::new(Scalar::one().0))
|
|
.await
|
|
.unwrap();
|
|
|
|
let block = self.get_latest_block_number().await.unwrap() + 1;
|
|
self.rpc.publish_transaction(&tx).await.unwrap();
|
|
for _ in 0 .. 10 {
|
|
self.mine_block().await;
|
|
}
|
|
self.get_block(block).await.unwrap()
|
|
}
|
|
}
|