mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-12 05:59:23 +00:00
Smash out RPC, wallet
This commit is contained in:
88
coins/monero/wallet/Cargo.toml
Normal file
88
coins/monero/wallet/Cargo.toml
Normal file
@@ -0,0 +1,88 @@
|
||||
[package]
|
||||
name = "monero-wallet"
|
||||
version = "0.1.0"
|
||||
description = "Wallet functionality for the Monero protocol, built around monero-serai"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/wallet"
|
||||
authors = ["Luke Parker <lukeparker5132@gmail.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.79"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
std-shims = { path = "../../../common/std-shims", version = "^0.1.1", default-features = false }
|
||||
|
||||
async-trait = { version = "0.1", default-features = false }
|
||||
thiserror = { version = "1", default-features = false, optional = true }
|
||||
|
||||
zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] }
|
||||
subtle = { version = "^2.4", default-features = false }
|
||||
|
||||
rand_core = { version = "0.6", default-features = false }
|
||||
# Used to send transactions
|
||||
rand = { version = "0.8", default-features = false }
|
||||
rand_chacha = { version = "0.3", default-features = false }
|
||||
# Used to select decoys
|
||||
rand_distr = { version = "0.4", default-features = false }
|
||||
|
||||
sha3 = { version = "0.10", default-features = false }
|
||||
pbkdf2 = { version = "0.12", features = ["simple"], default-features = false }
|
||||
|
||||
group = { version = "0.13", default-features = false }
|
||||
curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize", "group"] }
|
||||
|
||||
# Multisig dependencies
|
||||
transcript = { package = "flexible-transcript", path = "../../../crypto/transcript", version = "0.3", default-features = false, features = ["recommended"], optional = true }
|
||||
dalek-ff-group = { path = "../../../crypto/dalek-ff-group", version = "0.4", default-features = false, optional = true }
|
||||
frost = { package = "modular-frost", path = "../../../crypto/frost", default-features = false, features = ["ed25519"], optional = true }
|
||||
|
||||
hex = { version = "0.4", default-features = false, features = ["alloc"] }
|
||||
base58-monero = { version = "2", default-features = false, features = ["check"] }
|
||||
serde = { version = "1", default-features = false, features = ["derive", "alloc"] }
|
||||
serde_json = { version = "1", default-features = false, features = ["alloc"] }
|
||||
|
||||
monero-serai = { path = "..", default-features = false }
|
||||
monero-rpc = { path = "../rpc", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
hex-literal = "0.4"
|
||||
|
||||
frost = { package = "modular-frost", path = "../../../crypto/frost", default-features = false, features = ["ed25519", "tests"] }
|
||||
|
||||
tokio = { version = "1", features = ["sync", "macros"] }
|
||||
|
||||
monero-simple-request-rpc = { path = "../rpc/simple-request", default-features = false }
|
||||
|
||||
[features]
|
||||
std = [
|
||||
"std-shims/std",
|
||||
|
||||
"thiserror",
|
||||
|
||||
"zeroize/std",
|
||||
"subtle/std",
|
||||
|
||||
"rand_core/std",
|
||||
"rand/std",
|
||||
"rand_chacha/std",
|
||||
"rand_distr/std",
|
||||
|
||||
"sha3/std",
|
||||
"pbkdf2/std",
|
||||
|
||||
"hex/std",
|
||||
"base58-monero/std",
|
||||
"serde/std",
|
||||
"serde_json/std",
|
||||
|
||||
"monero-serai/std",
|
||||
"monero-rpc/std",
|
||||
]
|
||||
multisig = ["transcript", "dalek-ff-group", "frost", "monero-serai/multisig", "std"]
|
||||
default = ["std"]
|
||||
21
coins/monero/wallet/LICENSE
Normal file
21
coins/monero/wallet/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022-2024 Luke Parker
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
6
coins/monero/wallet/README.md
Normal file
6
coins/monero/wallet/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Monero Wallet
|
||||
|
||||
Wallet functionality for the Monero protocol, built around monero-serai.
|
||||
|
||||
This library is usable under no-std when the `std` feature (on by default) is
|
||||
disabled.
|
||||
325
coins/monero/wallet/src/address.rs
Normal file
325
coins/monero/wallet/src/address.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
use core::{marker::PhantomData, fmt};
|
||||
use std_shims::string::ToString;
|
||||
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use curve25519_dalek::edwards::EdwardsPoint;
|
||||
|
||||
use monero_serai::io::decompress_point;
|
||||
|
||||
use base58_monero::base58::{encode_check, decode_check};
|
||||
|
||||
/// The network this address is for.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)]
|
||||
pub enum Network {
|
||||
Mainnet,
|
||||
Testnet,
|
||||
Stagenet,
|
||||
}
|
||||
|
||||
/// The address type, supporting the officially documented addresses, along with
|
||||
/// [Featured Addresses](https://gist.github.com/kayabaNerve/01c50bbc35441e0bbdcee63a9d823789).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)]
|
||||
pub enum AddressType {
|
||||
Standard,
|
||||
Integrated([u8; 8]),
|
||||
Subaddress,
|
||||
Featured { subaddress: bool, payment_id: Option<[u8; 8]>, guaranteed: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)]
|
||||
pub struct SubaddressIndex {
|
||||
pub(crate) account: u32,
|
||||
pub(crate) address: u32,
|
||||
}
|
||||
|
||||
impl SubaddressIndex {
|
||||
pub const fn new(account: u32, address: u32) -> Option<SubaddressIndex> {
|
||||
if (account == 0) && (address == 0) {
|
||||
return None;
|
||||
}
|
||||
Some(SubaddressIndex { account, address })
|
||||
}
|
||||
|
||||
pub fn account(&self) -> u32 {
|
||||
self.account
|
||||
}
|
||||
|
||||
pub fn address(&self) -> u32 {
|
||||
self.address
|
||||
}
|
||||
}
|
||||
|
||||
/// Address specification. Used internally to create addresses.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)]
|
||||
pub enum AddressSpec {
|
||||
Standard,
|
||||
Integrated([u8; 8]),
|
||||
Subaddress(SubaddressIndex),
|
||||
Featured { subaddress: Option<SubaddressIndex>, payment_id: Option<[u8; 8]>, guaranteed: bool },
|
||||
}
|
||||
|
||||
impl AddressType {
|
||||
pub fn is_subaddress(&self) -> bool {
|
||||
matches!(self, AddressType::Subaddress) ||
|
||||
matches!(self, AddressType::Featured { subaddress: true, .. })
|
||||
}
|
||||
|
||||
pub fn payment_id(&self) -> Option<[u8; 8]> {
|
||||
if let AddressType::Integrated(id) = self {
|
||||
Some(*id)
|
||||
} else if let AddressType::Featured { payment_id, .. } = self {
|
||||
*payment_id
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_guaranteed(&self) -> bool {
|
||||
matches!(self, AddressType::Featured { guaranteed: true, .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// A type which returns the byte for a given address.
|
||||
pub trait AddressBytes: Clone + Copy + PartialEq + Eq + fmt::Debug {
|
||||
fn network_bytes(network: Network) -> (u8, u8, u8, u8);
|
||||
}
|
||||
|
||||
/// Address bytes for Monero.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct MoneroAddressBytes;
|
||||
impl AddressBytes for MoneroAddressBytes {
|
||||
fn network_bytes(network: Network) -> (u8, u8, u8, u8) {
|
||||
match network {
|
||||
Network::Mainnet => (18, 19, 42, 70),
|
||||
Network::Testnet => (53, 54, 63, 111),
|
||||
Network::Stagenet => (24, 25, 36, 86),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Address metadata.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct AddressMeta<B: AddressBytes> {
|
||||
_bytes: PhantomData<B>,
|
||||
pub network: Network,
|
||||
pub kind: AddressType,
|
||||
}
|
||||
|
||||
impl<B: AddressBytes> Zeroize for AddressMeta<B> {
|
||||
fn zeroize(&mut self) {
|
||||
self.network.zeroize();
|
||||
self.kind.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Error when decoding an address.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
#[cfg_attr(feature = "std", derive(thiserror::Error))]
|
||||
pub enum AddressError {
|
||||
#[cfg_attr(feature = "std", error("invalid address byte"))]
|
||||
InvalidByte,
|
||||
#[cfg_attr(feature = "std", error("invalid address encoding"))]
|
||||
InvalidEncoding,
|
||||
#[cfg_attr(feature = "std", error("invalid length"))]
|
||||
InvalidLength,
|
||||
#[cfg_attr(feature = "std", error("invalid key"))]
|
||||
InvalidKey,
|
||||
#[cfg_attr(feature = "std", error("unknown features"))]
|
||||
UnknownFeatures,
|
||||
#[cfg_attr(feature = "std", error("different network than expected"))]
|
||||
DifferentNetwork,
|
||||
}
|
||||
|
||||
impl<B: AddressBytes> AddressMeta<B> {
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn to_byte(&self) -> u8 {
|
||||
let bytes = B::network_bytes(self.network);
|
||||
match self.kind {
|
||||
AddressType::Standard => bytes.0,
|
||||
AddressType::Integrated(_) => bytes.1,
|
||||
AddressType::Subaddress => bytes.2,
|
||||
AddressType::Featured { .. } => bytes.3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an address's metadata.
|
||||
pub fn new(network: Network, kind: AddressType) -> Self {
|
||||
AddressMeta { _bytes: PhantomData, network, kind }
|
||||
}
|
||||
|
||||
// Returns an incomplete instantiation in the case of Integrated/Featured addresses
|
||||
fn from_byte(byte: u8) -> Result<Self, AddressError> {
|
||||
let mut meta = None;
|
||||
for network in [Network::Mainnet, Network::Testnet, Network::Stagenet] {
|
||||
let (standard, integrated, subaddress, featured) = B::network_bytes(network);
|
||||
if let Some(kind) = match byte {
|
||||
_ if byte == standard => Some(AddressType::Standard),
|
||||
_ if byte == integrated => Some(AddressType::Integrated([0; 8])),
|
||||
_ if byte == subaddress => Some(AddressType::Subaddress),
|
||||
_ if byte == featured => {
|
||||
Some(AddressType::Featured { subaddress: false, payment_id: None, guaranteed: false })
|
||||
}
|
||||
_ => None,
|
||||
} {
|
||||
meta = Some(AddressMeta::new(network, kind));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
meta.ok_or(AddressError::InvalidByte)
|
||||
}
|
||||
|
||||
pub fn is_subaddress(&self) -> bool {
|
||||
self.kind.is_subaddress()
|
||||
}
|
||||
|
||||
pub fn payment_id(&self) -> Option<[u8; 8]> {
|
||||
self.kind.payment_id()
|
||||
}
|
||||
|
||||
pub fn is_guaranteed(&self) -> bool {
|
||||
self.kind.is_guaranteed()
|
||||
}
|
||||
}
|
||||
|
||||
/// A Monero address, composed of metadata and a spend/view key.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Address<B: AddressBytes> {
|
||||
pub meta: AddressMeta<B>,
|
||||
pub spend: EdwardsPoint,
|
||||
pub view: EdwardsPoint,
|
||||
}
|
||||
|
||||
impl<B: AddressBytes> fmt::Debug for Address<B> {
|
||||
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
|
||||
fmt
|
||||
.debug_struct("Address")
|
||||
.field("meta", &self.meta)
|
||||
.field("spend", &hex::encode(self.spend.compress().0))
|
||||
.field("view", &hex::encode(self.view.compress().0))
|
||||
// This is not a real field yet is the most valuable thing to know when debugging
|
||||
.field("(address)", &self.to_string())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: AddressBytes> Zeroize for Address<B> {
|
||||
fn zeroize(&mut self) {
|
||||
self.meta.zeroize();
|
||||
self.spend.zeroize();
|
||||
self.view.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: AddressBytes> fmt::Display for Address<B> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut data = vec![self.meta.to_byte()];
|
||||
data.extend(self.spend.compress().to_bytes());
|
||||
data.extend(self.view.compress().to_bytes());
|
||||
if let AddressType::Featured { subaddress, payment_id, guaranteed } = self.meta.kind {
|
||||
// Technically should be a VarInt, yet we don't have enough features it's needed
|
||||
data.push(
|
||||
u8::from(subaddress) + (u8::from(payment_id.is_some()) << 1) + (u8::from(guaranteed) << 2),
|
||||
);
|
||||
}
|
||||
if let Some(id) = self.meta.kind.payment_id() {
|
||||
data.extend(id);
|
||||
}
|
||||
write!(f, "{}", encode_check(&data).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: AddressBytes> Address<B> {
|
||||
pub fn new(meta: AddressMeta<B>, spend: EdwardsPoint, view: EdwardsPoint) -> Self {
|
||||
Address { meta, spend, view }
|
||||
}
|
||||
|
||||
pub fn from_str_raw(s: &str) -> Result<Self, AddressError> {
|
||||
let raw = decode_check(s).map_err(|_| AddressError::InvalidEncoding)?;
|
||||
if raw.len() < (1 + 32 + 32) {
|
||||
Err(AddressError::InvalidLength)?;
|
||||
}
|
||||
|
||||
let mut meta = AddressMeta::from_byte(raw[0])?;
|
||||
let spend =
|
||||
decompress_point(raw[1 .. 33].try_into().unwrap()).ok_or(AddressError::InvalidKey)?;
|
||||
let view =
|
||||
decompress_point(raw[33 .. 65].try_into().unwrap()).ok_or(AddressError::InvalidKey)?;
|
||||
let mut read = 65;
|
||||
|
||||
if matches!(meta.kind, AddressType::Featured { .. }) {
|
||||
if raw[read] >= (2 << 3) {
|
||||
Err(AddressError::UnknownFeatures)?;
|
||||
}
|
||||
|
||||
let subaddress = (raw[read] & 1) == 1;
|
||||
let integrated = ((raw[read] >> 1) & 1) == 1;
|
||||
let guaranteed = ((raw[read] >> 2) & 1) == 1;
|
||||
|
||||
meta.kind = AddressType::Featured {
|
||||
subaddress,
|
||||
payment_id: Some([0; 8]).filter(|_| integrated),
|
||||
guaranteed,
|
||||
};
|
||||
read += 1;
|
||||
}
|
||||
|
||||
// Update read early so we can verify the length
|
||||
if meta.kind.payment_id().is_some() {
|
||||
read += 8;
|
||||
}
|
||||
if raw.len() != read {
|
||||
Err(AddressError::InvalidLength)?;
|
||||
}
|
||||
|
||||
if let AddressType::Integrated(ref mut id) = meta.kind {
|
||||
id.copy_from_slice(&raw[(read - 8) .. read]);
|
||||
}
|
||||
if let AddressType::Featured { payment_id: Some(ref mut id), .. } = meta.kind {
|
||||
id.copy_from_slice(&raw[(read - 8) .. read]);
|
||||
}
|
||||
|
||||
Ok(Address { meta, spend, view })
|
||||
}
|
||||
|
||||
pub fn from_str(network: Network, s: &str) -> Result<Self, AddressError> {
|
||||
Self::from_str_raw(s).and_then(|addr| {
|
||||
if addr.meta.network == network {
|
||||
Ok(addr)
|
||||
} else {
|
||||
Err(AddressError::DifferentNetwork)?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn network(&self) -> Network {
|
||||
self.meta.network
|
||||
}
|
||||
|
||||
pub fn is_subaddress(&self) -> bool {
|
||||
self.meta.is_subaddress()
|
||||
}
|
||||
|
||||
pub fn payment_id(&self) -> Option<[u8; 8]> {
|
||||
self.meta.payment_id()
|
||||
}
|
||||
|
||||
pub fn is_guaranteed(&self) -> bool {
|
||||
self.meta.is_guaranteed()
|
||||
}
|
||||
}
|
||||
|
||||
/// Instantiation of the Address type with Monero's network bytes.
|
||||
pub type MoneroAddress = Address<MoneroAddressBytes>;
|
||||
// Allow re-interpreting of an arbitrary address as a monero address so it can be used with the
|
||||
// rest of this library. Doesn't use From as it was conflicting with From<T> for T.
|
||||
impl MoneroAddress {
|
||||
pub fn from<B: AddressBytes>(address: Address<B>) -> MoneroAddress {
|
||||
MoneroAddress::new(
|
||||
AddressMeta::new(address.meta.network, address.meta.kind),
|
||||
address.spend,
|
||||
address.view,
|
||||
)
|
||||
}
|
||||
}
|
||||
336
coins/monero/wallet/src/decoys.rs
Normal file
336
coins/monero/wallet/src/decoys.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
use std_shims::{vec::Vec, collections::HashSet};
|
||||
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use rand_core::{RngCore, CryptoRng};
|
||||
use rand_distr::{Distribution, Gamma};
|
||||
#[cfg(not(feature = "std"))]
|
||||
use rand_distr::num_traits::Float;
|
||||
|
||||
use curve25519_dalek::edwards::EdwardsPoint;
|
||||
|
||||
use monero_serai::{DEFAULT_LOCK_WINDOW, COINBASE_LOCK_WINDOW, BLOCK_TIME};
|
||||
use monero_rpc::{RpcError, RpcConnection, Rpc};
|
||||
use crate::SpendableOutput;
|
||||
|
||||
const RECENT_WINDOW: usize = 15;
|
||||
const BLOCKS_PER_YEAR: usize = 365 * 24 * 60 * 60 / BLOCK_TIME;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
const TIP_APPLICATION: f64 = (DEFAULT_LOCK_WINDOW * BLOCK_TIME) as f64;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn select_n<'a, R: RngCore + CryptoRng, RPC: RpcConnection>(
|
||||
rng: &mut R,
|
||||
rpc: &Rpc<RPC>,
|
||||
distribution: &[u64],
|
||||
height: usize,
|
||||
high: u64,
|
||||
per_second: f64,
|
||||
real: &[u64],
|
||||
used: &mut HashSet<u64>,
|
||||
count: usize,
|
||||
fingerprintable_canonical: bool,
|
||||
) -> Result<Vec<(u64, [EdwardsPoint; 2])>, RpcError> {
|
||||
// TODO: consider removing this extra RPC and expect the caller to handle it
|
||||
if fingerprintable_canonical && height > rpc.get_height().await? {
|
||||
// TODO: Don't use InternalError for the caller's failure
|
||||
Err(RpcError::InternalError("decoys being requested from too young blocks"))?;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
let mut iters = 0;
|
||||
let mut confirmed = Vec::with_capacity(count);
|
||||
// Retries on failure. Retries are obvious as decoys, yet should be minimal
|
||||
while confirmed.len() != count {
|
||||
let remaining = count - confirmed.len();
|
||||
// TODO: over-request candidates in case some are locked to avoid needing
|
||||
// round trips to the daemon (and revealing obvious decoys to the daemon)
|
||||
let mut candidates = Vec::with_capacity(remaining);
|
||||
while candidates.len() != remaining {
|
||||
#[cfg(test)]
|
||||
{
|
||||
iters += 1;
|
||||
// This is cheap and on fresh chains, a lot of rounds may be needed
|
||||
if iters == 100 {
|
||||
Err(RpcError::InternalError("hit decoy selection round limit"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Use a gamma distribution
|
||||
let mut age = Gamma::<f64>::new(19.28, 1.0 / 1.61).unwrap().sample(rng).exp();
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
if age > TIP_APPLICATION {
|
||||
age -= TIP_APPLICATION;
|
||||
} else {
|
||||
// f64 does not have try_from available, which is why these are written with `as`
|
||||
age = (rng.next_u64() % u64::try_from(RECENT_WINDOW * BLOCK_TIME).unwrap()) as f64;
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||
let o = (age * per_second) as u64;
|
||||
if o < high {
|
||||
let i = distribution.partition_point(|s| *s < (high - 1 - o));
|
||||
let prev = i.saturating_sub(1);
|
||||
let n = distribution[i] - distribution[prev];
|
||||
if n != 0 {
|
||||
let o = distribution[prev] + (rng.next_u64() % n);
|
||||
if !used.contains(&o) {
|
||||
// It will either actually be used, or is unusable and this prevents trying it again
|
||||
used.insert(o);
|
||||
candidates.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the first time we're requesting these outputs, include the real one as well
|
||||
// Prevents the node we're connected to from having a list of known decoys and then seeing a
|
||||
// TX which uses all of them, with one additional output (the true spend)
|
||||
let mut real_indexes = HashSet::with_capacity(real.len());
|
||||
if confirmed.is_empty() {
|
||||
for real in real {
|
||||
candidates.push(*real);
|
||||
}
|
||||
// Sort candidates so the real spends aren't the ones at the end
|
||||
candidates.sort();
|
||||
for real in real {
|
||||
real_indexes.insert(candidates.binary_search(real).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: make sure that the real output is included in the response, and
|
||||
// that mask and key are equal to expected
|
||||
for (i, output) in rpc
|
||||
.get_unlocked_outputs(&candidates, height, fingerprintable_canonical)
|
||||
.await?
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
{
|
||||
// Don't include the real spend as a decoy, despite requesting it
|
||||
if real_indexes.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(output) = output.take() {
|
||||
confirmed.push((candidates[i], output));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(confirmed)
|
||||
}
|
||||
|
||||
fn offset(ring: &[u64]) -> Vec<u64> {
|
||||
let mut res = vec![ring[0]];
|
||||
res.resize(ring.len(), 0);
|
||||
for m in (1 .. ring.len()).rev() {
|
||||
res[m] = ring[m] - ring[m - 1];
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
async fn select_decoys<R: RngCore + CryptoRng, RPC: RpcConnection>(
|
||||
rng: &mut R,
|
||||
rpc: &Rpc<RPC>,
|
||||
ring_len: usize,
|
||||
height: usize,
|
||||
inputs: &[SpendableOutput],
|
||||
fingerprintable_canonical: bool,
|
||||
) -> Result<Vec<Decoys>, RpcError> {
|
||||
let mut distribution = vec![];
|
||||
|
||||
let decoy_count = ring_len - 1;
|
||||
|
||||
// Convert the inputs in question to the raw output data
|
||||
let mut real = Vec::with_capacity(inputs.len());
|
||||
let mut outputs = Vec::with_capacity(inputs.len());
|
||||
for input in inputs {
|
||||
real.push(input.global_index);
|
||||
outputs.push((real[real.len() - 1], [input.key(), input.commitment().calculate()]));
|
||||
}
|
||||
|
||||
if distribution.len() < height {
|
||||
// TODO: verify distribution elems are strictly increasing
|
||||
let extension =
|
||||
rpc.get_output_distribution(distribution.len(), height.saturating_sub(1)).await?;
|
||||
distribution.extend(extension);
|
||||
}
|
||||
// If asked to use an older height than previously asked, truncate to ensure accuracy
|
||||
// Should never happen, yet risks desyncing if it did
|
||||
distribution.truncate(height);
|
||||
|
||||
if distribution.len() < DEFAULT_LOCK_WINDOW {
|
||||
Err(RpcError::InternalError("not enough decoy candidates"))?;
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let per_second = {
|
||||
let blocks = distribution.len().min(BLOCKS_PER_YEAR);
|
||||
let initial = distribution[distribution.len().saturating_sub(blocks + 1)];
|
||||
let outputs = distribution[distribution.len() - 1].saturating_sub(initial);
|
||||
(outputs as f64) / ((blocks * BLOCK_TIME) as f64)
|
||||
};
|
||||
|
||||
let mut used = HashSet::<u64>::new();
|
||||
for o in &outputs {
|
||||
used.insert(o.0);
|
||||
}
|
||||
|
||||
// TODO: Create a TX with less than the target amount, as allowed by the protocol
|
||||
let high = distribution[distribution.len() - DEFAULT_LOCK_WINDOW];
|
||||
if high.saturating_sub(COINBASE_LOCK_WINDOW as u64) <
|
||||
u64::try_from(inputs.len() * ring_len).unwrap()
|
||||
{
|
||||
Err(RpcError::InternalError("not enough coinbase candidates"))?;
|
||||
}
|
||||
|
||||
// Select all decoys for this transaction, assuming we generate a sane transaction
|
||||
// We should almost never naturally generate an insane transaction, hence why this doesn't
|
||||
// bother with an overage
|
||||
let mut decoys = select_n(
|
||||
rng,
|
||||
rpc,
|
||||
&distribution,
|
||||
height,
|
||||
high,
|
||||
per_second,
|
||||
&real,
|
||||
&mut used,
|
||||
inputs.len() * decoy_count,
|
||||
fingerprintable_canonical,
|
||||
)
|
||||
.await?;
|
||||
real.zeroize();
|
||||
|
||||
let mut res = Vec::with_capacity(inputs.len());
|
||||
for o in outputs {
|
||||
// Grab the decoys for this specific output
|
||||
let mut ring = decoys.drain((decoys.len() - decoy_count) ..).collect::<Vec<_>>();
|
||||
ring.push(o);
|
||||
ring.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
// Sanity checks are only run when 1000 outputs are available in Monero
|
||||
// We run this check whenever the highest output index, which we acknowledge, is > 500
|
||||
// This means we assume (for presumably test blockchains) the height being used has not had
|
||||
// 500 outputs since while itself not being a sufficiently mature blockchain
|
||||
// Considering Monero's p2p layer doesn't actually check transaction sanity, it should be
|
||||
// fine for us to not have perfectly matching rules, especially since this code will infinite
|
||||
// loop if it can't determine sanity, which is possible with sufficient inputs on
|
||||
// sufficiently small chains
|
||||
if high > 500 {
|
||||
// Make sure the TX passes the sanity check that the median output is within the last 40%
|
||||
let target_median = high * 3 / 5;
|
||||
while ring[ring_len / 2].0 < target_median {
|
||||
// If it's not, update the bottom half with new values to ensure the median only moves up
|
||||
for removed in ring.drain(0 .. (ring_len / 2)).collect::<Vec<_>>() {
|
||||
// If we removed the real spend, add it back
|
||||
if removed.0 == o.0 {
|
||||
ring.push(o);
|
||||
} else {
|
||||
// We could not remove this, saving CPU time and removing low values as
|
||||
// possibilities, yet it'd increase the amount of decoys required to create this
|
||||
// transaction and some removed outputs may be the best option (as we drop the first
|
||||
// half, not just the bottom n)
|
||||
used.remove(&removed.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Select new outputs until we have a full sized ring again
|
||||
ring.extend(
|
||||
select_n(
|
||||
rng,
|
||||
rpc,
|
||||
&distribution,
|
||||
height,
|
||||
high,
|
||||
per_second,
|
||||
&[],
|
||||
&mut used,
|
||||
ring_len - ring.len(),
|
||||
fingerprintable_canonical,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
ring.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
}
|
||||
|
||||
// The other sanity check rule is about duplicates, yet we already enforce unique ring
|
||||
// members
|
||||
}
|
||||
|
||||
res.push(
|
||||
Decoys::new(
|
||||
offset(&ring.iter().map(|output| output.0).collect::<Vec<_>>()),
|
||||
// Binary searches for the real spend since we don't know where it sorted to
|
||||
u8::try_from(ring.partition_point(|x| x.0 < o.0)).unwrap(),
|
||||
ring.iter().map(|output| output.1).collect(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub use monero_serai::primitives::Decoys;
|
||||
|
||||
// TODO: Remove this trait
|
||||
#[cfg(feature = "std")]
|
||||
#[async_trait::async_trait]
|
||||
pub trait DecoySelection {
|
||||
async fn select<R: Send + Sync + RngCore + CryptoRng, RPC: Send + Sync + RpcConnection>(
|
||||
rng: &mut R,
|
||||
rpc: &Rpc<RPC>,
|
||||
ring_len: usize,
|
||||
height: usize,
|
||||
inputs: &[SpendableOutput],
|
||||
) -> Result<Vec<Decoys>, RpcError>;
|
||||
|
||||
async fn fingerprintable_canonical_select<
|
||||
R: Send + Sync + RngCore + CryptoRng,
|
||||
RPC: Send + Sync + RpcConnection,
|
||||
>(
|
||||
rng: &mut R,
|
||||
rpc: &Rpc<RPC>,
|
||||
ring_len: usize,
|
||||
height: usize,
|
||||
inputs: &[SpendableOutput],
|
||||
) -> Result<Vec<Decoys>, RpcError>;
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
#[async_trait::async_trait]
|
||||
impl DecoySelection for Decoys {
|
||||
/// Select decoys using the same distribution as Monero. Relies on the monerod RPC
|
||||
/// response for an output's unlocked status, minimizing trips to the daemon.
|
||||
async fn select<R: Send + Sync + RngCore + CryptoRng, RPC: Send + Sync + RpcConnection>(
|
||||
rng: &mut R,
|
||||
rpc: &Rpc<RPC>,
|
||||
ring_len: usize,
|
||||
height: usize,
|
||||
inputs: &[SpendableOutput],
|
||||
) -> Result<Vec<Decoys>, RpcError> {
|
||||
select_decoys(rng, rpc, ring_len, height, inputs, false).await
|
||||
}
|
||||
|
||||
/// If no reorg has occurred and an honest RPC, any caller who passes the same height to this
|
||||
/// function will use the same distribution to select decoys. It is fingerprintable
|
||||
/// because a caller using this will not be able to select decoys that are timelocked
|
||||
/// with a timestamp. Any transaction which includes timestamp timelocked decoys in its
|
||||
/// rings could not be constructed using this function.
|
||||
///
|
||||
/// TODO: upstream change to monerod get_outs RPC to accept a height param for checking
|
||||
/// output's unlocked status and remove all usage of fingerprintable_canonical
|
||||
async fn fingerprintable_canonical_select<
|
||||
R: Send + Sync + RngCore + CryptoRng,
|
||||
RPC: Send + Sync + RpcConnection,
|
||||
>(
|
||||
rng: &mut R,
|
||||
rpc: &Rpc<RPC>,
|
||||
ring_len: usize,
|
||||
height: usize,
|
||||
inputs: &[SpendableOutput],
|
||||
) -> Result<Vec<Decoys>, RpcError> {
|
||||
select_decoys(rng, rpc, ring_len, height, inputs, true).await
|
||||
}
|
||||
}
|
||||
252
coins/monero/wallet/src/extra.rs
Normal file
252
coins/monero/wallet/src/extra.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use core::ops::BitXor;
|
||||
use std_shims::{
|
||||
vec::Vec,
|
||||
io::{self, Read, BufRead, Write},
|
||||
};
|
||||
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use curve25519_dalek::edwards::EdwardsPoint;
|
||||
|
||||
use monero_serai::io::*;
|
||||
|
||||
pub const MAX_TX_EXTRA_PADDING_COUNT: usize = 255;
|
||||
pub const MAX_TX_EXTRA_NONCE_SIZE: usize = 255;
|
||||
|
||||
pub const PAYMENT_ID_MARKER: u8 = 0;
|
||||
pub const ENCRYPTED_PAYMENT_ID_MARKER: u8 = 1;
|
||||
// Used as it's the highest value not interpretable as a continued VarInt
|
||||
pub const ARBITRARY_DATA_MARKER: u8 = 127;
|
||||
|
||||
// 1 byte is used for the marker
|
||||
pub const MAX_ARBITRARY_DATA_SIZE: usize = MAX_TX_EXTRA_NONCE_SIZE - 1;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)]
|
||||
pub enum PaymentId {
|
||||
Unencrypted([u8; 32]),
|
||||
Encrypted([u8; 8]),
|
||||
}
|
||||
|
||||
impl BitXor<[u8; 8]> for PaymentId {
|
||||
type Output = PaymentId;
|
||||
|
||||
fn bitxor(self, bytes: [u8; 8]) -> PaymentId {
|
||||
match self {
|
||||
// Don't perform the xor since this isn't intended to be encrypted with xor
|
||||
PaymentId::Unencrypted(_) => self,
|
||||
PaymentId::Encrypted(id) => {
|
||||
PaymentId::Encrypted((u64::from_le_bytes(id) ^ u64::from_le_bytes(bytes)).to_le_bytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PaymentId {
|
||||
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
match self {
|
||||
PaymentId::Unencrypted(id) => {
|
||||
w.write_all(&[PAYMENT_ID_MARKER])?;
|
||||
w.write_all(id)?;
|
||||
}
|
||||
PaymentId::Encrypted(id) => {
|
||||
w.write_all(&[ENCRYPTED_PAYMENT_ID_MARKER])?;
|
||||
w.write_all(id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read<R: Read>(r: &mut R) -> io::Result<PaymentId> {
|
||||
Ok(match read_byte(r)? {
|
||||
0 => PaymentId::Unencrypted(read_bytes(r)?),
|
||||
1 => PaymentId::Encrypted(read_bytes(r)?),
|
||||
_ => Err(io::Error::other("unknown payment ID type"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Doesn't bother with padding nor MinerGate
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Zeroize)]
|
||||
pub enum ExtraField {
|
||||
Padding(usize),
|
||||
PublicKey(EdwardsPoint),
|
||||
Nonce(Vec<u8>),
|
||||
MergeMining(usize, [u8; 32]),
|
||||
PublicKeys(Vec<EdwardsPoint>),
|
||||
MysteriousMinergate(Vec<u8>),
|
||||
}
|
||||
|
||||
impl ExtraField {
|
||||
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
match self {
|
||||
ExtraField::Padding(size) => {
|
||||
w.write_all(&[0])?;
|
||||
for _ in 1 .. *size {
|
||||
write_byte(&0u8, w)?;
|
||||
}
|
||||
}
|
||||
ExtraField::PublicKey(key) => {
|
||||
w.write_all(&[1])?;
|
||||
w.write_all(&key.compress().to_bytes())?;
|
||||
}
|
||||
ExtraField::Nonce(data) => {
|
||||
w.write_all(&[2])?;
|
||||
write_vec(write_byte, data, w)?;
|
||||
}
|
||||
ExtraField::MergeMining(height, merkle) => {
|
||||
w.write_all(&[3])?;
|
||||
write_varint(&u64::try_from(*height).unwrap(), w)?;
|
||||
w.write_all(merkle)?;
|
||||
}
|
||||
ExtraField::PublicKeys(keys) => {
|
||||
w.write_all(&[4])?;
|
||||
write_vec(write_point, keys, w)?;
|
||||
}
|
||||
ExtraField::MysteriousMinergate(data) => {
|
||||
w.write_all(&[0xDE])?;
|
||||
write_vec(write_byte, data, w)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read<R: BufRead>(r: &mut R) -> io::Result<ExtraField> {
|
||||
Ok(match read_byte(r)? {
|
||||
0 => ExtraField::Padding({
|
||||
// Read until either non-zero, max padding count, or end of buffer
|
||||
let mut size: usize = 1;
|
||||
loop {
|
||||
let buf = r.fill_buf()?;
|
||||
let mut n_consume = 0;
|
||||
for v in buf {
|
||||
if *v != 0u8 {
|
||||
Err(io::Error::other("non-zero value after padding"))?
|
||||
}
|
||||
n_consume += 1;
|
||||
size += 1;
|
||||
if size > MAX_TX_EXTRA_PADDING_COUNT {
|
||||
Err(io::Error::other("padding exceeded max count"))?
|
||||
}
|
||||
}
|
||||
if n_consume == 0 {
|
||||
break;
|
||||
}
|
||||
r.consume(n_consume);
|
||||
}
|
||||
size
|
||||
}),
|
||||
1 => ExtraField::PublicKey(read_point(r)?),
|
||||
2 => ExtraField::Nonce({
|
||||
let nonce = read_vec(read_byte, r)?;
|
||||
if nonce.len() > MAX_TX_EXTRA_NONCE_SIZE {
|
||||
Err(io::Error::other("too long nonce"))?;
|
||||
}
|
||||
nonce
|
||||
}),
|
||||
3 => ExtraField::MergeMining(read_varint(r)?, read_bytes(r)?),
|
||||
4 => ExtraField::PublicKeys(read_vec(read_point, r)?),
|
||||
0xDE => ExtraField::MysteriousMinergate(read_vec(read_byte, r)?),
|
||||
_ => Err(io::Error::other("unknown extra field"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Zeroize)]
|
||||
pub struct Extra(pub(crate) Vec<ExtraField>);
|
||||
impl Extra {
|
||||
pub fn keys(&self) -> Option<(Vec<EdwardsPoint>, Option<Vec<EdwardsPoint>>)> {
|
||||
let mut keys = vec![];
|
||||
let mut additional = None;
|
||||
for field in &self.0 {
|
||||
match field.clone() {
|
||||
ExtraField::PublicKey(this_key) => keys.push(this_key),
|
||||
ExtraField::PublicKeys(these_additional) => {
|
||||
additional = additional.or(Some(these_additional))
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
// Don't return any keys if this was non-standard and didn't include the primary key
|
||||
if keys.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((keys, additional))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn payment_id(&self) -> Option<PaymentId> {
|
||||
for field in &self.0 {
|
||||
if let ExtraField::Nonce(data) = field {
|
||||
return PaymentId::read::<&[u8]>(&mut data.as_ref()).ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn data(&self) -> Vec<Vec<u8>> {
|
||||
let mut res = vec![];
|
||||
for field in &self.0 {
|
||||
if let ExtraField::Nonce(data) = field {
|
||||
if data[0] == ARBITRARY_DATA_MARKER {
|
||||
res.push(data[1 ..].to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
pub(crate) fn new(key: EdwardsPoint, additional: Vec<EdwardsPoint>) -> Extra {
|
||||
let mut res = Extra(Vec::with_capacity(3));
|
||||
res.push(ExtraField::PublicKey(key));
|
||||
if !additional.is_empty() {
|
||||
res.push(ExtraField::PublicKeys(additional));
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, field: ExtraField) {
|
||||
self.0.push(field);
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub fn fee_weight(
|
||||
outputs: usize,
|
||||
additional: bool,
|
||||
payment_id: bool,
|
||||
data: &[Vec<u8>]
|
||||
) -> usize {
|
||||
// PublicKey, key
|
||||
(1 + 32) +
|
||||
// PublicKeys, length, additional keys
|
||||
(if additional { 1 + 1 + (outputs * 32) } else { 0 }) +
|
||||
// PaymentId (Nonce), length, encrypted, ID
|
||||
(if payment_id { 1 + 1 + 1 + 8 } else { 0 }) +
|
||||
// Nonce, length, ARBITRARY_DATA_MARKER, data
|
||||
data.iter().map(|v| 1 + varint_len(1 + v.len()) + 1 + v.len()).sum::<usize>()
|
||||
}
|
||||
|
||||
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
for field in &self.0 {
|
||||
field.write(w)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
let mut buf = vec![];
|
||||
self.write(&mut buf).unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn read<R: BufRead>(r: &mut R) -> io::Result<Extra> {
|
||||
let mut res = Extra(vec![]);
|
||||
let mut field;
|
||||
while {
|
||||
field = ExtraField::read(r);
|
||||
field.is_ok()
|
||||
} {
|
||||
res.0.push(field.unwrap());
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
291
coins/monero/wallet/src/lib.rs
Normal file
291
coins/monero/wallet/src/lib.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
|
||||
#![doc = include_str!("../README.md")]
|
||||
// #![deny(missing_docs)] // TODO
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
use core::ops::Deref;
|
||||
use std_shims::collections::{HashSet, HashMap};
|
||||
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
|
||||
|
||||
use curve25519_dalek::{
|
||||
constants::ED25519_BASEPOINT_TABLE,
|
||||
scalar::Scalar,
|
||||
edwards::{EdwardsPoint, CompressedEdwardsY},
|
||||
};
|
||||
|
||||
use monero_serai::{
|
||||
io::write_varint,
|
||||
primitives::{Commitment, keccak256, keccak256_to_scalar},
|
||||
ringct::EncryptedAmount,
|
||||
transaction::Input,
|
||||
};
|
||||
pub use monero_serai as monero;
|
||||
|
||||
pub use monero_rpc as rpc;
|
||||
|
||||
pub mod extra;
|
||||
pub(crate) use extra::{PaymentId, ExtraField, Extra};
|
||||
|
||||
/// Seed creation and parsing functionality.
|
||||
pub mod seed;
|
||||
|
||||
/// Address encoding and decoding functionality.
|
||||
pub mod address;
|
||||
use address::{Network, AddressType, SubaddressIndex, AddressSpec, AddressMeta, MoneroAddress};
|
||||
|
||||
mod scan;
|
||||
pub use scan::{ReceivedOutput, SpendableOutput, Timelocked};
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
pub mod decoys;
|
||||
#[cfg(not(feature = "std"))]
|
||||
pub mod decoys {
|
||||
pub use monero_serai::primitives::Decoys;
|
||||
pub trait DecoySelection {}
|
||||
}
|
||||
pub use decoys::{DecoySelection, Decoys};
|
||||
|
||||
mod send;
|
||||
pub use send::{FeePriority, Fee, TransactionError, Change, SignableTransaction, Eventuality};
|
||||
#[cfg(feature = "std")]
|
||||
pub use send::SignableTransactionBuilder;
|
||||
#[cfg(feature = "multisig")]
|
||||
pub(crate) use send::InternalPayment;
|
||||
#[cfg(feature = "multisig")]
|
||||
pub use send::TransactionMachine;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
fn key_image_sort(x: &EdwardsPoint, y: &EdwardsPoint) -> core::cmp::Ordering {
|
||||
x.compress().to_bytes().cmp(&y.compress().to_bytes()).reverse()
|
||||
}
|
||||
|
||||
// https://gist.github.com/kayabaNerve/8066c13f1fe1573286ba7a2fd79f6100
|
||||
pub(crate) fn uniqueness(inputs: &[Input]) -> [u8; 32] {
|
||||
let mut u = b"uniqueness".to_vec();
|
||||
for input in inputs {
|
||||
match input {
|
||||
// If Gen, this should be the only input, making this loop somewhat pointless
|
||||
// This works and even if there were somehow multiple inputs, it'd be a false negative
|
||||
Input::Gen(height) => {
|
||||
write_varint(height, &mut u).unwrap();
|
||||
}
|
||||
Input::ToKey { key_image, .. } => u.extend(key_image.compress().to_bytes()),
|
||||
}
|
||||
}
|
||||
keccak256(u)
|
||||
}
|
||||
|
||||
// Hs("view_tag" || 8Ra || o), Hs(8Ra || o), and H(8Ra || 0x8d) with uniqueness inclusion in the
|
||||
// Scalar as an option
|
||||
#[allow(non_snake_case)]
|
||||
pub(crate) fn shared_key(
|
||||
uniqueness: Option<[u8; 32]>,
|
||||
ecdh: EdwardsPoint,
|
||||
o: usize,
|
||||
) -> (u8, Scalar, [u8; 8]) {
|
||||
// 8Ra
|
||||
let mut output_derivation = ecdh.mul_by_cofactor().compress().to_bytes().to_vec();
|
||||
|
||||
let mut payment_id_xor = [0; 8];
|
||||
payment_id_xor
|
||||
.copy_from_slice(&keccak256([output_derivation.as_ref(), [0x8d].as_ref()].concat())[.. 8]);
|
||||
|
||||
// || o
|
||||
write_varint(&o, &mut output_derivation).unwrap();
|
||||
|
||||
let view_tag = keccak256([b"view_tag".as_ref(), &output_derivation].concat())[0];
|
||||
|
||||
// uniqueness ||
|
||||
let shared_key = if let Some(uniqueness) = uniqueness {
|
||||
[uniqueness.as_ref(), &output_derivation].concat()
|
||||
} else {
|
||||
output_derivation
|
||||
};
|
||||
|
||||
(view_tag, keccak256_to_scalar(shared_key), payment_id_xor)
|
||||
}
|
||||
|
||||
pub(crate) fn commitment_mask(shared_key: Scalar) -> Scalar {
|
||||
let mut mask = b"commitment_mask".to_vec();
|
||||
mask.extend(shared_key.to_bytes());
|
||||
keccak256_to_scalar(mask)
|
||||
}
|
||||
|
||||
pub(crate) fn compact_amount_encryption(amount: u64, key: Scalar) -> [u8; 8] {
|
||||
let mut amount_mask = b"amount".to_vec();
|
||||
amount_mask.extend(key.to_bytes());
|
||||
(amount ^ u64::from_le_bytes(keccak256(amount_mask)[.. 8].try_into().unwrap())).to_le_bytes()
|
||||
}
|
||||
|
||||
pub trait EncryptedAmountExt {
|
||||
/// Decrypt an EncryptedAmount into the Commitment it encrypts.
|
||||
///
|
||||
/// The caller must verify the decrypted Commitment matches with the actual Commitment used
|
||||
/// within in the Monero protocol.
|
||||
fn decrypt(&self, key: Scalar) -> Commitment;
|
||||
}
|
||||
impl EncryptedAmountExt for EncryptedAmount {
|
||||
/// Decrypt an EncryptedAmount into the Commitment it encrypts.
|
||||
///
|
||||
/// The caller must verify the decrypted Commitment matches with the actual Commitment used
|
||||
/// within in the Monero protocol.
|
||||
fn decrypt(&self, key: Scalar) -> Commitment {
|
||||
match self {
|
||||
// TODO: Add a test vector for this
|
||||
EncryptedAmount::Original { mask, amount } => {
|
||||
let mask_shared_sec = keccak256(key.as_bytes());
|
||||
let mask =
|
||||
Scalar::from_bytes_mod_order(*mask) - Scalar::from_bytes_mod_order(mask_shared_sec);
|
||||
|
||||
let amount_shared_sec = keccak256(mask_shared_sec);
|
||||
let amount_scalar =
|
||||
Scalar::from_bytes_mod_order(*amount) - Scalar::from_bytes_mod_order(amount_shared_sec);
|
||||
// d2b from rctTypes.cpp
|
||||
let amount = u64::from_le_bytes(amount_scalar.to_bytes()[0 .. 8].try_into().unwrap());
|
||||
|
||||
Commitment::new(mask, amount)
|
||||
}
|
||||
EncryptedAmount::Compact { amount } => Commitment::new(
|
||||
commitment_mask(key),
|
||||
u64::from_le_bytes(compact_amount_encryption(u64::from_le_bytes(*amount), key)),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The private view key and public spend key, enabling scanning transactions.
|
||||
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct ViewPair {
|
||||
spend: EdwardsPoint,
|
||||
view: Zeroizing<Scalar>,
|
||||
}
|
||||
|
||||
impl ViewPair {
|
||||
pub fn new(spend: EdwardsPoint, view: Zeroizing<Scalar>) -> ViewPair {
|
||||
ViewPair { spend, view }
|
||||
}
|
||||
|
||||
pub fn spend(&self) -> EdwardsPoint {
|
||||
self.spend
|
||||
}
|
||||
|
||||
pub fn view(&self) -> EdwardsPoint {
|
||||
self.view.deref() * ED25519_BASEPOINT_TABLE
|
||||
}
|
||||
|
||||
fn subaddress_derivation(&self, index: SubaddressIndex) -> Scalar {
|
||||
keccak256_to_scalar(Zeroizing::new(
|
||||
[
|
||||
b"SubAddr\0".as_ref(),
|
||||
Zeroizing::new(self.view.to_bytes()).as_ref(),
|
||||
&index.account().to_le_bytes(),
|
||||
&index.address().to_le_bytes(),
|
||||
]
|
||||
.concat(),
|
||||
))
|
||||
}
|
||||
|
||||
fn subaddress_keys(&self, index: SubaddressIndex) -> (EdwardsPoint, EdwardsPoint) {
|
||||
let scalar = self.subaddress_derivation(index);
|
||||
let spend = self.spend + (&scalar * ED25519_BASEPOINT_TABLE);
|
||||
let view = self.view.deref() * spend;
|
||||
(spend, view)
|
||||
}
|
||||
|
||||
/// Returns an address with the provided specification.
|
||||
pub fn address(&self, network: Network, spec: AddressSpec) -> MoneroAddress {
|
||||
let mut spend = self.spend;
|
||||
let mut view: EdwardsPoint = self.view.deref() * ED25519_BASEPOINT_TABLE;
|
||||
|
||||
// construct the address meta
|
||||
let meta = match spec {
|
||||
AddressSpec::Standard => AddressMeta::new(network, AddressType::Standard),
|
||||
AddressSpec::Integrated(payment_id) => {
|
||||
AddressMeta::new(network, AddressType::Integrated(payment_id))
|
||||
}
|
||||
AddressSpec::Subaddress(index) => {
|
||||
(spend, view) = self.subaddress_keys(index);
|
||||
AddressMeta::new(network, AddressType::Subaddress)
|
||||
}
|
||||
AddressSpec::Featured { subaddress, payment_id, guaranteed } => {
|
||||
if let Some(index) = subaddress {
|
||||
(spend, view) = self.subaddress_keys(index);
|
||||
}
|
||||
AddressMeta::new(
|
||||
network,
|
||||
AddressType::Featured { subaddress: subaddress.is_some(), payment_id, guaranteed },
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
MoneroAddress::new(meta, spend, view)
|
||||
}
|
||||
}
|
||||
|
||||
/// Transaction scanner.
|
||||
/// This scanner is capable of generating subaddresses, additionally scanning for them once they've
|
||||
/// been explicitly generated. If the burning bug is attempted, any secondary outputs will be
|
||||
/// ignored.
|
||||
#[derive(Clone)]
|
||||
pub struct Scanner {
|
||||
pair: ViewPair,
|
||||
// Also contains the spend key as None
|
||||
pub(crate) subaddresses: HashMap<CompressedEdwardsY, Option<SubaddressIndex>>,
|
||||
pub(crate) burning_bug: Option<HashSet<CompressedEdwardsY>>,
|
||||
}
|
||||
|
||||
impl Zeroize for Scanner {
|
||||
fn zeroize(&mut self) {
|
||||
self.pair.zeroize();
|
||||
|
||||
// These may not be effective, unfortunately
|
||||
for (mut key, mut value) in self.subaddresses.drain() {
|
||||
key.zeroize();
|
||||
value.zeroize();
|
||||
}
|
||||
if let Some(ref mut burning_bug) = self.burning_bug.take() {
|
||||
for mut output in burning_bug.drain() {
|
||||
output.zeroize();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scanner {
|
||||
fn drop(&mut self) {
|
||||
self.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl ZeroizeOnDrop for Scanner {}
|
||||
|
||||
impl Scanner {
|
||||
/// Create a Scanner from a ViewPair.
|
||||
///
|
||||
/// burning_bug is a HashSet of used keys, intended to prevent key reuse which would burn funds.
|
||||
///
|
||||
/// When an output is successfully scanned, the output key MUST be saved to disk.
|
||||
///
|
||||
/// When a new scanner is created, ALL saved output keys must be passed in to be secure.
|
||||
///
|
||||
/// If None is passed, a modified shared key derivation is used which is immune to the burning
|
||||
/// bug (specifically the Guaranteed feature from Featured Addresses).
|
||||
pub fn from_view(pair: ViewPair, burning_bug: Option<HashSet<CompressedEdwardsY>>) -> Scanner {
|
||||
let mut subaddresses = HashMap::new();
|
||||
subaddresses.insert(pair.spend.compress(), None);
|
||||
Scanner { pair, subaddresses, burning_bug }
|
||||
}
|
||||
|
||||
/// Register a subaddress.
|
||||
// There used to be an address function here, yet it wasn't safe. It could generate addresses
|
||||
// incompatible with the Scanner. While we could return None for that, then we have the issue
|
||||
// of runtime failures to generate an address.
|
||||
// Removing that API was the simplest option.
|
||||
pub fn register_subaddress(&mut self, subaddress: SubaddressIndex) {
|
||||
let (spend, _) = self.pair.subaddress_keys(subaddress);
|
||||
self.subaddresses.insert(spend.compress(), Some(subaddress));
|
||||
}
|
||||
}
|
||||
517
coins/monero/wallet/src/scan.rs
Normal file
517
coins/monero/wallet/src/scan.rs
Normal file
@@ -0,0 +1,517 @@
|
||||
use core::ops::Deref;
|
||||
use std_shims::{
|
||||
vec::Vec,
|
||||
string::ToString,
|
||||
io::{self, Read, Write},
|
||||
};
|
||||
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar, edwards::EdwardsPoint};
|
||||
|
||||
use monero_rpc::{RpcError, RpcConnection, Rpc};
|
||||
use monero_serai::{
|
||||
io::*,
|
||||
primitives::Commitment,
|
||||
transaction::{Input, Timelock, Transaction},
|
||||
block::Block,
|
||||
};
|
||||
use crate::{
|
||||
PaymentId, Extra, address::SubaddressIndex, Scanner, EncryptedAmountExt, uniqueness, shared_key,
|
||||
};
|
||||
|
||||
/// An absolute output ID, defined as its transaction hash and output index.
|
||||
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct AbsoluteId {
|
||||
pub tx: [u8; 32],
|
||||
pub o: u8,
|
||||
}
|
||||
|
||||
impl core::fmt::Debug for AbsoluteId {
|
||||
fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> {
|
||||
fmt.debug_struct("AbsoluteId").field("tx", &hex::encode(self.tx)).field("o", &self.o).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AbsoluteId {
|
||||
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
w.write_all(&self.tx)?;
|
||||
w.write_all(&[self.o])
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
let mut serialized = Vec::with_capacity(32 + 1);
|
||||
self.write(&mut serialized).unwrap();
|
||||
serialized
|
||||
}
|
||||
|
||||
pub fn read<R: Read>(r: &mut R) -> io::Result<AbsoluteId> {
|
||||
Ok(AbsoluteId { tx: read_bytes(r)?, o: read_byte(r)? })
|
||||
}
|
||||
}
|
||||
|
||||
/// The data contained with an output.
|
||||
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct OutputData {
|
||||
pub key: EdwardsPoint,
|
||||
/// Absolute difference between the spend key and the key in this output
|
||||
pub key_offset: Scalar,
|
||||
pub commitment: Commitment,
|
||||
}
|
||||
|
||||
impl core::fmt::Debug for OutputData {
|
||||
fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> {
|
||||
fmt
|
||||
.debug_struct("OutputData")
|
||||
.field("key", &hex::encode(self.key.compress().0))
|
||||
.field("key_offset", &hex::encode(self.key_offset.to_bytes()))
|
||||
.field("commitment", &self.commitment)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputData {
|
||||
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
w.write_all(&self.key.compress().to_bytes())?;
|
||||
w.write_all(&self.key_offset.to_bytes())?;
|
||||
w.write_all(&self.commitment.mask.to_bytes())?;
|
||||
w.write_all(&self.commitment.amount.to_le_bytes())
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
let mut serialized = Vec::with_capacity(32 + 32 + 32 + 8);
|
||||
self.write(&mut serialized).unwrap();
|
||||
serialized
|
||||
}
|
||||
|
||||
pub fn read<R: Read>(r: &mut R) -> io::Result<OutputData> {
|
||||
Ok(OutputData {
|
||||
key: read_point(r)?,
|
||||
key_offset: read_scalar(r)?,
|
||||
commitment: Commitment::new(read_scalar(r)?, read_u64(r)?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The metadata for an output.
|
||||
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct Metadata {
|
||||
/// The subaddress this output was sent to.
|
||||
pub subaddress: Option<SubaddressIndex>,
|
||||
/// The payment ID included with this output.
|
||||
/// There are 2 circumstances in which the reference wallet2 ignores the payment ID
|
||||
/// but the payment ID will be returned here anyway:
|
||||
///
|
||||
/// 1) If the payment ID is tied to an output received by a subaddress account
|
||||
/// that spent Monero in the transaction (the received output is considered
|
||||
/// "change" and is not considered a "payment" in this case). If there are multiple
|
||||
/// spending subaddress accounts in a transaction, the highest index spent key image
|
||||
/// is used to determine the spending subaddress account.
|
||||
///
|
||||
/// 2) If the payment ID is the unencrypted variant and the block's hf version is
|
||||
/// v12 or higher (https://github.com/serai-dex/serai/issues/512)
|
||||
pub payment_id: Option<PaymentId>,
|
||||
/// Arbitrary data encoded in TX extra.
|
||||
pub arbitrary_data: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl core::fmt::Debug for Metadata {
|
||||
fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> {
|
||||
fmt
|
||||
.debug_struct("Metadata")
|
||||
.field("subaddress", &self.subaddress)
|
||||
.field("payment_id", &self.payment_id)
|
||||
.field("arbitrary_data", &self.arbitrary_data.iter().map(hex::encode).collect::<Vec<_>>())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
if let Some(subaddress) = self.subaddress {
|
||||
w.write_all(&[1])?;
|
||||
w.write_all(&subaddress.account().to_le_bytes())?;
|
||||
w.write_all(&subaddress.address().to_le_bytes())?;
|
||||
} else {
|
||||
w.write_all(&[0])?;
|
||||
}
|
||||
|
||||
if let Some(payment_id) = self.payment_id {
|
||||
w.write_all(&[1])?;
|
||||
payment_id.write(w)?;
|
||||
} else {
|
||||
w.write_all(&[0])?;
|
||||
}
|
||||
|
||||
w.write_all(&u32::try_from(self.arbitrary_data.len()).unwrap().to_le_bytes())?;
|
||||
for part in &self.arbitrary_data {
|
||||
w.write_all(&[u8::try_from(part.len()).unwrap()])?;
|
||||
w.write_all(part)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
let mut serialized = Vec::with_capacity(1 + 8 + 1);
|
||||
self.write(&mut serialized).unwrap();
|
||||
serialized
|
||||
}
|
||||
|
||||
pub fn read<R: Read>(r: &mut R) -> io::Result<Metadata> {
|
||||
let subaddress = if read_byte(r)? == 1 {
|
||||
Some(
|
||||
SubaddressIndex::new(read_u32(r)?, read_u32(r)?)
|
||||
.ok_or_else(|| io::Error::other("invalid subaddress in metadata"))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Metadata {
|
||||
subaddress,
|
||||
payment_id: if read_byte(r)? == 1 { PaymentId::read(r).ok() } else { None },
|
||||
arbitrary_data: {
|
||||
let mut data = vec![];
|
||||
for _ in 0 .. read_u32(r)? {
|
||||
let len = read_byte(r)?;
|
||||
data.push(read_raw_vec(read_byte, usize::from(len), r)?);
|
||||
}
|
||||
data
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A received output, defined as its absolute ID, data, and metadara.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct ReceivedOutput {
|
||||
pub absolute: AbsoluteId,
|
||||
pub data: OutputData,
|
||||
pub metadata: Metadata,
|
||||
}
|
||||
|
||||
impl ReceivedOutput {
|
||||
pub fn key(&self) -> EdwardsPoint {
|
||||
self.data.key
|
||||
}
|
||||
|
||||
pub fn key_offset(&self) -> Scalar {
|
||||
self.data.key_offset
|
||||
}
|
||||
|
||||
pub fn commitment(&self) -> Commitment {
|
||||
self.data.commitment.clone()
|
||||
}
|
||||
|
||||
pub fn arbitrary_data(&self) -> &[Vec<u8>] {
|
||||
&self.metadata.arbitrary_data
|
||||
}
|
||||
|
||||
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
self.absolute.write(w)?;
|
||||
self.data.write(w)?;
|
||||
self.metadata.write(w)
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
let mut serialized = vec![];
|
||||
self.write(&mut serialized).unwrap();
|
||||
serialized
|
||||
}
|
||||
|
||||
pub fn read<R: Read>(r: &mut R) -> io::Result<ReceivedOutput> {
|
||||
Ok(ReceivedOutput {
|
||||
absolute: AbsoluteId::read(r)?,
|
||||
data: OutputData::read(r)?,
|
||||
metadata: Metadata::read(r)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A spendable output, defined as a received output and its index on the Monero blockchain.
|
||||
/// This index is dependent on the Monero blockchain and will only be known once the output is
|
||||
/// included within a block. This may change if there's a reorganization.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct SpendableOutput {
|
||||
pub output: ReceivedOutput,
|
||||
pub global_index: u64,
|
||||
}
|
||||
|
||||
impl SpendableOutput {
|
||||
/// Update the spendable output's global index. This is intended to be called if a
|
||||
/// re-organization occurred.
|
||||
pub async fn refresh_global_index<RPC: RpcConnection>(
|
||||
&mut self,
|
||||
rpc: &Rpc<RPC>,
|
||||
) -> Result<(), RpcError> {
|
||||
self.global_index = *rpc
|
||||
.get_o_indexes(self.output.absolute.tx)
|
||||
.await?
|
||||
.get(usize::from(self.output.absolute.o))
|
||||
.ok_or(RpcError::InvalidNode(
|
||||
"node returned output indexes didn't include an index for this output".to_string(),
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn from<RPC: RpcConnection>(
|
||||
rpc: &Rpc<RPC>,
|
||||
output: ReceivedOutput,
|
||||
) -> Result<SpendableOutput, RpcError> {
|
||||
let mut output = SpendableOutput { output, global_index: 0 };
|
||||
output.refresh_global_index(rpc).await?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn key(&self) -> EdwardsPoint {
|
||||
self.output.key()
|
||||
}
|
||||
|
||||
pub fn key_offset(&self) -> Scalar {
|
||||
self.output.key_offset()
|
||||
}
|
||||
|
||||
pub fn commitment(&self) -> Commitment {
|
||||
self.output.commitment()
|
||||
}
|
||||
|
||||
pub fn arbitrary_data(&self) -> &[Vec<u8>] {
|
||||
self.output.arbitrary_data()
|
||||
}
|
||||
|
||||
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
self.output.write(w)?;
|
||||
w.write_all(&self.global_index.to_le_bytes())
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
let mut serialized = vec![];
|
||||
self.write(&mut serialized).unwrap();
|
||||
serialized
|
||||
}
|
||||
|
||||
pub fn read<R: Read>(r: &mut R) -> io::Result<SpendableOutput> {
|
||||
Ok(SpendableOutput { output: ReceivedOutput::read(r)?, global_index: read_u64(r)? })
|
||||
}
|
||||
}
|
||||
|
||||
/// A collection of timelocked outputs, either received or spendable.
|
||||
#[derive(Zeroize)]
|
||||
pub struct Timelocked<O: Clone + Zeroize>(Timelock, Vec<O>);
|
||||
impl<O: Clone + Zeroize> Drop for Timelocked<O> {
|
||||
fn drop(&mut self) {
|
||||
self.zeroize();
|
||||
}
|
||||
}
|
||||
impl<O: Clone + Zeroize> ZeroizeOnDrop for Timelocked<O> {}
|
||||
|
||||
impl<O: Clone + Zeroize> Timelocked<O> {
|
||||
pub fn timelock(&self) -> Timelock {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Return the outputs if they're not timelocked, or an empty vector if they are.
|
||||
#[must_use]
|
||||
pub fn not_locked(&self) -> Vec<O> {
|
||||
if self.0 == Timelock::None {
|
||||
return self.1.clone();
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Returns None if the Timelocks aren't comparable. Returns Some(vec![]) if none are unlocked.
|
||||
#[must_use]
|
||||
pub fn unlocked(&self, timelock: Timelock) -> Option<Vec<O>> {
|
||||
// If the Timelocks are comparable, return the outputs if they're now unlocked
|
||||
if self.0 <= timelock {
|
||||
Some(self.1.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn ignore_timelock(&self) -> Vec<O> {
|
||||
self.1.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Scanner {
|
||||
/// Scan a transaction to discover the received outputs.
|
||||
pub fn scan_transaction(&mut self, tx: &Transaction) -> Timelocked<ReceivedOutput> {
|
||||
// Only scan RCT TXs since we can only spend RCT outputs
|
||||
if tx.prefix.version != 2 {
|
||||
return Timelocked(tx.prefix.timelock, vec![]);
|
||||
}
|
||||
|
||||
let Ok(extra) = Extra::read::<&[u8]>(&mut tx.prefix.extra.as_ref()) else {
|
||||
return Timelocked(tx.prefix.timelock, vec![]);
|
||||
};
|
||||
|
||||
let Some((tx_keys, additional)) = extra.keys() else {
|
||||
return Timelocked(tx.prefix.timelock, vec![]);
|
||||
};
|
||||
|
||||
let payment_id = extra.payment_id();
|
||||
|
||||
let mut res = vec![];
|
||||
for (o, output) in tx.prefix.outputs.iter().enumerate() {
|
||||
// https://github.com/serai-dex/serai/issues/106
|
||||
if let Some(burning_bug) = self.burning_bug.as_ref() {
|
||||
if burning_bug.contains(&output.key) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let output_key = decompress_point(output.key.to_bytes());
|
||||
if output_key.is_none() {
|
||||
continue;
|
||||
}
|
||||
let output_key = output_key.unwrap();
|
||||
|
||||
let additional = additional.as_ref().map(|additional| additional.get(o));
|
||||
|
||||
for key in tx_keys.iter().map(|key| Some(Some(key))).chain(core::iter::once(additional)) {
|
||||
let key = match key {
|
||||
Some(Some(key)) => key,
|
||||
Some(None) => {
|
||||
// This is non-standard. There were additional keys, yet not one for this output
|
||||
// https://github.com/monero-project/monero/
|
||||
// blob/04a1e2875d6e35e27bb21497988a6c822d319c28/
|
||||
// src/cryptonote_basic/cryptonote_format_utils.cpp#L1062
|
||||
continue;
|
||||
}
|
||||
None => {
|
||||
break;
|
||||
}
|
||||
};
|
||||
let (view_tag, shared_key, payment_id_xor) = shared_key(
|
||||
if self.burning_bug.is_none() { Some(uniqueness(&tx.prefix.inputs)) } else { None },
|
||||
self.pair.view.deref() * key,
|
||||
o,
|
||||
);
|
||||
|
||||
let payment_id = payment_id.map(|id| id ^ payment_id_xor);
|
||||
|
||||
if let Some(actual_view_tag) = output.view_tag {
|
||||
if actual_view_tag != view_tag {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// P - shared == spend
|
||||
let subaddress =
|
||||
self.subaddresses.get(&(output_key - (&shared_key * ED25519_BASEPOINT_TABLE)).compress());
|
||||
if subaddress.is_none() {
|
||||
continue;
|
||||
}
|
||||
let subaddress = *subaddress.unwrap();
|
||||
|
||||
// If it has torsion, it'll subtract the non-torsioned shared key to a torsioned key
|
||||
// We will not have a torsioned key in our HashMap of keys, so we wouldn't identify it as
|
||||
// ours
|
||||
// If we did though, it'd enable bypassing the included burning bug protection
|
||||
assert!(output_key.is_torsion_free());
|
||||
|
||||
let mut key_offset = shared_key;
|
||||
if let Some(subaddress) = subaddress {
|
||||
key_offset += self.pair.subaddress_derivation(subaddress);
|
||||
}
|
||||
// Since we've found an output to us, get its amount
|
||||
let mut commitment = Commitment::zero();
|
||||
|
||||
// Miner transaction
|
||||
if let Some(amount) = output.amount {
|
||||
commitment.amount = amount;
|
||||
// Regular transaction
|
||||
} else {
|
||||
commitment = match tx.rct_signatures.base.encrypted_amounts.get(o) {
|
||||
Some(amount) => amount.decrypt(shared_key),
|
||||
// This should never happen, yet it may be possible with miner transactions?
|
||||
// Using get just decreases the possibility of a panic and lets us move on in that case
|
||||
None => break,
|
||||
};
|
||||
|
||||
// If this is a malicious commitment, move to the next output
|
||||
// Any other R value will calculate to a different spend key and are therefore ignorable
|
||||
if Some(&commitment.calculate()) != tx.rct_signatures.base.commitments.get(o) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if commitment.amount != 0 {
|
||||
res.push(ReceivedOutput {
|
||||
absolute: AbsoluteId { tx: tx.hash(), o: o.try_into().unwrap() },
|
||||
|
||||
data: OutputData { key: output_key, key_offset, commitment },
|
||||
|
||||
metadata: Metadata { subaddress, payment_id, arbitrary_data: extra.data() },
|
||||
});
|
||||
|
||||
if let Some(burning_bug) = self.burning_bug.as_mut() {
|
||||
burning_bug.insert(output.key);
|
||||
}
|
||||
}
|
||||
// Break to prevent public keys from being included multiple times, triggering multiple
|
||||
// inclusions of the same output
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Timelocked(tx.prefix.timelock, res)
|
||||
}
|
||||
|
||||
/// Scan a block to obtain its spendable outputs. Its the presence in a block giving these
|
||||
/// transactions their global index, and this must be batched as asking for the index of specific
|
||||
/// transactions is a dead giveaway for which transactions you successfully scanned. This
|
||||
/// function obtains the output indexes for the miner transaction, incrementing from there
|
||||
/// instead.
|
||||
pub async fn scan<RPC: RpcConnection>(
|
||||
&mut self,
|
||||
rpc: &Rpc<RPC>,
|
||||
block: &Block,
|
||||
) -> Result<Vec<Timelocked<SpendableOutput>>, RpcError> {
|
||||
let mut index = rpc.get_o_indexes(block.miner_tx.hash()).await?[0];
|
||||
let mut txs = vec![block.miner_tx.clone()];
|
||||
txs.extend(rpc.get_transactions(&block.txs).await?);
|
||||
|
||||
let map = |mut timelock: Timelocked<ReceivedOutput>, index| {
|
||||
if timelock.1.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Timelocked(
|
||||
timelock.0,
|
||||
timelock
|
||||
.1
|
||||
.drain(..)
|
||||
.map(|output| SpendableOutput {
|
||||
global_index: index + u64::from(output.absolute.o),
|
||||
output,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let mut res = vec![];
|
||||
for tx in txs {
|
||||
if let Some(timelock) = map(self.scan_transaction(&tx), index) {
|
||||
res.push(timelock);
|
||||
}
|
||||
index += u64::try_from(
|
||||
tx.prefix
|
||||
.outputs
|
||||
.iter()
|
||||
// Filter to v2 miner TX outputs/RCT outputs since we're tracking the RCT output index
|
||||
.filter(|output| {
|
||||
let is_v2_miner_tx =
|
||||
(tx.prefix.version == 2) && matches!(tx.prefix.inputs.first(), Some(Input::Gen(..)));
|
||||
is_v2_miner_tx || output.amount.is_none()
|
||||
})
|
||||
.count(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
311
coins/monero/wallet/src/seed/classic.rs
Normal file
311
coins/monero/wallet/src/seed/classic.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
use core::ops::Deref;
|
||||
use std_shims::{
|
||||
sync::OnceLock,
|
||||
vec::Vec,
|
||||
string::{String, ToString},
|
||||
collections::HashMap,
|
||||
};
|
||||
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
use rand_core::{RngCore, CryptoRng};
|
||||
|
||||
use curve25519_dalek::scalar::Scalar;
|
||||
|
||||
use crate::seed::SeedError;
|
||||
|
||||
pub(crate) const CLASSIC_SEED_LENGTH: usize = 24;
|
||||
pub(crate) const CLASSIC_SEED_LENGTH_WITH_CHECKSUM: usize = 25;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Zeroize)]
|
||||
pub enum Language {
|
||||
Chinese,
|
||||
English,
|
||||
Dutch,
|
||||
French,
|
||||
Spanish,
|
||||
German,
|
||||
Italian,
|
||||
Portuguese,
|
||||
Japanese,
|
||||
Russian,
|
||||
Esperanto,
|
||||
Lojban,
|
||||
EnglishOld,
|
||||
}
|
||||
|
||||
fn trim(word: &str, len: usize) -> Zeroizing<String> {
|
||||
Zeroizing::new(word.chars().take(len).collect())
|
||||
}
|
||||
|
||||
struct WordList {
|
||||
word_list: Vec<&'static str>,
|
||||
word_map: HashMap<&'static str, usize>,
|
||||
trimmed_word_map: HashMap<String, usize>,
|
||||
unique_prefix_length: usize,
|
||||
}
|
||||
|
||||
impl WordList {
|
||||
fn new(word_list: Vec<&'static str>, prefix_length: usize) -> WordList {
|
||||
let mut lang = WordList {
|
||||
word_list,
|
||||
word_map: HashMap::new(),
|
||||
trimmed_word_map: HashMap::new(),
|
||||
unique_prefix_length: prefix_length,
|
||||
};
|
||||
|
||||
for (i, word) in lang.word_list.iter().enumerate() {
|
||||
lang.word_map.insert(word, i);
|
||||
lang.trimmed_word_map.insert(trim(word, lang.unique_prefix_length).deref().clone(), i);
|
||||
}
|
||||
|
||||
lang
|
||||
}
|
||||
}
|
||||
|
||||
static LANGUAGES_CELL: OnceLock<HashMap<Language, WordList>> = OnceLock::new();
|
||||
#[allow(non_snake_case)]
|
||||
fn LANGUAGES() -> &'static HashMap<Language, WordList> {
|
||||
LANGUAGES_CELL.get_or_init(|| {
|
||||
HashMap::from([
|
||||
(Language::Chinese, WordList::new(include!("./classic/zh.rs"), 1)),
|
||||
(Language::English, WordList::new(include!("./classic/en.rs"), 3)),
|
||||
(Language::Dutch, WordList::new(include!("./classic/nl.rs"), 4)),
|
||||
(Language::French, WordList::new(include!("./classic/fr.rs"), 4)),
|
||||
(Language::Spanish, WordList::new(include!("./classic/es.rs"), 4)),
|
||||
(Language::German, WordList::new(include!("./classic/de.rs"), 4)),
|
||||
(Language::Italian, WordList::new(include!("./classic/it.rs"), 4)),
|
||||
(Language::Portuguese, WordList::new(include!("./classic/pt.rs"), 4)),
|
||||
(Language::Japanese, WordList::new(include!("./classic/ja.rs"), 3)),
|
||||
(Language::Russian, WordList::new(include!("./classic/ru.rs"), 4)),
|
||||
(Language::Esperanto, WordList::new(include!("./classic/eo.rs"), 4)),
|
||||
(Language::Lojban, WordList::new(include!("./classic/jbo.rs"), 4)),
|
||||
(Language::EnglishOld, WordList::new(include!("./classic/ang.rs"), 4)),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn trim_by_lang(word: &str, lang: Language) -> String {
|
||||
if lang != Language::EnglishOld {
|
||||
word.chars().take(LANGUAGES()[&lang].unique_prefix_length).collect()
|
||||
} else {
|
||||
word.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn checksum_index(words: &[Zeroizing<String>], lang: &WordList) -> usize {
|
||||
let mut trimmed_words = Zeroizing::new(String::new());
|
||||
for w in words {
|
||||
*trimmed_words += &trim(w, lang.unique_prefix_length);
|
||||
}
|
||||
|
||||
const fn crc32_table() -> [u32; 256] {
|
||||
let poly = 0xedb88320u32;
|
||||
|
||||
let mut res = [0; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
let mut entry = i;
|
||||
let mut b = 0;
|
||||
while b < 8 {
|
||||
let trigger = entry & 1;
|
||||
entry >>= 1;
|
||||
if trigger == 1 {
|
||||
entry ^= poly;
|
||||
}
|
||||
b += 1;
|
||||
}
|
||||
res[i as usize] = entry;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
const CRC32_TABLE: [u32; 256] = crc32_table();
|
||||
|
||||
let trimmed_words = trimmed_words.as_bytes();
|
||||
let mut checksum = u32::MAX;
|
||||
for i in 0 .. trimmed_words.len() {
|
||||
checksum = CRC32_TABLE[usize::from(u8::try_from(checksum % 256).unwrap() ^ trimmed_words[i])] ^
|
||||
(checksum >> 8);
|
||||
}
|
||||
|
||||
usize::try_from(!checksum).unwrap() % words.len()
|
||||
}
|
||||
|
||||
// Convert a private key to a seed
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn key_to_seed(lang: Language, key: Zeroizing<Scalar>) -> ClassicSeed {
|
||||
let bytes = Zeroizing::new(key.to_bytes());
|
||||
|
||||
// get the language words
|
||||
let words = &LANGUAGES()[&lang].word_list;
|
||||
let list_len = u64::try_from(words.len()).unwrap();
|
||||
|
||||
// To store the found words & add the checksum word later.
|
||||
let mut seed = Vec::with_capacity(25);
|
||||
|
||||
// convert to words
|
||||
// 4 bytes -> 3 words. 8 digits base 16 -> 3 digits base 1626
|
||||
let mut segment = [0; 4];
|
||||
let mut indices = [0; 4];
|
||||
for i in 0 .. 8 {
|
||||
// convert first 4 byte to u32 & get the word indices
|
||||
let start = i * 4;
|
||||
// convert 4 byte to u32
|
||||
segment.copy_from_slice(&bytes[start .. (start + 4)]);
|
||||
// Actually convert to a u64 so we can add without overflowing
|
||||
indices[0] = u64::from(u32::from_le_bytes(segment));
|
||||
indices[1] = indices[0];
|
||||
indices[0] /= list_len;
|
||||
indices[2] = indices[0] + indices[1];
|
||||
indices[0] /= list_len;
|
||||
indices[3] = indices[0] + indices[2];
|
||||
|
||||
// append words to seed
|
||||
for i in indices.iter().skip(1) {
|
||||
let word = usize::try_from(i % list_len).unwrap();
|
||||
seed.push(Zeroizing::new(words[word].to_string()));
|
||||
}
|
||||
}
|
||||
segment.zeroize();
|
||||
indices.zeroize();
|
||||
|
||||
// create a checksum word for all languages except old english
|
||||
if lang != Language::EnglishOld {
|
||||
let checksum = seed[checksum_index(&seed, &LANGUAGES()[&lang])].clone();
|
||||
seed.push(checksum);
|
||||
}
|
||||
|
||||
let mut res = Zeroizing::new(String::new());
|
||||
for (i, word) in seed.iter().enumerate() {
|
||||
if i != 0 {
|
||||
*res += " ";
|
||||
}
|
||||
*res += word;
|
||||
}
|
||||
ClassicSeed(lang, res)
|
||||
}
|
||||
|
||||
// Convert a seed to bytes
|
||||
pub(crate) fn seed_to_bytes(lang: Language, words: &str) -> Result<Zeroizing<[u8; 32]>, SeedError> {
|
||||
// get seed words
|
||||
let words = words.split_whitespace().map(|w| Zeroizing::new(w.to_string())).collect::<Vec<_>>();
|
||||
if (words.len() != CLASSIC_SEED_LENGTH) && (words.len() != CLASSIC_SEED_LENGTH_WITH_CHECKSUM) {
|
||||
panic!("invalid seed passed to seed_to_bytes");
|
||||
}
|
||||
|
||||
let has_checksum = words.len() == CLASSIC_SEED_LENGTH_WITH_CHECKSUM;
|
||||
if has_checksum && lang == Language::EnglishOld {
|
||||
Err(SeedError::EnglishOldWithChecksum)?;
|
||||
}
|
||||
|
||||
// Validate words are in the language word list
|
||||
let lang_word_list: &WordList = &LANGUAGES()[&lang];
|
||||
let matched_indices = (|| {
|
||||
let has_checksum = words.len() == CLASSIC_SEED_LENGTH_WITH_CHECKSUM;
|
||||
let mut matched_indices = Zeroizing::new(vec![]);
|
||||
|
||||
// Iterate through all the words and see if they're all present
|
||||
for word in &words {
|
||||
let trimmed = trim(word, lang_word_list.unique_prefix_length);
|
||||
let word = if has_checksum { &trimmed } else { word };
|
||||
|
||||
if let Some(index) = if has_checksum {
|
||||
lang_word_list.trimmed_word_map.get(word.deref())
|
||||
} else {
|
||||
lang_word_list.word_map.get(&word.as_str())
|
||||
} {
|
||||
matched_indices.push(*index);
|
||||
} else {
|
||||
Err(SeedError::InvalidSeed)?;
|
||||
}
|
||||
}
|
||||
|
||||
if has_checksum {
|
||||
// exclude the last word when calculating a checksum.
|
||||
let last_word = words.last().unwrap().clone();
|
||||
let checksum = words[checksum_index(&words[.. words.len() - 1], lang_word_list)].clone();
|
||||
|
||||
// check the trimmed checksum and trimmed last word line up
|
||||
if trim(&checksum, lang_word_list.unique_prefix_length) !=
|
||||
trim(&last_word, lang_word_list.unique_prefix_length)
|
||||
{
|
||||
Err(SeedError::InvalidChecksum)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(matched_indices)
|
||||
})()?;
|
||||
|
||||
// convert to bytes
|
||||
let mut res = Zeroizing::new([0; 32]);
|
||||
let mut indices = Zeroizing::new([0; 4]);
|
||||
for i in 0 .. 8 {
|
||||
// read 3 indices at a time
|
||||
let i3 = i * 3;
|
||||
indices[1] = matched_indices[i3];
|
||||
indices[2] = matched_indices[i3 + 1];
|
||||
indices[3] = matched_indices[i3 + 2];
|
||||
|
||||
let inner = |i| {
|
||||
let mut base = (lang_word_list.word_list.len() - indices[i] + indices[i + 1]) %
|
||||
lang_word_list.word_list.len();
|
||||
// Shift the index over
|
||||
for _ in 0 .. i {
|
||||
base *= lang_word_list.word_list.len();
|
||||
}
|
||||
base
|
||||
};
|
||||
// set the last index
|
||||
indices[0] = indices[1] + inner(1) + inner(2);
|
||||
if (indices[0] % lang_word_list.word_list.len()) != indices[1] {
|
||||
Err(SeedError::InvalidSeed)?;
|
||||
}
|
||||
|
||||
let pos = i * 4;
|
||||
let mut bytes = u32::try_from(indices[0]).unwrap().to_le_bytes();
|
||||
res[pos .. (pos + 4)].copy_from_slice(&bytes);
|
||||
bytes.zeroize();
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Zeroize)]
|
||||
pub struct ClassicSeed(Language, Zeroizing<String>);
|
||||
impl ClassicSeed {
|
||||
pub(crate) fn new<R: RngCore + CryptoRng>(rng: &mut R, lang: Language) -> ClassicSeed {
|
||||
key_to_seed(lang, Zeroizing::new(Scalar::random(rng)))
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn from_string(lang: Language, words: Zeroizing<String>) -> Result<ClassicSeed, SeedError> {
|
||||
let entropy = seed_to_bytes(lang, &words)?;
|
||||
|
||||
// Make sure this is a valid scalar
|
||||
let scalar = Scalar::from_canonical_bytes(*entropy);
|
||||
if scalar.is_none().into() {
|
||||
Err(SeedError::InvalidSeed)?;
|
||||
}
|
||||
let mut scalar = scalar.unwrap();
|
||||
scalar.zeroize();
|
||||
|
||||
// Call from_entropy so a trimmed seed becomes a full seed
|
||||
Ok(Self::from_entropy(lang, entropy).unwrap())
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn from_entropy(lang: Language, entropy: Zeroizing<[u8; 32]>) -> Option<ClassicSeed> {
|
||||
Option::from(Scalar::from_canonical_bytes(*entropy))
|
||||
.map(|scalar| key_to_seed(lang, Zeroizing::new(scalar)))
|
||||
}
|
||||
|
||||
pub(crate) fn to_string(&self) -> Zeroizing<String> {
|
||||
self.1.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn entropy(&self) -> Zeroizing<[u8; 32]> {
|
||||
seed_to_bytes(self.0, &self.1).unwrap()
|
||||
}
|
||||
}
|
||||
1628
coins/monero/wallet/src/seed/classic/ang.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/ang.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/de.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/de.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/en.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/en.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/eo.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/eo.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/es.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/es.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/fr.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/fr.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/it.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/it.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/ja.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/ja.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/jbo.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/jbo.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/nl.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/nl.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/pt.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/pt.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/ru.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/ru.rs
Normal file
File diff suppressed because it is too large
Load Diff
1628
coins/monero/wallet/src/seed/classic/zh.rs
Normal file
1628
coins/monero/wallet/src/seed/classic/zh.rs
Normal file
File diff suppressed because it is too large
Load Diff
136
coins/monero/wallet/src/seed/mod.rs
Normal file
136
coins/monero/wallet/src/seed/mod.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use core::fmt;
|
||||
use std_shims::string::String;
|
||||
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
|
||||
use rand_core::{RngCore, CryptoRng};
|
||||
|
||||
pub(crate) mod classic;
|
||||
pub(crate) mod polyseed;
|
||||
use classic::{CLASSIC_SEED_LENGTH, CLASSIC_SEED_LENGTH_WITH_CHECKSUM, ClassicSeed};
|
||||
use polyseed::{POLYSEED_LENGTH, Polyseed};
|
||||
|
||||
/// Error when decoding a seed.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
#[cfg_attr(feature = "std", derive(thiserror::Error))]
|
||||
pub enum SeedError {
|
||||
#[cfg_attr(feature = "std", error("invalid number of words in seed"))]
|
||||
InvalidSeedLength,
|
||||
#[cfg_attr(feature = "std", error("unknown language"))]
|
||||
UnknownLanguage,
|
||||
#[cfg_attr(feature = "std", error("invalid checksum"))]
|
||||
InvalidChecksum,
|
||||
#[cfg_attr(feature = "std", error("english old seeds don't support checksums"))]
|
||||
EnglishOldWithChecksum,
|
||||
#[cfg_attr(feature = "std", error("provided entropy is not valid"))]
|
||||
InvalidEntropy,
|
||||
#[cfg_attr(feature = "std", error("invalid seed"))]
|
||||
InvalidSeed,
|
||||
#[cfg_attr(feature = "std", error("provided features are not supported"))]
|
||||
UnsupportedFeatures,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum SeedType {
|
||||
Classic(classic::Language),
|
||||
Polyseed(polyseed::Language),
|
||||
}
|
||||
|
||||
/// A Monero seed.
|
||||
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
|
||||
pub enum Seed {
|
||||
Classic(ClassicSeed),
|
||||
Polyseed(Polyseed),
|
||||
}
|
||||
|
||||
impl fmt::Debug for Seed {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Seed::Classic(_) => f.debug_struct("Seed::Classic").finish_non_exhaustive(),
|
||||
Seed::Polyseed(_) => f.debug_struct("Seed::Polyseed").finish_non_exhaustive(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Seed {
|
||||
/// Creates a new `Seed`.
|
||||
pub fn new<R: RngCore + CryptoRng>(rng: &mut R, seed_type: SeedType) -> Seed {
|
||||
match seed_type {
|
||||
SeedType::Classic(lang) => Seed::Classic(ClassicSeed::new(rng, lang)),
|
||||
SeedType::Polyseed(lang) => Seed::Polyseed(Polyseed::new(rng, lang)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a seed from a `String`.
|
||||
pub fn from_string(seed_type: SeedType, words: Zeroizing<String>) -> Result<Seed, SeedError> {
|
||||
let word_count = words.split_whitespace().count();
|
||||
match seed_type {
|
||||
SeedType::Classic(lang) => {
|
||||
if word_count != CLASSIC_SEED_LENGTH && word_count != CLASSIC_SEED_LENGTH_WITH_CHECKSUM {
|
||||
Err(SeedError::InvalidSeedLength)?
|
||||
} else {
|
||||
ClassicSeed::from_string(lang, words).map(Seed::Classic)
|
||||
}
|
||||
}
|
||||
SeedType::Polyseed(lang) => {
|
||||
if word_count != POLYSEED_LENGTH {
|
||||
Err(SeedError::InvalidSeedLength)?
|
||||
} else {
|
||||
Polyseed::from_string(lang, words).map(Seed::Polyseed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a `Seed` from an entropy and an optional birthday (denoted in seconds since the
|
||||
/// epoch).
|
||||
///
|
||||
/// For `SeedType::Classic`, the birthday is ignored.
|
||||
///
|
||||
/// For `SeedType::Polyseed`, the last 13 bytes of `entropy` must be `0`.
|
||||
// TODO: Return Result, not Option
|
||||
pub fn from_entropy(
|
||||
seed_type: SeedType,
|
||||
entropy: Zeroizing<[u8; 32]>,
|
||||
birthday: Option<u64>,
|
||||
) -> Option<Seed> {
|
||||
match seed_type {
|
||||
SeedType::Classic(lang) => ClassicSeed::from_entropy(lang, entropy).map(Seed::Classic),
|
||||
SeedType::Polyseed(lang) => {
|
||||
Polyseed::from(lang, 0, birthday.unwrap_or(0), entropy).map(Seed::Polyseed).ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns seed as `String`.
|
||||
pub fn to_string(&self) -> Zeroizing<String> {
|
||||
match self {
|
||||
Seed::Classic(seed) => seed.to_string(),
|
||||
Seed::Polyseed(seed) => seed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the entropy for this seed.
|
||||
pub fn entropy(&self) -> Zeroizing<[u8; 32]> {
|
||||
match self {
|
||||
Seed::Classic(seed) => seed.entropy(),
|
||||
Seed::Polyseed(seed) => seed.entropy().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the key derived from this seed.
|
||||
pub fn key(&self) -> Zeroizing<[u8; 32]> {
|
||||
match self {
|
||||
// Classic does not differentiate between its entropy and its key
|
||||
Seed::Classic(seed) => seed.entropy(),
|
||||
Seed::Polyseed(seed) => seed.key(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the birthday of this seed.
|
||||
pub fn birthday(&self) -> u64 {
|
||||
match self {
|
||||
Seed::Classic(_) => 0,
|
||||
Seed::Polyseed(seed) => seed.birthday(),
|
||||
}
|
||||
}
|
||||
}
|
||||
439
coins/monero/wallet/src/seed/polyseed.rs
Normal file
439
coins/monero/wallet/src/seed/polyseed.rs
Normal file
@@ -0,0 +1,439 @@
|
||||
use core::fmt;
|
||||
use std_shims::{sync::OnceLock, vec::Vec, string::String, collections::HashMap};
|
||||
#[cfg(feature = "std")]
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
use zeroize::{Zeroize, Zeroizing, ZeroizeOnDrop};
|
||||
use rand_core::{RngCore, CryptoRng};
|
||||
|
||||
use sha3::Sha3_256;
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
|
||||
use super::SeedError;
|
||||
|
||||
// Features
|
||||
const FEATURE_BITS: u8 = 5;
|
||||
#[allow(dead_code)]
|
||||
const INTERNAL_FEATURES: u8 = 2;
|
||||
const USER_FEATURES: u8 = 3;
|
||||
|
||||
const USER_FEATURES_MASK: u8 = (1 << USER_FEATURES) - 1;
|
||||
const ENCRYPTED_MASK: u8 = 1 << 4;
|
||||
const RESERVED_FEATURES_MASK: u8 = ((1 << FEATURE_BITS) - 1) ^ ENCRYPTED_MASK;
|
||||
|
||||
fn user_features(features: u8) -> u8 {
|
||||
features & USER_FEATURES_MASK
|
||||
}
|
||||
|
||||
fn polyseed_features_supported(features: u8) -> bool {
|
||||
(features & RESERVED_FEATURES_MASK) == 0
|
||||
}
|
||||
|
||||
// Dates
|
||||
const DATE_BITS: u8 = 10;
|
||||
const DATE_MASK: u16 = (1u16 << DATE_BITS) - 1;
|
||||
const POLYSEED_EPOCH: u64 = 1635768000; // 1st November 2021 12:00 UTC
|
||||
pub(crate) const TIME_STEP: u64 = 2629746; // 30.436875 days = 1/12 of the Gregorian year
|
||||
|
||||
// After ~85 years, this will roll over.
|
||||
fn birthday_encode(time: u64) -> u16 {
|
||||
u16::try_from((time.saturating_sub(POLYSEED_EPOCH) / TIME_STEP) & u64::from(DATE_MASK))
|
||||
.expect("value masked by 2**10 - 1 didn't fit into a u16")
|
||||
}
|
||||
|
||||
fn birthday_decode(birthday: u16) -> u64 {
|
||||
POLYSEED_EPOCH + (u64::from(birthday) * TIME_STEP)
|
||||
}
|
||||
|
||||
// Polyseed parameters
|
||||
const SECRET_BITS: usize = 150;
|
||||
|
||||
const BITS_PER_BYTE: usize = 8;
|
||||
const SECRET_SIZE: usize = SECRET_BITS.div_ceil(BITS_PER_BYTE); // 19
|
||||
const CLEAR_BITS: usize = (SECRET_SIZE * BITS_PER_BYTE) - SECRET_BITS; // 2
|
||||
|
||||
// Polyseed calls this CLEAR_MASK and has a very complicated formula for this fundamental
|
||||
// equivalency
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
const LAST_BYTE_SECRET_BITS_MASK: u8 = ((1 << (BITS_PER_BYTE - CLEAR_BITS)) - 1) as u8;
|
||||
|
||||
const SECRET_BITS_PER_WORD: usize = 10;
|
||||
|
||||
// Amount of words in a seed
|
||||
pub(crate) const POLYSEED_LENGTH: usize = 16;
|
||||
// Amount of characters each word must have if trimmed
|
||||
pub(crate) const PREFIX_LEN: usize = 4;
|
||||
|
||||
const POLY_NUM_CHECK_DIGITS: usize = 1;
|
||||
const DATA_WORDS: usize = POLYSEED_LENGTH - POLY_NUM_CHECK_DIGITS;
|
||||
|
||||
// Polynomial
|
||||
const GF_BITS: usize = 11;
|
||||
const POLYSEED_MUL2_TABLE: [u16; 8] = [5, 7, 1, 3, 13, 15, 9, 11];
|
||||
|
||||
type Poly = [u16; POLYSEED_LENGTH];
|
||||
|
||||
fn elem_mul2(x: u16) -> u16 {
|
||||
if x < 1024 {
|
||||
return 2 * x;
|
||||
}
|
||||
POLYSEED_MUL2_TABLE[usize::from(x % 8)] + (16 * ((x - 1024) / 8))
|
||||
}
|
||||
|
||||
fn poly_eval(poly: &Poly) -> u16 {
|
||||
// Horner's method at x = 2
|
||||
let mut result = poly[POLYSEED_LENGTH - 1];
|
||||
for i in (0 .. (POLYSEED_LENGTH - 1)).rev() {
|
||||
result = elem_mul2(result) ^ poly[i];
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// Key gen parameters
|
||||
const POLYSEED_SALT: &[u8] = b"POLYSEED key";
|
||||
const POLYSEED_KEYGEN_ITERATIONS: u32 = 10000;
|
||||
|
||||
// Polyseed technically supports multiple coins, and the value for Monero is 0
|
||||
// See: https://github.com/tevador/polyseed/blob/master/include/polyseed.h#L58
|
||||
const COIN: u16 = 0;
|
||||
|
||||
/// Language options for Polyseed.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Zeroize)]
|
||||
pub enum Language {
|
||||
English,
|
||||
Spanish,
|
||||
French,
|
||||
Italian,
|
||||
Japanese,
|
||||
Korean,
|
||||
Czech,
|
||||
Portuguese,
|
||||
ChineseSimplified,
|
||||
ChineseTraditional,
|
||||
}
|
||||
|
||||
struct WordList {
|
||||
words: Vec<String>,
|
||||
has_prefix: bool,
|
||||
has_accent: bool,
|
||||
}
|
||||
|
||||
impl WordList {
|
||||
fn new(words: &str, has_prefix: bool, has_accent: bool) -> WordList {
|
||||
let res = WordList { words: serde_json::from_str(words).unwrap(), has_prefix, has_accent };
|
||||
// This is needed for a later unwrap to not fails
|
||||
assert!(words.len() < usize::from(u16::MAX));
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
static LANGUAGES_CELL: OnceLock<HashMap<Language, WordList>> = OnceLock::new();
|
||||
#[allow(non_snake_case)]
|
||||
fn LANGUAGES() -> &'static HashMap<Language, WordList> {
|
||||
LANGUAGES_CELL.get_or_init(|| {
|
||||
HashMap::from([
|
||||
(Language::Czech, WordList::new(include_str!("./polyseed/cs.json"), true, false)),
|
||||
(Language::French, WordList::new(include_str!("./polyseed/fr.json"), true, true)),
|
||||
(Language::Korean, WordList::new(include_str!("./polyseed/ko.json"), false, false)),
|
||||
(Language::English, WordList::new(include_str!("./polyseed/en.json"), true, false)),
|
||||
(Language::Italian, WordList::new(include_str!("./polyseed/it.json"), true, false)),
|
||||
(Language::Spanish, WordList::new(include_str!("./polyseed/es.json"), true, true)),
|
||||
(Language::Japanese, WordList::new(include_str!("./polyseed/ja.json"), false, false)),
|
||||
(Language::Portuguese, WordList::new(include_str!("./polyseed/pt.json"), true, false)),
|
||||
(
|
||||
Language::ChineseSimplified,
|
||||
WordList::new(include_str!("./polyseed/zh_simplified.json"), false, false),
|
||||
),
|
||||
(
|
||||
Language::ChineseTraditional,
|
||||
WordList::new(include_str!("./polyseed/zh_traditional.json"), false, false),
|
||||
),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct Polyseed {
|
||||
language: Language,
|
||||
features: u8,
|
||||
birthday: u16,
|
||||
entropy: Zeroizing<[u8; 32]>,
|
||||
checksum: u16,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Polyseed {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.debug_struct("Polyseed").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_entropy(entropy: &Zeroizing<[u8; 32]>) -> bool {
|
||||
// Last byte of the entropy should only use certain bits
|
||||
let mut res =
|
||||
entropy[SECRET_SIZE - 1].ct_eq(&(entropy[SECRET_SIZE - 1] & LAST_BYTE_SECRET_BITS_MASK));
|
||||
// Last 13 bytes of the buffer should be unused
|
||||
for b in SECRET_SIZE .. entropy.len() {
|
||||
res &= entropy[b].ct_eq(&0);
|
||||
}
|
||||
res.into()
|
||||
}
|
||||
|
||||
impl Polyseed {
|
||||
// TODO: Clean this
|
||||
fn to_poly(&self) -> Poly {
|
||||
let mut extra_bits = u32::from(FEATURE_BITS + DATE_BITS);
|
||||
let extra_val = (u16::from(self.features) << DATE_BITS) | self.birthday;
|
||||
|
||||
let mut entropy_idx = 0;
|
||||
let mut secret_bits = BITS_PER_BYTE;
|
||||
let mut seed_rem_bits = SECRET_BITS - BITS_PER_BYTE;
|
||||
|
||||
let mut poly = [0; POLYSEED_LENGTH];
|
||||
for i in 0 .. DATA_WORDS {
|
||||
extra_bits -= 1;
|
||||
|
||||
let mut word_bits = 0;
|
||||
let mut word_val = 0;
|
||||
while word_bits < SECRET_BITS_PER_WORD {
|
||||
if secret_bits == 0 {
|
||||
entropy_idx += 1;
|
||||
secret_bits = seed_rem_bits.min(BITS_PER_BYTE);
|
||||
seed_rem_bits -= secret_bits;
|
||||
}
|
||||
let chunk_bits = secret_bits.min(SECRET_BITS_PER_WORD - word_bits);
|
||||
secret_bits -= chunk_bits;
|
||||
word_bits += chunk_bits;
|
||||
word_val <<= chunk_bits;
|
||||
word_val |=
|
||||
(u16::from(self.entropy[entropy_idx]) >> secret_bits) & ((1u16 << chunk_bits) - 1);
|
||||
}
|
||||
|
||||
word_val <<= 1;
|
||||
word_val |= (extra_val >> extra_bits) & 1;
|
||||
poly[POLY_NUM_CHECK_DIGITS + i] = word_val;
|
||||
}
|
||||
|
||||
poly
|
||||
}
|
||||
|
||||
fn from_internal(
|
||||
language: Language,
|
||||
masked_features: u8,
|
||||
encoded_birthday: u16,
|
||||
entropy: Zeroizing<[u8; 32]>,
|
||||
) -> Result<Polyseed, SeedError> {
|
||||
if !polyseed_features_supported(masked_features) {
|
||||
Err(SeedError::UnsupportedFeatures)?;
|
||||
}
|
||||
|
||||
if !valid_entropy(&entropy) {
|
||||
Err(SeedError::InvalidEntropy)?;
|
||||
}
|
||||
|
||||
let mut res = Polyseed {
|
||||
language,
|
||||
birthday: encoded_birthday,
|
||||
features: masked_features,
|
||||
entropy,
|
||||
checksum: 0,
|
||||
};
|
||||
res.checksum = poly_eval(&res.to_poly());
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Create a new `Polyseed` with specific internals.
|
||||
///
|
||||
/// `birthday` is defined in seconds since the Unix epoch.
|
||||
pub fn from(
|
||||
language: Language,
|
||||
features: u8,
|
||||
birthday: u64,
|
||||
entropy: Zeroizing<[u8; 32]>,
|
||||
) -> Result<Polyseed, SeedError> {
|
||||
Self::from_internal(language, user_features(features), birthday_encode(birthday), entropy)
|
||||
}
|
||||
|
||||
/// Create a new `Polyseed`.
|
||||
///
|
||||
/// This uses the system's time for the birthday, if available.
|
||||
pub fn new<R: RngCore + CryptoRng>(rng: &mut R, language: Language) -> Polyseed {
|
||||
// Get the birthday
|
||||
#[cfg(feature = "std")]
|
||||
let birthday = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
||||
#[cfg(not(feature = "std"))]
|
||||
let birthday = 0;
|
||||
|
||||
// Derive entropy
|
||||
let mut entropy = Zeroizing::new([0; 32]);
|
||||
rng.fill_bytes(entropy.as_mut());
|
||||
entropy[SECRET_SIZE ..].fill(0);
|
||||
entropy[SECRET_SIZE - 1] &= LAST_BYTE_SECRET_BITS_MASK;
|
||||
|
||||
Self::from(language, 0, birthday, entropy).unwrap()
|
||||
}
|
||||
|
||||
/// Create a new `Polyseed` from a String.
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn from_string(lang: Language, seed: Zeroizing<String>) -> Result<Polyseed, SeedError> {
|
||||
// Decode the seed into its polynomial coefficients
|
||||
let mut poly = [0; POLYSEED_LENGTH];
|
||||
|
||||
// Validate words are in the lang word list
|
||||
let lang_word_list: &WordList = &LANGUAGES()[&lang];
|
||||
for (i, word) in seed.split_whitespace().enumerate() {
|
||||
// Find the word's index
|
||||
fn check_if_matches<S: AsRef<str>, I: Iterator<Item = S>>(
|
||||
has_prefix: bool,
|
||||
mut lang_words: I,
|
||||
word: &str,
|
||||
) -> Option<usize> {
|
||||
if has_prefix {
|
||||
// Get the position of the word within the iterator
|
||||
// Doesn't use starts_with and some words are substrs of others, leading to false
|
||||
// positives
|
||||
let mut get_position = || {
|
||||
lang_words.position(|lang_word| {
|
||||
let mut lang_word = lang_word.as_ref().chars();
|
||||
let mut word = word.chars();
|
||||
|
||||
let mut res = true;
|
||||
for _ in 0 .. PREFIX_LEN {
|
||||
res &= lang_word.next() == word.next();
|
||||
}
|
||||
res
|
||||
})
|
||||
};
|
||||
let res = get_position();
|
||||
// If another word has this prefix, don't call it a match
|
||||
if get_position().is_some() {
|
||||
return None;
|
||||
}
|
||||
res
|
||||
} else {
|
||||
lang_words.position(|lang_word| lang_word.as_ref() == word)
|
||||
}
|
||||
}
|
||||
|
||||
let Some(coeff) = (if lang_word_list.has_accent {
|
||||
let ascii = |word: &str| word.chars().filter(char::is_ascii).collect::<String>();
|
||||
check_if_matches(
|
||||
lang_word_list.has_prefix,
|
||||
lang_word_list.words.iter().map(|lang_word| ascii(lang_word)),
|
||||
&ascii(word),
|
||||
)
|
||||
} else {
|
||||
check_if_matches(lang_word_list.has_prefix, lang_word_list.words.iter(), word)
|
||||
}) else {
|
||||
Err(SeedError::InvalidSeed)?
|
||||
};
|
||||
|
||||
// WordList asserts the word list length is less than u16::MAX
|
||||
poly[i] = u16::try_from(coeff).expect("coeff exceeded u16");
|
||||
}
|
||||
|
||||
// xor out the coin
|
||||
poly[POLY_NUM_CHECK_DIGITS] ^= COIN;
|
||||
|
||||
// Validate the checksum
|
||||
if poly_eval(&poly) != 0 {
|
||||
Err(SeedError::InvalidChecksum)?;
|
||||
}
|
||||
|
||||
// Convert the polynomial into entropy
|
||||
let mut entropy = Zeroizing::new([0; 32]);
|
||||
|
||||
let mut extra = 0;
|
||||
|
||||
let mut entropy_idx = 0;
|
||||
let mut entropy_bits = 0;
|
||||
|
||||
let checksum = poly[0];
|
||||
for mut word_val in poly.into_iter().skip(POLY_NUM_CHECK_DIGITS) {
|
||||
// Parse the bottom bit, which is one of the bits of extra
|
||||
// This iterates for less than 16 iters, meaning this won't drop any bits
|
||||
extra <<= 1;
|
||||
extra |= word_val & 1;
|
||||
word_val >>= 1;
|
||||
|
||||
// 10 bits per word creates a [8, 2], [6, 4], [4, 6], [2, 8] cycle
|
||||
// 15 % 4 is 3, leaving 2 bits off, and 152 (19 * 8) - 2 is 150, the amount of bits in the
|
||||
// secret
|
||||
let mut word_bits = GF_BITS - 1;
|
||||
while word_bits > 0 {
|
||||
if entropy_bits == BITS_PER_BYTE {
|
||||
entropy_idx += 1;
|
||||
entropy_bits = 0;
|
||||
}
|
||||
let chunk_bits = word_bits.min(BITS_PER_BYTE - entropy_bits);
|
||||
word_bits -= chunk_bits;
|
||||
let chunk_mask = (1u16 << chunk_bits) - 1;
|
||||
if chunk_bits < BITS_PER_BYTE {
|
||||
entropy[entropy_idx] <<= chunk_bits;
|
||||
}
|
||||
entropy[entropy_idx] |=
|
||||
u8::try_from((word_val >> word_bits) & chunk_mask).expect("chunk exceeded u8");
|
||||
entropy_bits += chunk_bits;
|
||||
}
|
||||
}
|
||||
|
||||
let birthday = extra & DATE_MASK;
|
||||
// extra is contained to u16, and DATE_BITS > 8
|
||||
let features =
|
||||
u8::try_from(extra >> DATE_BITS).expect("couldn't convert extra >> DATE_BITS to u8");
|
||||
|
||||
let res = Self::from_internal(lang, features, birthday, entropy);
|
||||
if let Ok(res) = res.as_ref() {
|
||||
debug_assert_eq!(res.checksum, checksum);
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// When this seed was created, defined in seconds since the epoch.
|
||||
pub fn birthday(&self) -> u64 {
|
||||
birthday_decode(self.birthday)
|
||||
}
|
||||
|
||||
/// This seed's features.
|
||||
pub fn features(&self) -> u8 {
|
||||
self.features
|
||||
}
|
||||
|
||||
/// This seed's entropy.
|
||||
pub fn entropy(&self) -> &Zeroizing<[u8; 32]> {
|
||||
&self.entropy
|
||||
}
|
||||
|
||||
/// The key derived from this seed.
|
||||
pub fn key(&self) -> Zeroizing<[u8; 32]> {
|
||||
let mut key = Zeroizing::new([0; 32]);
|
||||
pbkdf2_hmac::<Sha3_256>(
|
||||
self.entropy.as_slice(),
|
||||
POLYSEED_SALT,
|
||||
POLYSEED_KEYGEN_ITERATIONS,
|
||||
key.as_mut(),
|
||||
);
|
||||
key
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> Zeroizing<String> {
|
||||
// Encode the polynomial with the existing checksum
|
||||
let mut poly = self.to_poly();
|
||||
poly[0] = self.checksum;
|
||||
|
||||
// Embed the coin
|
||||
poly[POLY_NUM_CHECK_DIGITS] ^= COIN;
|
||||
|
||||
// Output words
|
||||
let mut seed = Zeroizing::new(String::new());
|
||||
let words = &LANGUAGES()[&self.language].words;
|
||||
for i in 0 .. poly.len() {
|
||||
seed.push_str(&words[usize::from(poly[i])]);
|
||||
if i < poly.len() - 1 {
|
||||
seed.push(' ');
|
||||
}
|
||||
}
|
||||
|
||||
seed
|
||||
}
|
||||
}
|
||||
2050
coins/monero/wallet/src/seed/polyseed/cs.json
Normal file
2050
coins/monero/wallet/src/seed/polyseed/cs.json
Normal file
File diff suppressed because it is too large
Load Diff
2050
coins/monero/wallet/src/seed/polyseed/en.json
Normal file
2050
coins/monero/wallet/src/seed/polyseed/en.json
Normal file
File diff suppressed because it is too large
Load Diff
2050
coins/monero/wallet/src/seed/polyseed/es.json
Normal file
2050
coins/monero/wallet/src/seed/polyseed/es.json
Normal file
File diff suppressed because it is too large
Load Diff
2050
coins/monero/wallet/src/seed/polyseed/fr.json
Normal file
2050
coins/monero/wallet/src/seed/polyseed/fr.json
Normal file
File diff suppressed because it is too large
Load Diff
2050
coins/monero/wallet/src/seed/polyseed/it.json
Normal file
2050
coins/monero/wallet/src/seed/polyseed/it.json
Normal file
File diff suppressed because it is too large
Load Diff
2050
coins/monero/wallet/src/seed/polyseed/ja.json
Normal file
2050
coins/monero/wallet/src/seed/polyseed/ja.json
Normal file
File diff suppressed because it is too large
Load Diff
2050
coins/monero/wallet/src/seed/polyseed/ko.json
Normal file
2050
coins/monero/wallet/src/seed/polyseed/ko.json
Normal file
File diff suppressed because it is too large
Load Diff
2050
coins/monero/wallet/src/seed/polyseed/pt.json
Normal file
2050
coins/monero/wallet/src/seed/polyseed/pt.json
Normal file
File diff suppressed because it is too large
Load Diff
2050
coins/monero/wallet/src/seed/polyseed/zh_simplified.json
Normal file
2050
coins/monero/wallet/src/seed/polyseed/zh_simplified.json
Normal file
File diff suppressed because it is too large
Load Diff
2050
coins/monero/wallet/src/seed/polyseed/zh_traditional.json
Normal file
2050
coins/monero/wallet/src/seed/polyseed/zh_traditional.json
Normal file
File diff suppressed because it is too large
Load Diff
142
coins/monero/wallet/src/send/builder.rs
Normal file
142
coins/monero/wallet/src/send/builder.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
|
||||
|
||||
use monero_serai::Protocol;
|
||||
use crate::{
|
||||
address::MoneroAddress, Fee, SpendableOutput, Change, Decoys, SignableTransaction,
|
||||
TransactionError, extra::MAX_ARBITRARY_DATA_SIZE,
|
||||
};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)]
|
||||
struct SignableTransactionBuilderInternal {
|
||||
protocol: Protocol,
|
||||
fee_rate: Fee,
|
||||
|
||||
r_seed: Option<Zeroizing<[u8; 32]>>,
|
||||
inputs: Vec<(SpendableOutput, Decoys)>,
|
||||
payments: Vec<(MoneroAddress, u64)>,
|
||||
change_address: Change,
|
||||
data: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl SignableTransactionBuilderInternal {
|
||||
// Takes in the change address so users don't miss that they have to manually set one
|
||||
// If they don't, all leftover funds will become part of the fee
|
||||
fn new(protocol: Protocol, fee_rate: Fee, change_address: Change) -> Self {
|
||||
Self {
|
||||
protocol,
|
||||
fee_rate,
|
||||
r_seed: None,
|
||||
inputs: vec![],
|
||||
payments: vec![],
|
||||
change_address,
|
||||
data: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn set_r_seed(&mut self, r_seed: Zeroizing<[u8; 32]>) {
|
||||
self.r_seed = Some(r_seed);
|
||||
}
|
||||
|
||||
fn add_input(&mut self, input: (SpendableOutput, Decoys)) {
|
||||
self.inputs.push(input);
|
||||
}
|
||||
fn add_inputs(&mut self, inputs: &[(SpendableOutput, Decoys)]) {
|
||||
self.inputs.extend(inputs.iter().cloned());
|
||||
}
|
||||
|
||||
fn add_payment(&mut self, dest: MoneroAddress, amount: u64) {
|
||||
self.payments.push((dest, amount));
|
||||
}
|
||||
fn add_payments(&mut self, payments: &[(MoneroAddress, u64)]) {
|
||||
self.payments.extend(payments);
|
||||
}
|
||||
|
||||
fn add_data(&mut self, data: Vec<u8>) {
|
||||
self.data.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Transaction Builder for Monero transactions.
|
||||
/// All methods provided will modify self while also returning a shallow copy, enabling efficient
|
||||
/// chaining with a clean API.
|
||||
/// In order to fork the builder at some point, clone will still return a deep copy.
|
||||
#[derive(Debug)]
|
||||
pub struct SignableTransactionBuilder(Arc<RwLock<SignableTransactionBuilderInternal>>);
|
||||
impl Clone for SignableTransactionBuilder {
|
||||
fn clone(&self) -> Self {
|
||||
Self(Arc::new(RwLock::new((*self.0.read().unwrap()).clone())))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for SignableTransactionBuilder {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
*self.0.read().unwrap() == *other.0.read().unwrap()
|
||||
}
|
||||
}
|
||||
impl Eq for SignableTransactionBuilder {}
|
||||
|
||||
impl Zeroize for SignableTransactionBuilder {
|
||||
fn zeroize(&mut self) {
|
||||
self.0.write().unwrap().zeroize()
|
||||
}
|
||||
}
|
||||
|
||||
impl SignableTransactionBuilder {
|
||||
fn shallow_copy(&self) -> Self {
|
||||
Self(self.0.clone())
|
||||
}
|
||||
|
||||
pub fn new(protocol: Protocol, fee_rate: Fee, change_address: Change) -> Self {
|
||||
Self(Arc::new(RwLock::new(SignableTransactionBuilderInternal::new(
|
||||
protocol,
|
||||
fee_rate,
|
||||
change_address,
|
||||
))))
|
||||
}
|
||||
|
||||
pub fn set_r_seed(&mut self, r_seed: Zeroizing<[u8; 32]>) -> Self {
|
||||
self.0.write().unwrap().set_r_seed(r_seed);
|
||||
self.shallow_copy()
|
||||
}
|
||||
|
||||
pub fn add_input(&mut self, input: (SpendableOutput, Decoys)) -> Self {
|
||||
self.0.write().unwrap().add_input(input);
|
||||
self.shallow_copy()
|
||||
}
|
||||
pub fn add_inputs(&mut self, inputs: &[(SpendableOutput, Decoys)]) -> Self {
|
||||
self.0.write().unwrap().add_inputs(inputs);
|
||||
self.shallow_copy()
|
||||
}
|
||||
|
||||
pub fn add_payment(&mut self, dest: MoneroAddress, amount: u64) -> Self {
|
||||
self.0.write().unwrap().add_payment(dest, amount);
|
||||
self.shallow_copy()
|
||||
}
|
||||
pub fn add_payments(&mut self, payments: &[(MoneroAddress, u64)]) -> Self {
|
||||
self.0.write().unwrap().add_payments(payments);
|
||||
self.shallow_copy()
|
||||
}
|
||||
|
||||
pub fn add_data(&mut self, data: Vec<u8>) -> Result<Self, TransactionError> {
|
||||
if data.len() > MAX_ARBITRARY_DATA_SIZE {
|
||||
Err(TransactionError::TooMuchData)?;
|
||||
}
|
||||
self.0.write().unwrap().add_data(data);
|
||||
Ok(self.shallow_copy())
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<SignableTransaction, TransactionError> {
|
||||
let read = self.0.read().unwrap();
|
||||
SignableTransaction::new(
|
||||
read.protocol,
|
||||
read.r_seed.clone(),
|
||||
read.inputs.clone(),
|
||||
read.payments.clone(),
|
||||
&read.change_address,
|
||||
read.data.clone(),
|
||||
read.fee_rate,
|
||||
)
|
||||
}
|
||||
}
|
||||
1015
coins/monero/wallet/src/send/mod.rs
Normal file
1015
coins/monero/wallet/src/send/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
412
coins/monero/wallet/src/send/multisig.rs
Normal file
412
coins/monero/wallet/src/send/multisig.rs
Normal file
@@ -0,0 +1,412 @@
|
||||
use std_shims::{
|
||||
vec::Vec,
|
||||
io::{self, Read},
|
||||
collections::HashMap,
|
||||
};
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use rand_core::{RngCore, CryptoRng, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
use group::ff::Field;
|
||||
use curve25519_dalek::{traits::Identity, scalar::Scalar, edwards::EdwardsPoint};
|
||||
use dalek_ff_group as dfg;
|
||||
|
||||
use transcript::{Transcript, RecommendedTranscript};
|
||||
use frost::{
|
||||
curve::Ed25519,
|
||||
Participant, FrostError, ThresholdKeys,
|
||||
dkg::lagrange,
|
||||
sign::{
|
||||
Writable, Preprocess, CachedPreprocess, SignatureShare, PreprocessMachine, SignMachine,
|
||||
SignatureMachine, AlgorithmMachine, AlgorithmSignMachine, AlgorithmSignatureMachine,
|
||||
},
|
||||
};
|
||||
|
||||
use monero_serai::{
|
||||
ringct::{
|
||||
clsag::{ClsagContext, ClsagMultisigMaskSender, ClsagAddendum, ClsagMultisig},
|
||||
RctPrunable,
|
||||
},
|
||||
transaction::{Input, Transaction},
|
||||
};
|
||||
use crate::{TransactionError, InternalPayment, SignableTransaction, key_image_sort, uniqueness};
|
||||
|
||||
/// FROST signing machine to produce a signed transaction.
|
||||
pub struct TransactionMachine {
|
||||
signable: SignableTransaction,
|
||||
|
||||
i: Participant,
|
||||
transcript: RecommendedTranscript,
|
||||
|
||||
// Hashed key and scalar offset
|
||||
key_images: Vec<(EdwardsPoint, Scalar)>,
|
||||
clsag_mask_sends: Vec<ClsagMultisigMaskSender>,
|
||||
clsags: Vec<AlgorithmMachine<Ed25519, ClsagMultisig>>,
|
||||
}
|
||||
|
||||
pub struct TransactionSignMachine {
|
||||
signable: SignableTransaction,
|
||||
|
||||
i: Participant,
|
||||
transcript: RecommendedTranscript,
|
||||
|
||||
key_images: Vec<(EdwardsPoint, Scalar)>,
|
||||
clsag_mask_sends: Vec<ClsagMultisigMaskSender>,
|
||||
clsags: Vec<AlgorithmSignMachine<Ed25519, ClsagMultisig>>,
|
||||
|
||||
our_preprocess: Vec<Preprocess<Ed25519, ClsagAddendum>>,
|
||||
}
|
||||
|
||||
pub struct TransactionSignatureMachine {
|
||||
tx: Transaction,
|
||||
clsags: Vec<AlgorithmSignatureMachine<Ed25519, ClsagMultisig>>,
|
||||
}
|
||||
|
||||
impl SignableTransaction {
|
||||
/// Create a FROST signing machine out of this signable transaction.
|
||||
/// The height is the Monero blockchain height to synchronize around.
|
||||
pub fn multisig(
|
||||
self,
|
||||
keys: &ThresholdKeys<Ed25519>,
|
||||
mut transcript: RecommendedTranscript,
|
||||
) -> Result<TransactionMachine, TransactionError> {
|
||||
let mut clsag_mask_sends = vec![];
|
||||
let mut clsags = vec![];
|
||||
|
||||
// Create a RNG out of the input shared keys, which either requires the view key or being every
|
||||
// sender, and the payments (address and amount), which a passive adversary may be able to know
|
||||
// depending on how these transactions are coordinated
|
||||
// Being every sender would already let you note rings which happen to use your transactions
|
||||
// multiple times, already breaking privacy there
|
||||
|
||||
transcript.domain_separate(b"monero_transaction");
|
||||
|
||||
// Also include the spend_key as below only the key offset is included, so this transcripts the
|
||||
// sum product
|
||||
// Useful as transcripting the sum product effectively transcripts the key image, further
|
||||
// guaranteeing the one time properties noted below
|
||||
transcript.append_message(b"spend_key", keys.group_key().0.compress().to_bytes());
|
||||
|
||||
if let Some(r_seed) = &self.r_seed {
|
||||
transcript.append_message(b"r_seed", r_seed);
|
||||
}
|
||||
|
||||
for (input, decoys) in &self.inputs {
|
||||
// These outputs can only be spent once. Therefore, it forces all RNGs derived from this
|
||||
// transcript (such as the one used to create one time keys) to be unique
|
||||
transcript.append_message(b"input_hash", input.output.absolute.tx);
|
||||
transcript.append_message(b"input_output_index", [input.output.absolute.o]);
|
||||
// Not including this, with a doxxed list of payments, would allow brute forcing the inputs
|
||||
// to determine RNG seeds and therefore the true spends
|
||||
transcript.append_message(b"input_shared_key", input.key_offset().to_bytes());
|
||||
|
||||
// Ensure all signers are signing the same rings
|
||||
transcript.append_message(b"real_spend", [decoys.signer_index()]);
|
||||
for (i, ring_member) in decoys.ring().iter().enumerate() {
|
||||
transcript
|
||||
.append_message(b"ring_member", [u8::try_from(i).expect("ring size exceeded 255")]);
|
||||
transcript.append_message(b"ring_member_offset", decoys.offsets()[i].to_le_bytes());
|
||||
transcript.append_message(b"ring_member_key", ring_member[0].compress().to_bytes());
|
||||
transcript.append_message(b"ring_member_commitment", ring_member[1].compress().to_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
for payment in &self.payments {
|
||||
match payment {
|
||||
InternalPayment::Payment(payment, need_dummy_payment_id) => {
|
||||
transcript.append_message(b"payment_address", payment.0.to_string().as_bytes());
|
||||
transcript.append_message(b"payment_amount", payment.1.to_le_bytes());
|
||||
transcript.append_message(
|
||||
b"need_dummy_payment_id",
|
||||
[if *need_dummy_payment_id { 1u8 } else { 0u8 }],
|
||||
);
|
||||
}
|
||||
InternalPayment::Change(change, change_view) => {
|
||||
transcript.append_message(b"change_address", change.0.to_string().as_bytes());
|
||||
transcript.append_message(b"change_amount", change.1.to_le_bytes());
|
||||
if let Some(view) = change_view.as_ref() {
|
||||
transcript.append_message(b"change_view_key", Zeroizing::new(view.to_bytes()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut key_images = vec![];
|
||||
for (i, (input, decoys)) in self.inputs.iter().enumerate() {
|
||||
// Check this the right set of keys
|
||||
let offset = keys.offset(dfg::Scalar(input.key_offset()));
|
||||
if offset.group_key().0 != input.key() {
|
||||
Err(TransactionError::WrongPrivateKey)?;
|
||||
}
|
||||
|
||||
let context = ClsagContext::new(decoys.clone(), input.commitment())
|
||||
.map_err(TransactionError::ClsagError)?;
|
||||
let (clsag, clsag_mask_send) = ClsagMultisig::new(transcript.clone(), context);
|
||||
clsag_mask_sends.push(clsag_mask_send);
|
||||
key_images.push((
|
||||
clsag.key_image_generator(),
|
||||
keys.current_offset().unwrap_or(dfg::Scalar::ZERO).0 + self.inputs[i].0.key_offset(),
|
||||
));
|
||||
clsags.push(AlgorithmMachine::new(clsag, offset));
|
||||
}
|
||||
|
||||
Ok(TransactionMachine {
|
||||
signable: self,
|
||||
i: keys.params().i(),
|
||||
transcript,
|
||||
key_images,
|
||||
clsag_mask_sends,
|
||||
clsags,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PreprocessMachine for TransactionMachine {
|
||||
type Preprocess = Vec<Preprocess<Ed25519, ClsagAddendum>>;
|
||||
type Signature = Transaction;
|
||||
type SignMachine = TransactionSignMachine;
|
||||
|
||||
fn preprocess<R: RngCore + CryptoRng>(
|
||||
mut self,
|
||||
rng: &mut R,
|
||||
) -> (TransactionSignMachine, Self::Preprocess) {
|
||||
// Iterate over each CLSAG calling preprocess
|
||||
let mut preprocesses = Vec::with_capacity(self.clsags.len());
|
||||
let clsags = self
|
||||
.clsags
|
||||
.drain(..)
|
||||
.map(|clsag| {
|
||||
let (clsag, preprocess) = clsag.preprocess(rng);
|
||||
preprocesses.push(preprocess);
|
||||
clsag
|
||||
})
|
||||
.collect();
|
||||
let our_preprocess = preprocesses.clone();
|
||||
|
||||
// We could add further entropy here, and previous versions of this library did so
|
||||
// As of right now, the multisig's key, the inputs being spent, and the FROST data itself
|
||||
// will be used for RNG seeds. In order to recreate these RNG seeds, breaking privacy,
|
||||
// counterparties must have knowledge of the multisig, either the view key or access to the
|
||||
// coordination layer, and then access to the actual FROST signing process
|
||||
// If the commitments are sent in plain text, then entropy here also would be, making it not
|
||||
// increase privacy. If they're not sent in plain text, or are otherwise inaccessible, they
|
||||
// already offer sufficient entropy. That's why further entropy is not included
|
||||
|
||||
(
|
||||
TransactionSignMachine {
|
||||
signable: self.signable,
|
||||
|
||||
i: self.i,
|
||||
transcript: self.transcript,
|
||||
|
||||
key_images: self.key_images,
|
||||
clsag_mask_sends: self.clsag_mask_sends,
|
||||
clsags,
|
||||
|
||||
our_preprocess,
|
||||
},
|
||||
preprocesses,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SignMachine<Transaction> for TransactionSignMachine {
|
||||
type Params = ();
|
||||
type Keys = ThresholdKeys<Ed25519>;
|
||||
type Preprocess = Vec<Preprocess<Ed25519, ClsagAddendum>>;
|
||||
type SignatureShare = Vec<SignatureShare<Ed25519>>;
|
||||
type SignatureMachine = TransactionSignatureMachine;
|
||||
|
||||
fn cache(self) -> CachedPreprocess {
|
||||
unimplemented!(
|
||||
"Monero transactions don't support caching their preprocesses due to {}",
|
||||
"being already bound to a specific transaction"
|
||||
);
|
||||
}
|
||||
|
||||
fn from_cache(
|
||||
(): (),
|
||||
_: ThresholdKeys<Ed25519>,
|
||||
_: CachedPreprocess,
|
||||
) -> (Self, Self::Preprocess) {
|
||||
unimplemented!(
|
||||
"Monero transactions don't support caching their preprocesses due to {}",
|
||||
"being already bound to a specific transaction"
|
||||
);
|
||||
}
|
||||
|
||||
fn read_preprocess<R: Read>(&self, reader: &mut R) -> io::Result<Self::Preprocess> {
|
||||
self.clsags.iter().map(|clsag| clsag.read_preprocess(reader)).collect()
|
||||
}
|
||||
|
||||
fn sign(
|
||||
mut self,
|
||||
mut commitments: HashMap<Participant, Self::Preprocess>,
|
||||
msg: &[u8],
|
||||
) -> Result<(TransactionSignatureMachine, Self::SignatureShare), FrostError> {
|
||||
if !msg.is_empty() {
|
||||
panic!("message was passed to the TransactionMachine when it generates its own");
|
||||
}
|
||||
|
||||
// Find out who's included
|
||||
// This may not be a valid set of signers yet the algorithm machine will error if it's not
|
||||
commitments.remove(&self.i); // Remove, if it was included for some reason
|
||||
let mut included = commitments.keys().copied().collect::<Vec<_>>();
|
||||
included.push(self.i);
|
||||
included.sort_unstable();
|
||||
|
||||
// Start calculating the key images, as needed on the TX level
|
||||
let mut images = vec![EdwardsPoint::identity(); self.clsags.len()];
|
||||
for (image, (generator, offset)) in images.iter_mut().zip(&self.key_images) {
|
||||
*image = generator * offset;
|
||||
}
|
||||
|
||||
// Convert the serialized nonces commitments to a parallelized Vec
|
||||
let mut commitments = (0 .. self.clsags.len())
|
||||
.map(|c| {
|
||||
included
|
||||
.iter()
|
||||
.map(|l| {
|
||||
// Add all commitments to the transcript for their entropy
|
||||
// While each CLSAG will do this as they need to for security, they have their own
|
||||
// transcripts cloned from this TX's initial premise's transcript. For our TX
|
||||
// transcript to have the CLSAG data for entropy, it'll have to be added ourselves here
|
||||
self.transcript.append_message(b"participant", (*l).to_bytes());
|
||||
|
||||
let preprocess = if *l == self.i {
|
||||
self.our_preprocess[c].clone()
|
||||
} else {
|
||||
commitments.get_mut(l).ok_or(FrostError::MissingParticipant(*l))?[c].clone()
|
||||
};
|
||||
|
||||
{
|
||||
let mut buf = vec![];
|
||||
preprocess.write(&mut buf).unwrap();
|
||||
self.transcript.append_message(b"preprocess", buf);
|
||||
}
|
||||
|
||||
// While here, calculate the key image
|
||||
// Clsag will parse/calculate/validate this as needed, yet doing so here as well
|
||||
// provides the easiest API overall, as this is where the TX is (which needs the key
|
||||
// images in its message), along with where the outputs are determined (where our
|
||||
// outputs may need these in order to guarantee uniqueness)
|
||||
images[c] +=
|
||||
preprocess.addendum.key_image_share().0 * lagrange::<dfg::Scalar>(*l, &included).0;
|
||||
|
||||
Ok((*l, preprocess))
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>, _>>()
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// Remove our preprocess which shouldn't be here. It was just the easiest way to implement the
|
||||
// above
|
||||
for map in &mut commitments {
|
||||
map.remove(&self.i);
|
||||
}
|
||||
|
||||
// Create the actual transaction
|
||||
let (mut tx, output_masks) = {
|
||||
let mut sorted_images = images.clone();
|
||||
sorted_images.sort_by(key_image_sort);
|
||||
|
||||
self.signable.prepare_transaction(
|
||||
// Technically, r_seed is used for the transaction keys if it's provided
|
||||
&mut ChaCha20Rng::from_seed(self.transcript.rng_seed(b"transaction_keys_bulletproofs")),
|
||||
uniqueness(
|
||||
&sorted_images
|
||||
.iter()
|
||||
.map(|image| Input::ToKey { amount: None, key_offsets: vec![], key_image: *image })
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
// Sort the inputs, as expected
|
||||
let mut sorted = Vec::with_capacity(self.clsags.len());
|
||||
while !self.clsags.is_empty() {
|
||||
sorted.push((
|
||||
images.swap_remove(0),
|
||||
self.signable.inputs.swap_remove(0).1,
|
||||
self.clsag_mask_sends.swap_remove(0),
|
||||
self.clsags.swap_remove(0),
|
||||
commitments.swap_remove(0),
|
||||
));
|
||||
}
|
||||
sorted.sort_by(|x, y| key_image_sort(&x.0, &y.0));
|
||||
|
||||
let mut rng = ChaCha20Rng::from_seed(self.transcript.rng_seed(b"pseudo_out_masks"));
|
||||
let mut sum_pseudo_outs = Scalar::ZERO;
|
||||
while !sorted.is_empty() {
|
||||
let value = sorted.remove(0);
|
||||
|
||||
let mut mask = Scalar::random(&mut rng);
|
||||
if sorted.is_empty() {
|
||||
mask = output_masks - sum_pseudo_outs;
|
||||
} else {
|
||||
sum_pseudo_outs += mask;
|
||||
}
|
||||
value.2.send(mask);
|
||||
|
||||
tx.prefix.inputs.push(Input::ToKey {
|
||||
amount: None,
|
||||
key_offsets: value.1.offsets().to_vec(),
|
||||
key_image: value.0,
|
||||
});
|
||||
|
||||
self.clsags.push(value.3);
|
||||
commitments.push(value.4);
|
||||
}
|
||||
|
||||
let msg = tx.signature_hash();
|
||||
|
||||
// Iterate over each CLSAG calling sign
|
||||
let mut shares = Vec::with_capacity(self.clsags.len());
|
||||
let clsags = self
|
||||
.clsags
|
||||
.drain(..)
|
||||
.map(|clsag| {
|
||||
let (clsag, share) = clsag.sign(commitments.remove(0), &msg)?;
|
||||
shares.push(share);
|
||||
Ok(clsag)
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
Ok((TransactionSignatureMachine { tx, clsags }, shares))
|
||||
}
|
||||
}
|
||||
|
||||
impl SignatureMachine<Transaction> for TransactionSignatureMachine {
|
||||
type SignatureShare = Vec<SignatureShare<Ed25519>>;
|
||||
|
||||
fn read_share<R: Read>(&self, reader: &mut R) -> io::Result<Self::SignatureShare> {
|
||||
self.clsags.iter().map(|clsag| clsag.read_share(reader)).collect()
|
||||
}
|
||||
|
||||
fn complete(
|
||||
mut self,
|
||||
shares: HashMap<Participant, Self::SignatureShare>,
|
||||
) -> Result<Transaction, FrostError> {
|
||||
let mut tx = self.tx;
|
||||
match tx.rct_signatures.prunable {
|
||||
RctPrunable::Null => panic!("Signing for RctPrunable::Null"),
|
||||
RctPrunable::Clsag { ref mut clsags, ref mut pseudo_outs, .. } => {
|
||||
for (c, clsag) in self.clsags.drain(..).enumerate() {
|
||||
let (clsag, pseudo_out) = clsag.complete(
|
||||
shares.iter().map(|(l, shares)| (*l, shares[c].clone())).collect::<HashMap<_, _>>(),
|
||||
)?;
|
||||
clsags.push(clsag);
|
||||
pseudo_outs.push(pseudo_out);
|
||||
}
|
||||
}
|
||||
RctPrunable::AggregateMlsagBorromean { .. } |
|
||||
RctPrunable::MlsagBorromean { .. } |
|
||||
RctPrunable::MlsagBulletproofs { .. } => {
|
||||
unreachable!("attempted to sign a multisig TX which wasn't CLSAG")
|
||||
}
|
||||
}
|
||||
Ok(tx)
|
||||
}
|
||||
}
|
||||
173
coins/monero/wallet/src/tests/address.rs
Normal file
173
coins/monero/wallet/src/tests/address.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use hex_literal::hex;
|
||||
|
||||
use rand_core::{RngCore, OsRng};
|
||||
|
||||
use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar};
|
||||
|
||||
use monero_serai::io::decompress_point;
|
||||
|
||||
use crate::address::{Network, AddressType, AddressMeta, MoneroAddress};
|
||||
|
||||
const SPEND: [u8; 32] = hex!("f8631661f6ab4e6fda310c797330d86e23a682f20d5bc8cc27b18051191f16d7");
|
||||
const VIEW: [u8; 32] = hex!("4a1535063ad1fee2dabbf909d4fd9a873e29541b401f0944754e17c9a41820ce");
|
||||
|
||||
const STANDARD: &str =
|
||||
"4B33mFPMq6mKi7Eiyd5XuyKRVMGVZz1Rqb9ZTyGApXW5d1aT7UBDZ89ewmnWFkzJ5wPd2SFbn313vCT8a4E2Qf4KQH4pNey";
|
||||
|
||||
const PAYMENT_ID: [u8; 8] = hex!("b8963a57855cf73f");
|
||||
const INTEGRATED: &str =
|
||||
"4Ljin4CrSNHKi7Eiyd5XuyKRVMGVZz1Rqb9ZTyGApXW5d1aT7UBDZ89ewmnWFkzJ5wPd2SFbn313vCT8a4E2Qf4KbaTH6Mn\
|
||||
pXSn88oBX35";
|
||||
|
||||
const SUB_SPEND: [u8; 32] =
|
||||
hex!("fe358188b528335ad1cfdc24a22a23988d742c882b6f19a602892eaab3c1b62b");
|
||||
const SUB_VIEW: [u8; 32] = hex!("9bc2b464de90d058468522098d5610c5019c45fd1711a9517db1eea7794f5470");
|
||||
const SUBADDRESS: &str =
|
||||
"8C5zHM5ud8nGC4hC2ULiBLSWx9infi8JUUmWEat4fcTf8J4H38iWYVdFmPCA9UmfLTZxD43RsyKnGEdZkoGij6csDeUnbEB";
|
||||
|
||||
const FEATURED_JSON: &str = include_str!("vectors/featured_addresses.json");
|
||||
|
||||
#[test]
|
||||
fn standard_address() {
|
||||
let addr = MoneroAddress::from_str(Network::Mainnet, STANDARD).unwrap();
|
||||
assert_eq!(addr.meta.network, Network::Mainnet);
|
||||
assert_eq!(addr.meta.kind, AddressType::Standard);
|
||||
assert!(!addr.meta.kind.is_subaddress());
|
||||
assert_eq!(addr.meta.kind.payment_id(), None);
|
||||
assert!(!addr.meta.kind.is_guaranteed());
|
||||
assert_eq!(addr.spend.compress().to_bytes(), SPEND);
|
||||
assert_eq!(addr.view.compress().to_bytes(), VIEW);
|
||||
assert_eq!(addr.to_string(), STANDARD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrated_address() {
|
||||
let addr = MoneroAddress::from_str(Network::Mainnet, INTEGRATED).unwrap();
|
||||
assert_eq!(addr.meta.network, Network::Mainnet);
|
||||
assert_eq!(addr.meta.kind, AddressType::Integrated(PAYMENT_ID));
|
||||
assert!(!addr.meta.kind.is_subaddress());
|
||||
assert_eq!(addr.meta.kind.payment_id(), Some(PAYMENT_ID));
|
||||
assert!(!addr.meta.kind.is_guaranteed());
|
||||
assert_eq!(addr.spend.compress().to_bytes(), SPEND);
|
||||
assert_eq!(addr.view.compress().to_bytes(), VIEW);
|
||||
assert_eq!(addr.to_string(), INTEGRATED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subaddress() {
|
||||
let addr = MoneroAddress::from_str(Network::Mainnet, SUBADDRESS).unwrap();
|
||||
assert_eq!(addr.meta.network, Network::Mainnet);
|
||||
assert_eq!(addr.meta.kind, AddressType::Subaddress);
|
||||
assert!(addr.meta.kind.is_subaddress());
|
||||
assert_eq!(addr.meta.kind.payment_id(), None);
|
||||
assert!(!addr.meta.kind.is_guaranteed());
|
||||
assert_eq!(addr.spend.compress().to_bytes(), SUB_SPEND);
|
||||
assert_eq!(addr.view.compress().to_bytes(), SUB_VIEW);
|
||||
assert_eq!(addr.to_string(), SUBADDRESS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn featured() {
|
||||
for (network, first) in
|
||||
[(Network::Mainnet, 'C'), (Network::Testnet, 'K'), (Network::Stagenet, 'F')]
|
||||
{
|
||||
for _ in 0 .. 100 {
|
||||
let spend = &Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE;
|
||||
let view = &Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE;
|
||||
|
||||
for features in 0 .. (1 << 3) {
|
||||
const SUBADDRESS_FEATURE_BIT: u8 = 1;
|
||||
const INTEGRATED_FEATURE_BIT: u8 = 1 << 1;
|
||||
const GUARANTEED_FEATURE_BIT: u8 = 1 << 2;
|
||||
|
||||
let subaddress = (features & SUBADDRESS_FEATURE_BIT) == SUBADDRESS_FEATURE_BIT;
|
||||
|
||||
let mut payment_id = [0; 8];
|
||||
OsRng.fill_bytes(&mut payment_id);
|
||||
let payment_id = Some(payment_id)
|
||||
.filter(|_| (features & INTEGRATED_FEATURE_BIT) == INTEGRATED_FEATURE_BIT);
|
||||
|
||||
let guaranteed = (features & GUARANTEED_FEATURE_BIT) == GUARANTEED_FEATURE_BIT;
|
||||
|
||||
let kind = AddressType::Featured { subaddress, payment_id, guaranteed };
|
||||
let meta = AddressMeta::new(network, kind);
|
||||
let addr = MoneroAddress::new(meta, spend, view);
|
||||
|
||||
assert_eq!(addr.to_string().chars().next().unwrap(), first);
|
||||
assert_eq!(MoneroAddress::from_str(network, &addr.to_string()).unwrap(), addr);
|
||||
|
||||
assert_eq!(addr.spend, spend);
|
||||
assert_eq!(addr.view, view);
|
||||
|
||||
assert_eq!(addr.is_subaddress(), subaddress);
|
||||
assert_eq!(addr.payment_id(), payment_id);
|
||||
assert_eq!(addr.is_guaranteed(), guaranteed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn featured_vectors() {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Vector {
|
||||
address: String,
|
||||
|
||||
network: String,
|
||||
spend: String,
|
||||
view: String,
|
||||
|
||||
subaddress: bool,
|
||||
integrated: bool,
|
||||
payment_id: Option<[u8; 8]>,
|
||||
guaranteed: bool,
|
||||
}
|
||||
|
||||
let vectors = serde_json::from_str::<Vec<Vector>>(FEATURED_JSON).unwrap();
|
||||
for vector in vectors {
|
||||
let first = vector.address.chars().next().unwrap();
|
||||
let network = match vector.network.as_str() {
|
||||
"Mainnet" => {
|
||||
assert_eq!(first, 'C');
|
||||
Network::Mainnet
|
||||
}
|
||||
"Testnet" => {
|
||||
assert_eq!(first, 'K');
|
||||
Network::Testnet
|
||||
}
|
||||
"Stagenet" => {
|
||||
assert_eq!(first, 'F');
|
||||
Network::Stagenet
|
||||
}
|
||||
_ => panic!("Unknown network"),
|
||||
};
|
||||
let spend = decompress_point(hex::decode(vector.spend).unwrap().try_into().unwrap()).unwrap();
|
||||
let view = decompress_point(hex::decode(vector.view).unwrap().try_into().unwrap()).unwrap();
|
||||
|
||||
let addr = MoneroAddress::from_str(network, &vector.address).unwrap();
|
||||
assert_eq!(addr.spend, spend);
|
||||
assert_eq!(addr.view, view);
|
||||
|
||||
assert_eq!(addr.is_subaddress(), vector.subaddress);
|
||||
assert_eq!(vector.integrated, vector.payment_id.is_some());
|
||||
assert_eq!(addr.payment_id(), vector.payment_id);
|
||||
assert_eq!(addr.is_guaranteed(), vector.guaranteed);
|
||||
|
||||
assert_eq!(
|
||||
MoneroAddress::new(
|
||||
AddressMeta::new(
|
||||
network,
|
||||
AddressType::Featured {
|
||||
subaddress: vector.subaddress,
|
||||
payment_id: vector.payment_id,
|
||||
guaranteed: vector.guaranteed
|
||||
}
|
||||
),
|
||||
spend,
|
||||
view
|
||||
)
|
||||
.to_string(),
|
||||
vector.address
|
||||
);
|
||||
}
|
||||
}
|
||||
156
coins/monero/wallet/src/tests/extra.rs
Normal file
156
coins/monero/wallet/src/tests/extra.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use curve25519_dalek::edwards::{EdwardsPoint, CompressedEdwardsY};
|
||||
|
||||
use monero_serai::io::write_varint;
|
||||
use crate::{ExtraField, Extra, extra::MAX_TX_EXTRA_PADDING_COUNT};
|
||||
|
||||
// Borrowed tests from
|
||||
// https://github.com/monero-project/monero/blob/ac02af92867590ca80b2779a7bbeafa99ff94dcb/
|
||||
// tests/unit_tests/test_tx_utils.cpp
|
||||
|
||||
const PUB_KEY_BYTES: [u8; 33] = [
|
||||
1, 30, 208, 98, 162, 133, 64, 85, 83, 112, 91, 188, 89, 211, 24, 131, 39, 154, 22, 228, 80, 63,
|
||||
198, 141, 173, 111, 244, 183, 4, 149, 186, 140, 230,
|
||||
];
|
||||
|
||||
fn pub_key() -> EdwardsPoint {
|
||||
CompressedEdwardsY(PUB_KEY_BYTES[1 .. PUB_KEY_BYTES.len()].try_into().expect("invalid pub key"))
|
||||
.decompress()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn test_write_buf(extra: &Extra, buf: &[u8]) {
|
||||
let mut w: Vec<u8> = vec![];
|
||||
Extra::write(extra, &mut w).unwrap();
|
||||
assert_eq!(buf, w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_extra() {
|
||||
let buf: Vec<u8> = vec![];
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert!(extra.0.is_empty());
|
||||
test_write_buf(&extra, &buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_only_size_1() {
|
||||
let buf: Vec<u8> = vec![0];
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert_eq!(extra.0, vec![ExtraField::Padding(1)]);
|
||||
test_write_buf(&extra, &buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_only_size_2() {
|
||||
let buf: Vec<u8> = vec![0, 0];
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert_eq!(extra.0, vec![ExtraField::Padding(2)]);
|
||||
test_write_buf(&extra, &buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_only_max_size() {
|
||||
let buf: Vec<u8> = vec![0; MAX_TX_EXTRA_PADDING_COUNT];
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert_eq!(extra.0, vec![ExtraField::Padding(MAX_TX_EXTRA_PADDING_COUNT)]);
|
||||
test_write_buf(&extra, &buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_only_exceed_max_size() {
|
||||
let buf: Vec<u8> = vec![0; MAX_TX_EXTRA_PADDING_COUNT + 1];
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert!(extra.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_padding_only() {
|
||||
let buf: Vec<u8> = vec![0, 42];
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert!(extra.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pub_key_only() {
|
||||
let buf: Vec<u8> = PUB_KEY_BYTES.to_vec();
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert_eq!(extra.0, vec![ExtraField::PublicKey(pub_key())]);
|
||||
test_write_buf(&extra, &buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_nonce_only() {
|
||||
let buf: Vec<u8> = vec![2, 1, 42];
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert_eq!(extra.0, vec![ExtraField::Nonce(vec![42])]);
|
||||
test_write_buf(&extra, &buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_nonce_only_wrong_size() {
|
||||
let mut buf: Vec<u8> = vec![0; 20];
|
||||
buf[0] = 2;
|
||||
buf[1] = 255;
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert!(extra.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pub_key_and_padding() {
|
||||
let mut buf: Vec<u8> = PUB_KEY_BYTES.to_vec();
|
||||
buf.extend([
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
]);
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert_eq!(extra.0, vec![ExtraField::PublicKey(pub_key()), ExtraField::Padding(76)]);
|
||||
test_write_buf(&extra, &buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pub_key_and_invalid_padding() {
|
||||
let mut buf: Vec<u8> = PUB_KEY_BYTES.to_vec();
|
||||
buf.extend([0, 1]);
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert_eq!(extra.0, vec![ExtraField::PublicKey(pub_key())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_mysterious_minergate_only() {
|
||||
let buf: Vec<u8> = vec![222, 1, 42];
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert_eq!(extra.0, vec![ExtraField::MysteriousMinergate(vec![42])]);
|
||||
test_write_buf(&extra, &buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_mysterious_minergate_only_large() {
|
||||
let mut buf: Vec<u8> = vec![222];
|
||||
write_varint(&512u64, &mut buf).unwrap();
|
||||
buf.extend_from_slice(&vec![0; 512]);
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert_eq!(extra.0, vec![ExtraField::MysteriousMinergate(vec![0; 512])]);
|
||||
test_write_buf(&extra, &buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_mysterious_minergate_only_wrong_size() {
|
||||
let mut buf: Vec<u8> = vec![0; 20];
|
||||
buf[0] = 222;
|
||||
buf[1] = 255;
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert!(extra.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_mysterious_minergate_and_pub_key() {
|
||||
let mut buf: Vec<u8> = vec![222, 1, 42];
|
||||
buf.extend(PUB_KEY_BYTES.to_vec());
|
||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
||||
assert_eq!(
|
||||
extra.0,
|
||||
vec![ExtraField::MysteriousMinergate(vec![42]), ExtraField::PublicKey(pub_key())]
|
||||
);
|
||||
test_write_buf(&extra, &buf);
|
||||
}
|
||||
3
coins/monero/wallet/src/tests/mod.rs
Normal file
3
coins/monero/wallet/src/tests/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod address;
|
||||
mod seed;
|
||||
mod extra;
|
||||
481
coins/monero/wallet/src/tests/seed.rs
Normal file
481
coins/monero/wallet/src/tests/seed.rs
Normal file
@@ -0,0 +1,481 @@
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use rand_core::OsRng;
|
||||
|
||||
use curve25519_dalek::scalar::Scalar;
|
||||
|
||||
use monero_serai::primitives::keccak256;
|
||||
|
||||
use crate::seed::{
|
||||
Seed, SeedType, SeedError,
|
||||
classic::{self, trim_by_lang},
|
||||
polyseed,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_classic_seed() {
|
||||
struct Vector {
|
||||
language: classic::Language,
|
||||
seed: String,
|
||||
spend: String,
|
||||
view: String,
|
||||
}
|
||||
|
||||
let vectors = [
|
||||
Vector {
|
||||
language: classic::Language::Chinese,
|
||||
seed: "摇 曲 艺 武 滴 然 效 似 赏 式 祥 歌 买 疑 小 碧 堆 博 键 房 鲜 悲 付 喷 武".into(),
|
||||
spend: "a5e4fff1706ef9212993a69f246f5c95ad6d84371692d63e9bb0ea112a58340d".into(),
|
||||
view: "1176c43ce541477ea2f3ef0b49b25112b084e26b8a843e1304ac4677b74cdf02".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::English,
|
||||
seed: "washing thirsty occur lectures tuesday fainted toxic adapt \
|
||||
abnormal memoir nylon mostly building shrugged online ember northern \
|
||||
ruby woes dauntless boil family illness inroads northern"
|
||||
.into(),
|
||||
spend: "c0af65c0dd837e666b9d0dfed62745f4df35aed7ea619b2798a709f0fe545403".into(),
|
||||
view: "513ba91c538a5a9069e0094de90e927c0cd147fa10428ce3ac1afd49f63e3b01".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::Dutch,
|
||||
seed: "setwinst riphagen vimmetje extase blief tuitelig fuiven meifeest \
|
||||
ponywagen zesmaal ripdeal matverf codetaal leut ivoor rotten \
|
||||
wisgerhof winzucht typograaf atrium rein zilt traktaat verzaagd setwinst"
|
||||
.into(),
|
||||
spend: "e2d2873085c447c2bc7664222ac8f7d240df3aeac137f5ff2022eaa629e5b10a".into(),
|
||||
view: "eac30b69477e3f68093d131c7fd961564458401b07f8c87ff8f6030c1a0c7301".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::French,
|
||||
seed: "poids vaseux tarte bazar poivre effet entier nuance \
|
||||
sensuel ennui pacte osselet poudre battre alibi mouton \
|
||||
stade paquet pliage gibier type question position projet pliage"
|
||||
.into(),
|
||||
spend: "2dd39ff1a4628a94b5c2ec3e42fb3dfe15c2b2f010154dc3b3de6791e805b904".into(),
|
||||
view: "6725b32230400a1032f31d622b44c3a227f88258939b14a7c72e00939e7bdf0e".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::Spanish,
|
||||
seed: "minero ocupar mirar evadir octubre cal logro miope \
|
||||
opaco disco ancla litio clase cuello nasal clase \
|
||||
fiar avance deseo mente grumo negro cordón croqueta clase"
|
||||
.into(),
|
||||
spend: "ae2c9bebdddac067d73ec0180147fc92bdf9ac7337f1bcafbbe57dd13558eb02".into(),
|
||||
view: "18deafb34d55b7a43cae2c1c1c206a3c80c12cc9d1f84640b484b95b7fec3e05".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::German,
|
||||
seed: "Kaliber Gabelung Tapir Liveband Favorit Specht Enklave Nabel \
|
||||
Jupiter Foliant Chronik nisten löten Vase Aussage Rekord \
|
||||
Yeti Gesetz Eleganz Alraune Künstler Almweide Jahr Kastanie Almweide"
|
||||
.into(),
|
||||
spend: "79801b7a1b9796856e2397d862a113862e1fdc289a205e79d8d70995b276db06".into(),
|
||||
view: "99f0ec556643bd9c038a4ed86edcb9c6c16032c4622ed2e000299d527a792701".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::Italian,
|
||||
seed: "cavo pancetta auto fulmine alleanza filmato diavolo prato \
|
||||
forzare meritare litigare lezione segreto evasione votare buio \
|
||||
licenza cliente dorso natale crescere vento tutelare vetta evasione"
|
||||
.into(),
|
||||
spend: "5e7fd774eb00fa5877e2a8b4dc9c7ffe111008a3891220b56a6e49ac816d650a".into(),
|
||||
view: "698a1dce6018aef5516e82ca0cb3e3ec7778d17dfb41a137567bfa2e55e63a03".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::Portuguese,
|
||||
seed: "agito eventualidade onus itrio holograma sodomizar objetos dobro \
|
||||
iugoslavo bcrepuscular odalisca abjeto iuane darwinista eczema acetona \
|
||||
cibernetico hoquei gleba driver buffer azoto megera nogueira agito"
|
||||
.into(),
|
||||
spend: "13b3115f37e35c6aa1db97428b897e584698670c1b27854568d678e729200c0f".into(),
|
||||
view: "ad1b4fd35270f5f36c4da7166672b347e75c3f4d41346ec2a06d1d0193632801".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::Japanese,
|
||||
seed: "ぜんぶ どうぐ おたがい せんきょ おうじ そんちょう じゅしん いろえんぴつ \
|
||||
かほう つかれる えらぶ にちじょう くのう にちようび ぬまえび さんきゃく \
|
||||
おおや ちぬき うすめる いがく せつでん さうな すいえい せつだん おおや"
|
||||
.into(),
|
||||
spend: "c56e895cdb13007eda8399222974cdbab493640663804b93cbef3d8c3df80b0b".into(),
|
||||
view: "6c3634a313ec2ee979d565c33888fd7c3502d696ce0134a8bc1a2698c7f2c508".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::Russian,
|
||||
seed: "шатер икра нация ехать получать инерция доза реальный \
|
||||
рыжий таможня лопата душа веселый клетка атлас лекция \
|
||||
обгонять паек наивный лыжный дурак стать ежик задача паек"
|
||||
.into(),
|
||||
spend: "7cb5492df5eb2db4c84af20766391cd3e3662ab1a241c70fc881f3d02c381f05".into(),
|
||||
view: "fcd53e41ec0df995ab43927f7c44bc3359c93523d5009fb3f5ba87431d545a03".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::Esperanto,
|
||||
seed: "ukazo klini peco etikedo fabriko imitado onklino urino \
|
||||
pudro incidento kumuluso ikono smirgi hirundo uretro krii \
|
||||
sparkado super speciala pupo alpinisto cvana vokegi zombio fabriko"
|
||||
.into(),
|
||||
spend: "82ebf0336d3b152701964ed41df6b6e9a035e57fc98b84039ed0bd4611c58904".into(),
|
||||
view: "cd4d120e1ea34360af528f6a3e6156063312d9cefc9aa6b5218d366c0ed6a201".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::Lojban,
|
||||
seed: "jetnu vensa julne xrotu xamsi julne cutci dakli \
|
||||
mlatu xedja muvgau palpi xindo sfubu ciste cinri \
|
||||
blabi darno dembi janli blabi fenki bukpu burcu blabi"
|
||||
.into(),
|
||||
spend: "e4f8c6819ab6cf792cebb858caabac9307fd646901d72123e0367ebc0a79c200".into(),
|
||||
view: "c806ce62bafaa7b2d597f1a1e2dbe4a2f96bfd804bf6f8420fc7f4a6bd700c00".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::EnglishOld,
|
||||
seed: "glorious especially puff son moment add youth nowhere \
|
||||
throw glide grip wrong rhythm consume very swear \
|
||||
bitter heavy eventually begin reason flirt type unable"
|
||||
.into(),
|
||||
spend: "647f4765b66b636ff07170ab6280a9a6804dfbaf19db2ad37d23be024a18730b".into(),
|
||||
view: "045da65316a906a8c30046053119c18020b07a7a3a6ef5c01ab2a8755416bd02".into(),
|
||||
},
|
||||
// The following seeds require the language specification in order to calculate
|
||||
// a single valid checksum
|
||||
Vector {
|
||||
language: classic::Language::Spanish,
|
||||
seed: "pluma laico atraer pintor peor cerca balde buscar \
|
||||
lancha batir nulo reloj resto gemelo nevera poder columna gol \
|
||||
oveja latir amplio bolero feliz fuerza nevera"
|
||||
.into(),
|
||||
spend: "30303983fc8d215dd020cc6b8223793318d55c466a86e4390954f373fdc7200a".into(),
|
||||
view: "97c649143f3c147ba59aa5506cc09c7992c5c219bb26964442142bf97980800e".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::Spanish,
|
||||
seed: "pluma pluma pluma pluma pluma pluma pluma pluma \
|
||||
pluma pluma pluma pluma pluma pluma pluma pluma \
|
||||
pluma pluma pluma pluma pluma pluma pluma pluma pluma"
|
||||
.into(),
|
||||
spend: "b4050000b4050000b4050000b4050000b4050000b4050000b4050000b4050000".into(),
|
||||
view: "d73534f7912b395eb70ef911791a2814eb6df7ce56528eaaa83ff2b72d9f5e0f".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::English,
|
||||
seed: "plus plus plus plus plus plus plus plus \
|
||||
plus plus plus plus plus plus plus plus \
|
||||
plus plus plus plus plus plus plus plus plus"
|
||||
.into(),
|
||||
spend: "3b0400003b0400003b0400003b0400003b0400003b0400003b0400003b040000".into(),
|
||||
view: "43a8a7715eed11eff145a2024ddcc39740255156da7bbd736ee66a0838053a02".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::Spanish,
|
||||
seed: "audio audio audio audio audio audio audio audio \
|
||||
audio audio audio audio audio audio audio audio \
|
||||
audio audio audio audio audio audio audio audio audio"
|
||||
.into(),
|
||||
spend: "ba000000ba000000ba000000ba000000ba000000ba000000ba000000ba000000".into(),
|
||||
view: "1437256da2c85d029b293d8c6b1d625d9374969301869b12f37186e3f906c708".into(),
|
||||
},
|
||||
Vector {
|
||||
language: classic::Language::English,
|
||||
seed: "audio audio audio audio audio audio audio audio \
|
||||
audio audio audio audio audio audio audio audio \
|
||||
audio audio audio audio audio audio audio audio audio"
|
||||
.into(),
|
||||
spend: "7900000079000000790000007900000079000000790000007900000079000000".into(),
|
||||
view: "20bec797ab96780ae6a045dd816676ca7ed1d7c6773f7022d03ad234b581d600".into(),
|
||||
},
|
||||
];
|
||||
|
||||
for vector in vectors {
|
||||
let trim_seed = |seed: &str| {
|
||||
seed
|
||||
.split_whitespace()
|
||||
.map(|word| trim_by_lang(word, vector.language))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
};
|
||||
|
||||
// Test against Monero
|
||||
{
|
||||
println!("{}. language: {:?}, seed: {}", line!(), vector.language, vector.seed.clone());
|
||||
let seed =
|
||||
Seed::from_string(SeedType::Classic(vector.language), Zeroizing::new(vector.seed.clone()))
|
||||
.unwrap();
|
||||
let trim = trim_seed(&vector.seed);
|
||||
assert_eq!(
|
||||
seed,
|
||||
Seed::from_string(SeedType::Classic(vector.language), Zeroizing::new(trim)).unwrap()
|
||||
);
|
||||
|
||||
let spend: [u8; 32] = hex::decode(vector.spend).unwrap().try_into().unwrap();
|
||||
// For classical seeds, Monero directly uses the entropy as a spend key
|
||||
assert_eq!(
|
||||
Option::<Scalar>::from(Scalar::from_canonical_bytes(*seed.entropy())),
|
||||
Option::<Scalar>::from(Scalar::from_canonical_bytes(spend)),
|
||||
);
|
||||
|
||||
let view: [u8; 32] = hex::decode(vector.view).unwrap().try_into().unwrap();
|
||||
// Monero then derives the view key as H(spend)
|
||||
assert_eq!(
|
||||
Scalar::from_bytes_mod_order(keccak256(spend)),
|
||||
Scalar::from_canonical_bytes(view).unwrap()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Seed::from_entropy(SeedType::Classic(vector.language), Zeroizing::new(spend), None)
|
||||
.unwrap(),
|
||||
seed
|
||||
);
|
||||
}
|
||||
|
||||
// Test against ourselves
|
||||
{
|
||||
let seed = Seed::new(&mut OsRng, SeedType::Classic(vector.language));
|
||||
println!("{}. seed: {}", line!(), *seed.to_string());
|
||||
let trim = trim_seed(&seed.to_string());
|
||||
assert_eq!(
|
||||
seed,
|
||||
Seed::from_string(SeedType::Classic(vector.language), Zeroizing::new(trim)).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
seed,
|
||||
Seed::from_entropy(SeedType::Classic(vector.language), seed.entropy(), None).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
seed,
|
||||
Seed::from_string(SeedType::Classic(vector.language), seed.to_string()).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_polyseed() {
|
||||
struct Vector {
|
||||
language: polyseed::Language,
|
||||
seed: String,
|
||||
entropy: String,
|
||||
birthday: u64,
|
||||
has_prefix: bool,
|
||||
has_accent: bool,
|
||||
}
|
||||
|
||||
let vectors = [
|
||||
Vector {
|
||||
language: polyseed::Language::English,
|
||||
seed: "raven tail swear infant grief assist regular lamp \
|
||||
duck valid someone little harsh puppy airport language"
|
||||
.into(),
|
||||
entropy: "dd76e7359a0ded37cd0ff0f3c829a5ae01673300000000000000000000000000".into(),
|
||||
birthday: 1638446400,
|
||||
has_prefix: true,
|
||||
has_accent: false,
|
||||
},
|
||||
Vector {
|
||||
language: polyseed::Language::Spanish,
|
||||
seed: "eje fin parte célebre tabú pestaña lienzo puma \
|
||||
prisión hora regalo lengua existir lápiz lote sonoro"
|
||||
.into(),
|
||||
entropy: "5a2b02df7db21fcbe6ec6df137d54c7b20fd2b00000000000000000000000000".into(),
|
||||
birthday: 3118651200,
|
||||
has_prefix: true,
|
||||
has_accent: true,
|
||||
},
|
||||
Vector {
|
||||
language: polyseed::Language::French,
|
||||
seed: "valable arracher décaler jeudi amusant dresser mener épaissir risible \
|
||||
prouesse réserve ampleur ajuster muter caméra enchère"
|
||||
.into(),
|
||||
entropy: "11cfd870324b26657342c37360c424a14a050b00000000000000000000000000".into(),
|
||||
birthday: 1679314966,
|
||||
has_prefix: true,
|
||||
has_accent: true,
|
||||
},
|
||||
Vector {
|
||||
language: polyseed::Language::Italian,
|
||||
seed: "caduco midollo copione meninge isotopo illogico riflesso tartaruga fermento \
|
||||
olandese normale tristezza episodio voragine forbito achille"
|
||||
.into(),
|
||||
entropy: "7ecc57c9b4652d4e31428f62bec91cfd55500600000000000000000000000000".into(),
|
||||
birthday: 1679316358,
|
||||
has_prefix: true,
|
||||
has_accent: false,
|
||||
},
|
||||
Vector {
|
||||
language: polyseed::Language::Portuguese,
|
||||
seed: "caverna custear azedo adeus senador apertada sedoso omitir \
|
||||
sujeito aurora videira molho cartaz gesso dentista tapar"
|
||||
.into(),
|
||||
entropy: "45473063711376cae38f1b3eba18c874124e1d00000000000000000000000000".into(),
|
||||
birthday: 1679316657,
|
||||
has_prefix: true,
|
||||
has_accent: false,
|
||||
},
|
||||
Vector {
|
||||
language: polyseed::Language::Czech,
|
||||
seed: "usmrtit nora dotaz komunita zavalit funkce mzda sotva akce \
|
||||
vesta kabel herna stodola uvolnit ustrnout email"
|
||||
.into(),
|
||||
entropy: "7ac8a4efd62d9c3c4c02e350d32326df37821c00000000000000000000000000".into(),
|
||||
birthday: 1679316898,
|
||||
has_prefix: true,
|
||||
has_accent: false,
|
||||
},
|
||||
Vector {
|
||||
language: polyseed::Language::Korean,
|
||||
seed: "전망 선풍기 국제 무궁화 설사 기름 이론적 해안 절망 예선 \
|
||||
지우개 보관 절망 말기 시각 귀신"
|
||||
.into(),
|
||||
entropy: "684663fda420298f42ed94b2c512ed38ddf12b00000000000000000000000000".into(),
|
||||
birthday: 1679317073,
|
||||
has_prefix: false,
|
||||
has_accent: false,
|
||||
},
|
||||
Vector {
|
||||
language: polyseed::Language::Japanese,
|
||||
seed: "うちあわせ ちつじょ つごう しはい けんこう とおる てみやげ はんとし たんとう \
|
||||
といれ おさない おさえる むかう ぬぐう なふだ せまる"
|
||||
.into(),
|
||||
entropy: "94e6665518a6286c6e3ba508a2279eb62b771f00000000000000000000000000".into(),
|
||||
birthday: 1679318722,
|
||||
has_prefix: false,
|
||||
has_accent: false,
|
||||
},
|
||||
Vector {
|
||||
language: polyseed::Language::ChineseTraditional,
|
||||
seed: "亂 挖 斤 柄 代 圈 枝 轄 魯 論 函 開 勘 番 榮 壁".into(),
|
||||
entropy: "b1594f585987ab0fd5a31da1f0d377dae5283f00000000000000000000000000".into(),
|
||||
birthday: 1679426433,
|
||||
has_prefix: false,
|
||||
has_accent: false,
|
||||
},
|
||||
Vector {
|
||||
language: polyseed::Language::ChineseSimplified,
|
||||
seed: "啊 百 族 府 票 划 伪 仓 叶 虾 借 溜 晨 左 等 鬼".into(),
|
||||
entropy: "21cdd366f337b89b8d1bc1df9fe73047c22b0300000000000000000000000000".into(),
|
||||
birthday: 1679426817,
|
||||
has_prefix: false,
|
||||
has_accent: false,
|
||||
},
|
||||
// The following seed requires the language specification in order to calculate
|
||||
// a single valid checksum
|
||||
Vector {
|
||||
language: polyseed::Language::Spanish,
|
||||
seed: "impo sort usua cabi venu nobl oliv clim \
|
||||
cont barr marc auto prod vaca torn fati"
|
||||
.into(),
|
||||
entropy: "dbfce25fe09b68a340e01c62417eeef43ad51800000000000000000000000000".into(),
|
||||
birthday: 1701511650,
|
||||
has_prefix: true,
|
||||
has_accent: true,
|
||||
},
|
||||
];
|
||||
|
||||
for vector in vectors {
|
||||
let add_whitespace = |mut seed: String| {
|
||||
seed.push(' ');
|
||||
seed
|
||||
};
|
||||
|
||||
let seed_without_accents = |seed: &str| {
|
||||
seed
|
||||
.split_whitespace()
|
||||
.map(|w| w.chars().filter(char::is_ascii).collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
};
|
||||
|
||||
let trim_seed = |seed: &str| {
|
||||
let seed_to_trim =
|
||||
if vector.has_accent { seed_without_accents(seed) } else { seed.to_string() };
|
||||
seed_to_trim
|
||||
.split_whitespace()
|
||||
.map(|w| {
|
||||
let mut ascii = 0;
|
||||
let mut to_take = w.len();
|
||||
for (i, char) in w.chars().enumerate() {
|
||||
if char.is_ascii() {
|
||||
ascii += 1;
|
||||
}
|
||||
if ascii == polyseed::PREFIX_LEN {
|
||||
// +1 to include this character, which put us at the prefix length
|
||||
to_take = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
w.chars().take(to_take).collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
};
|
||||
|
||||
// String -> Seed
|
||||
println!("{}. language: {:?}, seed: {}", line!(), vector.language, vector.seed.clone());
|
||||
let seed =
|
||||
Seed::from_string(SeedType::Polyseed(vector.language), Zeroizing::new(vector.seed.clone()))
|
||||
.unwrap();
|
||||
let trim = trim_seed(&vector.seed);
|
||||
let add_whitespace = add_whitespace(vector.seed.clone());
|
||||
let seed_without_accents = seed_without_accents(&vector.seed);
|
||||
|
||||
// Make sure a version with added whitespace still works
|
||||
let whitespaced_seed =
|
||||
Seed::from_string(SeedType::Polyseed(vector.language), Zeroizing::new(add_whitespace))
|
||||
.unwrap();
|
||||
assert_eq!(seed, whitespaced_seed);
|
||||
// Check trimmed versions works
|
||||
if vector.has_prefix {
|
||||
let trimmed_seed =
|
||||
Seed::from_string(SeedType::Polyseed(vector.language), Zeroizing::new(trim)).unwrap();
|
||||
assert_eq!(seed, trimmed_seed);
|
||||
}
|
||||
// Check versions without accents work
|
||||
if vector.has_accent {
|
||||
let seed_without_accents = Seed::from_string(
|
||||
SeedType::Polyseed(vector.language),
|
||||
Zeroizing::new(seed_without_accents),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(seed, seed_without_accents);
|
||||
}
|
||||
|
||||
let entropy = Zeroizing::new(hex::decode(vector.entropy).unwrap().try_into().unwrap());
|
||||
assert_eq!(seed.entropy(), entropy);
|
||||
assert!(seed.birthday().abs_diff(vector.birthday) < polyseed::TIME_STEP);
|
||||
|
||||
// Entropy -> Seed
|
||||
let from_entropy =
|
||||
Seed::from_entropy(SeedType::Polyseed(vector.language), entropy, Some(seed.birthday()))
|
||||
.unwrap();
|
||||
assert_eq!(seed.to_string(), from_entropy.to_string());
|
||||
|
||||
// Check against ourselves
|
||||
{
|
||||
let seed = Seed::new(&mut OsRng, SeedType::Polyseed(vector.language));
|
||||
println!("{}. seed: {}", line!(), *seed.to_string());
|
||||
assert_eq!(
|
||||
seed,
|
||||
Seed::from_string(SeedType::Polyseed(vector.language), seed.to_string()).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
seed,
|
||||
Seed::from_entropy(
|
||||
SeedType::Polyseed(vector.language),
|
||||
seed.entropy(),
|
||||
Some(seed.birthday())
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_polyseed() {
|
||||
// This seed includes unsupported features bits and should error on decode
|
||||
let seed = "include domain claim resemble urban hire lunch bird \
|
||||
crucial fire best wife ring warm ignore model"
|
||||
.into();
|
||||
let res =
|
||||
Seed::from_string(SeedType::Polyseed(polyseed::Language::English), Zeroizing::new(seed));
|
||||
assert_eq!(res, Err(SeedError::UnsupportedFeatures));
|
||||
}
|
||||
230
coins/monero/wallet/src/tests/vectors/featured_addresses.json
Normal file
230
coins/monero/wallet/src/tests/vectors/featured_addresses.json
Normal file
@@ -0,0 +1,230 @@
|
||||
[
|
||||
{
|
||||
"address": "CjWdTpuDaZ69nTGxzm9YarR82YDYFECi1WaaREZTMy5yDsjaRX5bC3cbC3JpcrBPd7YYpjoWKuBMidgGaKBK5Jye2v3pYyUDn",
|
||||
"network": "Mainnet",
|
||||
"spend": "258dfe7eef9be934839f3b8e0d40e79035fe85879c0a9eb0d7372ae2deb0004c",
|
||||
"view": "f91382373045f3cc69233254ab0406bc9e008707569ff9db4718654812d839df",
|
||||
"subaddress": false,
|
||||
"integrated": false,
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "CjWdTpuDaZ69nTGxzm9YarR82YDYFECi1WaaREZTMy5yDsjaRX5bC3cbC3JpcrBPd7YYpjoWKuBMidgGaKBK5Jye2v3wfMHCy",
|
||||
"network": "Mainnet",
|
||||
"spend": "258dfe7eef9be934839f3b8e0d40e79035fe85879c0a9eb0d7372ae2deb0004c",
|
||||
"view": "f91382373045f3cc69233254ab0406bc9e008707569ff9db4718654812d839df",
|
||||
"subaddress": true,
|
||||
"integrated": false,
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "CjWdTpuDaZ69nTGxzm9YarR82YDYFECi1WaaREZTMy5yDsjaRX5bC3cbC3JpcrBPd7YYpjoWKuBMidgGaKBK5JyeeJTo4p5ayvj36PStM5AX",
|
||||
"network": "Mainnet",
|
||||
"spend": "258dfe7eef9be934839f3b8e0d40e79035fe85879c0a9eb0d7372ae2deb0004c",
|
||||
"view": "f91382373045f3cc69233254ab0406bc9e008707569ff9db4718654812d839df",
|
||||
"subaddress": false,
|
||||
"integrated": true,
|
||||
"payment_id": [46, 48, 134, 34, 245, 148, 243, 195],
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "CjWdTpuDaZ69nTGxzm9YarR82YDYFECi1WaaREZTMy5yDsjaRX5bC3cbC3JpcrBPd7YYpjoWKuBMidgGaKBK5JyeeJWv5WqMCNE2hRs9rJfy",
|
||||
"network": "Mainnet",
|
||||
"spend": "258dfe7eef9be934839f3b8e0d40e79035fe85879c0a9eb0d7372ae2deb0004c",
|
||||
"view": "f91382373045f3cc69233254ab0406bc9e008707569ff9db4718654812d839df",
|
||||
"subaddress": true,
|
||||
"integrated": true,
|
||||
"payment_id": [153, 176, 98, 204, 151, 27, 197, 168],
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "CjWdTpuDaZ69nTGxzm9YarR82YDYFECi1WaaREZTMy5yDsjaRX5bC3cbC3JpcrBPd7YYpjoWKuBMidgGaKBK5Jye2v4DwqwH1",
|
||||
"network": "Mainnet",
|
||||
"spend": "258dfe7eef9be934839f3b8e0d40e79035fe85879c0a9eb0d7372ae2deb0004c",
|
||||
"view": "f91382373045f3cc69233254ab0406bc9e008707569ff9db4718654812d839df",
|
||||
"subaddress": false,
|
||||
"integrated": false,
|
||||
"guaranteed": true
|
||||
},
|
||||
{
|
||||
"address": "CjWdTpuDaZ69nTGxzm9YarR82YDYFECi1WaaREZTMy5yDsjaRX5bC3cbC3JpcrBPd7YYpjoWKuBMidgGaKBK5Jye2v4Pyz8bD",
|
||||
"network": "Mainnet",
|
||||
"spend": "258dfe7eef9be934839f3b8e0d40e79035fe85879c0a9eb0d7372ae2deb0004c",
|
||||
"view": "f91382373045f3cc69233254ab0406bc9e008707569ff9db4718654812d839df",
|
||||
"subaddress": true,
|
||||
"integrated": false,
|
||||
"guaranteed": true
|
||||
},
|
||||
{
|
||||
"address": "CjWdTpuDaZ69nTGxzm9YarR82YDYFECi1WaaREZTMy5yDsjaRX5bC3cbC3JpcrBPd7YYpjoWKuBMidgGaKBK5JyeeJcwt7hykou237MqZZDA",
|
||||
"network": "Mainnet",
|
||||
"spend": "258dfe7eef9be934839f3b8e0d40e79035fe85879c0a9eb0d7372ae2deb0004c",
|
||||
"view": "f91382373045f3cc69233254ab0406bc9e008707569ff9db4718654812d839df",
|
||||
"subaddress": false,
|
||||
"integrated": true,
|
||||
"payment_id": [88, 37, 149, 111, 171, 108, 120, 181],
|
||||
"guaranteed": true
|
||||
},
|
||||
{
|
||||
"address": "CjWdTpuDaZ69nTGxzm9YarR82YDYFECi1WaaREZTMy5yDsjaRX5bC3cbC3JpcrBPd7YYpjoWKuBMidgGaKBK5JyeeJfTrFAp69u2MYbf5YeN",
|
||||
"network": "Mainnet",
|
||||
"spend": "258dfe7eef9be934839f3b8e0d40e79035fe85879c0a9eb0d7372ae2deb0004c",
|
||||
"view": "f91382373045f3cc69233254ab0406bc9e008707569ff9db4718654812d839df",
|
||||
"subaddress": true,
|
||||
"integrated": true,
|
||||
"payment_id": [125, 69, 155, 152, 140, 160, 157, 186],
|
||||
"guaranteed": true
|
||||
},
|
||||
{
|
||||
"address": "Kgx5uCVsMSEVm7seL8tjyRGmmVXjWfEowKpKjgaXUGVyMViBYMh13VQ4mfqpB7zEVVcJx3E8FFgAuQ8cq6mg5x712U9w7ScYA",
|
||||
"network": "Testnet",
|
||||
"spend": "bba3a8a5bb47f7abf2e2dffeaf43385e4b308fd63a9ff6707e355f3b0a6c247a",
|
||||
"view": "881713a4fa9777168a54bbdcb75290d319fb92fdf1026a8a4b125a8e341de8ab",
|
||||
"subaddress": false,
|
||||
"integrated": false,
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "Kgx5uCVsMSEVm7seL8tjyRGmmVXjWfEowKpKjgaXUGVyMViBYMh13VQ4mfqpB7zEVVcJx3E8FFgAuQ8cq6mg5x712UA2gCrT1",
|
||||
"network": "Testnet",
|
||||
"spend": "bba3a8a5bb47f7abf2e2dffeaf43385e4b308fd63a9ff6707e355f3b0a6c247a",
|
||||
"view": "881713a4fa9777168a54bbdcb75290d319fb92fdf1026a8a4b125a8e341de8ab",
|
||||
"subaddress": true,
|
||||
"integrated": false,
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "Kgx5uCVsMSEVm7seL8tjyRGmmVXjWfEowKpKjgaXUGVyMViBYMh13VQ4mfqpB7zEVVcJx3E8FFgAuQ8cq6mg5x71Vc1DbPKwJu81cxJjqBkS",
|
||||
"network": "Testnet",
|
||||
"spend": "bba3a8a5bb47f7abf2e2dffeaf43385e4b308fd63a9ff6707e355f3b0a6c247a",
|
||||
"view": "881713a4fa9777168a54bbdcb75290d319fb92fdf1026a8a4b125a8e341de8ab",
|
||||
"subaddress": false,
|
||||
"integrated": true,
|
||||
"payment_id": [92, 225, 118, 220, 39, 3, 72, 51],
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "Kgx5uCVsMSEVm7seL8tjyRGmmVXjWfEowKpKjgaXUGVyMViBYMh13VQ4mfqpB7zEVVcJx3E8FFgAuQ8cq6mg5x71Vc2o1rPMaXN31Fe5J6dn",
|
||||
"network": "Testnet",
|
||||
"spend": "bba3a8a5bb47f7abf2e2dffeaf43385e4b308fd63a9ff6707e355f3b0a6c247a",
|
||||
"view": "881713a4fa9777168a54bbdcb75290d319fb92fdf1026a8a4b125a8e341de8ab",
|
||||
"subaddress": true,
|
||||
"integrated": true,
|
||||
"payment_id": [20, 120, 47, 89, 72, 165, 233, 115],
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "Kgx5uCVsMSEVm7seL8tjyRGmmVXjWfEowKpKjgaXUGVyMViBYMh13VQ4mfqpB7zEVVcJx3E8FFgAuQ8cq6mg5x712UAQHCRZ4",
|
||||
"network": "Testnet",
|
||||
"spend": "bba3a8a5bb47f7abf2e2dffeaf43385e4b308fd63a9ff6707e355f3b0a6c247a",
|
||||
"view": "881713a4fa9777168a54bbdcb75290d319fb92fdf1026a8a4b125a8e341de8ab",
|
||||
"subaddress": false,
|
||||
"integrated": false,
|
||||
"guaranteed": true
|
||||
},
|
||||
{
|
||||
"address": "Kgx5uCVsMSEVm7seL8tjyRGmmVXjWfEowKpKjgaXUGVyMViBYMh13VQ4mfqpB7zEVVcJx3E8FFgAuQ8cq6mg5x712UAUzqaii",
|
||||
"network": "Testnet",
|
||||
"spend": "bba3a8a5bb47f7abf2e2dffeaf43385e4b308fd63a9ff6707e355f3b0a6c247a",
|
||||
"view": "881713a4fa9777168a54bbdcb75290d319fb92fdf1026a8a4b125a8e341de8ab",
|
||||
"subaddress": true,
|
||||
"integrated": false,
|
||||
"guaranteed": true
|
||||
},
|
||||
{
|
||||
"address": "Kgx5uCVsMSEVm7seL8tjyRGmmVXjWfEowKpKjgaXUGVyMViBYMh13VQ4mfqpB7zEVVcJx3E8FFgAuQ8cq6mg5x71VcAsfQc3gJQ2gHLd5DiQ",
|
||||
"network": "Testnet",
|
||||
"spend": "bba3a8a5bb47f7abf2e2dffeaf43385e4b308fd63a9ff6707e355f3b0a6c247a",
|
||||
"view": "881713a4fa9777168a54bbdcb75290d319fb92fdf1026a8a4b125a8e341de8ab",
|
||||
"subaddress": false,
|
||||
"integrated": true,
|
||||
"payment_id": [193, 149, 123, 214, 180, 205, 195, 91],
|
||||
"guaranteed": true
|
||||
},
|
||||
{
|
||||
"address": "Kgx5uCVsMSEVm7seL8tjyRGmmVXjWfEowKpKjgaXUGVyMViBYMh13VQ4mfqpB7zEVVcJx3E8FFgAuQ8cq6mg5x71VcDBAD5jbZQ3AMHFyvQB",
|
||||
"network": "Testnet",
|
||||
"spend": "bba3a8a5bb47f7abf2e2dffeaf43385e4b308fd63a9ff6707e355f3b0a6c247a",
|
||||
"view": "881713a4fa9777168a54bbdcb75290d319fb92fdf1026a8a4b125a8e341de8ab",
|
||||
"subaddress": true,
|
||||
"integrated": true,
|
||||
"payment_id": [205, 170, 65, 0, 51, 175, 251, 184],
|
||||
"guaranteed": true
|
||||
},
|
||||
{
|
||||
"address": "FSDinqdKK54PbjF73GgW3nUpf7bF8QbyxFCUurENmUyeEfSxSLL2hxwANBLzq1A8gTSAzzEn65hKjetA8o5BvjV61VPJnBtTP",
|
||||
"network": "Stagenet",
|
||||
"spend": "4cd503040f5e43871bf37d8ca7177da655bda410859af754e24e7b44437f3151",
|
||||
"view": "af60d42b6c6e4437fd93eb32657a14967efa393630d7aee27b5973c8e1c5ad39",
|
||||
"subaddress": false,
|
||||
"integrated": false,
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "FSDinqdKK54PbjF73GgW3nUpf7bF8QbyxFCUurENmUyeEfSxSLL2hxwANBLzq1A8gTSAzzEn65hKjetA8o5BvjV61VPUrwMvP",
|
||||
"network": "Stagenet",
|
||||
"spend": "4cd503040f5e43871bf37d8ca7177da655bda410859af754e24e7b44437f3151",
|
||||
"view": "af60d42b6c6e4437fd93eb32657a14967efa393630d7aee27b5973c8e1c5ad39",
|
||||
"subaddress": true,
|
||||
"integrated": false,
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "FSDinqdKK54PbjF73GgW3nUpf7bF8QbyxFCUurENmUyeEfSxSLL2hxwANBLzq1A8gTSAzzEn65hKjetA8o5BvjV6AY5ECEhP5Nr1aCRPXdxk",
|
||||
"network": "Stagenet",
|
||||
"spend": "4cd503040f5e43871bf37d8ca7177da655bda410859af754e24e7b44437f3151",
|
||||
"view": "af60d42b6c6e4437fd93eb32657a14967efa393630d7aee27b5973c8e1c5ad39",
|
||||
"subaddress": false,
|
||||
"integrated": true,
|
||||
"payment_id": [173, 149, 78, 64, 215, 211, 66, 170],
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "FSDinqdKK54PbjF73GgW3nUpf7bF8QbyxFCUurENmUyeEfSxSLL2hxwANBLzq1A8gTSAzzEn65hKjetA8o5BvjV6AY882kTUS1D2LttnPvTR",
|
||||
"network": "Stagenet",
|
||||
"spend": "4cd503040f5e43871bf37d8ca7177da655bda410859af754e24e7b44437f3151",
|
||||
"view": "af60d42b6c6e4437fd93eb32657a14967efa393630d7aee27b5973c8e1c5ad39",
|
||||
"subaddress": true,
|
||||
"integrated": true,
|
||||
"payment_id": [254, 159, 186, 162, 1, 8, 156, 108],
|
||||
"guaranteed": false
|
||||
},
|
||||
{
|
||||
"address": "FSDinqdKK54PbjF73GgW3nUpf7bF8QbyxFCUurENmUyeEfSxSLL2hxwANBLzq1A8gTSAzzEn65hKjetA8o5BvjV61VPpBBo8F",
|
||||
"network": "Stagenet",
|
||||
"spend": "4cd503040f5e43871bf37d8ca7177da655bda410859af754e24e7b44437f3151",
|
||||
"view": "af60d42b6c6e4437fd93eb32657a14967efa393630d7aee27b5973c8e1c5ad39",
|
||||
"subaddress": false,
|
||||
"integrated": false,
|
||||
"guaranteed": true
|
||||
},
|
||||
{
|
||||
"address": "FSDinqdKK54PbjF73GgW3nUpf7bF8QbyxFCUurENmUyeEfSxSLL2hxwANBLzq1A8gTSAzzEn65hKjetA8o5BvjV61VPuUJX3b",
|
||||
"network": "Stagenet",
|
||||
"spend": "4cd503040f5e43871bf37d8ca7177da655bda410859af754e24e7b44437f3151",
|
||||
"view": "af60d42b6c6e4437fd93eb32657a14967efa393630d7aee27b5973c8e1c5ad39",
|
||||
"subaddress": true,
|
||||
"integrated": false,
|
||||
"guaranteed": true
|
||||
},
|
||||
{
|
||||
"address": "FSDinqdKK54PbjF73GgW3nUpf7bF8QbyxFCUurENmUyeEfSxSLL2hxwANBLzq1A8gTSAzzEn65hKjetA8o5BvjV6AYCZPxVAoDu21DryMoto",
|
||||
"network": "Stagenet",
|
||||
"spend": "4cd503040f5e43871bf37d8ca7177da655bda410859af754e24e7b44437f3151",
|
||||
"view": "af60d42b6c6e4437fd93eb32657a14967efa393630d7aee27b5973c8e1c5ad39",
|
||||
"subaddress": false,
|
||||
"integrated": true,
|
||||
"payment_id": [3, 115, 230, 129, 172, 108, 116, 235],
|
||||
"guaranteed": true
|
||||
},
|
||||
{
|
||||
"address": "FSDinqdKK54PbjF73GgW3nUpf7bF8QbyxFCUurENmUyeEfSxSLL2hxwANBLzq1A8gTSAzzEn65hKjetA8o5BvjV6AYFYCqKQAWL18KkpBQ8R",
|
||||
"network": "Stagenet",
|
||||
"spend": "4cd503040f5e43871bf37d8ca7177da655bda410859af754e24e7b44437f3151",
|
||||
"view": "af60d42b6c6e4437fd93eb32657a14967efa393630d7aee27b5973c8e1c5ad39",
|
||||
"subaddress": true,
|
||||
"integrated": true,
|
||||
"payment_id": [94, 122, 63, 167, 209, 225, 14, 180],
|
||||
"guaranteed": true
|
||||
}
|
||||
]
|
||||
75
coins/monero/wallet/tests/add_data.rs
Normal file
75
coins/monero/wallet/tests/add_data.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use monero_serai::transaction::Transaction;
|
||||
use monero_wallet::{TransactionError, extra::MAX_ARBITRARY_DATA_SIZE};
|
||||
|
||||
mod runner;
|
||||
|
||||
test!(
|
||||
add_single_data_less_than_max,
|
||||
(
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
let arbitrary_data = vec![b'\0'; MAX_ARBITRARY_DATA_SIZE - 1];
|
||||
|
||||
// make sure we can add to tx
|
||||
builder.add_data(arbitrary_data.clone()).unwrap();
|
||||
|
||||
builder.add_payment(addr, 5);
|
||||
(builder.build().unwrap(), (arbitrary_data,))
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, data: (Vec<u8>,)| async move {
|
||||
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
assert_eq!(output.arbitrary_data()[0], data.0);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
add_multiple_data_less_than_max,
|
||||
(
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
let mut data = vec![];
|
||||
for b in 1 ..= 3 {
|
||||
data.push(vec![b; MAX_ARBITRARY_DATA_SIZE - 1]);
|
||||
}
|
||||
|
||||
// Add data multiple times
|
||||
for data in &data {
|
||||
builder.add_data(data.clone()).unwrap();
|
||||
}
|
||||
|
||||
builder.add_payment(addr, 5);
|
||||
(builder.build().unwrap(), data)
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, data: Vec<Vec<u8>>| async move {
|
||||
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
assert_eq!(output.arbitrary_data(), data);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
add_single_data_more_than_max,
|
||||
(
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
// Make a data that is bigger than the maximum
|
||||
let mut data = vec![b'a'; MAX_ARBITRARY_DATA_SIZE + 1];
|
||||
|
||||
// Make sure we get an error if we try to add it to the TX
|
||||
assert_eq!(builder.add_data(data.clone()), Err(TransactionError::TooMuchData));
|
||||
|
||||
// Reduce data size and retry. The data will now be 255 bytes long (including the added
|
||||
// marker), exactly
|
||||
data.pop();
|
||||
builder.add_data(data.clone()).unwrap();
|
||||
|
||||
builder.add_payment(addr, 5);
|
||||
(builder.build().unwrap(), data)
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, data: Vec<u8>| async move {
|
||||
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
assert_eq!(output.arbitrary_data(), vec![data]);
|
||||
},
|
||||
),
|
||||
);
|
||||
159
coins/monero/wallet/tests/decoys.rs
Normal file
159
coins/monero/wallet/tests/decoys.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use monero_rpc::{Rpc, OutputResponse};
|
||||
use monero_serai::{transaction::Transaction, Protocol, DEFAULT_LOCK_WINDOW};
|
||||
use monero_wallet::SpendableOutput;
|
||||
|
||||
mod runner;
|
||||
|
||||
test!(
|
||||
select_latest_output_as_decoy_canonical,
|
||||
(
|
||||
// First make an initial tx0
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
builder.add_payment(addr, 2000000000000);
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 2000000000000);
|
||||
SpendableOutput::from(&rpc, output).await.unwrap()
|
||||
},
|
||||
),
|
||||
(
|
||||
// Then make a second tx1
|
||||
|protocol: Protocol, rpc: Rpc<_>, mut builder: Builder, addr, state: _| async move {
|
||||
let output_tx0: SpendableOutput = state;
|
||||
let decoys = Decoys::fingerprintable_canonical_select(
|
||||
&mut OsRng,
|
||||
&rpc,
|
||||
protocol.ring_len(),
|
||||
rpc.get_height().await.unwrap(),
|
||||
&[output_tx0.clone()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let inputs = [output_tx0.clone()].into_iter().zip(decoys).collect::<Vec<_>>();
|
||||
builder.add_inputs(&inputs);
|
||||
builder.add_payment(addr, 1000000000000);
|
||||
|
||||
(builder.build().unwrap(), (protocol, output_tx0))
|
||||
},
|
||||
// Then make sure DSA selects freshly unlocked output from tx1 as a decoy
|
||||
|rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, state: (_, _)| async move {
|
||||
use rand_core::OsRng;
|
||||
|
||||
let height = rpc.get_height().await.unwrap();
|
||||
|
||||
let output_tx1 =
|
||||
SpendableOutput::from(&rpc, scanner.scan_transaction(&tx).not_locked().swap_remove(0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Make sure output from tx1 is in the block in which it unlocks
|
||||
let out_tx1: OutputResponse =
|
||||
rpc.get_outs(&[output_tx1.global_index]).await.unwrap().swap_remove(0);
|
||||
assert_eq!(out_tx1.height, height - DEFAULT_LOCK_WINDOW);
|
||||
assert!(out_tx1.unlocked);
|
||||
|
||||
// Select decoys using spendable output from tx0 as the real, and make sure DSA selects
|
||||
// the freshly unlocked output from tx1 as a decoy
|
||||
let (protocol, output_tx0): (Protocol, SpendableOutput) = state;
|
||||
let mut selected_fresh_decoy = false;
|
||||
let mut attempts = 1000;
|
||||
while !selected_fresh_decoy && attempts > 0 {
|
||||
let decoys = Decoys::fingerprintable_canonical_select(
|
||||
&mut OsRng, // TODO: use a seeded RNG to consistently select the latest output
|
||||
&rpc,
|
||||
protocol.ring_len(),
|
||||
height,
|
||||
&[output_tx0.clone()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
selected_fresh_decoy = decoys[0].positions().contains(&output_tx1.global_index);
|
||||
attempts -= 1;
|
||||
}
|
||||
|
||||
assert!(selected_fresh_decoy);
|
||||
assert_eq!(height, rpc.get_height().await.unwrap());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
select_latest_output_as_decoy,
|
||||
(
|
||||
// First make an initial tx0
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
builder.add_payment(addr, 2000000000000);
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 2000000000000);
|
||||
SpendableOutput::from(&rpc, output).await.unwrap()
|
||||
},
|
||||
),
|
||||
(
|
||||
// Then make a second tx1
|
||||
|protocol: Protocol, rpc: Rpc<_>, mut builder: Builder, addr, state: _| async move {
|
||||
let output_tx0: SpendableOutput = state;
|
||||
let decoys = Decoys::select(
|
||||
&mut OsRng,
|
||||
&rpc,
|
||||
protocol.ring_len(),
|
||||
rpc.get_height().await.unwrap(),
|
||||
&[output_tx0.clone()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let inputs = [output_tx0.clone()].into_iter().zip(decoys).collect::<Vec<_>>();
|
||||
builder.add_inputs(&inputs);
|
||||
builder.add_payment(addr, 1000000000000);
|
||||
|
||||
(builder.build().unwrap(), (protocol, output_tx0))
|
||||
},
|
||||
// Then make sure DSA selects freshly unlocked output from tx1 as a decoy
|
||||
|rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, state: (_, _)| async move {
|
||||
use rand_core::OsRng;
|
||||
|
||||
let height = rpc.get_height().await.unwrap();
|
||||
|
||||
let output_tx1 =
|
||||
SpendableOutput::from(&rpc, scanner.scan_transaction(&tx).not_locked().swap_remove(0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Make sure output from tx1 is in the block in which it unlocks
|
||||
let out_tx1: OutputResponse =
|
||||
rpc.get_outs(&[output_tx1.global_index]).await.unwrap().swap_remove(0);
|
||||
assert_eq!(out_tx1.height, height - DEFAULT_LOCK_WINDOW);
|
||||
assert!(out_tx1.unlocked);
|
||||
|
||||
// Select decoys using spendable output from tx0 as the real, and make sure DSA selects
|
||||
// the freshly unlocked output from tx1 as a decoy
|
||||
let (protocol, output_tx0): (Protocol, SpendableOutput) = state;
|
||||
let mut selected_fresh_decoy = false;
|
||||
let mut attempts = 1000;
|
||||
while !selected_fresh_decoy && attempts > 0 {
|
||||
let decoys = Decoys::select(
|
||||
&mut OsRng, // TODO: use a seeded RNG to consistently select the latest output
|
||||
&rpc,
|
||||
protocol.ring_len(),
|
||||
height,
|
||||
&[output_tx0.clone()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
selected_fresh_decoy = decoys[0].positions().contains(&output_tx1.global_index);
|
||||
attempts -= 1;
|
||||
}
|
||||
|
||||
assert!(selected_fresh_decoy);
|
||||
assert_eq!(height, rpc.get_height().await.unwrap());
|
||||
},
|
||||
),
|
||||
);
|
||||
77
coins/monero/wallet/tests/eventuality.rs
Normal file
77
coins/monero/wallet/tests/eventuality.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use curve25519_dalek::constants::ED25519_BASEPOINT_POINT;
|
||||
|
||||
use monero_serai::transaction::Transaction;
|
||||
use monero_wallet::{
|
||||
Eventuality,
|
||||
address::{AddressType, AddressMeta, MoneroAddress},
|
||||
};
|
||||
|
||||
mod runner;
|
||||
|
||||
test!(
|
||||
eventuality,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
// Add a standard address, a payment ID address, a subaddress, and a guaranteed address
|
||||
// Each have their own slight implications to eventualities
|
||||
builder.add_payment(
|
||||
MoneroAddress::new(
|
||||
AddressMeta::new(Network::Mainnet, AddressType::Standard),
|
||||
ED25519_BASEPOINT_POINT,
|
||||
ED25519_BASEPOINT_POINT,
|
||||
),
|
||||
1,
|
||||
);
|
||||
builder.add_payment(
|
||||
MoneroAddress::new(
|
||||
AddressMeta::new(Network::Mainnet, AddressType::Integrated([0xaa; 8])),
|
||||
ED25519_BASEPOINT_POINT,
|
||||
ED25519_BASEPOINT_POINT,
|
||||
),
|
||||
2,
|
||||
);
|
||||
builder.add_payment(
|
||||
MoneroAddress::new(
|
||||
AddressMeta::new(Network::Mainnet, AddressType::Subaddress),
|
||||
ED25519_BASEPOINT_POINT,
|
||||
ED25519_BASEPOINT_POINT,
|
||||
),
|
||||
3,
|
||||
);
|
||||
builder.add_payment(
|
||||
MoneroAddress::new(
|
||||
AddressMeta::new(
|
||||
Network::Mainnet,
|
||||
AddressType::Featured { subaddress: false, payment_id: None, guaranteed: true },
|
||||
),
|
||||
ED25519_BASEPOINT_POINT,
|
||||
ED25519_BASEPOINT_POINT,
|
||||
),
|
||||
4,
|
||||
);
|
||||
builder.set_r_seed(Zeroizing::new([0xbb; 32]));
|
||||
let tx = builder.build().unwrap();
|
||||
let eventuality = tx.eventuality().unwrap();
|
||||
assert_eq!(
|
||||
eventuality,
|
||||
Eventuality::read::<&[u8]>(&mut eventuality.serialize().as_ref()).unwrap()
|
||||
);
|
||||
(tx, eventuality)
|
||||
},
|
||||
|_, mut tx: Transaction, _, eventuality: Eventuality| async move {
|
||||
// 4 explicitly outputs added and one change output
|
||||
assert_eq!(tx.prefix.outputs.len(), 5);
|
||||
|
||||
// The eventuality's available extra should be the actual TX's
|
||||
assert_eq!(tx.prefix.extra, eventuality.extra());
|
||||
|
||||
// The TX should match
|
||||
assert!(eventuality.matches(&tx));
|
||||
|
||||
// Mutate the TX
|
||||
tx.rct_signatures.base.commitments[0] += ED25519_BASEPOINT_POINT;
|
||||
// Verify it no longer matches
|
||||
assert!(!eventuality.matches(&tx));
|
||||
},
|
||||
),
|
||||
);
|
||||
322
coins/monero/wallet/tests/runner.rs
Normal file
322
coins/monero/wallet/tests/runner.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
use core::ops::Deref;
|
||||
use std_shims::{sync::OnceLock, collections::HashSet};
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
use rand_core::OsRng;
|
||||
|
||||
use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar};
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use monero_rpc::Rpc;
|
||||
use monero_simple_request_rpc::SimpleRequestRpc;
|
||||
use monero_serai::{transaction::Transaction, DEFAULT_LOCK_WINDOW};
|
||||
use monero_wallet::{
|
||||
ViewPair, Scanner,
|
||||
address::{Network, AddressType, AddressSpec, AddressMeta, MoneroAddress},
|
||||
SpendableOutput, Fee,
|
||||
};
|
||||
|
||||
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()),
|
||||
MoneroAddress {
|
||||
meta: AddressMeta::new(Network::Mainnet, AddressType::Standard),
|
||||
spend: spend_pub,
|
||||
view: 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: &Rpc<SimpleRequestRpc>, addr: &str, tx_hash: [u8; 32]) {
|
||||
// mine until tx is in a block
|
||||
let mut height = rpc.get_height().await.unwrap();
|
||||
let mut found = false;
|
||||
while !found {
|
||||
let block = rpc.get_block_by_number(height - 1).await.unwrap();
|
||||
found = match block.txs.iter().find(|&&x| x == tx_hash) {
|
||||
Some(_) => true,
|
||||
None => {
|
||||
height = rpc.generate_blocks(addr, 1).await.unwrap().1 + 1;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mine until tx's outputs are unlocked
|
||||
let o_indexes: Vec<u64> = rpc.get_o_indexes(tx_hash).await.unwrap();
|
||||
while rpc
|
||||
.get_outs(&o_indexes)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.all(|o| (!(o.unlocked && height >= (o.height + DEFAULT_LOCK_WINDOW))))
|
||||
{
|
||||
height = rpc.generate_blocks(addr, 1).await.unwrap().1 + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Mines 60 blocks and returns an unlocked miner TX output.
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_miner_tx_output(rpc: &Rpc<SimpleRequestRpc>, view: &ViewPair) -> SpendableOutput {
|
||||
let mut scanner = Scanner::from_view(view.clone(), Some(HashSet::new()));
|
||||
|
||||
// Mine 60 blocks to unlock a miner TX
|
||||
let start = rpc.get_height().await.unwrap();
|
||||
rpc
|
||||
.generate_blocks(&view.address(Network::Mainnet, AddressSpec::Standard).to_string(), 60)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let block = rpc.get_block_by_number(start).await.unwrap();
|
||||
scanner.scan(rpc, &block).await.unwrap().swap_remove(0).ignore_timelock().swap_remove(0)
|
||||
}
|
||||
|
||||
/// Make sure the weight and fee match the expected calculation.
|
||||
pub fn check_weight_and_fee(tx: &Transaction, fee_rate: Fee) {
|
||||
let fee = tx.rct_signatures.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() -> Rpc<SimpleRequestRpc> {
|
||||
let rpc =
|
||||
SimpleRequestRpc::new("http://serai:seraidex@127.0.0.1:18081".to_string()).await.unwrap();
|
||||
|
||||
// Only run once
|
||||
if rpc.get_height().await.unwrap() != 1 {
|
||||
return rpc;
|
||||
}
|
||||
|
||||
let addr = MoneroAddress {
|
||||
meta: AddressMeta::new(Network::Mainnet, AddressType::Standard),
|
||||
spend: &Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE,
|
||||
view: &Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE,
|
||||
}
|
||||
.to_string();
|
||||
|
||||
// Mine 40 blocks to ensure decoy availability
|
||||
rpc.generate_blocks(&addr, 40).await.unwrap();
|
||||
|
||||
// Make sure we recognize the protocol
|
||||
rpc.get_protocol().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};
|
||||
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, scalar::Scalar};
|
||||
|
||||
#[cfg(feature = "multisig")]
|
||||
use transcript::{Transcript, RecommendedTranscript};
|
||||
#[cfg(feature = "multisig")]
|
||||
use frost::{
|
||||
curve::Ed25519,
|
||||
Participant,
|
||||
tests::{THRESHOLD, key_gen},
|
||||
};
|
||||
|
||||
use monero_wallet::{
|
||||
address::{Network, AddressSpec},
|
||||
ViewPair, Scanner, Change, DecoySelection, Decoys, FeePriority,
|
||||
SignableTransaction, SignableTransactionBuilder,
|
||||
};
|
||||
|
||||
use runner::{
|
||||
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 = ViewPair::new(spend_pub, Zeroizing::new(Scalar::random(&mut OsRng)));
|
||||
let addr = view.address(Network::Mainnet, AddressSpec::Standard);
|
||||
|
||||
let miner_tx = get_miner_tx_output(&rpc, &view).await;
|
||||
|
||||
let protocol = rpc.get_protocol().await.unwrap();
|
||||
|
||||
let builder = SignableTransactionBuilder::new(
|
||||
protocol,
|
||||
rpc.get_fee(protocol, FeePriority::Unimportant).await.unwrap(),
|
||||
Change::new(
|
||||
&ViewPair::new(
|
||||
&Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE,
|
||||
Zeroizing::new(Scalar::random(&mut OsRng))
|
||||
),
|
||||
false
|
||||
),
|
||||
);
|
||||
|
||||
let sign = |tx: SignableTransaction| {
|
||||
let spend = spend.clone();
|
||||
#[cfg(feature = "multisig")]
|
||||
let keys = keys.clone();
|
||||
async move {
|
||||
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],
|
||||
RecommendedTranscript::new(b"Monero Serai Test Transaction"),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
frost::tests::sign_without_caching(&mut OsRng, machines, &[])
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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 decoys = Decoys::fingerprintable_canonical_select(
|
||||
&mut OsRng,
|
||||
&rpc,
|
||||
protocol.ring_len(),
|
||||
rpc.get_height().await.unwrap(),
|
||||
&[miner_tx.clone()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
builder.add_input((miner_tx, decoys.first().unwrap().clone()));
|
||||
|
||||
let (tx, state) = ($first_tx)(rpc.clone(), builder, next_addr).await;
|
||||
let fee_rate = tx.fee_rate().clone();
|
||||
let signed = sign(tx).await;
|
||||
rpc.publish_transaction(&signed).await.unwrap();
|
||||
mine_until_unlocked(&rpc, &random_address().2.to_string(), signed.hash()).await;
|
||||
let tx = rpc.get_transaction(signed.hash()).await.unwrap();
|
||||
check_weight_and_fee(&tx, fee_rate);
|
||||
let scanner =
|
||||
Scanner::from_view(view.clone(), Some(HashSet::new()));
|
||||
($first_checks)(rpc.clone(), tx, scanner, state).await
|
||||
});
|
||||
#[allow(unused_variables, unused_mut, unused_assignments)]
|
||||
let mut carried_state: Box<dyn Any> = temp;
|
||||
|
||||
$(
|
||||
let (tx, state) = ($tx)(
|
||||
protocol,
|
||||
rpc.clone(),
|
||||
builder.clone(),
|
||||
next_addr,
|
||||
*carried_state.downcast().unwrap()
|
||||
).await;
|
||||
let fee_rate = tx.fee_rate().clone();
|
||||
let signed = sign(tx).await;
|
||||
rpc.publish_transaction(&signed).await.unwrap();
|
||||
mine_until_unlocked(&rpc, &random_address().2.to_string(), 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::from_view(view.clone(), Some(HashSet::new()));
|
||||
carried_state =
|
||||
Box::new(($checks)(rpc.clone(), tx, scanner, state).await);
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
303
coins/monero/wallet/tests/scan.rs
Normal file
303
coins/monero/wallet/tests/scan.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
use rand_core::RngCore;
|
||||
|
||||
use monero_serai::transaction::Transaction;
|
||||
use monero_wallet::{address::SubaddressIndex, extra::PaymentId};
|
||||
|
||||
mod runner;
|
||||
|
||||
test!(
|
||||
scan_standard_address,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
let view = runner::random_address().1;
|
||||
let scanner = Scanner::from_view(view.clone(), Some(HashSet::new()));
|
||||
builder.add_payment(view.address(Network::Mainnet, AddressSpec::Standard), 5);
|
||||
(builder.build().unwrap(), scanner)
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: Scanner| async move {
|
||||
let output = state.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
let dummy_payment_id = PaymentId::Encrypted([0u8; 8]);
|
||||
assert_eq!(output.metadata.payment_id, Some(dummy_payment_id));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
scan_subaddress,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
let subaddress = SubaddressIndex::new(0, 1).unwrap();
|
||||
|
||||
let view = runner::random_address().1;
|
||||
let mut scanner = Scanner::from_view(view.clone(), Some(HashSet::new()));
|
||||
scanner.register_subaddress(subaddress);
|
||||
|
||||
builder.add_payment(view.address(Network::Mainnet, AddressSpec::Subaddress(subaddress)), 5);
|
||||
(builder.build().unwrap(), (scanner, subaddress))
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: (Scanner, SubaddressIndex)| async move {
|
||||
let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
assert_eq!(output.metadata.subaddress, Some(state.1));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
scan_integrated_address,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
let view = runner::random_address().1;
|
||||
let scanner = Scanner::from_view(view.clone(), Some(HashSet::new()));
|
||||
|
||||
let mut payment_id = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut payment_id);
|
||||
|
||||
builder.add_payment(view.address(Network::Mainnet, AddressSpec::Integrated(payment_id)), 5);
|
||||
(builder.build().unwrap(), (scanner, payment_id))
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: (Scanner, [u8; 8])| async move {
|
||||
let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(state.1)));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
scan_featured_standard,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
let view = runner::random_address().1;
|
||||
let scanner = Scanner::from_view(view.clone(), Some(HashSet::new()));
|
||||
builder.add_payment(
|
||||
view.address(
|
||||
Network::Mainnet,
|
||||
AddressSpec::Featured { subaddress: None, payment_id: None, guaranteed: false },
|
||||
),
|
||||
5,
|
||||
);
|
||||
(builder.build().unwrap(), scanner)
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: Scanner| async move {
|
||||
let output = state.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
scan_featured_subaddress,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
let subaddress = SubaddressIndex::new(0, 2).unwrap();
|
||||
|
||||
let view = runner::random_address().1;
|
||||
let mut scanner = Scanner::from_view(view.clone(), Some(HashSet::new()));
|
||||
scanner.register_subaddress(subaddress);
|
||||
|
||||
builder.add_payment(
|
||||
view.address(
|
||||
Network::Mainnet,
|
||||
AddressSpec::Featured {
|
||||
subaddress: Some(subaddress),
|
||||
payment_id: None,
|
||||
guaranteed: false,
|
||||
},
|
||||
),
|
||||
5,
|
||||
);
|
||||
(builder.build().unwrap(), (scanner, subaddress))
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: (Scanner, SubaddressIndex)| async move {
|
||||
let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
assert_eq!(output.metadata.subaddress, Some(state.1));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
scan_featured_integrated,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
let view = runner::random_address().1;
|
||||
let scanner = Scanner::from_view(view.clone(), Some(HashSet::new()));
|
||||
let mut payment_id = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut payment_id);
|
||||
|
||||
builder.add_payment(
|
||||
view.address(
|
||||
Network::Mainnet,
|
||||
AddressSpec::Featured {
|
||||
subaddress: None,
|
||||
payment_id: Some(payment_id),
|
||||
guaranteed: false,
|
||||
},
|
||||
),
|
||||
5,
|
||||
);
|
||||
(builder.build().unwrap(), (scanner, payment_id))
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: (Scanner, [u8; 8])| async move {
|
||||
let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(state.1)));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
scan_featured_integrated_subaddress,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
let subaddress = SubaddressIndex::new(0, 3).unwrap();
|
||||
|
||||
let view = runner::random_address().1;
|
||||
let mut scanner = Scanner::from_view(view.clone(), Some(HashSet::new()));
|
||||
scanner.register_subaddress(subaddress);
|
||||
|
||||
let mut payment_id = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut payment_id);
|
||||
|
||||
builder.add_payment(
|
||||
view.address(
|
||||
Network::Mainnet,
|
||||
AddressSpec::Featured {
|
||||
subaddress: Some(subaddress),
|
||||
payment_id: Some(payment_id),
|
||||
guaranteed: false,
|
||||
},
|
||||
),
|
||||
5,
|
||||
);
|
||||
(builder.build().unwrap(), (scanner, payment_id, subaddress))
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: (Scanner, [u8; 8], SubaddressIndex)| async move {
|
||||
let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(state.1)));
|
||||
assert_eq!(output.metadata.subaddress, Some(state.2));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
scan_guaranteed_standard,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
let view = runner::random_address().1;
|
||||
let scanner = Scanner::from_view(view.clone(), None);
|
||||
|
||||
builder.add_payment(
|
||||
view.address(
|
||||
Network::Mainnet,
|
||||
AddressSpec::Featured { subaddress: None, payment_id: None, guaranteed: true },
|
||||
),
|
||||
5,
|
||||
);
|
||||
(builder.build().unwrap(), scanner)
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: Scanner| async move {
|
||||
let output = state.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
scan_guaranteed_subaddress,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
let subaddress = SubaddressIndex::new(1, 0).unwrap();
|
||||
|
||||
let view = runner::random_address().1;
|
||||
let mut scanner = Scanner::from_view(view.clone(), None);
|
||||
scanner.register_subaddress(subaddress);
|
||||
|
||||
builder.add_payment(
|
||||
view.address(
|
||||
Network::Mainnet,
|
||||
AddressSpec::Featured {
|
||||
subaddress: Some(subaddress),
|
||||
payment_id: None,
|
||||
guaranteed: true,
|
||||
},
|
||||
),
|
||||
5,
|
||||
);
|
||||
(builder.build().unwrap(), (scanner, subaddress))
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: (Scanner, SubaddressIndex)| async move {
|
||||
let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
assert_eq!(output.metadata.subaddress, Some(state.1));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
scan_guaranteed_integrated,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
let view = runner::random_address().1;
|
||||
let scanner = Scanner::from_view(view.clone(), None);
|
||||
let mut payment_id = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut payment_id);
|
||||
|
||||
builder.add_payment(
|
||||
view.address(
|
||||
Network::Mainnet,
|
||||
AddressSpec::Featured {
|
||||
subaddress: None,
|
||||
payment_id: Some(payment_id),
|
||||
guaranteed: true,
|
||||
},
|
||||
),
|
||||
5,
|
||||
);
|
||||
(builder.build().unwrap(), (scanner, payment_id))
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: (Scanner, [u8; 8])| async move {
|
||||
let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(state.1)));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
scan_guaranteed_integrated_subaddress,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
let subaddress = SubaddressIndex::new(1, 1).unwrap();
|
||||
|
||||
let view = runner::random_address().1;
|
||||
let mut scanner = Scanner::from_view(view.clone(), None);
|
||||
scanner.register_subaddress(subaddress);
|
||||
|
||||
let mut payment_id = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut payment_id);
|
||||
|
||||
builder.add_payment(
|
||||
view.address(
|
||||
Network::Mainnet,
|
||||
AddressSpec::Featured {
|
||||
subaddress: Some(subaddress),
|
||||
payment_id: Some(payment_id),
|
||||
guaranteed: true,
|
||||
},
|
||||
),
|
||||
5,
|
||||
);
|
||||
(builder.build().unwrap(), (scanner, payment_id, subaddress))
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: (Scanner, [u8; 8], SubaddressIndex)| async move {
|
||||
let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(state.1)));
|
||||
assert_eq!(output.metadata.subaddress, Some(state.2));
|
||||
},
|
||||
),
|
||||
);
|
||||
314
coins/monero/wallet/tests/send.rs
Normal file
314
coins/monero/wallet/tests/send.rs
Normal file
@@ -0,0 +1,314 @@
|
||||
use rand_core::OsRng;
|
||||
|
||||
use monero_serai::{transaction::Transaction, Protocol};
|
||||
use monero_rpc::Rpc;
|
||||
use monero_simple_request_rpc::SimpleRequestRpc;
|
||||
use monero_wallet::{
|
||||
extra::Extra, address::SubaddressIndex, ReceivedOutput, SpendableOutput, DecoySelection, Decoys,
|
||||
SignableTransactionBuilder,
|
||||
};
|
||||
|
||||
mod runner;
|
||||
|
||||
// Set up inputs, select decoys, then add them to the TX builder
|
||||
async fn add_inputs(
|
||||
protocol: Protocol,
|
||||
rpc: &Rpc<SimpleRequestRpc>,
|
||||
outputs: Vec<ReceivedOutput>,
|
||||
builder: &mut SignableTransactionBuilder,
|
||||
) {
|
||||
let mut spendable_outputs = Vec::with_capacity(outputs.len());
|
||||
for output in outputs {
|
||||
spendable_outputs.push(SpendableOutput::from(rpc, output).await.unwrap());
|
||||
}
|
||||
|
||||
let decoys = Decoys::fingerprintable_canonical_select(
|
||||
&mut OsRng,
|
||||
rpc,
|
||||
protocol.ring_len(),
|
||||
rpc.get_height().await.unwrap(),
|
||||
&spendable_outputs,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let inputs = spendable_outputs.into_iter().zip(decoys).collect::<Vec<_>>();
|
||||
|
||||
builder.add_inputs(&inputs);
|
||||
}
|
||||
|
||||
test!(
|
||||
spend_miner_output,
|
||||
(
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
builder.add_payment(addr, 5);
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 5);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
spend_multiple_outputs,
|
||||
(
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
builder.add_payment(addr, 1000000000000);
|
||||
builder.add_payment(addr, 2000000000000);
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
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
|
||||
},
|
||||
),
|
||||
(
|
||||
|protocol: Protocol, rpc, mut builder: Builder, addr, outputs: Vec<ReceivedOutput>| async move {
|
||||
add_inputs(protocol, &rpc, outputs, &mut builder).await;
|
||||
builder.add_payment(addr, 6);
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 6);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
// Ideally, this would be single_R, yet it isn't feasible to apply allow(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
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
builder.add_payment(addr, 1000000000000);
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
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);
|
||||
outputs
|
||||
},
|
||||
),
|
||||
(
|
||||
|protocol, rpc: Rpc<_>, _, _, outputs: Vec<ReceivedOutput>| async move {
|
||||
use monero_wallet::FeePriority;
|
||||
|
||||
let change_view = ViewPair::new(
|
||||
&Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE,
|
||||
Zeroizing::new(Scalar::random(&mut OsRng)),
|
||||
);
|
||||
|
||||
let mut builder = SignableTransactionBuilder::new(
|
||||
protocol,
|
||||
rpc.get_fee(protocol, FeePriority::Unimportant).await.unwrap(),
|
||||
Change::new(&change_view, false),
|
||||
);
|
||||
add_inputs(protocol, &rpc, vec![outputs.first().unwrap().clone()], &mut builder).await;
|
||||
|
||||
// Send to a subaddress
|
||||
let sub_view = ViewPair::new(
|
||||
&Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE,
|
||||
Zeroizing::new(Scalar::random(&mut OsRng)),
|
||||
);
|
||||
builder.add_payment(
|
||||
sub_view
|
||||
.address(Network::Mainnet, AddressSpec::Subaddress(SubaddressIndex::new(0, 1).unwrap())),
|
||||
1,
|
||||
);
|
||||
(builder.build().unwrap(), (change_view, sub_view))
|
||||
},
|
||||
|_, tx: Transaction, _, views: (ViewPair, ViewPair)| async move {
|
||||
// Make sure the change can pick up its output
|
||||
let mut change_scanner = Scanner::from_view(views.0, Some(HashSet::new()));
|
||||
assert!(change_scanner.scan_transaction(&tx).not_locked().len() == 1);
|
||||
|
||||
// Make sure the subaddress can pick up its output
|
||||
let mut sub_scanner = Scanner::from_view(views.1, Some(HashSet::new()));
|
||||
sub_scanner.register_subaddress(SubaddressIndex::new(0, 1).unwrap());
|
||||
let sub_outputs = sub_scanner.scan_transaction(&tx).not_locked();
|
||||
assert!(sub_outputs.len() == 1);
|
||||
assert_eq!(sub_outputs[0].commitment().amount, 1);
|
||||
|
||||
// Make sure only one R was included in TX extra
|
||||
assert!(Extra::read::<&[u8]>(&mut tx.prefix.extra.as_ref())
|
||||
.unwrap()
|
||||
.keys()
|
||||
.unwrap()
|
||||
.1
|
||||
.is_none());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
spend_one_input_to_one_output_plus_change,
|
||||
(
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
builder.add_payment(addr, 2000000000000);
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
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, 2000000000000);
|
||||
outputs
|
||||
},
|
||||
),
|
||||
(
|
||||
|protocol: Protocol, rpc, mut builder: Builder, addr, outputs: Vec<ReceivedOutput>| async move {
|
||||
add_inputs(protocol, &rpc, outputs, &mut builder).await;
|
||||
builder.add_payment(addr, 2);
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
assert_eq!(output.commitment().amount, 2);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
spend_max_outputs,
|
||||
(
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
builder.add_payment(addr, 1000000000000);
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
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);
|
||||
outputs
|
||||
},
|
||||
),
|
||||
(
|
||||
|protocol: Protocol, rpc, mut builder: Builder, addr, outputs: Vec<ReceivedOutput>| async move {
|
||||
add_inputs(protocol, &rpc, outputs, &mut builder).await;
|
||||
|
||||
for i in 0 .. 15 {
|
||||
builder.add_payment(addr, i + 1);
|
||||
}
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
let mut scanned_tx = scanner.scan_transaction(&tx).not_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);
|
||||
let amount = output.commitment().amount;
|
||||
assert!(output_amounts.contains(&amount));
|
||||
output_amounts.remove(&amount);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
spend_max_outputs_to_subaddresses,
|
||||
(
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
builder.add_payment(addr, 1000000000000);
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
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);
|
||||
outputs
|
||||
},
|
||||
),
|
||||
(
|
||||
|protocol: Protocol, rpc, mut builder: Builder, _, outputs: Vec<ReceivedOutput>| async move {
|
||||
add_inputs(protocol, &rpc, outputs, &mut builder).await;
|
||||
|
||||
let view = runner::random_address().1;
|
||||
let mut scanner = Scanner::from_view(view.clone(), Some(HashSet::new()));
|
||||
|
||||
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.address(Network::Mainnet, AddressSpec::Subaddress(subaddress)),
|
||||
u64::from(i + 1),
|
||||
);
|
||||
subaddresses.push(subaddress);
|
||||
}
|
||||
|
||||
(builder.build().unwrap(), (scanner, subaddresses))
|
||||
},
|
||||
|_, tx: Transaction, _, mut state: (Scanner, Vec<SubaddressIndex>)| async move {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut scanned_tx = state.0.scan_transaction(&tx).not_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);
|
||||
let amount = output.commitment().amount;
|
||||
|
||||
assert!(output_amounts_by_subaddress.contains_key(&amount));
|
||||
assert_eq!(output.metadata.subaddress, Some(output_amounts_by_subaddress[&amount]));
|
||||
|
||||
output_amounts_by_subaddress.remove(&amount);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
spend_one_input_to_two_outputs_no_change,
|
||||
(
|
||||
|_, mut builder: Builder, addr| async move {
|
||||
builder.add_payment(addr, 1000000000000);
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
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);
|
||||
outputs
|
||||
},
|
||||
),
|
||||
(
|
||||
|protocol, rpc: Rpc<_>, _, addr, outputs: Vec<ReceivedOutput>| async move {
|
||||
use monero_wallet::FeePriority;
|
||||
|
||||
let mut builder = SignableTransactionBuilder::new(
|
||||
protocol,
|
||||
rpc.get_fee(protocol, FeePriority::Unimportant).await.unwrap(),
|
||||
Change::fingerprintable(None),
|
||||
);
|
||||
add_inputs(protocol, &rpc, vec![outputs.first().unwrap().clone()], &mut builder).await;
|
||||
builder.add_payment(addr, 10000);
|
||||
builder.add_payment(addr, 50000);
|
||||
|
||||
(builder.build().unwrap(), ())
|
||||
},
|
||||
|_, tx: Transaction, mut scanner: Scanner, ()| async move {
|
||||
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, 10000);
|
||||
assert_eq!(outputs[1].commitment().amount, 50000);
|
||||
|
||||
// The remainder should get shunted to fee, which is fingerprintable
|
||||
assert_eq!(tx.rct_signatures.base.fee, 1000000000000 - 10000 - 50000);
|
||||
},
|
||||
),
|
||||
);
|
||||
353
coins/monero/wallet/tests/wallet2_compatibility.rs
Normal file
353
coins/monero/wallet/tests/wallet2_compatibility.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rand_core::{OsRng, RngCore};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use monero_serai::transaction::Transaction;
|
||||
use monero_rpc::{EmptyResponse, Rpc};
|
||||
use monero_simple_request_rpc::SimpleRequestRpc;
|
||||
use monero_wallet::{
|
||||
address::{Network, AddressSpec, SubaddressIndex, MoneroAddress},
|
||||
extra::{MAX_TX_EXTRA_NONCE_SIZE, Extra, PaymentId},
|
||||
Scanner,
|
||||
};
|
||||
|
||||
mod runner;
|
||||
|
||||
async fn make_integrated_address(rpc: &Rpc<SimpleRequestRpc>, payment_id: [u8; 8]) -> String {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IntegratedAddressResponse {
|
||||
integrated_address: String,
|
||||
}
|
||||
|
||||
let res = rpc
|
||||
.json_rpc_call::<IntegratedAddressResponse>(
|
||||
"make_integrated_address",
|
||||
Some(json!({ "payment_id": hex::encode(payment_id) })),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
res.integrated_address
|
||||
}
|
||||
|
||||
async fn initialize_rpcs() -> (Rpc<SimpleRequestRpc>, Rpc<SimpleRequestRpc>, String) {
|
||||
let wallet_rpc = SimpleRequestRpc::new("http://127.0.0.1:18082".to_string()).await.unwrap();
|
||||
let daemon_rpc = runner::rpc().await;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AddressResponse {
|
||||
address: String,
|
||||
}
|
||||
|
||||
let mut wallet_id = [0; 8];
|
||||
OsRng.fill_bytes(&mut wallet_id);
|
||||
let _: EmptyResponse = wallet_rpc
|
||||
.json_rpc_call(
|
||||
"create_wallet",
|
||||
Some(json!({ "filename": hex::encode(wallet_id), "language": "English" })),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let address: AddressResponse =
|
||||
wallet_rpc.json_rpc_call("get_address", Some(json!({ "account_index": 0 }))).await.unwrap();
|
||||
|
||||
// Fund the new wallet
|
||||
daemon_rpc.generate_blocks(&address.address, 70).await.unwrap();
|
||||
|
||||
(wallet_rpc, daemon_rpc, address.address)
|
||||
}
|
||||
|
||||
async fn from_wallet_rpc_to_self(spec: AddressSpec) {
|
||||
// initialize rpc
|
||||
let (wallet_rpc, daemon_rpc, wallet_rpc_addr) = initialize_rpcs().await;
|
||||
|
||||
// make an addr
|
||||
let (_, view_pair, _) = runner::random_address();
|
||||
let addr = view_pair.address(Network::Mainnet, spec);
|
||||
|
||||
// refresh & make a tx
|
||||
let _: EmptyResponse = wallet_rpc.json_rpc_call("refresh", None).await.unwrap();
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TransferResponse {
|
||||
tx_hash: String,
|
||||
}
|
||||
let tx: TransferResponse = wallet_rpc
|
||||
.json_rpc_call(
|
||||
"transfer",
|
||||
Some(json!({
|
||||
"destinations": [{"address": addr.to_string(), "amount": 1_000_000_000_000u64 }],
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let tx_hash = hex::decode(tx.tx_hash).unwrap().try_into().unwrap();
|
||||
|
||||
// TODO: Needs https://github.com/monero-project/monero/pull/9260
|
||||
// let fee_rate = daemon_rpc
|
||||
// .get_fee(daemon_rpc.get_protocol().await.unwrap(), FeePriority::Unimportant)
|
||||
// .await
|
||||
// .unwrap();
|
||||
|
||||
// unlock it
|
||||
runner::mine_until_unlocked(&daemon_rpc, &wallet_rpc_addr, tx_hash).await;
|
||||
|
||||
// Create the scanner
|
||||
let mut scanner = Scanner::from_view(view_pair, Some(HashSet::new()));
|
||||
if let AddressSpec::Subaddress(index) = spec {
|
||||
scanner.register_subaddress(index);
|
||||
}
|
||||
|
||||
// Retrieve it and scan it
|
||||
let tx = daemon_rpc.get_transaction(tx_hash).await.unwrap();
|
||||
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
|
||||
|
||||
// TODO: Needs https://github.com/monero-project/monero/pull/9260
|
||||
// runner::check_weight_and_fee(&tx, fee_rate);
|
||||
|
||||
match spec {
|
||||
AddressSpec::Subaddress(index) => {
|
||||
assert_eq!(output.metadata.subaddress, Some(index));
|
||||
assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted([0u8; 8])));
|
||||
}
|
||||
AddressSpec::Integrated(payment_id) => {
|
||||
assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(payment_id)));
|
||||
assert_eq!(output.metadata.subaddress, None);
|
||||
}
|
||||
AddressSpec::Standard | AddressSpec::Featured { .. } => {
|
||||
assert_eq!(output.metadata.subaddress, None);
|
||||
assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted([0u8; 8])));
|
||||
}
|
||||
}
|
||||
assert_eq!(output.commitment().amount, 1000000000000);
|
||||
}
|
||||
|
||||
async_sequential!(
|
||||
async fn receipt_of_wallet_rpc_tx_standard() {
|
||||
from_wallet_rpc_to_self(AddressSpec::Standard).await;
|
||||
}
|
||||
|
||||
async fn receipt_of_wallet_rpc_tx_subaddress() {
|
||||
from_wallet_rpc_to_self(AddressSpec::Subaddress(SubaddressIndex::new(0, 1).unwrap())).await;
|
||||
}
|
||||
|
||||
async fn receipt_of_wallet_rpc_tx_integrated() {
|
||||
let mut payment_id = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut payment_id);
|
||||
from_wallet_rpc_to_self(AddressSpec::Integrated(payment_id)).await;
|
||||
}
|
||||
);
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Deserialize)]
|
||||
struct Index {
|
||||
major: u32,
|
||||
minor: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Transfer {
|
||||
payment_id: String,
|
||||
subaddr_index: Index,
|
||||
amount: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TransfersResponse {
|
||||
transfer: Transfer,
|
||||
transfers: Vec<Transfer>,
|
||||
}
|
||||
|
||||
test!(
|
||||
send_to_wallet_rpc_standard,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
// initialize rpc
|
||||
let (wallet_rpc, _, wallet_rpc_addr) = initialize_rpcs().await;
|
||||
|
||||
// add destination
|
||||
builder
|
||||
.add_payment(MoneroAddress::from_str(Network::Mainnet, &wallet_rpc_addr).unwrap(), 1000000);
|
||||
(builder.build().unwrap(), wallet_rpc)
|
||||
},
|
||||
|_, tx: Transaction, _, data: Rpc<SimpleRequestRpc>| async move {
|
||||
// confirm receipt
|
||||
let _: EmptyResponse = data.json_rpc_call("refresh", None).await.unwrap();
|
||||
let transfer: TransfersResponse = data
|
||||
.json_rpc_call("get_transfer_by_txid", Some(json!({ "txid": hex::encode(tx.hash()) })))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(transfer.transfer.subaddr_index, Index { major: 0, minor: 0 });
|
||||
assert_eq!(transfer.transfer.amount, 1000000);
|
||||
assert_eq!(transfer.transfer.payment_id, hex::encode([0u8; 8]));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
send_to_wallet_rpc_subaddress,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
// initialize rpc
|
||||
let (wallet_rpc, _, _) = initialize_rpcs().await;
|
||||
|
||||
// make the subaddress
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AccountResponse {
|
||||
address: String,
|
||||
account_index: u32,
|
||||
}
|
||||
let addr: AccountResponse = wallet_rpc.json_rpc_call("create_account", None).await.unwrap();
|
||||
assert!(addr.account_index != 0);
|
||||
|
||||
builder
|
||||
.add_payment(MoneroAddress::from_str(Network::Mainnet, &addr.address).unwrap(), 1000000);
|
||||
(builder.build().unwrap(), (wallet_rpc, addr.account_index))
|
||||
},
|
||||
|_, tx: Transaction, _, data: (Rpc<SimpleRequestRpc>, u32)| async move {
|
||||
// confirm receipt
|
||||
let _: EmptyResponse = data.0.json_rpc_call("refresh", None).await.unwrap();
|
||||
let transfer: TransfersResponse = data
|
||||
.0
|
||||
.json_rpc_call(
|
||||
"get_transfer_by_txid",
|
||||
Some(json!({ "txid": hex::encode(tx.hash()), "account_index": data.1 })),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(transfer.transfer.subaddr_index, Index { major: data.1, minor: 0 });
|
||||
assert_eq!(transfer.transfer.amount, 1000000);
|
||||
assert_eq!(transfer.transfer.payment_id, hex::encode([0u8; 8]));
|
||||
|
||||
// Make sure only one R was included in TX extra
|
||||
assert!(Extra::read::<&[u8]>(&mut tx.prefix.extra.as_ref())
|
||||
.unwrap()
|
||||
.keys()
|
||||
.unwrap()
|
||||
.1
|
||||
.is_none());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
send_to_wallet_rpc_subaddresses,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
// initialize rpc
|
||||
let (wallet_rpc, daemon_rpc, _) = initialize_rpcs().await;
|
||||
|
||||
// make the subaddress
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AddressesResponse {
|
||||
addresses: Vec<String>,
|
||||
address_index: u32,
|
||||
}
|
||||
let addrs: AddressesResponse = wallet_rpc
|
||||
.json_rpc_call("create_address", Some(json!({ "account_index": 0, "count": 2 })))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(addrs.address_index != 0);
|
||||
assert!(addrs.addresses.len() == 2);
|
||||
|
||||
builder.add_payments(&[
|
||||
(MoneroAddress::from_str(Network::Mainnet, &addrs.addresses[0]).unwrap(), 1000000),
|
||||
(MoneroAddress::from_str(Network::Mainnet, &addrs.addresses[1]).unwrap(), 2000000),
|
||||
]);
|
||||
(builder.build().unwrap(), (wallet_rpc, daemon_rpc, addrs.address_index))
|
||||
},
|
||||
|_, tx: Transaction, _, data: (Rpc<SimpleRequestRpc>, Rpc<SimpleRequestRpc>, u32)| async move {
|
||||
// confirm receipt
|
||||
let _: EmptyResponse = data.0.json_rpc_call("refresh", None).await.unwrap();
|
||||
let transfer: TransfersResponse = data
|
||||
.0
|
||||
.json_rpc_call(
|
||||
"get_transfer_by_txid",
|
||||
Some(json!({ "txid": hex::encode(tx.hash()), "account_index": 0 })),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(transfer.transfers.len(), 2);
|
||||
for t in transfer.transfers {
|
||||
match t.amount {
|
||||
1000000 => assert_eq!(t.subaddr_index, Index { major: 0, minor: data.2 }),
|
||||
2000000 => assert_eq!(t.subaddr_index, Index { major: 0, minor: data.2 + 1 }),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure 3 additional pub keys are included in TX extra
|
||||
let keys =
|
||||
Extra::read::<&[u8]>(&mut tx.prefix.extra.as_ref()).unwrap().keys().unwrap().1.unwrap();
|
||||
|
||||
assert_eq!(keys.len(), 3);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
send_to_wallet_rpc_integrated,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
// initialize rpc
|
||||
let (wallet_rpc, _, _) = initialize_rpcs().await;
|
||||
|
||||
// make the addr
|
||||
let mut payment_id = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut payment_id);
|
||||
let addr = make_integrated_address(&wallet_rpc, payment_id).await;
|
||||
|
||||
builder.add_payment(MoneroAddress::from_str(Network::Mainnet, &addr).unwrap(), 1000000);
|
||||
(builder.build().unwrap(), (wallet_rpc, payment_id))
|
||||
},
|
||||
|_, tx: Transaction, _, data: (Rpc<SimpleRequestRpc>, [u8; 8])| async move {
|
||||
// confirm receipt
|
||||
let _: EmptyResponse = data.0.json_rpc_call("refresh", None).await.unwrap();
|
||||
let transfer: TransfersResponse = data
|
||||
.0
|
||||
.json_rpc_call("get_transfer_by_txid", Some(json!({ "txid": hex::encode(tx.hash()) })))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(transfer.transfer.subaddr_index, Index { major: 0, minor: 0 });
|
||||
assert_eq!(transfer.transfer.payment_id, hex::encode(data.1));
|
||||
assert_eq!(transfer.transfer.amount, 1000000);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test!(
|
||||
send_to_wallet_rpc_with_arb_data,
|
||||
(
|
||||
|_, mut builder: Builder, _| async move {
|
||||
// initialize rpc
|
||||
let (wallet_rpc, _, wallet_rpc_addr) = initialize_rpcs().await;
|
||||
|
||||
// add destination
|
||||
builder
|
||||
.add_payment(MoneroAddress::from_str(Network::Mainnet, &wallet_rpc_addr).unwrap(), 1000000);
|
||||
|
||||
// Make 2 data that is the full 255 bytes
|
||||
for _ in 0 .. 2 {
|
||||
// Subtract 1 since we prefix data with 127
|
||||
let data = vec![b'a'; MAX_TX_EXTRA_NONCE_SIZE - 1];
|
||||
builder.add_data(data).unwrap();
|
||||
}
|
||||
|
||||
(builder.build().unwrap(), wallet_rpc)
|
||||
},
|
||||
|_, tx: Transaction, _, data: Rpc<SimpleRequestRpc>| async move {
|
||||
// confirm receipt
|
||||
let _: EmptyResponse = data.json_rpc_call("refresh", None).await.unwrap();
|
||||
let transfer: TransfersResponse = data
|
||||
.json_rpc_call("get_transfer_by_txid", Some(json!({ "txid": hex::encode(tx.hash()) })))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(transfer.transfer.subaddr_index, Index { major: 0, minor: 0 });
|
||||
assert_eq!(transfer.transfer.amount, 1000000);
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user