mirror of
https://github.com/serai-dex/serai.git
synced 2024-12-27 05:59:35 +00:00
7d2d739042
* 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
355 lines
11 KiB
Rust
355 lines
11 KiB
Rust
use core::ops::Deref;
|
|
use std_shims::sync::OnceLock;
|
|
|
|
use zeroize::Zeroizing;
|
|
use rand_core::OsRng;
|
|
|
|
use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar};
|
|
|
|
use tokio::sync::Mutex;
|
|
|
|
use monero_simple_request_rpc::SimpleRequestRpc;
|
|
use monero_wallet::{
|
|
ringct::RctType,
|
|
transaction::Transaction,
|
|
block::Block,
|
|
rpc::{Rpc, FeeRate},
|
|
address::{Network, AddressType, MoneroAddress},
|
|
DEFAULT_LOCK_WINDOW, ViewPair, GuaranteedViewPair, WalletOutput, Scanner,
|
|
};
|
|
|
|
mod builder;
|
|
pub use builder::SignableTransactionBuilder;
|
|
|
|
pub fn ring_len(rct_type: RctType) -> usize {
|
|
match rct_type {
|
|
RctType::ClsagBulletproof => 11,
|
|
RctType::ClsagBulletproofPlus => 16,
|
|
_ => panic!("ring size unknown for RctType"),
|
|
}
|
|
}
|
|
|
|
pub fn random_address() -> (Scalar, ViewPair, MoneroAddress) {
|
|
let spend = Scalar::random(&mut OsRng);
|
|
let spend_pub = &spend * ED25519_BASEPOINT_TABLE;
|
|
let view = Zeroizing::new(Scalar::random(&mut OsRng));
|
|
(
|
|
spend,
|
|
ViewPair::new(spend_pub, view.clone()).unwrap(),
|
|
MoneroAddress::new(
|
|
Network::Mainnet,
|
|
AddressType::Legacy,
|
|
spend_pub,
|
|
view.deref() * ED25519_BASEPOINT_TABLE,
|
|
),
|
|
)
|
|
}
|
|
|
|
#[allow(unused)]
|
|
pub fn random_guaranteed_address() -> (Scalar, GuaranteedViewPair, MoneroAddress) {
|
|
let spend = Scalar::random(&mut OsRng);
|
|
let spend_pub = &spend * ED25519_BASEPOINT_TABLE;
|
|
let view = Zeroizing::new(Scalar::random(&mut OsRng));
|
|
(
|
|
spend,
|
|
GuaranteedViewPair::new(spend_pub, view.clone()).unwrap(),
|
|
MoneroAddress::new(
|
|
Network::Mainnet,
|
|
AddressType::Legacy,
|
|
spend_pub,
|
|
view.deref() * ED25519_BASEPOINT_TABLE,
|
|
),
|
|
)
|
|
}
|
|
|
|
// TODO: Support transactions already on-chain
|
|
// TODO: Don't have a side effect of mining blocks more blocks than needed under race conditions
|
|
pub async fn mine_until_unlocked(
|
|
rpc: &SimpleRequestRpc,
|
|
addr: &MoneroAddress,
|
|
tx_hash: [u8; 32],
|
|
) -> Block {
|
|
// mine until tx is in a block
|
|
let mut height = rpc.get_height().await.unwrap();
|
|
let mut found = false;
|
|
let mut block = None;
|
|
while !found {
|
|
let inner_block = rpc.get_block_by_number(height - 1).await.unwrap();
|
|
found = match inner_block.transactions.iter().find(|&&x| x == tx_hash) {
|
|
Some(_) => {
|
|
block = Some(inner_block);
|
|
true
|
|
}
|
|
None => {
|
|
height = rpc.generate_blocks(addr, 1).await.unwrap().1 + 1;
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mine until tx's outputs are unlocked
|
|
for _ in 0 .. (DEFAULT_LOCK_WINDOW - 1) {
|
|
rpc.generate_blocks(addr, 1).await.unwrap();
|
|
}
|
|
|
|
block.unwrap()
|
|
}
|
|
|
|
// Mines 60 blocks and returns an unlocked miner TX output.
|
|
#[allow(dead_code)]
|
|
pub async fn get_miner_tx_output(rpc: &SimpleRequestRpc, view: &ViewPair) -> WalletOutput {
|
|
let mut scanner = Scanner::new(view.clone());
|
|
|
|
// Mine 60 blocks to unlock a miner TX
|
|
let start = rpc.get_height().await.unwrap();
|
|
rpc.generate_blocks(&view.legacy_address(Network::Mainnet), 60).await.unwrap();
|
|
|
|
let block = rpc.get_block_by_number(start).await.unwrap();
|
|
scanner.scan(rpc, &block).await.unwrap().ignore_additional_timelock().swap_remove(0)
|
|
}
|
|
|
|
/// Make sure the weight and fee match the expected calculation.
|
|
pub fn check_weight_and_fee(tx: &Transaction, fee_rate: FeeRate) {
|
|
let Transaction::V2 { proofs: Some(ref proofs), .. } = tx else { panic!("TX wasn't RingCT") };
|
|
let fee = proofs.base.fee;
|
|
|
|
let weight = tx.weight();
|
|
let expected_weight = fee_rate.calculate_weight_from_fee(fee);
|
|
assert_eq!(weight, expected_weight);
|
|
|
|
let expected_fee = fee_rate.calculate_fee_from_weight(weight);
|
|
assert_eq!(fee, expected_fee);
|
|
}
|
|
|
|
pub async fn rpc() -> SimpleRequestRpc {
|
|
let rpc =
|
|
SimpleRequestRpc::new("http://serai:seraidex@127.0.0.1:18081".to_string()).await.unwrap();
|
|
|
|
const BLOCKS_TO_MINE: usize = 110;
|
|
|
|
// Only run once
|
|
if rpc.get_height().await.unwrap() > BLOCKS_TO_MINE {
|
|
return rpc;
|
|
}
|
|
|
|
let addr = MoneroAddress::new(
|
|
Network::Mainnet,
|
|
AddressType::Legacy,
|
|
&Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE,
|
|
&Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE,
|
|
);
|
|
|
|
// Mine enough blocks to ensure decoy availability
|
|
rpc.generate_blocks(&addr, BLOCKS_TO_MINE).await.unwrap();
|
|
|
|
rpc
|
|
}
|
|
|
|
pub static SEQUENTIAL: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
#[macro_export]
|
|
macro_rules! async_sequential {
|
|
($(async fn $name: ident() $body: block)*) => {
|
|
$(
|
|
#[tokio::test]
|
|
async fn $name() {
|
|
let guard = runner::SEQUENTIAL.get_or_init(|| tokio::sync::Mutex::new(())).lock().await;
|
|
let local = tokio::task::LocalSet::new();
|
|
local.run_until(async move {
|
|
if let Err(err) = tokio::task::spawn_local(async move { $body }).await {
|
|
drop(guard);
|
|
Err(err).unwrap()
|
|
}
|
|
}).await;
|
|
}
|
|
)*
|
|
}
|
|
}
|
|
|
|
#[macro_export]
|
|
macro_rules! test {
|
|
(
|
|
$name: ident,
|
|
(
|
|
$first_tx: expr,
|
|
$first_checks: expr,
|
|
),
|
|
$((
|
|
$tx: expr,
|
|
$checks: expr,
|
|
)$(,)?),*
|
|
) => {
|
|
async_sequential! {
|
|
async fn $name() {
|
|
use core::{ops::Deref, any::Any};
|
|
#[cfg(feature = "multisig")]
|
|
use std::collections::HashMap;
|
|
|
|
use zeroize::Zeroizing;
|
|
use rand_core::{RngCore, OsRng};
|
|
|
|
use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar};
|
|
|
|
#[cfg(feature = "multisig")]
|
|
use frost::{
|
|
curve::Ed25519,
|
|
Participant,
|
|
tests::{THRESHOLD, key_gen},
|
|
};
|
|
|
|
use monero_wallet::{
|
|
ringct::RctType,
|
|
rpc::FeePriority,
|
|
address::Network,
|
|
ViewPair, Scanner, OutputWithDecoys,
|
|
send::{Change, SignableTransaction, Eventuality},
|
|
};
|
|
|
|
use runner::{
|
|
SignableTransactionBuilder, ring_len, random_address, rpc, mine_until_unlocked,
|
|
get_miner_tx_output, check_weight_and_fee,
|
|
};
|
|
|
|
type Builder = SignableTransactionBuilder;
|
|
|
|
// Run each function as both a single signer and as a multisig
|
|
#[allow(clippy::redundant_closure_call)]
|
|
for multisig in [false, true] {
|
|
// Only run the multisig variant if multisig is enabled
|
|
if multisig {
|
|
#[cfg(not(feature = "multisig"))]
|
|
continue;
|
|
}
|
|
|
|
let spend = Zeroizing::new(Scalar::random(&mut OsRng));
|
|
#[cfg(feature = "multisig")]
|
|
let keys = key_gen::<_, Ed25519>(&mut OsRng);
|
|
|
|
let spend_pub = if !multisig {
|
|
spend.deref() * ED25519_BASEPOINT_TABLE
|
|
} else {
|
|
#[cfg(not(feature = "multisig"))]
|
|
panic!("Multisig branch called without the multisig feature");
|
|
#[cfg(feature = "multisig")]
|
|
keys[&Participant::new(1).unwrap()].group_key().0
|
|
};
|
|
|
|
let rpc = rpc().await;
|
|
|
|
let view_priv = Zeroizing::new(Scalar::random(&mut OsRng));
|
|
let mut outgoing_view = Zeroizing::new([0; 32]);
|
|
OsRng.fill_bytes(outgoing_view.as_mut());
|
|
let view = ViewPair::new(spend_pub, view_priv.clone()).unwrap();
|
|
let addr = view.legacy_address(Network::Mainnet);
|
|
|
|
let miner_tx = get_miner_tx_output(&rpc, &view).await;
|
|
|
|
let rct_type = match rpc.get_hardfork_version().await.unwrap() {
|
|
14 => RctType::ClsagBulletproof,
|
|
15 | 16 => RctType::ClsagBulletproofPlus,
|
|
_ => panic!("unrecognized hardfork version"),
|
|
};
|
|
|
|
let builder = SignableTransactionBuilder::new(
|
|
rct_type,
|
|
outgoing_view,
|
|
Change::new(
|
|
ViewPair::new(
|
|
&Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE,
|
|
Zeroizing::new(Scalar::random(&mut OsRng))
|
|
).unwrap(),
|
|
None,
|
|
),
|
|
rpc.get_fee_rate(FeePriority::Unimportant).await.unwrap(),
|
|
);
|
|
|
|
let sign = |tx: SignableTransaction| {
|
|
let spend = spend.clone();
|
|
#[cfg(feature = "multisig")]
|
|
let keys = keys.clone();
|
|
|
|
assert_eq!(&SignableTransaction::read(&mut tx.serialize().as_slice()).unwrap(), &tx);
|
|
|
|
let eventuality = Eventuality::from(tx.clone());
|
|
|
|
let tx = if !multisig {
|
|
tx.sign(&mut OsRng, &spend).unwrap()
|
|
} else {
|
|
#[cfg(not(feature = "multisig"))]
|
|
panic!("multisig branch called without the multisig feature");
|
|
#[cfg(feature = "multisig")]
|
|
{
|
|
let mut machines = HashMap::new();
|
|
for i in (1 ..= THRESHOLD).map(|i| Participant::new(i).unwrap()) {
|
|
machines.insert(i, tx.clone().multisig(&keys[&i]).unwrap());
|
|
}
|
|
|
|
frost::tests::sign_without_caching(&mut OsRng, machines, &[])
|
|
}
|
|
};
|
|
|
|
assert_eq!(&eventuality.extra(), &tx.prefix().extra, "eventuality extra was distinct");
|
|
assert!(eventuality.matches(&tx.clone().into()), "eventuality didn't match");
|
|
|
|
tx
|
|
};
|
|
|
|
// TODO: Generate a distinct wallet for each transaction to prevent overlap
|
|
let next_addr = addr;
|
|
|
|
let temp = Box::new({
|
|
let mut builder = builder.clone();
|
|
|
|
let input = OutputWithDecoys::fingerprintable_deterministic_new(
|
|
&mut OsRng,
|
|
&rpc,
|
|
ring_len(rct_type),
|
|
rpc.get_height().await.unwrap(),
|
|
miner_tx,
|
|
).await.unwrap();
|
|
builder.add_input(input);
|
|
|
|
let (tx, state) = ($first_tx)(rpc.clone(), builder, next_addr).await;
|
|
let fee_rate = tx.fee_rate().clone();
|
|
let signed = sign(tx);
|
|
rpc.publish_transaction(&signed).await.unwrap();
|
|
let block =
|
|
mine_until_unlocked(&rpc, &random_address().2, signed.hash()).await;
|
|
let tx = rpc.get_transaction(signed.hash()).await.unwrap();
|
|
check_weight_and_fee(&tx, fee_rate);
|
|
let scanner = Scanner::new(view.clone());
|
|
($first_checks)(rpc.clone(), block, tx, scanner, state).await
|
|
});
|
|
#[allow(unused_variables, unused_mut, unused_assignments)]
|
|
let mut carried_state: Box<dyn Any> = temp;
|
|
|
|
$(
|
|
let (tx, state) = ($tx)(
|
|
rct_type,
|
|
rpc.clone(),
|
|
builder.clone(),
|
|
next_addr,
|
|
*carried_state.downcast().unwrap()
|
|
).await;
|
|
let fee_rate = tx.fee_rate().clone();
|
|
let signed = sign(tx);
|
|
rpc.publish_transaction(&signed).await.unwrap();
|
|
let block =
|
|
mine_until_unlocked(&rpc, &random_address().2, signed.hash()).await;
|
|
let tx = rpc.get_transaction(signed.hash()).await.unwrap();
|
|
if stringify!($name) != "spend_one_input_to_two_outputs_no_change" {
|
|
// Skip weight and fee check for the above test because when there is no change,
|
|
// the change is added to the fee
|
|
check_weight_and_fee(&tx, fee_rate);
|
|
}
|
|
#[allow(unused_assignments)]
|
|
{
|
|
let scanner = Scanner::new(view.clone());
|
|
carried_state = Box::new(($checks)(rpc.clone(), block, tx, scanner, state).await);
|
|
}
|
|
)*
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|