Write a test runner for Monero transactions

Also includes a few fixes for the library itself. Supersedes #172.
This commit is contained in:
Luke Parker 2022-12-05 17:25:09 -05:00
parent 7a16ce78b0
commit 8f352353ba
No known key found for this signature in database
6 changed files with 321 additions and 279 deletions

View file

@ -27,7 +27,7 @@ pub struct JsonRpcResponse<T> {
#[derive(Deserialize, Debug)]
struct TransactionResponse {
tx_hash: String,
block_height: usize,
block_height: Option<usize>,
as_hex: String,
pruned_as_hex: String,
}
@ -274,7 +274,7 @@ impl Rpc {
self.get_transactions(&[tx]).await.map(|mut txs| txs.swap_remove(0))
}
pub async fn get_transaction_block_number(&self, tx: &[u8]) -> Result<usize, RpcError> {
pub async fn get_transaction_block_number(&self, tx: &[u8]) -> Result<Option<usize>, RpcError> {
let txs: TransactionsResponse =
self.rpc_call("get_transactions", Some(json!({ "txs_hashes": [hex::encode(tx)] }))).await?;

View file

@ -18,7 +18,7 @@ pub mod address;
use address::{Network, AddressType, AddressMeta, MoneroAddress};
mod scan;
pub use scan::SpendableOutput;
pub use scan::{ReceivedOutput, SpendableOutput};
pub(crate) mod decoys;
pub(crate) use decoys::Decoys;

View file

@ -362,7 +362,7 @@ impl SignableTransaction {
/// Sign this transaction.
pub async fn sign<R: RngCore + CryptoRng>(
&mut self,
mut self,
rng: &mut R,
rpc: &Rpc,
spend: &Zeroizing<Scalar>,

View file

@ -1,49 +0,0 @@
use rand_core::OsRng;
use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE;
use serde_json::json;
use monero_serai::{
Protocol, random_scalar,
wallet::address::{Network, AddressType, AddressMeta, MoneroAddress},
rpc::{EmptyResponse, RpcError, Rpc},
};
pub async fn rpc() -> Rpc {
let rpc = Rpc::new("http://127.0.0.1:18081".to_string()).unwrap();
// Only run once
if rpc.get_height().await.unwrap() != 1 {
return rpc;
}
let addr = MoneroAddress {
meta: AddressMeta::new(Network::Mainnet, AddressType::Standard),
spend: &random_scalar(&mut OsRng) * &ED25519_BASEPOINT_TABLE,
view: &random_scalar(&mut OsRng) * &ED25519_BASEPOINT_TABLE,
}
.to_string();
// Mine 20 blocks to ensure decoy availability
mine_block(&rpc, &addr).await.unwrap();
mine_block(&rpc, &addr).await.unwrap();
assert!(!matches!(rpc.get_protocol().await.unwrap(), Protocol::Unsupported(_)));
rpc
}
pub async fn mine_block(rpc: &Rpc, address: &str) -> Result<EmptyResponse, RpcError> {
rpc
.rpc_call(
"json_rpc",
Some(json!({
"method": "generateblocks",
"params": {
"wallet_address": address,
"amount_of_blocks": 10
},
})),
)
.await
}

View file

@ -0,0 +1,266 @@
use std::sync::Mutex;
use lazy_static::lazy_static;
use rand_core::OsRng;
use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar};
use serde_json::json;
use monero_serai::{
Protocol, random_scalar,
wallet::{
ViewPair,
address::{Network, AddressType, AddressMeta, MoneroAddress},
},
rpc::{EmptyResponse, Rpc},
};
pub fn random_address() -> (Scalar, ViewPair, MoneroAddress) {
let spend = random_scalar(&mut OsRng);
let spend_pub = &spend * &ED25519_BASEPOINT_TABLE;
let view = random_scalar(&mut OsRng);
(
spend,
ViewPair::new(spend_pub, view),
MoneroAddress {
meta: AddressMeta::new(Network::Mainnet, AddressType::Standard),
spend: spend_pub,
view: &view * &ED25519_BASEPOINT_TABLE,
},
)
}
pub async fn mine_blocks(rpc: &Rpc, address: &str) {
rpc
.rpc_call::<_, EmptyResponse>(
"json_rpc",
Some(json!({
"method": "generateblocks",
"params": {
"wallet_address": address,
"amount_of_blocks": 10
},
})),
)
.await
.unwrap();
}
pub async fn rpc() -> Rpc {
let rpc = Rpc::new("http://127.0.0.1:18081".to_string()).unwrap();
// Only run once
if rpc.get_height().await.unwrap() != 1 {
return rpc;
}
let addr = MoneroAddress {
meta: AddressMeta::new(Network::Mainnet, AddressType::Standard),
spend: &random_scalar(&mut OsRng) * &ED25519_BASEPOINT_TABLE,
view: &random_scalar(&mut OsRng) * &ED25519_BASEPOINT_TABLE,
}
.to_string();
// Mine 40 blocks to ensure decoy availability
for _ in 0 .. 4 {
mine_blocks(&rpc, &addr).await;
}
assert!(!matches!(rpc.get_protocol().await.unwrap(), Protocol::Unsupported(_)));
rpc
}
lazy_static! {
pub static ref SEQUENTIAL: Mutex<()> = Mutex::new(());
}
#[macro_export]
macro_rules! async_sequential {
($(async fn $name: ident() $body: block)*) => {
$(
#[tokio::test]
async fn $name() {
let guard = runner::SEQUENTIAL.lock().unwrap();
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};
use std::collections::HashSet;
#[cfg(feature = "multisig")]
use std::collections::HashMap;
use zeroize::Zeroizing;
use rand_core::OsRng;
use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE;
#[cfg(feature = "multisig")]
use transcript::{Transcript, RecommendedTranscript};
#[cfg(feature = "multisig")]
use frost::{
curve::Ed25519,
tests::{THRESHOLD, key_gen},
};
use monero_serai::{
random_scalar,
wallet::{
address::Network, ViewPair, Scanner, SignableTransaction,
SignableTransactionBuilder,
},
};
use runner::{random_address, rpc, mine_blocks};
type Builder = SignableTransactionBuilder;
// Run each function as both a single signer and as a multisig
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(random_scalar(&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[&1].group_key().0
};
let view = ViewPair::new(spend_pub, random_scalar(&mut OsRng));
let rpc = rpc().await;
let (addr, miner_tx) = {
let mut scanner =
Scanner::from_view(view.clone(), Network::Mainnet, Some(HashSet::new()));
let addr = scanner.address();
let start = rpc.get_height().await.unwrap();
for _ in 0 .. 7 {
mine_blocks(&rpc, &addr.to_string()).await;
}
let block = rpc.get_block(start).await.unwrap();
(
addr,
scanner.scan(
&rpc,
&block
).await.unwrap().swap_remove(0).ignore_timelock().swap_remove(0)
)
};
let builder = SignableTransactionBuilder::new(
rpc.get_protocol().await.unwrap(),
rpc.get_fee().await.unwrap(),
Some(random_address().2),
);
let sign = |tx: SignableTransaction| {
let rpc = rpc.clone();
let spend = spend.clone();
#[cfg(feature = "multisig")]
let keys = keys.clone();
async move {
if !multisig {
tx.sign(&mut OsRng, &rpc, &spend).await.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 {
machines.insert(
i,
tx
.clone()
.multisig(
&rpc,
keys[&i].clone(),
RecommendedTranscript::new(b"Monero Serai Test Transaction"),
rpc.get_height().await.unwrap() - 10,
(1 ..= THRESHOLD).collect::<Vec<_>>(),
)
.await
.unwrap(),
);
}
frost::tests::sign(&mut OsRng, machines, &vec![])
}
}
}
};
// TODO: Generate a distinct wallet for each transaction to prevent overlap
let next_addr = addr;
let temp = Box::new({
let mut builder = builder.clone();
builder.add_input(miner_tx);
let (tx, state) = ($first_tx)(rpc.clone(), builder, next_addr).await;
let signed = sign(tx).await;
rpc.publish_transaction(&signed).await.unwrap();
mine_blocks(&rpc, &random_address().2.to_string()).await;
($first_checks)(rpc.clone(), signed.hash(), view.clone(), state).await
});
#[allow(unused_variables, unused_mut, unused_assignments)]
let mut carried_state: Box<dyn Any> = temp;
$(
let (tx, state) = ($tx)(
rpc.clone(),
builder.clone(),
next_addr,
*carried_state.downcast().unwrap()
).await;
let signed = sign(tx).await;
rpc.publish_transaction(&signed).await.unwrap();
#[allow(unused_assignments)]
{
carried_state =
Box::new(($checks)(rpc.clone(), signed.hash(), view.clone(), state).await);
}
)*
}
}
}
}
}

View file

@ -1,232 +1,57 @@
use core::ops::Deref;
use std::{sync::Mutex, collections::HashSet};
#[cfg(feature = "multisig")]
use std::collections::HashMap;
use lazy_static::lazy_static;
use zeroize::Zeroizing;
use rand_core::OsRng;
#[cfg(feature = "multisig")]
use blake2::{digest::Update, Digest, Blake2b512};
use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE;
#[cfg(feature = "multisig")]
use dalek_ff_group::Scalar;
#[cfg(feature = "multisig")]
use transcript::{Transcript, RecommendedTranscript};
#[cfg(feature = "multisig")]
use frost::{
curve::Ed25519,
tests::{THRESHOLD, key_gen, sign},
};
use monero_serai::{
random_scalar,
wallet::{
address::Network, ViewPair, Scanner, SpendableOutput, SignableTransaction,
SignableTransactionBuilder,
},
rpc::Rpc,
wallet::{ReceivedOutput, SpendableOutput},
};
mod rpc;
use crate::rpc::{rpc, mine_block};
mod runner;
lazy_static! {
static ref SEQUENTIAL: Mutex<()> = Mutex::new(());
}
test!(
spend_miner_output,
(
|_, mut builder: Builder, addr| async move {
builder.add_payment(addr, 5);
(builder.build().unwrap(), ())
},
|rpc: Rpc, hash, view, _| async move {
let mut scanner = Scanner::from_view(view, Network::Mainnet, Some(HashSet::new()));
let tx = rpc.get_transaction(hash).await.unwrap();
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
assert_eq!(output.commitment().amount, 5);
},
),
);
macro_rules! async_sequential {
($(async fn $name: ident() $body: block)*) => {
$(
#[tokio::test]
async fn $name() {
let guard = SEQUENTIAL.lock().unwrap();
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;
test!(
spend_multiple_outputs,
(
|_, mut builder: Builder, addr| async move {
builder.add_payment(addr, 1000000000000);
builder.add_payment(addr, 2000000000000);
(builder.build().unwrap(), ())
},
|rpc: Rpc, hash, view, _| async move {
let mut scanner = Scanner::from_view(view, Network::Mainnet, Some(HashSet::new()));
let tx = rpc.get_transaction(hash).await.unwrap();
let mut outputs = scanner.scan_transaction(&tx).not_locked();
outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount));
assert_eq!(outputs[0].commitment().amount, 1000000000000);
assert_eq!(outputs[1].commitment().amount, 2000000000000);
outputs
},
),
(
|rpc, mut builder: Builder, addr, mut outputs: Vec<ReceivedOutput>| async move {
for output in outputs.drain(..) {
builder.add_input(SpendableOutput::from(&rpc, output).await.unwrap());
}
)*
};
}
async fn send_core(test: usize, multisig: bool) {
let rpc = rpc().await;
// Generate an address
let spend = Zeroizing::new(random_scalar(&mut OsRng));
#[allow(unused_mut)]
let mut view = random_scalar(&mut OsRng);
#[allow(unused_mut)]
let mut spend_pub = spend.deref() * &ED25519_BASEPOINT_TABLE;
#[cfg(feature = "multisig")]
let keys = key_gen::<_, Ed25519>(&mut OsRng);
if multisig {
#[cfg(not(feature = "multisig"))]
panic!("Running a multisig test without the multisig feature");
#[cfg(feature = "multisig")]
{
view = Scalar::from_hash(Blake2b512::new().chain("Monero Serai Transaction Test")).0;
spend_pub = keys[&1].group_key().0;
}
}
let view_pair = ViewPair::new(spend_pub, view);
let mut scanner = Scanner::from_view(view_pair, Network::Mainnet, Some(HashSet::new()));
let addr = scanner.address();
let fee = rpc.get_fee().await.unwrap();
let start = rpc.get_height().await.unwrap();
for _ in 0 .. 7 {
mine_block(&rpc, &addr.to_string()).await.unwrap();
}
let mut tx = None;
// Allow tests to test variable transactions
for i in 0 .. [2, 1][test] {
let mut outputs = vec![];
let mut amount = 0;
// Test spending both a miner output and a normal output
if test == 0 {
if i == 0 {
tx = Some(rpc.get_block_transactions(start).await.unwrap().swap_remove(0));
}
// Grab the largest output available
let output = {
let mut outputs = scanner.scan_transaction(tx.as_ref().unwrap()).ignore_timelock();
outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount).reverse());
outputs.swap_remove(0)
};
// Test creating a zero change output and a non-zero change output
amount = output.commitment().amount - u64::try_from(i).unwrap();
outputs.push(SpendableOutput::from(&rpc, output).await.unwrap());
// Test spending multiple inputs
} else if test == 1 {
if i != 0 {
continue;
}
// We actually need 120 decoys for this transaction, so mine until then
// 120 + 60 (miner TX maturity) + 10 (lock blocks)
// It is possible for this to be lower, by noting maturity is sufficient regardless of lock
// blocks, yet that's not currently implemented
// TODO, if we care
while rpc.get_height().await.unwrap() < 200 {
mine_block(&rpc, &addr.to_string()).await.unwrap();
}
for i in (start + 1) .. (start + 9) {
let mut txs = scanner.scan(&rpc, &rpc.get_block(i).await.unwrap()).await.unwrap();
let output = txs.swap_remove(0).ignore_timelock().swap_remove(0);
amount += output.commitment().amount;
outputs.push(output);
}
}
let mut signable = SignableTransaction::new(
rpc.get_protocol().await.unwrap(),
outputs,
vec![(addr, amount - 10000000000)],
Some(addr),
None,
fee,
)
.unwrap();
if !multisig {
tx = Some(signable.sign(&mut OsRng, &rpc, &spend).await.unwrap());
} else {
#[cfg(feature = "multisig")]
{
let mut machines = HashMap::new();
for i in 1 ..= THRESHOLD {
machines.insert(
i,
signable
.clone()
.multisig(
&rpc,
keys[&i].clone(),
RecommendedTranscript::new(b"Monero Serai Test Transaction"),
rpc.get_height().await.unwrap() - 10,
(1 ..= THRESHOLD).collect::<Vec<_>>(),
)
.await
.unwrap(),
);
}
tx = Some(sign(&mut OsRng, machines, &vec![]));
}
}
rpc.publish_transaction(tx.as_ref().unwrap()).await.unwrap();
mine_block(&rpc, &addr.to_string()).await.unwrap();
}
}
async_sequential! {
async fn send_single_input() {
send_core(0, false).await;
}
async fn send_multiple_inputs() {
send_core(1, false).await;
}
}
#[cfg(feature = "multisig")]
async_sequential! {
async fn multisig_send_single_input() {
send_core(0, true).await;
}
async fn multisig_send_multiple_inputs() {
send_core(1, true).await;
}
}
async_sequential! {
async fn builder() {
let rpc = rpc().await;
// Generate an address
let spend = Zeroizing::new(random_scalar(&mut OsRng));
let view = random_scalar(&mut OsRng);
let spend_pub = spend.deref() * &ED25519_BASEPOINT_TABLE;
let view_pair = ViewPair::new(spend_pub, view);
let mut scanner = Scanner::from_view(view_pair, Network::Mainnet, Some(HashSet::new()));
let addr = scanner.address();
let fee = rpc.get_fee().await.unwrap();
let start = rpc.get_height().await.unwrap();
for _ in 0 .. 7 {
mine_block(&rpc, &addr.to_string()).await.unwrap();
}
let coinbase = rpc.get_block_transactions(start).await.unwrap().swap_remove(0);
let output = scanner.scan_transaction(&coinbase).ignore_timelock().swap_remove(0);
rpc.publish_transaction(
&SignableTransactionBuilder::new(rpc.get_protocol().await.unwrap(), fee, Some(addr))
.add_input(SpendableOutput::from(&rpc, output).await.unwrap())
.add_payment(addr, 0)
.build()
.unwrap()
.sign(&mut OsRng, &rpc, &spend)
.await
.unwrap()
).await.unwrap();
}
}
builder.add_payment(addr, 6);
(builder.build().unwrap(), ())
},
|rpc: Rpc, hash, view, _| async move {
let mut scanner = Scanner::from_view(view, Network::Mainnet, Some(HashSet::new()));
let tx = rpc.get_transaction(hash).await.unwrap();
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
assert_eq!(output.commitment().amount, 6);
},
),
);