From 0b20004ba15bd31effcc0140cbee974b619bf70d Mon Sep 17 00:00:00 2001 From: Luke Parker Date: Sun, 23 Jun 2024 10:08:51 -0400 Subject: [PATCH] Get the repo to compile again --- Cargo.lock | 28 +- Cargo.toml | 1 + coins/monero/Cargo.toml | 1 - coins/monero/LICENSE | 2 +- coins/monero/rpc/src/lib.rs | 6 +- coins/monero/src/ringct.rs | 2 +- coins/monero/src/transaction.rs | 18 +- coins/monero/wallet/README.md | 3 + coins/monero/wallet/polyseed/Cargo.toml | 3 + coins/monero/wallet/polyseed/LICENSE | 21 + coins/monero/wallet/polyseed/src/lib.rs | 21 +- coins/monero/wallet/polyseed/src/tests.rs | 218 ++++++++ coins/monero/wallet/seed/Cargo.toml | 4 + coins/monero/wallet/seed/LICENSE | 21 + coins/monero/wallet/seed/src/lib.rs | 34 +- coins/monero/wallet/seed/src/tests.rs | 234 ++++++++ coins/monero/wallet/src/lib.rs | 34 +- coins/monero/wallet/src/scan.rs | 28 +- coins/monero/wallet/src/seed/mod.rs | 136 ----- coins/monero/wallet/src/send/mod.rs | 59 +- coins/monero/wallet/src/send/multisig.rs | 24 +- coins/monero/wallet/src/send/scan.rs | 516 ++++++++++++++++++ coins/monero/wallet/src/tests/mod.rs | 1 - coins/monero/wallet/src/tests/seed.rs | 485 ---------------- coins/monero/wallet/tests/decoys.rs | 3 +- coins/monero/wallet/tests/eventuality.rs | 9 +- coins/monero/wallet/tests/runner.rs | 5 +- coins/monero/wallet/tests/send.rs | 7 +- .../wallet/tests/wallet2_compatibility.rs | 6 +- coins/monero/wallet/util/Cargo.toml | 51 ++ coins/monero/wallet/util/LICENSE | 21 + coins/monero/wallet/util/README.md | 3 + coins/monero/wallet/util/src/lib.rs | 11 +- coins/monero/wallet/util/src/seed.rs | 150 +++++ processor/src/networks/monero.rs | 27 +- tests/full-stack/src/tests/mint_and_burn.rs | 10 +- tests/no-std/Cargo.toml | 12 +- tests/no-std/src/lib.rs | 10 +- tests/processor/src/lib.rs | 2 +- tests/processor/src/networks.rs | 2 +- 40 files changed, 1452 insertions(+), 777 deletions(-) create mode 100644 coins/monero/wallet/polyseed/LICENSE create mode 100644 coins/monero/wallet/polyseed/src/tests.rs create mode 100644 coins/monero/wallet/seed/LICENSE create mode 100644 coins/monero/wallet/seed/src/tests.rs delete mode 100644 coins/monero/wallet/src/seed/mod.rs create mode 100644 coins/monero/wallet/src/send/scan.rs delete mode 100644 coins/monero/wallet/src/tests/seed.rs create mode 100644 coins/monero/wallet/util/Cargo.toml create mode 100644 coins/monero/wallet/util/LICENSE create mode 100644 coins/monero/wallet/util/src/seed.rs diff --git a/Cargo.lock b/Cargo.lock index 14ced837..e0a0871f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4867,6 +4867,8 @@ name = "monero-seed" version = "0.1.0" dependencies = [ "curve25519-dalek", + "hex", + "monero-primitives", "rand_core", "std-shims", "thiserror", @@ -4931,6 +4933,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "monero-wallet-util" +version = "0.1.0" +dependencies = [ + "curve25519-dalek", + "hex", + "monero-seed", + "monero-wallet", + "polyseed", + "rand_core", + "std-shims", + "thiserror", + "zeroize", +] + [[package]] name = "multiaddr" version = "0.18.1" @@ -5818,6 +5835,7 @@ dependencies = [ name = "polyseed" version = "0.1.0" dependencies = [ + "hex", "pbkdf2 0.12.2", "rand_core", "sha3", @@ -8184,15 +8202,7 @@ dependencies = [ "dleq", "flexible-transcript", "minimal-ed448", - "monero-bulletproofs", - "monero-clsag", - "monero-generators", - "monero-io", - "monero-mlsag", - "monero-primitives", - "monero-rpc", - "monero-serai", - "monero-wallet", + "monero-wallet-util", "multiexp", "schnorr-signatures", ] diff --git a/Cargo.toml b/Cargo.toml index c17baff3..d33ce871 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ members = [ "coins/monero/wallet", "coins/monero/wallet/seed", "coins/monero/wallet/polyseed", + "coins/monero/wallet/util", "message-queue", diff --git a/coins/monero/Cargo.toml b/coins/monero/Cargo.toml index d9662a27..d4e92109 100644 --- a/coins/monero/Cargo.toml +++ b/coins/monero/Cargo.toml @@ -38,7 +38,6 @@ std = [ "std-shims/std", "zeroize/std", - "rand_core/std", "monero-io/std", diff --git a/coins/monero/LICENSE b/coins/monero/LICENSE index 6779f0ec..91d893c1 100644 --- a/coins/monero/LICENSE +++ b/coins/monero/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022-2023 Luke Parker +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 diff --git a/coins/monero/rpc/src/lib.rs b/coins/monero/rpc/src/lib.rs index daccac0c..b237bb22 100644 --- a/coins/monero/rpc/src/lib.rs +++ b/coins/monero/rpc/src/lib.rs @@ -348,7 +348,7 @@ pub trait Rpc: Sync + Clone + Debug { // https://github.com/monero-project/monero/issues/8311 if res.as_hex.is_empty() { - match tx.prefix.inputs.first() { + match tx.prefix().inputs.first() { Some(Input::Gen { .. }) => (), _ => Err(RpcError::PrunedTransaction)?, } @@ -431,7 +431,7 @@ pub trait Rpc: Sync + Clone + Debug { .map_err(|_| RpcError::InvalidNode("invalid block".to_string()))?; // Make sure this is actually the block for this number - match block.miner_tx.prefix.inputs.first() { + match block.miner_tx.prefix().inputs.first() { Some(Input::Gen(actual)) => { if usize::try_from(*actual).unwrap() == number { Ok(block) @@ -733,7 +733,7 @@ pub trait Rpc: Sync + Clone + Debug { }; Ok(Some([key, rpc_point(&out.mask)?]).filter(|_| { if fingerprintable_canonical { - Timelock::Block(height) >= txs[i].prefix.timelock + Timelock::Block(height) >= txs[i].prefix().timelock } else { out.unlocked } diff --git a/coins/monero/src/ringct.rs b/coins/monero/src/ringct.rs index b0341a07..72cc1c2d 100644 --- a/coins/monero/src/ringct.rs +++ b/coins/monero/src/ringct.rs @@ -115,7 +115,7 @@ impl TryFrom for RctType { impl RctType { /// True if this RctType uses compact encrypted amounts, false otherwise. - fn compact_encrypted_amounts(&self) -> bool { + pub fn compact_encrypted_amounts(&self) -> bool { match self { RctType::AggregateMlsagBorromean | RctType::MlsagBorromean | RctType::MlsagBulletproofs => { false diff --git a/coins/monero/src/transaction.rs b/coins/monero/src/transaction.rs index e7a389f4..b365cfe4 100644 --- a/coins/monero/src/transaction.rs +++ b/coins/monero/src/transaction.rs @@ -301,6 +301,14 @@ pub enum Transaction { } impl Transaction { + /// Get the version of this transaction. + pub fn version(&self) -> u8 { + match self { + Transaction::V1 { .. } => 1, + Transaction::V2 { .. } => 2, + } + } + /// Get the TransactionPrefix of this transaction. pub fn prefix(&self) -> &TransactionPrefix { match self { @@ -308,6 +316,13 @@ impl Transaction { } } + /// Get a mutable reference to the TransactionPrefix of this transaction. + pub fn prefix_mut(&mut self) -> &mut TransactionPrefix { + match self { + Transaction::V1 { prefix, .. } | Transaction::V2 { prefix, .. } => prefix, + } + } + /// The weight of this Transaction, as relevant for fees. // TODO: Replace ring_len, decoy_weights for &[&[usize]], where the inner buf is the decoy // offsets @@ -329,16 +344,15 @@ impl Transaction { /// Some writable transactions may not be readable if they're malformed, per Monero's consensus /// rules. pub fn write(&self, w: &mut W) -> io::Result<()> { + write_varint(&self.version(), w)?; match self { Transaction::V1 { prefix, signatures } => { - write_varint(&1u8, w)?; prefix.write(w)?; for ring_sig in signatures { ring_sig.write(w)?; } } Transaction::V2 { prefix, proofs } => { - write_varint(&2u8, w)?; prefix.write(w)?; match proofs { None => w.write_all(&[0])?, diff --git a/coins/monero/wallet/README.md b/coins/monero/wallet/README.md index 4700f716..83483dda 100644 --- a/coins/monero/wallet/README.md +++ b/coins/monero/wallet/README.md @@ -43,3 +43,6 @@ It also won't act as a wallet, just as a wallet functionality library. wallet2 has several *non-transaction-level* policies, such as always attempting to use two inputs to create transactions. These are considered out of scope to monero-serai. + +Finally, this library only supports producing transactions with CLSAG +signatures. That means this library cannot spend non-RingCT outputs. diff --git a/coins/monero/wallet/polyseed/Cargo.toml b/coins/monero/wallet/polyseed/Cargo.toml index 68c2eab8..a4434353 100644 --- a/coins/monero/wallet/polyseed/Cargo.toml +++ b/coins/monero/wallet/polyseed/Cargo.toml @@ -27,6 +27,9 @@ rand_core = { version = "0.6", default-features = false } sha3 = { version = "0.10", default-features = false } pbkdf2 = { version = "0.12", features = ["simple"], default-features = false } +[dev-dependencies] +hex = { version = "0.4", default-features = false, features = ["std"] } + [features] std = [ "std-shims/std", diff --git a/coins/monero/wallet/polyseed/LICENSE b/coins/monero/wallet/polyseed/LICENSE new file mode 100644 index 00000000..91d893c1 --- /dev/null +++ b/coins/monero/wallet/polyseed/LICENSE @@ -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. diff --git a/coins/monero/wallet/polyseed/src/lib.rs b/coins/monero/wallet/polyseed/src/lib.rs index b1a8ad75..8c4b502f 100644 --- a/coins/monero/wallet/polyseed/src/lib.rs +++ b/coins/monero/wallet/polyseed/src/lib.rs @@ -15,6 +15,9 @@ use rand_core::{RngCore, CryptoRng}; use sha3::Sha3_256; use pbkdf2::pbkdf2_hmac; +#[cfg(test)] +mod tests; + // Features const FEATURE_BITS: u8 = 5; #[allow(dead_code)] @@ -63,10 +66,10 @@ const LAST_BYTE_SECRET_BITS_MASK: u8 = ((1 << (BITS_PER_BYTE - CLEAR_BITS)) - 1) const SECRET_BITS_PER_WORD: usize = 10; -// Amount of words in a seed +// The amount of words in a seed. const POLYSEED_LENGTH: usize = 16; // Amount of characters each word must have if trimmed -const PREFIX_LEN: usize = 4; +pub(crate) const PREFIX_LEN: usize = 4; const POLY_NUM_CHECK_DIGITS: usize = 1; const DATA_WORDS: usize = POLYSEED_LENGTH - POLY_NUM_CHECK_DIGITS; @@ -105,18 +108,18 @@ const COIN: u16 = 0; #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[cfg_attr(feature = "std", derive(thiserror::Error))] pub enum PolyseedError { - /// Unsupported feature bits were set. - #[cfg_attr(feature = "std", error("unsupported features"))] - UnsupportedFeatures, + /// The seed was invalid. + #[cfg_attr(feature = "std", error("invalid seed"))] + InvalidSeed, /// The entropy was invalid. #[cfg_attr(feature = "std", error("invalid entropy"))] InvalidEntropy, - #[cfg_attr(feature = "std", error("invalid seed"))] - /// The seed was invalid. - InvalidSeed, /// The checksum did not match the data. #[cfg_attr(feature = "std", error("invalid checksum"))] InvalidChecksum, + /// Unsupported feature bits were set. + #[cfg_attr(feature = "std", error("unsupported features"))] + UnsupportedFeatures, } /// Language options for Polyseed. @@ -277,7 +280,7 @@ impl Polyseed { /// Create a new `Polyseed` with specific internals. /// /// `birthday` is defined in seconds since the epoch. - fn from( + pub fn from( language: Language, features: u8, birthday: u64, diff --git a/coins/monero/wallet/polyseed/src/tests.rs b/coins/monero/wallet/polyseed/src/tests.rs new file mode 100644 index 00000000..4913c217 --- /dev/null +++ b/coins/monero/wallet/polyseed/src/tests.rs @@ -0,0 +1,218 @@ +use zeroize::Zeroizing; +use rand_core::OsRng; + +use crate::*; + +#[test] +fn test_polyseed() { + struct Vector { + language: Language, + seed: String, + entropy: String, + birthday: u64, + has_prefix: bool, + has_accent: bool, + } + + let vectors = [ + Vector { + language: 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: 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: 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: 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: 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: 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: Language::Korean, + seed: "전망 선풍기 국제 무궁화 설사 기름 이론적 해안 절망 예선 \ + 지우개 보관 절망 말기 시각 귀신" + .into(), + entropy: "684663fda420298f42ed94b2c512ed38ddf12b00000000000000000000000000".into(), + birthday: 1679317073, + has_prefix: false, + has_accent: false, + }, + Vector { + language: Language::Japanese, + seed: "うちあわせ ちつじょ つごう しはい けんこう とおる てみやげ はんとし たんとう \ + といれ おさない おさえる むかう ぬぐう なふだ せまる" + .into(), + entropy: "94e6665518a6286c6e3ba508a2279eb62b771f00000000000000000000000000".into(), + birthday: 1679318722, + has_prefix: false, + has_accent: false, + }, + Vector { + language: Language::ChineseTraditional, + seed: "亂 挖 斤 柄 代 圈 枝 轄 魯 論 函 開 勘 番 榮 壁".into(), + entropy: "b1594f585987ab0fd5a31da1f0d377dae5283f00000000000000000000000000".into(), + birthday: 1679426433, + has_prefix: false, + has_accent: false, + }, + Vector { + language: 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: 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::()) + .collect::>() + .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 == 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::() + }) + .collect::>() + .join(" ") + }; + + // String -> Seed + println!("{}. language: {:?}, seed: {}", line!(), vector.language, vector.seed.clone()); + let seed = Polyseed::from_string(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 = + Polyseed::from_string(vector.language, Zeroizing::new(add_whitespace)).unwrap(); + assert_eq!(seed, whitespaced_seed); + // Check trimmed versions works + if vector.has_prefix { + let trimmed_seed = Polyseed::from_string(vector.language, Zeroizing::new(trim)).unwrap(); + assert_eq!(seed, trimmed_seed); + } + // Check versions without accents work + if vector.has_accent { + let seed_without_accents = + Polyseed::from_string(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) < TIME_STEP); + + // Entropy -> Seed + let from_entropy = Polyseed::from(vector.language, 0, seed.birthday(), entropy).unwrap(); + assert_eq!(seed.to_string(), from_entropy.to_string()); + + // Check against ourselves + { + let seed = Polyseed::new(&mut OsRng, vector.language); + println!("{}. seed: {}", line!(), *seed.to_string()); + assert_eq!(seed, Polyseed::from_string(vector.language, seed.to_string()).unwrap()); + assert_eq!( + seed, + Polyseed::from(vector.language, 0, seed.birthday(), seed.entropy().clone(),).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 = Polyseed::from_string(Language::English, Zeroizing::new(seed)); + assert_eq!(res, Err(PolyseedError::UnsupportedFeatures)); +} diff --git a/coins/monero/wallet/seed/Cargo.toml b/coins/monero/wallet/seed/Cargo.toml index d1e56c97..32ba8cfd 100644 --- a/coins/monero/wallet/seed/Cargo.toml +++ b/coins/monero/wallet/seed/Cargo.toml @@ -25,6 +25,10 @@ rand_core = { version = "0.6", default-features = false } curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } +[dev-dependencies] +hex = { version = "0.4", default-features = false, features = ["std"] } +monero-primitives = { path = "../../primitives", default-features = false, features = ["std"] } + [features] std = [ "std-shims/std", diff --git a/coins/monero/wallet/seed/LICENSE b/coins/monero/wallet/seed/LICENSE new file mode 100644 index 00000000..91d893c1 --- /dev/null +++ b/coins/monero/wallet/seed/LICENSE @@ -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. diff --git a/coins/monero/wallet/seed/src/lib.rs b/coins/monero/wallet/seed/src/lib.rs index 41e56ed6..7035d560 100644 --- a/coins/monero/wallet/seed/src/lib.rs +++ b/coins/monero/wallet/seed/src/lib.rs @@ -1,4 +1,4 @@ -use core::ops::Deref; +use core::{ops::Deref, fmt}; use std_shims::{ sync::OnceLock, vec::Vec, @@ -11,24 +11,29 @@ use rand_core::{RngCore, CryptoRng}; use curve25519_dalek::scalar::Scalar; -const CLASSIC_SEED_LENGTH: usize = 24; -const CLASSIC_SEED_LENGTH_WITH_CHECKSUM: usize = 25; +#[cfg(test)] +mod tests; + +// The amount of words in a seed without a checksum. +const SEED_LENGTH: usize = 24; +// The amount of words in a seed with a checksum. +const SEED_LENGTH_WITH_CHECKSUM: usize = 25; /// An error when working with a seed. #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[cfg_attr(feature = "std", derive(thiserror::Error))] pub enum SeedError { - /// The deprecated English language option was used with a checksum. - /// - /// The deprecated English language option did not include a checksum. - #[cfg_attr(feature = "std", error("deprecated English language option included a checksum"))] - DeprecatedEnglishWithChecksum, #[cfg_attr(feature = "std", error("invalid seed"))] /// The seed was invalid. InvalidSeed, /// The checksum did not match the data. #[cfg_attr(feature = "std", error("invalid checksum"))] InvalidChecksum, + /// The deprecated English language option was used with a checksum. + /// + /// The deprecated English language option did not include a checksum. + #[cfg_attr(feature = "std", error("deprecated English language option included a checksum"))] + DeprecatedEnglishWithChecksum, } /// Language options. @@ -211,11 +216,11 @@ fn key_to_seed(lang: Language, key: Zeroizing) -> Seed { fn seed_to_bytes(lang: Language, words: &str) -> Result, SeedError> { // get seed words let words = words.split_whitespace().map(|w| Zeroizing::new(w.to_string())).collect::>(); - if (words.len() != CLASSIC_SEED_LENGTH) && (words.len() != CLASSIC_SEED_LENGTH_WITH_CHECKSUM) { + if (words.len() != SEED_LENGTH) && (words.len() != SEED_LENGTH_WITH_CHECKSUM) { panic!("invalid seed passed to seed_to_bytes"); } - let has_checksum = words.len() == CLASSIC_SEED_LENGTH_WITH_CHECKSUM; + let has_checksum = words.len() == SEED_LENGTH_WITH_CHECKSUM; if has_checksum && lang == Language::DeprecatedEnglish { Err(SeedError::DeprecatedEnglishWithChecksum)?; } @@ -223,7 +228,7 @@ fn seed_to_bytes(lang: Language, words: &str) -> Result, See // 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 has_checksum = words.len() == SEED_LENGTH_WITH_CHECKSUM; let mut matched_indices = Zeroizing::new(vec![]); // Iterate through all the words and see if they're all present @@ -295,6 +300,13 @@ fn seed_to_bytes(lang: Language, words: &str) -> Result, See /// A Monero seed. #[derive(Clone, PartialEq, Eq, Zeroize)] pub struct Seed(Language, Zeroizing); + +impl fmt::Debug for Seed { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Seed").finish_non_exhaustive() + } +} + impl Seed { /// Create a new seed. pub fn new(rng: &mut R, lang: Language) -> Seed { diff --git a/coins/monero/wallet/seed/src/tests.rs b/coins/monero/wallet/seed/src/tests.rs new file mode 100644 index 00000000..c477a00d --- /dev/null +++ b/coins/monero/wallet/seed/src/tests.rs @@ -0,0 +1,234 @@ +use zeroize::Zeroizing; +use rand_core::OsRng; + +use curve25519_dalek::scalar::Scalar; + +use monero_primitives::keccak256; + +use crate::*; + +#[test] +fn test_original_seed() { + struct Vector { + language: Language, + seed: String, + spend: String, + view: String, + } + + let vectors = [ + Vector { + language: Language::Chinese, + seed: "摇 曲 艺 武 滴 然 效 似 赏 式 祥 歌 买 疑 小 碧 堆 博 键 房 鲜 悲 付 喷 武".into(), + spend: "a5e4fff1706ef9212993a69f246f5c95ad6d84371692d63e9bb0ea112a58340d".into(), + view: "1176c43ce541477ea2f3ef0b49b25112b084e26b8a843e1304ac4677b74cdf02".into(), + }, + Vector { + language: 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: 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: 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: 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: 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: 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: 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: Language::Japanese, + seed: "ぜんぶ どうぐ おたがい せんきょ おうじ そんちょう じゅしん いろえんぴつ \ + かほう つかれる えらぶ にちじょう くのう にちようび ぬまえび さんきゃく \ + おおや ちぬき うすめる いがく せつでん さうな すいえい せつだん おおや" + .into(), + spend: "c56e895cdb13007eda8399222974cdbab493640663804b93cbef3d8c3df80b0b".into(), + view: "6c3634a313ec2ee979d565c33888fd7c3502d696ce0134a8bc1a2698c7f2c508".into(), + }, + Vector { + language: Language::Russian, + seed: "шатер икра нация ехать получать инерция доза реальный \ + рыжий таможня лопата душа веселый клетка атлас лекция \ + обгонять паек наивный лыжный дурак стать ежик задача паек" + .into(), + spend: "7cb5492df5eb2db4c84af20766391cd3e3662ab1a241c70fc881f3d02c381f05".into(), + view: "fcd53e41ec0df995ab43927f7c44bc3359c93523d5009fb3f5ba87431d545a03".into(), + }, + Vector { + language: 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: 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: Language::DeprecatedEnglish, + 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: 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: 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: 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: 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: 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 { + fn trim_by_lang(word: &str, lang: Language) -> String { + if lang != Language::DeprecatedEnglish { + word.chars().take(LANGUAGES()[&lang].unique_prefix_length).collect() + } else { + word.to_string() + } + } + + let trim_seed = |seed: &str| { + seed + .split_whitespace() + .map(|word| trim_by_lang(word, vector.language)) + .collect::>() + .join(" ") + }; + + // Test against Monero + { + println!("{}. language: {:?}, seed: {}", line!(), vector.language, vector.seed.clone()); + let seed = Seed::from_string(vector.language, Zeroizing::new(vector.seed.clone())).unwrap(); + let trim = trim_seed(&vector.seed); + assert_eq!(seed, Seed::from_string(vector.language, Zeroizing::new(trim)).unwrap()); + + let spend: [u8; 32] = hex::decode(vector.spend).unwrap().try_into().unwrap(); + // For originalal seeds, Monero directly uses the entropy as a spend key + assert_eq!( + Option::::from(Scalar::from_canonical_bytes(*seed.entropy())), + Option::::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(vector.language, Zeroizing::new(spend)).unwrap(), seed); + } + + // Test against ourselves + { + let seed = Seed::new(&mut OsRng, vector.language); + println!("{}. seed: {}", line!(), *seed.to_string()); + let trim = trim_seed(&seed.to_string()); + assert_eq!(seed, Seed::from_string(vector.language, Zeroizing::new(trim)).unwrap()); + assert_eq!(seed, Seed::from_entropy(vector.language, seed.entropy()).unwrap()); + assert_eq!(seed, Seed::from_string(vector.language, seed.to_string()).unwrap()); + } + } +} diff --git a/coins/monero/wallet/src/lib.rs b/coins/monero/wallet/src/lib.rs index cb2aba22..ce503988 100644 --- a/coins/monero/wallet/src/lib.rs +++ b/coins/monero/wallet/src/lib.rs @@ -5,7 +5,7 @@ use core::ops::Deref; use std_shims::{ - io, + io as stdio, collections::{HashSet, HashMap}, }; @@ -23,16 +23,14 @@ use monero_serai::{ ringct::{RctType, EncryptedAmount}, transaction::Input, }; -pub use monero_serai as monero; + +pub use monero_serai::*; 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}; @@ -114,8 +112,8 @@ impl Protocol { // TODO: Make this an Option when we support pre-RCT protocols pub fn optimal_rct_type(&self) -> RctType { match self { - Protocol::v14 => RctType::Clsag, - Protocol::v16 => RctType::BulletproofsPlus, + Protocol::v14 => RctType::ClsagBulletproof, + Protocol::v16 => RctType::ClsagBulletproofPlus, Protocol::Custom { optimal_rct_type, .. } => *optimal_rct_type, } } @@ -139,7 +137,7 @@ impl Protocol { } } - pub fn write(&self, w: &mut W) -> io::Result<()> { + pub fn write(&self, w: &mut W) -> stdio::Result<()> { match self { Protocol::v14 => w.write_all(&[0, 14]), Protocol::v16 => w.write_all(&[0, 16]), @@ -148,20 +146,20 @@ impl Protocol { w.write_all(&[1, 0])?; w.write_all(&u16::try_from(*ring_len).unwrap().to_le_bytes())?; w.write_all(&[u8::from(*bp_plus)])?; - w.write_all(&[optimal_rct_type.to_byte()])?; + w.write_all(&[u8::from(*optimal_rct_type)])?; w.write_all(&[u8::from(*view_tags)])?; w.write_all(&[u8::from(*v16_fee)]) } } } - pub fn read(r: &mut R) -> io::Result { + pub fn read(r: &mut R) -> stdio::Result { Ok(match read_byte(r)? { // Monero protocol 0 => match read_byte(r)? { 14 => Protocol::v14, 16 => Protocol::v16, - _ => Err(io::Error::other("unrecognized monero protocol"))?, + _ => Err(stdio::Error::other("unrecognized monero protocol"))?, }, // Custom 1 => match read_byte(r)? { @@ -170,24 +168,24 @@ impl Protocol { bp_plus: match read_byte(r)? { 0 => false, 1 => true, - _ => Err(io::Error::other("invalid bool serialization"))?, + _ => Err(stdio::Error::other("invalid bool serialization"))?, }, - optimal_rct_type: RctType::from_byte(read_byte(r)?) - .ok_or_else(|| io::Error::other("invalid RctType serialization"))?, + optimal_rct_type: RctType::try_from(read_byte(r)?) + .map_err(|()| stdio::Error::other("invalid RctType serialization"))?, view_tags: match read_byte(r)? { 0 => false, 1 => true, - _ => Err(io::Error::other("invalid bool serialization"))?, + _ => Err(stdio::Error::other("invalid bool serialization"))?, }, v16_fee: match read_byte(r)? { 0 => false, 1 => true, - _ => Err(io::Error::other("invalid bool serialization"))?, + _ => Err(stdio::Error::other("invalid bool serialization"))?, }, }, - _ => Err(io::Error::other("unrecognized custom protocol serialization"))?, + _ => Err(stdio::Error::other("unrecognized custom protocol serialization"))?, }, - _ => Err(io::Error::other("unrecognized protocol serialization"))?, + _ => Err(stdio::Error::other("unrecognized protocol serialization"))?, }) } } diff --git a/coins/monero/wallet/src/scan.rs b/coins/monero/wallet/src/scan.rs index d816be8b..a49790a3 100644 --- a/coins/monero/wallet/src/scan.rs +++ b/coins/monero/wallet/src/scan.rs @@ -334,22 +334,22 @@ impl Scanner { /// Scan a transaction to discover the received outputs. pub fn scan_transaction(&mut self, tx: &Transaction) -> Timelocked { // Only scan RCT TXs since we can only spend RCT outputs - if tx.prefix.version != 2 { - return Timelocked(tx.prefix.timelock, vec![]); + if tx.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 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![]); + 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() { + 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) { @@ -380,7 +380,7 @@ impl Scanner { } }; let (view_tag, shared_key, payment_id_xor) = shared_key( - if self.burning_bug.is_none() { Some(uniqueness(&tx.prefix.inputs)) } else { None }, + if self.burning_bug.is_none() { Some(uniqueness(&tx.prefix().inputs)) } else { None }, self.pair.view.deref() * key, o, ); @@ -419,7 +419,11 @@ impl Scanner { commitment.amount = amount; // Regular transaction } else { - commitment = match tx.proofs.base.encrypted_amounts.get(o) { + let Transaction::V2 { proofs: Some(ref proofs), .. } = &tx else { + return Timelocked(tx.prefix().timelock, vec![]); + }; + + commitment = match proofs.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 @@ -428,7 +432,7 @@ impl Scanner { // 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.proofs.base.commitments.get(o) { + if Some(&commitment.calculate()) != proofs.base.commitments.get(o) { break; } } @@ -452,7 +456,7 @@ impl Scanner { } } - Timelocked(tx.prefix.timelock, res) + Timelocked(tx.prefix().timelock, res) } /// Scan a block to obtain its spendable outputs. Its the presence in a block giving these @@ -493,13 +497,13 @@ impl Scanner { res.push(timelock); } index += u64::try_from( - tx.prefix + 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(..))); + (tx.version() == 2) && matches!(tx.prefix().inputs.first(), Some(Input::Gen(..))); is_v2_miner_tx || output.amount.is_none() }) .count(), diff --git a/coins/monero/wallet/src/seed/mod.rs b/coins/monero/wallet/src/seed/mod.rs deleted file mode 100644 index 1b9c062b..00000000 --- a/coins/monero/wallet/src/seed/mod.rs +++ /dev/null @@ -1,136 +0,0 @@ -use core::fmt; -use std_shims::string::String; - -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; -use rand_core::{RngCore, CryptoRng}; - -pub(crate) use monero_seed as classic; -pub(crate) use 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(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) -> Result { - 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, - ) -> Option { - 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 { - 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(), - } - } -} diff --git a/coins/monero/wallet/src/send/mod.rs b/coins/monero/wallet/src/send/mod.rs index 24dca949..658186fd 100644 --- a/coins/monero/wallet/src/send/mod.rs +++ b/coins/monero/wallet/src/send/mod.rs @@ -25,9 +25,9 @@ use monero_rpc::RpcError; pub use monero_rpc::{FeePriority, FeeRate}; use monero_serai::{ io::*, + generators::hash_to_point, primitives::{Commitment, keccak256}, ringct::{ - hash_to_point, clsag::{ClsagError, ClsagContext, Clsag}, bulletproofs::{MAX_COMMITMENTS, Bulletproof}, RctBase, RctPrunable, RctProofs, @@ -181,7 +181,7 @@ fn prepare_inputs( .map_err(TransactionError::ClsagError)?, )); - tx.prefix.inputs.push(Input::ToKey { + tx.prefix_mut().inputs.push(Input::ToKey { amount: None, key_offsets: decoys.offsets().to_vec(), key_image: image, @@ -191,7 +191,7 @@ fn prepare_inputs( // We now need to sort the inputs by their key image // We take the transaction's inputs, temporarily let mut tx_inputs = Vec::with_capacity(inputs.len()); - core::mem::swap(&mut tx_inputs, &mut tx.prefix.inputs); + core::mem::swap(&mut tx_inputs, &mut tx.prefix_mut().inputs); // Then we join them with their signable contexts let mut joint = tx_inputs.into_iter().zip(signable).collect::>(); @@ -204,11 +204,11 @@ fn prepare_inputs( } }); - // We now re-create the consumed signable (tx.prefix.inputs already having an empty vector) and + // We now re-create the consumed signable (tx.prefix().inputs already having an empty vector) and // split the joint iterator back into two Vecs let mut signable = Vec::with_capacity(inputs.len()); for (input, signable_i) in joint { - tx.prefix.inputs.push(input); + tx.prefix_mut().inputs.push(input); signable.push(signable_i); } @@ -800,24 +800,22 @@ impl SignableTransaction { } ( - Transaction { + Transaction::V2 { prefix: TransactionPrefix { - version: 2, timelock: Timelock::None, inputs: vec![], outputs: tx_outputs, extra, }, - signatures: vec![], - proofs: RctProofs { + proofs: Some(RctProofs { base: RctBase { fee, encrypted_amounts, pseudo_outs: vec![], commitments: commitments.iter().map(Commitment::calculate).collect(), }, - prunable: RctPrunable::Clsag { bulletproofs: bp, clsags: vec![], pseudo_outs: vec![] }, - }, + prunable: RctPrunable::Clsag { bulletproof: bp, clsags: vec![], pseudo_outs: vec![] }, + }), }, sum, ) @@ -853,21 +851,28 @@ impl SignableTransaction { let signable = prepare_inputs(&self.inputs, spend, &mut tx)?; - let clsag_pairs = Clsag::sign(rng, signable, mask_sum, tx.signature_hash()) + let clsag_pairs = Clsag::sign(rng, signable, mask_sum, tx.signature_hash().unwrap()) .map_err(|_| TransactionError::WrongPrivateKey)?; - match tx.proofs.prunable { - RctPrunable::Null => panic!("Signing for RctPrunable::Null"), - RctPrunable::Clsag { ref mut clsags, ref mut pseudo_outs, .. } => { + let fee = match tx { + Transaction::V2 { + proofs: + Some(RctProofs { + ref base, + prunable: RctPrunable::Clsag { ref mut clsags, ref mut pseudo_outs, .. }, + }), + .. + } => { clsags.append(&mut clsag_pairs.iter().map(|clsag| clsag.0.clone()).collect::>()); pseudo_outs.append(&mut clsag_pairs.iter().map(|clsag| clsag.1).collect::>()); + base.fee } _ => unreachable!("attempted to sign a TX which wasn't CLSAG"), - } + }; if self.has_change { debug_assert_eq!( self.fee_rate.calculate_fee_from_weight(tx.weight()), - tx.proofs.base.fee, + fee, "transaction used unexpected fee", ); } @@ -892,7 +897,7 @@ impl Eventuality { #[must_use] pub fn matches(&self, tx: &Transaction) -> bool { - if self.payments.len() != tx.prefix.outputs.len() { + if self.payments.len() != tx.prefix().outputs.len() { return false; } @@ -900,12 +905,12 @@ impl Eventuality { // Even if all the outputs were correct, a malicious extra could still cause a recipient to // fail to receive their funds. // This is the cheapest check available to perform as it does not require TX-specific ECC ops. - if self.extra != tx.prefix.extra { + if self.extra != tx.prefix().extra { return false; } // Also ensure no timelock was set. - if tx.prefix.timelock != Timelock::None { + if tx.prefix().timelock != Timelock::None { return false; } @@ -914,10 +919,14 @@ impl Eventuality { &self.r_seed, &self.inputs, &mut self.payments.clone(), - uniqueness(&tx.prefix.inputs), + uniqueness(&tx.prefix().inputs), ); - let rct_type = tx.proofs.rct_type(); + let Transaction::V2 { proofs: Some(ref proofs), .. } = &tx else { + return false; + }; + + let rct_type = proofs.rct_type(); if rct_type != self.protocol.optimal_rct_type() { return false; } @@ -928,16 +937,16 @@ impl Eventuality { "created an Eventuality for a very old RctType we don't support proving for" ); - for (o, (expected, actual)) in outputs.iter().zip(tx.prefix.outputs.iter()).enumerate() { + for (o, (expected, actual)) in outputs.iter().zip(tx.prefix().outputs.iter()).enumerate() { // Verify the output, commitment, and encrypted amount. if (&Output { amount: None, key: expected.dest.compress(), view_tag: Some(expected.view_tag).filter(|_| self.protocol.view_tags()), } != actual) || - (Some(&expected.commitment.calculate()) != tx.proofs.base.commitments.get(o)) || + (Some(&expected.commitment.calculate()) != proofs.base.commitments.get(o)) || (Some(&EncryptedAmount::Compact { amount: expected.amount }) != - tx.proofs.base.encrypted_amounts.get(o)) + proofs.base.encrypted_amounts.get(o)) { return false; } diff --git a/coins/monero/wallet/src/send/multisig.rs b/coins/monero/wallet/src/send/multisig.rs index b3a5a493..8eb279e0 100644 --- a/coins/monero/wallet/src/send/multisig.rs +++ b/coins/monero/wallet/src/send/multisig.rs @@ -27,7 +27,7 @@ use frost::{ use monero_serai::{ ringct::{ clsag::{ClsagContext, ClsagMultisigMaskSender, ClsagAddendum, ClsagMultisig}, - RctPrunable, + RctPrunable, RctProofs, }, transaction::{Input, Transaction}, }; @@ -350,7 +350,7 @@ impl SignMachine for TransactionSignMachine { } value.2.send(mask); - tx.prefix.inputs.push(Input::ToKey { + tx.prefix_mut().inputs.push(Input::ToKey { amount: None, key_offsets: value.1.offsets().to_vec(), key_image: value.0, @@ -360,7 +360,7 @@ impl SignMachine for TransactionSignMachine { commitments.push(value.4); } - let msg = tx.signature_hash(); + let msg = tx.signature_hash().unwrap(); // Iterate over each CLSAG calling sign let mut shares = Vec::with_capacity(self.clsags.len()); @@ -390,9 +390,15 @@ impl SignatureMachine for TransactionSignatureMachine { shares: HashMap, ) -> Result { let mut tx = self.tx; - match tx.proofs.prunable { - RctPrunable::Null => panic!("Signing for RctPrunable::Null"), - RctPrunable::Clsag { ref mut clsags, ref mut pseudo_outs, .. } => { + match tx { + Transaction::V2 { + proofs: + Some(RctProofs { + prunable: 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::>(), @@ -401,11 +407,7 @@ impl SignatureMachine for TransactionSignatureMachine { pseudo_outs.push(pseudo_out); } } - RctPrunable::AggregateMlsagBorromean { .. } | - RctPrunable::MlsagBorromean { .. } | - RctPrunable::MlsagBulletproofs { .. } => { - unreachable!("attempted to sign a multisig TX which wasn't CLSAG") - } + _ => unreachable!("attempted to sign a multisig TX which wasn't CLSAG"), } Ok(tx) } diff --git a/coins/monero/wallet/src/send/scan.rs b/coins/monero/wallet/src/send/scan.rs new file mode 100644 index 00000000..b33a42d9 --- /dev/null +++ b/coins/monero/wallet/src/send/scan.rs @@ -0,0 +1,516 @@ +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, 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(&self, w: &mut W) -> io::Result<()> { + w.write_all(&self.tx)?; + w.write_all(&[self.o]) + } + + pub fn serialize(&self) -> Vec { + let mut serialized = Vec::with_capacity(32 + 1); + self.write(&mut serialized).unwrap(); + serialized + } + + pub fn read(r: &mut R) -> io::Result { + 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(&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 { + let mut serialized = Vec::with_capacity(32 + 32 + 32 + 8); + self.write(&mut serialized).unwrap(); + serialized + } + + pub fn read(r: &mut R) -> io::Result { + 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, + /// 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, + /// Arbitrary data encoded in TX extra. + pub arbitrary_data: Vec>, +} + +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::>()) + .finish() + } +} + +impl Metadata { + pub fn 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 { + let mut serialized = Vec::with_capacity(1 + 8 + 1); + self.write(&mut serialized).unwrap(); + serialized + } + + pub fn read(r: &mut R) -> io::Result { + 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] { + &self.metadata.arbitrary_data + } + + pub fn write(&self, w: &mut W) -> io::Result<()> { + self.absolute.write(w)?; + self.data.write(w)?; + self.metadata.write(w) + } + + pub fn serialize(&self) -> Vec { + let mut serialized = vec![]; + self.write(&mut serialized).unwrap(); + serialized + } + + pub fn read(r: &mut R) -> io::Result { + 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(&mut self, rpc: &impl 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: &impl Rpc, output: ReceivedOutput) -> Result { + 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] { + self.output.arbitrary_data() + } + + pub fn 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 { + let mut serialized = vec![]; + self.write(&mut serialized).unwrap(); + serialized + } + + pub fn read(r: &mut R) -> io::Result { + 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(Timelock, Vec); +impl Drop for Timelocked { + fn drop(&mut self) { + self.zeroize(); + } +} +impl ZeroizeOnDrop for Timelocked {} + +impl Timelocked { + 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 { + 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> { + // 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 { + self.1.clone() + } +} + +impl Scanner { + /// Scan a transaction to discover the received outputs. + pub fn scan_transaction(&mut self, tx: &Transaction) -> Timelocked { + // Only scan RCT TXs since we can only spend RCT outputs + if tx.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 { + let proofs = match &tx { + Transaction::V2 { proofs: Some(proofs), .. } => &proofs, + _ => return Timelocked(tx.prefix().timelock, vec![]), + }; + + commitment = match proofs.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()) != proofs.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( + &mut self, + rpc: &impl Rpc, + block: &Block, + ) -> Result>, 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, 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.version() == 2) && matches!(tx.prefix().inputs.first(), Some(Input::Gen(..))); + is_v2_miner_tx || output.amount.is_none() + }) + .count(), + ) + .unwrap() + } + Ok(res) + } +} diff --git a/coins/monero/wallet/src/tests/mod.rs b/coins/monero/wallet/src/tests/mod.rs index 2662bbdd..f2b9d4d6 100644 --- a/coins/monero/wallet/src/tests/mod.rs +++ b/coins/monero/wallet/src/tests/mod.rs @@ -1,3 +1,2 @@ mod address; -mod seed; mod extra; diff --git a/coins/monero/wallet/src/tests/seed.rs b/coins/monero/wallet/src/tests/seed.rs deleted file mode 100644 index a9acbe0a..00000000 --- a/coins/monero/wallet/src/tests/seed.rs +++ /dev/null @@ -1,485 +0,0 @@ -use zeroize::Zeroizing; - -use rand_core::OsRng; - -use curve25519_dalek::scalar::Scalar; - -use monero_serai::primitives::keccak256; - -use crate::seed::{Seed, SeedType, SeedError, classic, 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 { - fn trim_by_lang(word: &str, lang: Language) -> String { - if lang != Language::DeprecatedEnglish { - word.chars().take(LANGUAGES()[&lang].unique_prefix_length).collect() - } else { - word.to_string() - } - } - - let trim_seed = |seed: &str| { - seed - .split_whitespace() - .map(|word| trim_by_lang(word, vector.language)) - .collect::>() - .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::::from(Scalar::from_canonical_bytes(*seed.entropy())), - Option::::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::()) - .collect::>() - .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::() - }) - .collect::>() - .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)); -} diff --git a/coins/monero/wallet/tests/decoys.rs b/coins/monero/wallet/tests/decoys.rs index e5103646..8964cce7 100644 --- a/coins/monero/wallet/tests/decoys.rs +++ b/coins/monero/wallet/tests/decoys.rs @@ -1,6 +1,7 @@ use monero_simple_request_rpc::SimpleRequestRpc; use monero_wallet::{ - monero::{transaction::Transaction, DEFAULT_LOCK_WINDOW}, + DEFAULT_LOCK_WINDOW, + transaction::Transaction, rpc::{OutputResponse, Rpc}, SpendableOutput, }; diff --git a/coins/monero/wallet/tests/eventuality.rs b/coins/monero/wallet/tests/eventuality.rs index d73c2665..5e6e1930 100644 --- a/coins/monero/wallet/tests/eventuality.rs +++ b/coins/monero/wallet/tests/eventuality.rs @@ -61,16 +61,19 @@ test!( }, |_, mut tx: Transaction, _, eventuality: Eventuality| async move { // 4 explicitly outputs added and one change output - assert_eq!(tx.prefix.outputs.len(), 5); + 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()); + assert_eq!(tx.prefix().extra, eventuality.extra()); // The TX should match assert!(eventuality.matches(&tx)); // Mutate the TX - tx.proofs.base.commitments[0] += ED25519_BASEPOINT_POINT; + let Transaction::V2 { proofs: Some(ref mut proofs), .. } = tx else { + panic!("TX wasn't RingCT") + }; + proofs.base.commitments[0] += ED25519_BASEPOINT_POINT; // Verify it no longer matches assert!(!eventuality.matches(&tx)); }, diff --git a/coins/monero/wallet/tests/runner.rs b/coins/monero/wallet/tests/runner.rs index bb0eeea4..83c89581 100644 --- a/coins/monero/wallet/tests/runner.rs +++ b/coins/monero/wallet/tests/runner.rs @@ -10,7 +10,7 @@ use tokio::sync::Mutex; use monero_simple_request_rpc::SimpleRequestRpc; use monero_wallet::{ - monero::transaction::Transaction, + transaction::Transaction, rpc::Rpc, ViewPair, Scanner, address::{Network, AddressType, AddressSpec, AddressMeta, MoneroAddress}, @@ -80,7 +80,8 @@ pub async fn get_miner_tx_output(rpc: &SimpleRequestRpc, view: &ViewPair) -> Spe /// Make sure the weight and fee match the expected calculation. pub fn check_weight_and_fee(tx: &Transaction, fee_rate: FeeRate) { - let fee = tx.proofs.base.fee; + let Transaction::V2 { proofs: Some(ref proofs), .. } = tx else { panic!("TX wasn't RingCT") }; + let fee = proofs.base.fee; let weight = tx.weight(); let expected_weight = fee_rate.calculate_weight_from_fee(fee); diff --git a/coins/monero/wallet/tests/send.rs b/coins/monero/wallet/tests/send.rs index 51c5f081..c700d52f 100644 --- a/coins/monero/wallet/tests/send.rs +++ b/coins/monero/wallet/tests/send.rs @@ -2,7 +2,7 @@ use rand_core::OsRng; use monero_simple_request_rpc::SimpleRequestRpc; use monero_wallet::{ - monero::transaction::Transaction, Protocol, rpc::Rpc, extra::Extra, address::SubaddressIndex, + transaction::Transaction, Protocol, rpc::Rpc, extra::Extra, address::SubaddressIndex, ReceivedOutput, SpendableOutput, DecoySelection, Decoys, SignableTransactionBuilder, }; @@ -136,7 +136,7 @@ test!( 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()) + assert!(Extra::read::<&[u8]>(&mut tx.prefix().extra.as_ref()) .unwrap() .keys() .unwrap() @@ -306,7 +306,8 @@ test!( assert_eq!(outputs[1].commitment().amount, 50000); // The remainder should get shunted to fee, which is fingerprintable - assert_eq!(tx.proofs.base.fee, 1000000000000 - 10000 - 50000); + let Transaction::V2 { proofs: Some(ref proofs), .. } = tx else { panic!("TX wasn't RingCT") }; + assert_eq!(proofs.base.fee, 1000000000000 - 10000 - 50000); }, ), ); diff --git a/coins/monero/wallet/tests/wallet2_compatibility.rs b/coins/monero/wallet/tests/wallet2_compatibility.rs index 019f1ec8..c4b71317 100644 --- a/coins/monero/wallet/tests/wallet2_compatibility.rs +++ b/coins/monero/wallet/tests/wallet2_compatibility.rs @@ -7,7 +7,7 @@ use serde_json::json; use monero_simple_request_rpc::SimpleRequestRpc; use monero_wallet::{ - monero::transaction::Transaction, + transaction::Transaction, rpc::Rpc, address::{Network, AddressSpec, SubaddressIndex, MoneroAddress}, extra::{MAX_TX_EXTRA_NONCE_SIZE, Extra, PaymentId}, @@ -226,7 +226,7 @@ test!( 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()) + assert!(Extra::read::<&[u8]>(&mut tx.prefix().extra.as_ref()) .unwrap() .keys() .unwrap() @@ -285,7 +285,7 @@ test!( // 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(); + Extra::read::<&[u8]>(&mut tx.prefix().extra.as_ref()).unwrap().keys().unwrap().1.unwrap(); assert_eq!(keys.len(), 3); }, diff --git a/coins/monero/wallet/util/Cargo.toml b/coins/monero/wallet/util/Cargo.toml new file mode 100644 index 00000000..bedc8aec --- /dev/null +++ b/coins/monero/wallet/util/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "monero-wallet-util" +version = "0.1.0" +description = "Additional utility functions for monero-wallet" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/wallet/util" +authors = ["Luke Parker "] +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 } + +thiserror = { version = "1", default-features = false, optional = true } + +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } +rand_core = { version = "0.6", default-features = false } + +monero-wallet = { path = "..", default-features = false } + +monero-seed = { path = "../seed", default-features = false } +polyseed = { path = "../polyseed", default-features = false } + +[dev-dependencies] +hex = { version = "0.4", default-features = false, features = ["std"] } +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } + +[features] +std = [ + "std-shims/std", + + "thiserror", + + "zeroize/std", + "rand_core/std", + + "monero-wallet/std", + + "monero-seed/std", + "polyseed/std", +] +compile-time-generators = ["monero-wallet/compile-time-generators"] +multisig = ["monero-wallet/multisig", "std"] +default = ["std", "compile-time-generators"] diff --git a/coins/monero/wallet/util/LICENSE b/coins/monero/wallet/util/LICENSE new file mode 100644 index 00000000..91d893c1 --- /dev/null +++ b/coins/monero/wallet/util/LICENSE @@ -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. diff --git a/coins/monero/wallet/util/README.md b/coins/monero/wallet/util/README.md index a8b35150..84761cfb 100644 --- a/coins/monero/wallet/util/README.md +++ b/coins/monero/wallet/util/README.md @@ -6,6 +6,9 @@ This library is isolated as it adds a notable amount of dependencies to the tree, and to be a subject to a distinct versioning policy. This library may more frequently undergo breaking API changes. +This library is usable under no-std when the `std` feature (on by default) is +disabled. + ### Features - Support for Monero's seed algorithm diff --git a/coins/monero/wallet/util/src/lib.rs b/coins/monero/wallet/util/src/lib.rs index 7c01bc6f..e2aaa696 100644 --- a/coins/monero/wallet/util/src/lib.rs +++ b/coins/monero/wallet/util/src/lib.rs @@ -1,2 +1,9 @@ -pub use monero_seed as seed; -pub use monero_polyseed as seed; +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + +pub use monero_wallet::*; + +/// Seed creation and parsing functionality. +pub mod seed; diff --git a/coins/monero/wallet/util/src/seed.rs b/coins/monero/wallet/util/src/seed.rs new file mode 100644 index 00000000..77ca3358 --- /dev/null +++ b/coins/monero/wallet/util/src/seed.rs @@ -0,0 +1,150 @@ +use core::fmt; +use std_shims::string::String; + +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; +use rand_core::{RngCore, CryptoRng}; + +pub use monero_seed as original; +pub use polyseed; + +use original::{SeedError as OriginalSeedError, Seed as OriginalSeed}; +use polyseed::{PolyseedError, Polyseed}; + +/// An error from working with seeds. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] +pub enum SeedError { + /// The seed was invalid. + #[cfg_attr(feature = "std", error("invalid seed"))] + InvalidSeed, + /// The entropy was invalid. + #[cfg_attr(feature = "std", error("invalid entropy"))] + InvalidEntropy, + /// The checksum did not match the data. + #[cfg_attr(feature = "std", error("invalid checksum"))] + InvalidChecksum, + /// Unsupported features were enabled. + #[cfg_attr(feature = "std", error("unsupported features"))] + UnsupportedFeatures, +} + +impl From for SeedError { + fn from(error: OriginalSeedError) -> SeedError { + match error { + OriginalSeedError::DeprecatedEnglishWithChecksum | OriginalSeedError::InvalidChecksum => { + SeedError::InvalidChecksum + } + OriginalSeedError::InvalidSeed => SeedError::InvalidSeed, + } + } +} + +impl From for SeedError { + fn from(error: PolyseedError) -> SeedError { + match error { + PolyseedError::UnsupportedFeatures => SeedError::UnsupportedFeatures, + PolyseedError::InvalidEntropy => SeedError::InvalidEntropy, + PolyseedError::InvalidSeed => SeedError::InvalidSeed, + PolyseedError::InvalidChecksum => SeedError::InvalidChecksum, + } + } +} + +/// The type of the seed. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SeedType { + /// The seed format originally used by Monero, + Original(monero_seed::Language), + /// Polyseed. + Polyseed(polyseed::Language), +} + +/// A seed, internally either the original format or a Polyseed. +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub enum Seed { + /// The originally formatted seed. + Original(OriginalSeed), + /// A Polyseed. + Polyseed(Polyseed), +} + +impl fmt::Debug for Seed { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Seed::Original(_) => f.debug_struct("Seed::Original").finish_non_exhaustive(), + Seed::Polyseed(_) => f.debug_struct("Seed::Polyseed").finish_non_exhaustive(), + } + } +} + +impl Seed { + /// Create a new seed. + pub fn new(rng: &mut R, seed_type: SeedType) -> Seed { + match seed_type { + SeedType::Original(lang) => Seed::Original(OriginalSeed::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) -> Result { + match seed_type { + SeedType::Original(lang) => Ok(OriginalSeed::from_string(lang, words).map(Seed::Original)?), + SeedType::Polyseed(lang) => Ok(Polyseed::from_string(lang, words).map(Seed::Polyseed)?), + } + } + + /// Create a seed from entropy. + /// + /// A birthday may be optionally provided, denoted in seconds since the epoch. For + /// SeedType::Original, it will be ignored. For SeedType::Polyseed, it'll be embedded into the + /// seed. + /// + /// 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, + ) -> Option { + match seed_type { + SeedType::Original(lang) => OriginalSeed::from_entropy(lang, entropy).map(Seed::Original), + SeedType::Polyseed(lang) => { + Polyseed::from(lang, 0, birthday.unwrap_or(0), entropy).ok().map(Seed::Polyseed) + } + } + } + + /// Converts the seed to a string. + pub fn to_string(&self) -> Zeroizing { + match self { + Seed::Original(seed) => seed.to_string(), + Seed::Polyseed(seed) => seed.to_string(), + } + } + + /// Get the entropy for this seed. + pub fn entropy(&self) -> Zeroizing<[u8; 32]> { + match self { + Seed::Original(seed) => seed.entropy(), + Seed::Polyseed(seed) => seed.entropy().clone(), + } + } + + /// Get the key derived from this seed. + pub fn key(&self) -> Zeroizing<[u8; 32]> { + match self { + // Original does not differentiate between its entropy and its key + Seed::Original(seed) => seed.entropy(), + Seed::Polyseed(seed) => seed.key(), + } + } + + /// Get the birthday of this seed, denoted in seconds since the epoch. + pub fn birthday(&self) -> u64 { + match self { + Seed::Original(_) => 0, + Seed::Polyseed(seed) => seed.birthday(), + } + } +} diff --git a/processor/src/networks/monero.rs b/processor/src/networks/monero.rs index fb5393ad..626396ce 100644 --- a/processor/src/networks/monero.rs +++ b/processor/src/networks/monero.rs @@ -15,7 +15,8 @@ use frost::{curve::Ed25519, ThresholdKeys}; use monero_simple_request_rpc::SimpleRequestRpc; use monero_wallet::{ - monero::{ringct::RctType, transaction::Transaction, block::Block}, + transaction::Transaction, + block::Block, Protocol, rpc::{RpcError, Rpc}, ViewPair, Scanner, @@ -118,7 +119,10 @@ impl TransactionTrait for Transaction { #[cfg(test)] async fn fee(&self, _: &Monero) -> u64 { - self.rct_signatures.base.fee + match self { + Transaction::V1 { .. } => panic!("v1 TX in test-only function"), + Transaction::V2 { ref proofs, .. } => proofs.as_ref().unwrap().base.fee, + } } } @@ -282,14 +286,11 @@ impl Monero { let tx = self.rpc.get_transaction(*tx_hash).await.map_err(|_| NetworkError::ConnectionError)?; // Only consider fees from RCT transactions, else the fee property read wouldn't be accurate - if tx.rct_signatures.rct_type() != RctType::Null { - continue; - } - // This isn't entirely accurate as Bulletproof TXs will have a higher weight than their - // serialization length - // It's likely 'good enough' - // TODO2: Improve - fees.push(tx.rct_signatures.base.fee / u64::try_from(tx.serialize().len()).unwrap()); + let fee = match &tx { + Transaction::V2 { proofs: Some(proofs), .. } => proofs.base.fee, + _ => continue, + }; + fees.push(fee / u64::try_from(tx.weight()).unwrap()); } fees.sort(); let fee = fees.get(fees.len() / 2).copied().unwrap_or(0); @@ -317,7 +318,7 @@ impl Monero { // Get the protocol for the specified block number #[cfg(not(test))] - let protocol = Protocol::try_from(block_for_fee.header.major_version) + let protocol = Protocol::try_from(block_for_fee.header.hardfork_version) .map_err(|()| NetworkError::ConnectionError)?; // If this is a test, we won't be using a mainnet node and need a distinct protocol // determination @@ -576,10 +577,10 @@ impl Network for Monero { tx.unwrap() }; - if let Some((_, eventuality)) = eventualities.map.get(&tx.prefix.extra) { + if let Some((_, eventuality)) = eventualities.map.get(&tx.prefix().extra) { if eventuality.matches(&tx) { res.insert( - eventualities.map.remove(&tx.prefix.extra).unwrap().0, + eventualities.map.remove(&tx.prefix().extra).unwrap().0, (usize::try_from(block.number().unwrap()).unwrap(), tx.id(), tx), ); } diff --git a/tests/full-stack/src/tests/mint_and_burn.rs b/tests/full-stack/src/tests/mint_and_burn.rs index 0d4cbbbd..be9224ac 100644 --- a/tests/full-stack/src/tests/mint_and_burn.rs +++ b/tests/full-stack/src/tests/mint_and_burn.rs @@ -349,7 +349,8 @@ async fn mint_and_burn_test() { { use curve25519_dalek::{constants::ED25519_BASEPOINT_POINT, scalar::Scalar}; use monero_wallet::{ - monero::{io::decompress_point, transaction::Timelock}, + io::decompress_point, + transaction::Timelock, Protocol, rpc::Rpc, ViewPair, Scanner, DecoySelection, Decoys, Change, FeePriority, SignableTransaction, @@ -582,7 +583,7 @@ async fn mint_and_burn_test() { // Verify the received Monero TX { - use monero_wallet::{rpc::Rpc, ViewPair, Scanner}; + use monero_wallet::{transaction::Transaction, rpc::Rpc, ViewPair, Scanner}; let rpc = handles[0].monero(&ops).await; let mut scanner = Scanner::from_view( ViewPair::new(monero_spend, Zeroizing::new(monero_view)), @@ -603,7 +604,10 @@ async fn mint_and_burn_test() { assert_eq!(block.txs.len(), 1); let tx = rpc.get_transaction(block.txs[0]).await.unwrap(); - let tx_fee = tx.rct_signatures.base.fee; + let tx_fee = match &tx { + Transaction::V2 { proofs: Some(proofs), .. } => proofs.base.fee, + _ => panic!("fetched TX wasn't a signed V2 TX"), + }; assert_eq!(outputs[0].commitment().amount, 1_000_000_000_000 - tx_fee); found = true; diff --git a/tests/no-std/Cargo.toml b/tests/no-std/Cargo.toml index 64f641ed..59015d4d 100644 --- a/tests/no-std/Cargo.toml +++ b/tests/no-std/Cargo.toml @@ -35,14 +35,4 @@ dkg = { path = "../../crypto/dkg", default-features = false } bitcoin-serai = { path = "../../coins/bitcoin", default-features = false, features = ["hazmat"] } -monero-io = { path = "../../coins/monero/io", default-features = false } -monero-generators = { path = "../../coins/monero/generators", default-features = false } -monero-primitives = { path = "../../coins/monero/primitives", default-features = false } -monero-mlsag = { path = "../../coins/monero/ringct/mlsag", default-features = false } -monero-clsag = { path = "../../coins/monero/ringct/clsag", default-features = false } -monero-bulletproofs = { path = "../../coins/monero/ringct/bulletproofs", default-features = false } -monero-serai = { path = "../../coins/monero", default-features = false } -monero-rpc = { path = "../../coins/monero/rpc", default-features = false } -monero-seed = { path = "../../coins/monero/wallet/seed", default-features = false } -monero-polyseed = { path = "../../coins/monero/wallet/polyseed", default-features = false } -monero-wallet = { path = "../../coins/monero/wallet", default-features = false } +monero-wallet-util = { path = "../../coins/monero/wallet/util", default-features = false, features = ["compile-time-generators"] } diff --git a/tests/no-std/src/lib.rs b/tests/no-std/src/lib.rs index dd4c534b..fa9da268 100644 --- a/tests/no-std/src/lib.rs +++ b/tests/no-std/src/lib.rs @@ -20,12 +20,4 @@ pub use frost_schnorrkel; pub use bitcoin_serai; -pub use monero_io; -pub use monero_generators; -pub use monero_primitives; -pub use monero_mlsag; -pub use monero_clsag; -pub use monero_bulletproofs; -pub use monero_serai; -pub use monero_rpc; -pub use monero_wallet; +pub use monero_wallet_util; diff --git a/tests/processor/src/lib.rs b/tests/processor/src/lib.rs index 0399b043..431b5609 100644 --- a/tests/processor/src/lib.rs +++ b/tests/processor/src/lib.rs @@ -579,7 +579,7 @@ impl Coordinator { } NetworkId::Monero => { use monero_simple_request_rpc::SimpleRequestRpc; - use monero_wallet::{rpc::Rpc, monero::transaction::Transaction}; + use monero_wallet::{transaction::Transaction, rpc::Rpc}; let rpc = SimpleRequestRpc::new(rpc_url) .await diff --git a/tests/processor/src/networks.rs b/tests/processor/src/networks.rs index 2916315a..2bb23067 100644 --- a/tests/processor/src/networks.rs +++ b/tests/processor/src/networks.rs @@ -437,8 +437,8 @@ impl Wallet { use curve25519_dalek::constants::ED25519_BASEPOINT_POINT; use monero_simple_request_rpc::SimpleRequestRpc; use monero_wallet::{ + io::decompress_point, rpc::Rpc, - monero::io::decompress_point, Protocol, address::{Network, AddressType, AddressMeta, Address}, SpendableOutput, DecoySelection, Decoys, Change, FeePriority, Scanner,