monero-wallet 0.2.0

Wallet functionality for the Monero protocol, built around monero-oxide
Documentation
#![expect(missing_docs)]

use std::collections::HashSet;

use rand_core::OsRng;

use monero_simple_request_rpc::{prelude::MoneroDaemon, SimpleRequestTransport};
use monero_wallet::{
  ringct::RctType, transaction::Transaction, interface::prelude::*, address::SubaddressIndex,
  extra::Extra, WalletOutput, OutputWithDecoys,
};

mod runner;
use runner::{SignableTransactionBuilder, ring_len};

type Rpc = MoneroDaemon<SimpleRequestTransport>;
type SB = ScannableBlock;

// Set up inputs, select decoys, then add them to the TX builder
async fn add_inputs(
  rct_type: RctType,
  rpc: &Rpc,
  outputs: Vec<WalletOutput>,
  builder: &mut SignableTransactionBuilder,
) {
  for output in outputs {
    builder.add_input(
      OutputWithDecoys::fingerprintable_deterministic_new(
        &mut OsRng,
        rpc,
        ring_len(rct_type),
        rpc.latest_block_number().await.unwrap(),
        output,
      )
      .await
      .unwrap(),
    );
  }
}

test!(
  spend_miner_output,
  (
    async |_, mut builder: Builder, addr| {
      builder.add_payment(addr, 5);
      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let output = scanner.scan(block).unwrap().not_additionally_locked().swap_remove(0);
      assert_eq!(output.transaction(), tx.hash());
      assert_eq!(output.commitment().amount, 5);
    },
  ),
);

test!(
  spend_multiple_outputs,
  (
    async |_, mut builder: Builder, addr| {
      builder.add_payment(addr, 1_000_000_000_000);
      builder.add_payment(addr, 2_000_000_000_000);
      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let mut outputs = scanner.scan(block).unwrap().not_additionally_locked();
      assert_eq!(outputs.len(), 2);
      assert_eq!(outputs[0].transaction(), tx.hash());
      assert_eq!(outputs[0].transaction(), tx.hash());
      outputs.sort_by_key(|output| output.commitment().amount);
      assert_eq!(outputs[0].commitment().amount, 1_000_000_000_000);
      assert_eq!(outputs[1].commitment().amount, 2_000_000_000_000);
      outputs
    },
  ),
  (
    async |rct_type: RctType, rpc, mut builder: Builder, addr, outputs: Vec<WalletOutput>| {
      add_inputs(rct_type, &rpc, outputs, &mut builder).await;
      builder.add_payment(addr, 6);
      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let output = scanner.scan(block).unwrap().not_additionally_locked().swap_remove(0);
      assert_eq!(output.transaction(), tx.hash());
      assert_eq!(output.commitment().amount, 6);
    },
  ),
);

test!(
  // Ideally, this would be single_R, yet it isn't feasible to apply expect(non_snake_case) here
  single_r_subaddress_send,
  (
    // Consume this builder for an output we can use in the future
    // This is needed because we can't get the input from the passed in builder
    async |_, mut builder: Builder, addr| {
      builder.add_payment(addr, 1_000_000_000_000);
      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let outputs = scanner.scan(block).unwrap().not_additionally_locked();
      assert_eq!(outputs.len(), 1);
      assert_eq!(outputs[0].transaction(), tx.hash());
      assert_eq!(outputs[0].commitment().amount, 1_000_000_000_000);
      outputs
    },
  ),
  (
    async |rct_type, rpc: Rpc, _, _, outputs: Vec<WalletOutput>| {
      use monero_wallet::interface::FeePriority;

      let mut outgoing_view = Zeroizing::new([0; 32]);
      OsRng.fill_bytes(outgoing_view.as_mut());
      let change_view = ViewPair::new(
        Point::from(&Scalar::random(&mut OsRng).into() * ED25519_BASEPOINT_TABLE),
        Zeroizing::new(Scalar::random(&mut OsRng)),
      )
      .unwrap();

      let mut builder = SignableTransactionBuilder::new(
        rct_type,
        outgoing_view,
        Change::new(change_view.clone(), None),
        rpc.fee_rate(FeePriority::Unimportant, u64::MAX).await.unwrap(),
      );
      add_inputs(rct_type, &rpc, vec![outputs.first().unwrap().clone()], &mut builder).await;

      // Send to a subaddress
      let sub_view = ViewPair::new(
        Point::from(&Scalar::random(&mut OsRng).into() * ED25519_BASEPOINT_TABLE),
        Zeroizing::new(Scalar::random(&mut OsRng)),
      )
      .unwrap();
      builder
        .add_payment(sub_view.subaddress(Network::Mainnet, SubaddressIndex::new(0, 1).unwrap()), 1);
      (builder.build().unwrap(), (change_view, sub_view))
    },
    async |_rpc: Rpc, block: SB, tx: Transaction, _, views: (ViewPair, ViewPair)| {
      // Make sure the change can pick up its output
      let mut change_scanner = Scanner::new(views.0);
      assert_eq!(change_scanner.scan(block.clone()).unwrap().not_additionally_locked().len(), 1);

      // Make sure the subaddress can pick up its output
      let mut sub_scanner = Scanner::new(views.1);
      sub_scanner.register_subaddress(SubaddressIndex::new(0, 1).unwrap());
      let sub_outputs = sub_scanner.scan(block).unwrap().not_additionally_locked();
      assert_eq!(sub_outputs.len(), 1);
      assert_eq!(sub_outputs[0].transaction(), tx.hash());
      assert_eq!(sub_outputs[0].commitment().amount, 1);
      assert_eq!(sub_outputs[0].subaddress().unwrap().account(), 0);
      assert_eq!(sub_outputs[0].subaddress().unwrap().address(), 1);

      // Make sure only one R was included in TX extra
      assert!(Extra::read(&mut tx.prefix().extra.as_slice()).unwrap().keys().unwrap().1.is_none());
    },
  ),
);

test!(
  spend_one_input_to_one_output_plus_change,
  (
    async |_, mut builder: Builder, addr| {
      builder.add_payment(addr, 2_000_000_000_000);
      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let outputs = scanner.scan(block).unwrap().not_additionally_locked();
      assert_eq!(outputs.len(), 1);
      assert_eq!(outputs[0].transaction(), tx.hash());
      assert_eq!(outputs[0].commitment().amount, 2_000_000_000_000);
      outputs
    },
  ),
  (
    async |rct_type: RctType, rpc, mut builder: Builder, addr, outputs: Vec<WalletOutput>| {
      add_inputs(rct_type, &rpc, outputs, &mut builder).await;
      builder.add_payment(addr, 2);
      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let output = scanner.scan(block).unwrap().not_additionally_locked().swap_remove(0);
      assert_eq!(output.transaction(), tx.hash());
      assert_eq!(output.commitment().amount, 2);
    },
  ),
);

test!(
  spend_max_outputs,
  (
    async |_, mut builder: Builder, addr| {
      builder.add_payment(addr, 1_000_000_000_000);
      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let outputs = scanner.scan(block).unwrap().not_additionally_locked();
      assert_eq!(outputs.len(), 1);
      assert_eq!(outputs[0].transaction(), tx.hash());
      assert_eq!(outputs[0].commitment().amount, 1_000_000_000_000);
      outputs
    },
  ),
  (
    async |rct_type: RctType, rpc, mut builder: Builder, addr, outputs: Vec<WalletOutput>| {
      add_inputs(rct_type, &rpc, outputs, &mut builder).await;

      for i in 0 .. 15 {
        builder.add_payment(addr, i + 1);
      }
      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let mut scanned_tx = scanner.scan(block).unwrap().not_additionally_locked();

      let mut output_amounts = HashSet::new();
      for i in 0 .. 15 {
        output_amounts.insert(i + 1);
      }
      for _ in 0 .. 15 {
        let output = scanned_tx.swap_remove(0);
        assert_eq!(output.transaction(), tx.hash());
        let amount = output.commitment().amount;
        assert!(output_amounts.remove(&amount));
      }
      assert_eq!(output_amounts.len(), 0);
    },
  ),
);

test!(
  spend_max_outputs_to_subaddresses,
  (
    async |_, mut builder: Builder, addr| {
      builder.add_payment(addr, 1_000_000_000_000);
      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let outputs = scanner.scan(block).unwrap().not_additionally_locked();
      assert_eq!(outputs.len(), 1);
      assert_eq!(outputs[0].transaction(), tx.hash());
      assert_eq!(outputs[0].commitment().amount, 1_000_000_000_000);
      outputs
    },
  ),
  (
    async |rct_type: RctType, rpc, mut builder: Builder, _, outputs: Vec<WalletOutput>| {
      add_inputs(rct_type, &rpc, outputs, &mut builder).await;

      let view = runner::random_address().1;
      let mut scanner = Scanner::new(view.clone());

      let mut subaddresses = vec![];
      for i in 0 .. 15 {
        let subaddress = SubaddressIndex::new(0, i + 1).unwrap();
        scanner.register_subaddress(subaddress);

        builder.add_payment(view.subaddress(Network::Mainnet, subaddress), u64::from(i + 1));
        subaddresses.push(subaddress);
      }

      (builder.build().unwrap(), (scanner, subaddresses))
    },
    async |_rpc: Rpc, block, tx: Transaction, _, mut state: (Scanner, Vec<SubaddressIndex>)| {
      use std::collections::HashMap;

      let mut scanned_tx = state.0.scan(block).unwrap().not_additionally_locked();

      let mut output_amounts_by_subaddress = HashMap::new();
      for i in 0 .. 15 {
        output_amounts_by_subaddress.insert(u64::try_from(i + 1).unwrap(), state.1[i]);
      }
      for _ in 0 .. 15 {
        let output = scanned_tx.swap_remove(0);
        assert_eq!(output.transaction(), tx.hash());
        let amount = output.commitment().amount;

        assert_eq!(
          output.subaddress().unwrap(),
          output_amounts_by_subaddress.remove(&amount).unwrap()
        );
      }
      assert_eq!(output_amounts_by_subaddress.len(), 0);
    },
  ),
);

test!(
  spend_one_input_to_two_outputs_no_change,
  (
    async |_, mut builder: Builder, addr| {
      builder.add_payment(addr, 1_000_000_000_000);
      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let outputs = scanner.scan(block).unwrap().not_additionally_locked();
      assert_eq!(outputs.len(), 1);
      assert_eq!(outputs[0].transaction(), tx.hash());
      assert_eq!(outputs[0].commitment().amount, 1_000_000_000_000);
      outputs
    },
  ),
  (
    async |rct_type, rpc: Rpc, _, addr, outputs: Vec<WalletOutput>| {
      use monero_wallet::interface::FeePriority;

      let mut outgoing_view = Zeroizing::new([0; 32]);
      OsRng.fill_bytes(outgoing_view.as_mut());
      let mut builder = SignableTransactionBuilder::new(
        rct_type,
        outgoing_view,
        Change::fingerprintable(None),
        rpc.fee_rate(FeePriority::Unimportant, u64::MAX).await.unwrap(),
      );
      add_inputs(rct_type, &rpc, vec![outputs.first().unwrap().clone()], &mut builder).await;
      builder.add_payment(addr, 10_000);
      builder.add_payment(addr, 50_000);

      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let mut outputs = scanner.scan(block).unwrap().not_additionally_locked();
      assert_eq!(outputs.len(), 2);
      assert_eq!(outputs[0].transaction(), tx.hash());
      assert_eq!(outputs[1].transaction(), tx.hash());
      outputs.sort_by_key(|output| output.commitment().amount);
      assert_eq!(outputs[0].commitment().amount, 10_000);
      assert_eq!(outputs[1].commitment().amount, 50_000);

      // The remainder should get shunted to fee, which is fingerprintable
      let Transaction::V2 { proofs: Some(ref proofs), .. } = tx else { panic!("TX wasn't RingCT") };
      assert_eq!(proofs.base.fee, 1_000_000_000_000 - 10_000 - 50_000);
    },
  ),
);

test!(
  subaddress_change,
  (
    // Consume this builder for an output we can use in the future
    // This is needed because we can't get the input from the passed in builder
    async |_, mut builder: Builder, addr| {
      builder.add_payment(addr, 1_000_000_000_000);
      (builder.build().unwrap(), ())
    },
    async |_rpc: Rpc, block, tx: Transaction, mut scanner: Scanner, ()| {
      let outputs = scanner.scan(block).unwrap().not_additionally_locked();
      assert_eq!(outputs.len(), 1);
      assert_eq!(outputs[0].transaction(), tx.hash());
      assert_eq!(outputs[0].commitment().amount, 1_000_000_000_000);
      outputs
    },
  ),
  (
    async |rct_type, rpc: Rpc, _, _, outputs: Vec<WalletOutput>| {
      use monero_wallet::interface::FeePriority;

      let mut outgoing_view = Zeroizing::new([0; 32]);
      OsRng.fill_bytes(outgoing_view.as_mut());
      let change_view = ViewPair::new(
        Point::from(&Scalar::random(&mut OsRng).into() * ED25519_BASEPOINT_TABLE),
        Zeroizing::new(Scalar::random(&mut OsRng)),
      )
      .unwrap();

      let mut builder = SignableTransactionBuilder::new(
        rct_type,
        outgoing_view,
        Change::new(change_view.clone(), Some(SubaddressIndex::new(0, 1).unwrap())),
        rpc.fee_rate(FeePriority::Unimportant, u64::MAX).await.unwrap(),
      );
      add_inputs(rct_type, &rpc, vec![outputs.first().unwrap().clone()], &mut builder).await;

      // Send to a random address
      let view = ViewPair::new(
        Point::from(&Scalar::random(&mut OsRng).into() * ED25519_BASEPOINT_TABLE),
        Zeroizing::new(Scalar::random(&mut OsRng)),
      )
      .unwrap();
      builder.add_payment(view.legacy_address(Network::Mainnet), 1);
      (builder.build().unwrap(), change_view)
    },
    async |_rpc: Rpc, block, _, _, change_view: ViewPair| {
      // Make sure the change can pick up its output
      let mut change_scanner = Scanner::new(change_view);
      change_scanner.register_subaddress(SubaddressIndex::new(0, 1).unwrap());
      let outputs = change_scanner.scan(block).unwrap().not_additionally_locked();
      assert_eq!(outputs.len(), 1);
      assert_eq!(outputs[0].subaddress().unwrap().account(), 0);
      assert_eq!(outputs[0].subaddress().unwrap().address(), 1);
    },
  ),
);