feat: Recover from / write down seed (#439)

* vendor: seed crate from monero-serai

* refactor: make approval type generic over respone from frontend

* progress

* Raphaels Progress

* feat(gui): seed import flow skeleton

* fix(gui): Seed import in the development version

* fix(gui): specify the imported seed type

* remove initializeHandle, make tauri handle non optional in state, dont allow closing seed dialog

* feat(gui): check if seed is valid dynamically

* fix(gui): refine the dynamic seed validation

* push

* progress

* progress

* Fix pending trimeout

---------

Co-authored-by: Maksim Kirillov <artist@eduroam-141-23-183-184.wlan.tu-berlin.de>
Co-authored-by: Maksim Kirillov <artist@eduroam-141-23-189-144.wlan.tu-berlin.de>
Co-authored-by: Maksim Kirillov <maksim.kirillov@staticlabs.de>
This commit is contained in:
Mohan 2025-07-02 14:01:56 +02:00 committed by GitHub
parent b8982b5ac2
commit 7606982de3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 22465 additions and 171 deletions

79
Cargo.lock generated
View file

@ -2130,6 +2130,8 @@ dependencies = [
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest 0.10.7", "digest 0.10.7",
"fiat-crypto", "fiat-crypto",
"group",
"rand_core 0.6.4",
"rustc_version", "rustc_version",
"subtle", "subtle",
"zeroize", "zeroize",
@ -2219,6 +2221,22 @@ dependencies = [
"syn 2.0.104", "syn 2.0.104",
] ]
[[package]]
name = "dalek-ff-group"
version = "0.4.1"
source = "git+https://github.com/serai-dex/serai#dc1b8dfccd68b7c2eb4359a1e37b55ce5e4453b5"
dependencies = [
"crypto-bigint",
"curve25519-dalek 4.1.3",
"digest 0.10.7",
"ff",
"group",
"rand_core 0.6.4",
"rustversion",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.13.4" version = "0.13.4"
@ -3147,6 +3165,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [ dependencies = [
"bitvec",
"rand_core 0.6.4", "rand_core 0.6.4",
"subtle", "subtle",
] ]
@ -5929,6 +5948,20 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "monero-generators"
version = "0.4.0"
source = "git+https://github.com/serai-dex/serai#dc1b8dfccd68b7c2eb4359a1e37b55ce5e4453b5"
dependencies = [
"curve25519-dalek 4.1.3",
"dalek-ff-group",
"group",
"monero-io",
"sha3",
"std-shims",
"subtle",
]
[[package]] [[package]]
name = "monero-harness" name = "monero-harness"
version = "0.1.0" version = "0.1.0"
@ -5946,6 +5979,28 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "monero-io"
version = "0.1.0"
source = "git+https://github.com/serai-dex/serai#dc1b8dfccd68b7c2eb4359a1e37b55ce5e4453b5"
dependencies = [
"curve25519-dalek 4.1.3",
"std-shims",
]
[[package]]
name = "monero-primitives"
version = "0.1.0"
source = "git+https://github.com/serai-dex/serai#dc1b8dfccd68b7c2eb4359a1e37b55ce5e4453b5"
dependencies = [
"curve25519-dalek 4.1.3",
"monero-generators",
"monero-io",
"sha3",
"std-shims",
"zeroize",
]
[[package]] [[package]]
name = "monero-rpc" name = "monero-rpc"
version = "0.1.0" version = "0.1.0"
@ -5994,6 +6049,19 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "monero-seed"
version = "0.1.0"
dependencies = [
"curve25519-dalek 4.1.3",
"hex",
"monero-primitives",
"rand_core 0.6.4",
"std-shims",
"thiserror 1.0.69",
"zeroize",
]
[[package]] [[package]]
name = "monero-sys" name = "monero-sys"
version = "0.1.0" version = "0.1.0"
@ -9565,6 +9633,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "std-shims"
version = "0.1.1"
source = "git+https://github.com/serai-dex/serai#dc1b8dfccd68b7c2eb4359a1e37b55ce5e4453b5"
dependencies = [
"hashbrown 0.14.5",
"spin 0.9.8",
]
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.8.9" version = "0.8.9"
@ -9741,6 +9818,7 @@ dependencies = [
"monero-harness", "monero-harness",
"monero-rpc", "monero-rpc",
"monero-rpc-pool", "monero-rpc-pool",
"monero-seed",
"monero-sys", "monero-sys",
"once_cell", "once_cell",
"pem", "pem",
@ -9786,6 +9864,7 @@ dependencies = [
"uuid", "uuid",
"vergen", "vergen",
"void", "void",
"zeroize",
"zip 0.5.13", "zip 0.5.13",
] ]

View file

@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["electrum-pool", "monero-rpc", "monero-rpc-pool", "monero-sys", "src-tauri", "swap"] members = ["electrum-pool", "monero-rpc", "monero-rpc-pool", "monero-sys", "seed", "src-tauri", "swap"]
[patch.crates-io] [patch.crates-io]
# patch until new release https://github.com/thomaseizinger/rust-jsonrpc-client/pull/51 # patch until new release https://github.com/thomaseizinger/rust-jsonrpc-client/pull/51
@ -11,3 +11,6 @@ monero = { git = "https://github.com/comit-network/monero-rs", rev = "818f38b" }
bdk_wallet = { git = "https://github.com/Einliterflasche/bdk", branch = "bump/rusqlite-0.32", package = "bdk_wallet" } bdk_wallet = { git = "https://github.com/Einliterflasche/bdk", branch = "bump/rusqlite-0.32", package = "bdk_wallet" }
bdk_electrum = { git = "https://github.com/Einliterflasche/bdk", branch = "bump/rusqlite-0.32", package = "bdk_electrum" } bdk_electrum = { git = "https://github.com/Einliterflasche/bdk", branch = "bump/rusqlite-0.32", package = "bdk_electrum" }
bdk_chain = { git = "https://github.com/Einliterflasche/bdk", branch = "bump/rusqlite-0.32", package = "bdk_chain" } bdk_chain = { git = "https://github.com/Einliterflasche/bdk", branch = "bump/rusqlite-0.32", package = "bdk_chain" }
[workspace.lints]
rust.unused_crate_dependencies = "warn"

41
seed/Cargo.toml Normal file
View file

@ -0,0 +1,41 @@
[package]
name = "monero-seed"
version = "0.1.0"
authors = ["Luke Parker <lukeparker5132@gmail.com>"]
edition = "2021"
license = "MIT"
repository = "https://github.com/kayabaNerve/monero-wallet-util/tree/develop/seed"
rust-version = "1.80"
description = "Rust implementation of Monero's seed algorithm"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[lints]
workspace = true
[dependencies]
std-shims = { git = "https://github.com/serai-dex/serai", version = "^0.1.1", default-features = false }
thiserror = { version = "1", default-features = false, optional = true }
rand_core = { version = "0.6", default-features = false }
zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] }
curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] }
[dev-dependencies]
hex = { version = "0.4", default-features = false, features = ["std"] }
monero-primitives = { git = "https://github.com/serai-dex/serai", default-features = false, features = ["std"] }
[features]
std = [
"std-shims/std",
"thiserror",
"zeroize/std",
"rand_core/std",
]
default = ["std"]

21
seed/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022-2024 Luke Parker
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

11
seed/README.md Normal file
View file

@ -0,0 +1,11 @@
# Monero Seeds
Rust implementation of Monero's seed algorithm.
This library is usable under no-std when the `std` feature (on by default) is
disabled.
### Cargo Features
- `std` (on by default): Enables `std` (and with it, more efficient internal
implementations).

400
seed/src/lib.rs Normal file
View file

@ -0,0 +1,400 @@
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![cfg_attr(not(feature = "std"), no_std)]
use core::{fmt, ops::Deref};
use std_shims::{
collections::HashMap,
string::{String, ToString},
sync::LazyLock,
vec,
vec::Vec,
};
use rand_core::{CryptoRng, RngCore};
use zeroize::{Zeroize, Zeroizing};
use curve25519_dalek::scalar::Scalar;
#[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 {
#[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.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Zeroize)]
pub enum Language {
/// Chinese language option.
Chinese,
/// English language option.
English,
/// Dutch language option.
Dutch,
/// French language option.
French,
/// Spanish language option.
Spanish,
/// German language option.
German,
/// Italian language option.
Italian,
/// Portuguese language option.
Portuguese,
/// Japanese language option.
Japanese,
/// Russian language option.
Russian,
/// Esperanto language option.
Esperanto,
/// Lojban language option.
Lojban,
/// The original, and deprecated, English language.
DeprecatedEnglish,
}
fn trim(word: &str, len: usize) -> Zeroizing<String> {
Zeroizing::new(word.chars().take(len).collect())
}
struct WordList {
word_list: &'static [&'static str],
word_map: HashMap<&'static str, usize>,
trimmed_word_map: HashMap<String, usize>,
unique_prefix_length: usize,
}
impl WordList {
fn new(word_list: &'static [&'static str], prefix_length: usize) -> WordList {
let mut lang = WordList {
word_list,
word_map: HashMap::new(),
trimmed_word_map: HashMap::new(),
unique_prefix_length: prefix_length,
};
for (i, word) in lang.word_list.iter().enumerate() {
lang.word_map.insert(word, i);
lang.trimmed_word_map
.insert(trim(word, lang.unique_prefix_length).deref().clone(), i);
}
lang
}
}
static LANGUAGES: LazyLock<HashMap<Language, WordList>> = LazyLock::new(|| {
HashMap::from([
(
Language::Chinese,
WordList::new(include!("./words/zh.rs"), 1),
),
(
Language::English,
WordList::new(include!("./words/en.rs"), 3),
),
(Language::Dutch, WordList::new(include!("./words/nl.rs"), 4)),
(
Language::French,
WordList::new(include!("./words/fr.rs"), 4),
),
(
Language::Spanish,
WordList::new(include!("./words/es.rs"), 4),
),
(
Language::German,
WordList::new(include!("./words/de.rs"), 4),
),
(
Language::Italian,
WordList::new(include!("./words/it.rs"), 4),
),
(
Language::Portuguese,
WordList::new(include!("./words/pt.rs"), 4),
),
(
Language::Japanese,
WordList::new(include!("./words/ja.rs"), 3),
),
(
Language::Russian,
WordList::new(include!("./words/ru.rs"), 4),
),
(
Language::Esperanto,
WordList::new(include!("./words/eo.rs"), 4),
),
(
Language::Lojban,
WordList::new(include!("./words/jbo.rs"), 4),
),
(
Language::DeprecatedEnglish,
WordList::new(include!("./words/ang.rs"), 4),
),
])
});
fn checksum_index(words: &[Zeroizing<String>], lang: &WordList) -> usize {
let mut trimmed_words = Zeroizing::new(String::new());
for w in words {
*trimmed_words += &trim(w, lang.unique_prefix_length);
}
const fn crc32_table() -> [u32; 256] {
let poly = 0xedb88320u32;
let mut res = [0; 256];
let mut i = 0;
while i < 256 {
let mut entry = i;
let mut b = 0;
while b < 8 {
let trigger = entry & 1;
entry >>= 1;
if trigger == 1 {
entry ^= poly;
}
b += 1;
}
res[i as usize] = entry;
i += 1;
}
res
}
const CRC32_TABLE: [u32; 256] = crc32_table();
let trimmed_words = trimmed_words.as_bytes();
let mut checksum = u32::MAX;
for i in 0..trimmed_words.len() {
checksum = CRC32_TABLE
[usize::from(u8::try_from(checksum % 256).unwrap() ^ trimmed_words[i])]
^ (checksum >> 8);
}
usize::try_from(!checksum).unwrap() % words.len()
}
// Convert a private key to a seed
#[allow(clippy::needless_pass_by_value)]
fn key_to_seed(lang: Language, key: Zeroizing<Scalar>) -> Seed {
let bytes = Zeroizing::new(key.to_bytes());
// get the language words
let words = &LANGUAGES[&lang].word_list;
let list_len = u64::try_from(words.len()).unwrap();
// To store the found words & add the checksum word later.
let mut seed = Vec::with_capacity(25);
// convert to words
// 4 bytes -> 3 words. 8 digits base 16 -> 3 digits base 1626
let mut segment = [0; 4];
let mut indices = [0; 4];
for i in 0..8 {
// convert first 4 byte to u32 & get the word indices
let start = i * 4;
// convert 4 byte to u32
segment.copy_from_slice(&bytes[start..(start + 4)]);
// Actually convert to a u64 so we can add without overflowing
indices[0] = u64::from(u32::from_le_bytes(segment));
indices[1] = indices[0];
indices[0] /= list_len;
indices[2] = indices[0] + indices[1];
indices[0] /= list_len;
indices[3] = indices[0] + indices[2];
// append words to seed
for i in indices.iter().skip(1) {
let word = usize::try_from(i % list_len).unwrap();
seed.push(Zeroizing::new(words[word].to_string()));
}
}
segment.zeroize();
indices.zeroize();
// create a checksum word for all languages except old english
if lang != Language::DeprecatedEnglish {
let checksum = seed[checksum_index(&seed, &LANGUAGES[&lang])].clone();
seed.push(checksum);
}
let mut res = Zeroizing::new(String::new());
for (i, word) in seed.iter().enumerate() {
if i != 0 {
*res += " ";
}
*res += word;
}
Seed(lang, res)
}
// Convert a seed to bytes
fn seed_to_bytes(lang: Language, words: &str) -> Result<Zeroizing<[u8; 32]>, SeedError> {
// get seed words
let words = words
.split_whitespace()
.map(|w| Zeroizing::new(w.to_string()))
.collect::<Vec<_>>();
if (words.len() != SEED_LENGTH) && (words.len() != SEED_LENGTH_WITH_CHECKSUM) {
panic!("invalid seed passed to seed_to_bytes");
}
let has_checksum = words.len() == SEED_LENGTH_WITH_CHECKSUM;
if has_checksum && lang == Language::DeprecatedEnglish {
Err(SeedError::DeprecatedEnglishWithChecksum)?;
}
// Validate words are in the language word list
let lang_word_list: &WordList = &LANGUAGES[&lang];
let matched_indices = (|| {
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
for word in &words {
let trimmed = trim(word, lang_word_list.unique_prefix_length);
let word = if has_checksum { &trimmed } else { word };
if let Some(index) = if has_checksum {
lang_word_list.trimmed_word_map.get(word.deref())
} else {
lang_word_list.word_map.get(&word.as_str())
} {
matched_indices.push(*index);
} else {
Err(SeedError::InvalidSeed)?;
}
}
if has_checksum {
// exclude the last word when calculating a checksum.
let last_word = words.last().unwrap().clone();
let checksum = words[checksum_index(&words[..words.len() - 1], lang_word_list)].clone();
// check the trimmed checksum and trimmed last word line up
if trim(&checksum, lang_word_list.unique_prefix_length)
!= trim(&last_word, lang_word_list.unique_prefix_length)
{
Err(SeedError::InvalidChecksum)?;
}
}
Ok(matched_indices)
})()?;
// convert to bytes
let mut res = Zeroizing::new([0; 32]);
let mut indices = Zeroizing::new([0; 4]);
for i in 0..8 {
// read 3 indices at a time
let i3 = i * 3;
indices[1] = matched_indices[i3];
indices[2] = matched_indices[i3 + 1];
indices[3] = matched_indices[i3 + 2];
let inner = |i| {
let mut base = (lang_word_list.word_list.len() - indices[i] + indices[i + 1])
% lang_word_list.word_list.len();
// Shift the index over
for _ in 0..i {
base *= lang_word_list.word_list.len();
}
base
};
// set the last index
indices[0] = indices[1] + inner(1) + inner(2);
if (indices[0] % lang_word_list.word_list.len()) != indices[1] {
Err(SeedError::InvalidSeed)?;
}
let pos = i * 4;
let mut bytes = u32::try_from(indices[0]).unwrap().to_le_bytes();
res[pos..(pos + 4)].copy_from_slice(&bytes);
bytes.zeroize();
}
Ok(res)
}
/// A Monero seed.
#[derive(Clone, PartialEq, Eq, Zeroize)]
pub struct Seed(Language, Zeroizing<String>);
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<R: RngCore + CryptoRng>(rng: &mut R, lang: Language) -> Seed {
let mut scalar_bytes = Zeroizing::new([0; 64]);
rng.fill_bytes(scalar_bytes.as_mut());
key_to_seed(
lang,
Zeroizing::new(Scalar::from_bytes_mod_order_wide(scalar_bytes.deref())),
)
}
/// Parse a seed from a string.
#[allow(clippy::needless_pass_by_value)]
pub fn from_string(lang: Language, words: Zeroizing<String>) -> Result<Seed, SeedError> {
let entropy = seed_to_bytes(lang, &words)?;
// Make sure this is a valid scalar
let scalar = Scalar::from_canonical_bytes(*entropy);
if scalar.is_none().into() {
Err(SeedError::InvalidSeed)?;
}
let mut scalar = scalar.unwrap();
scalar.zeroize();
// Call from_entropy so a trimmed seed becomes a full seed
Ok(Self::from_entropy(lang, entropy).unwrap())
}
/// Create a seed from entropy.
#[allow(clippy::needless_pass_by_value)]
pub fn from_entropy(lang: Language, entropy: Zeroizing<[u8; 32]>) -> Option<Seed> {
Option::from(Scalar::from_canonical_bytes(*entropy))
.map(|scalar| key_to_seed(lang, Zeroizing::new(scalar)))
}
/// Convert a seed to a string.
pub fn to_string(&self) -> Zeroizing<String> {
self.1.clone()
}
/// Return the entropy underlying this seed.
pub fn entropy(&self) -> Zeroizing<[u8; 32]> {
seed_to_bytes(self.0, &self.1).unwrap()
}
}

257
seed/src/tests.rs Normal file
View file

@ -0,0 +1,257 @@
use rand_core::OsRng;
use zeroize::Zeroizing;
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::<Vec<_>>()
.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::<Scalar>::from(Scalar::from_canonical_bytes(*seed.entropy())),
Option::<Scalar>::from(Scalar::from_canonical_bytes(spend)),
);
let view: [u8; 32] = hex::decode(vector.view).unwrap().try_into().unwrap();
// Monero then derives the view key as H(spend)
assert_eq!(
Scalar::from_bytes_mod_order(keccak256(spend)),
Scalar::from_canonical_bytes(view).unwrap()
);
assert_eq!(
Seed::from_entropy(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()
);
}
}
}

1628
seed/src/words/ang.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/de.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/en.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/eo.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/es.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/fr.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/it.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/ja.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/jbo.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/nl.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/pt.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/ru.rs Normal file

File diff suppressed because it is too large Load diff

1628
seed/src/words/zh.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -253,27 +253,38 @@ export function isGetSwapInfoResponseWithTimelock(
return response.timelock !== null; return response.timelock !== null;
} }
export type PendingApprovalRequest = Extract< export type PendingApprovalRequest = ApprovalRequest & {
ApprovalRequest, content: Extract<ApprovalRequest["request_status"], { state: "Pending" }>;
{ state: "Pending" }
>;
export type PendingLockBitcoinApprovalRequest = PendingApprovalRequest & {
content: {
details: { type: "LockBitcoin" };
}; };
export type PendingLockBitcoinApprovalRequest = ApprovalRequest & {
request: Extract<ApprovalRequest["request"], { type: "LockBitcoin" }>;
content: Extract<ApprovalRequest["request_status"], { state: "Pending" }>;
};
export type PendingSeedSelectionApprovalRequest = ApprovalRequest & {
type: "SeedSelection";
content: Extract<ApprovalRequest["request_status"], { state: "Pending" }>;
}; };
export function isPendingLockBitcoinApprovalEvent( export function isPendingLockBitcoinApprovalEvent(
event: ApprovalRequest, event: ApprovalRequest,
): event is PendingLockBitcoinApprovalRequest { ): event is PendingLockBitcoinApprovalRequest {
// Check if the request is pending // Check if the request is a LockBitcoin request and is pending
if (event.state !== "Pending") { return (
return false; event.request.type === "LockBitcoin" &&
event.request_status.state === "Pending"
);
} }
// Check if the request is a LockBitcoin request export function isPendingSeedSelectionApprovalEvent(
return event.content.details.type === "LockBitcoin"; event: ApprovalRequest,
): event is PendingSeedSelectionApprovalRequest {
// Check if the request is a SeedSelection request and is pending
return (
event.request.type === "SeedSelection" &&
event.request_status.state === "Pending"
);
} }
export function isPendingBackgroundProcess( export function isPendingBackgroundProcess(

View file

@ -20,6 +20,7 @@ import { setupBackgroundTasks } from "renderer/background";
import "@fontsource/roboto"; import "@fontsource/roboto";
import FeedbackPage from "./pages/feedback/FeedbackPage"; import FeedbackPage from "./pages/feedback/FeedbackPage";
import IntroductionModal from "./modal/introduction/IntroductionModal"; import IntroductionModal from "./modal/introduction/IntroductionModal";
import SeedSelectionDialog from "./modal/seed-selection/SeedSelectionDialog";
declare module "@mui/material/styles" { declare module "@mui/material/styles" {
interface Theme { interface Theme {
@ -46,6 +47,7 @@ export default function App() {
<CssBaseline /> <CssBaseline />
<GlobalSnackbarProvider> <GlobalSnackbarProvider>
<IntroductionModal /> <IntroductionModal />
<SeedSelectionDialog />
<Router> <Router>
<Navigation /> <Navigation />
<InnerContent /> <InnerContent />

View file

@ -0,0 +1,121 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
Radio,
RadioGroup,
TextField,
Typography,
} from "@mui/material";
import { useState, useEffect } from "react";
import { usePendingSeedSelectionApproval } from "store/hooks";
import { resolveApproval, checkSeed } from "renderer/rpc";
export default function SeedSelectionDialog() {
const pendingApprovals = usePendingSeedSelectionApproval();
const [selectedOption, setSelectedOption] = useState<string>("RandomSeed");
const [customSeed, setCustomSeed] = useState<string>("");
const [isSeedValid, setIsSeedValid] = useState<boolean>(false);
const approval = pendingApprovals[0]; // Handle the first pending approval
useEffect(() => {
if (selectedOption === "FromSeed" && customSeed.trim()) {
checkSeed(customSeed.trim())
.then((valid) => {
setIsSeedValid(valid);
})
.catch(() => {
setIsSeedValid(false);
});
} else {
setIsSeedValid(false);
}
}, [customSeed, selectedOption]);
const handleClose = async (accept: boolean) => {
if (!approval) return;
if (accept) {
const seedChoice =
selectedOption === "RandomSeed"
? { type: "RandomSeed" }
: { type: "FromSeed", content: { seed: customSeed } };
await resolveApproval(approval.request_id, seedChoice);
} else {
// On reject, just close without approval
await resolveApproval(approval.request_id, { type: "RandomSeed" });
}
};
if (!approval) {
return null;
}
return (
<Dialog open={true} maxWidth="sm" fullWidth>
<DialogTitle>Monero Wallet</DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2 }}>
Choose what seed to use for the wallet.
</Typography>
<FormControl component="fieldset">
<RadioGroup
value={selectedOption}
onChange={(e) => setSelectedOption(e.target.value)}
>
<FormControlLabel
value="RandomSeed"
control={<Radio />}
label="Create a new wallet"
/>
<FormControlLabel
value="FromSeed"
control={<Radio />}
label="Restore wallet from seed"
/>
</RadioGroup>
</FormControl>
{selectedOption === "FromSeed" && (
<TextField
fullWidth
multiline
rows={3}
label="Enter your seed phrase"
value={customSeed}
onChange={(e) => setCustomSeed(e.target.value)}
sx={{ mt: 2 }}
placeholder="Enter your Monero 25 words seed phrase..."
error={!isSeedValid && customSeed.length > 0}
helperText={
isSeedValid
? "Seed is valid"
: customSeed.length > 0
? "Seed is invalid"
: ""
}
/>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => handleClose(true)}
variant="contained"
disabled={
selectedOption === "FromSeed"
? !customSeed.trim() || !isSeedValid
: false
}
>
Confirm
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -20,9 +20,7 @@ function useActiveLockBitcoinApprovalRequest(): PendingLockBitcoinApprovalReques
const activeSwapId = useActiveSwapId(); const activeSwapId = useActiveSwapId();
return ( return (
approvals?.find( approvals?.find((r) => r.request.content.swap_id === activeSwapId) || null
(r) => r.content.details.content.swap_id === activeSwapId,
) || null
); );
} }
@ -32,10 +30,18 @@ export default function SwapSetupInflightPage({
const request = useActiveLockBitcoinApprovalRequest(); const request = useActiveLockBitcoinApprovalRequest();
const [timeLeft, setTimeLeft] = useState<number>(0); const [timeLeft, setTimeLeft] = useState<number>(0);
const expirationTs =
const expiresAtMs = request?.content.expiration_ts * 1000 || 0; request?.request_status.state === "Pending"
? request.request_status.content.expiration_ts
: null;
useEffect(() => { useEffect(() => {
if (expirationTs == null) {
return;
}
const expiresAtMs = expirationTs * 1000 || 0;
const tick = () => { const tick = () => {
const remainingMs = Math.max(expiresAtMs - Date.now(), 0); const remainingMs = Math.max(expiresAtMs - Date.now(), 0);
setTimeLeft(Math.ceil(remainingMs / 1000)); setTimeLeft(Math.ceil(remainingMs / 1000));
@ -44,11 +50,11 @@ export default function SwapSetupInflightPage({
tick(); tick();
const id = setInterval(tick, 250); const id = setInterval(tick, 250);
return () => clearInterval(id); return () => clearInterval(id);
}, [expiresAtMs]); }, [request, expirationTs]);
// If we do not have an approval request yet for the Bitcoin lock transaction, we haven't received the offer from Alice yet // If we do not have an approval request yet for the Bitcoin lock transaction, we haven't received the offer from Alice yet
// Display a loading spinner to the user for as long as the swap_setup request is in flight // Display a loading spinner to the user for as long as the swap_setup request is in flight
if (!request) { if (request == null) {
return ( return (
<CircularProgressWithSubtitle <CircularProgressWithSubtitle
description={ description={
@ -61,7 +67,7 @@ export default function SwapSetupInflightPage({
} }
const { btc_network_fee, monero_receive_pool, xmr_receive_amount } = const { btc_network_fee, monero_receive_pool, xmr_receive_amount } =
request.content.details.content; request.request.content;
return ( return (
<Box <Box
@ -124,7 +130,9 @@ export default function SwapSetupInflightPage({
variant="text" variant="text"
size="large" size="large"
sx={(theme) => ({ color: theme.palette.text.secondary })} sx={(theme) => ({ color: theme.palette.text.secondary })}
onInvoke={() => resolveApproval(request.content.request_id, false)} onInvoke={() =>
resolveApproval(request.request_id, false as unknown as object)
}
displayErrorSnackbar displayErrorSnackbar
requiresContext requiresContext
> >
@ -135,7 +143,9 @@ export default function SwapSetupInflightPage({
variant="contained" variant="contained"
color="primary" color="primary"
size="large" size="large"
onInvoke={() => resolveApproval(request.content.request_id, true)} onInvoke={() =>
resolveApproval(request.request_id, true as unknown as object)
}
displayErrorSnackbar displayErrorSnackbar
requiresContext requiresContext
endIcon={<CheckIcon />} endIcon={<CheckIcon />}

View file

@ -17,6 +17,8 @@ import {
GetSwapInfoArgs, GetSwapInfoArgs,
ExportBitcoinWalletResponse, ExportBitcoinWalletResponse,
CheckMoneroNodeArgs, CheckMoneroNodeArgs,
CheckSeedArgs,
CheckSeedResponse,
CheckMoneroNodeResponse, CheckMoneroNodeResponse,
TauriSettings, TauriSettings,
CheckElectrumNodeArgs, CheckElectrumNodeArgs,
@ -304,10 +306,16 @@ export async function initializeContext() {
logger.info("Initializing context with settings", tauriSettings); logger.info("Initializing context with settings", tauriSettings);
try {
await invokeUnsafe<void>("initialize_context", { await invokeUnsafe<void>("initialize_context", {
settings: tauriSettings, settings: tauriSettings,
testnet, testnet,
}); });
} catch (error) {
throw new Error("Couldn't initialize context: " + error);
}
logger.info("Initialized context");
} }
export async function getWalletDescriptor() { export async function getWalletDescriptor() {
@ -395,7 +403,7 @@ export async function getDataDir(): Promise<string> {
export async function resolveApproval( export async function resolveApproval(
requestId: string, requestId: string,
accept: boolean, accept: object,
): Promise<void> { ): Promise<void> {
await invoke<ResolveApprovalArgs, ResolveApprovalResponse>( await invoke<ResolveApprovalArgs, ResolveApprovalResponse>(
"resolve_approval_request", "resolve_approval_request",
@ -403,6 +411,16 @@ export async function resolveApproval(
); );
} }
export async function checkSeed(seed: string): Promise<boolean> {
const response = await invoke<CheckSeedArgs, CheckSeedResponse>(
"check_seed",
{
seed,
},
);
return response.available;
}
export async function saveLogFiles( export async function saveLogFiles(
zipFileName: string, zipFileName: string,
content: Record<string, string>, content: Record<string, string>,

View file

@ -137,7 +137,7 @@ export const rpcSlice = createSlice({
}, },
approvalEventReceived(slice, action: PayloadAction<ApprovalRequest>) { approvalEventReceived(slice, action: PayloadAction<ApprovalRequest>) {
const event = action.payload; const event = action.payload;
const requestId = event.content.request_id; const requestId = event.request_id;
slice.state.approvalRequests[requestId] = event; slice.state.approvalRequests[requestId] = event;
}, },
backgroundProgressEventReceived( backgroundProgressEventReceived(

View file

@ -5,8 +5,10 @@ import {
isBitcoinSyncProgress, isBitcoinSyncProgress,
isPendingBackgroundProcess, isPendingBackgroundProcess,
isPendingLockBitcoinApprovalEvent, isPendingLockBitcoinApprovalEvent,
isPendingSeedSelectionApprovalEvent,
PendingApprovalRequest, PendingApprovalRequest,
PendingLockBitcoinApprovalRequest, PendingLockBitcoinApprovalRequest,
PendingSeedSelectionApprovalRequest,
} from "models/tauriModelExt"; } from "models/tauriModelExt";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "renderer/store/storeRenderer"; import type { AppDispatch, RootState } from "renderer/store/storeRenderer";
@ -155,7 +157,9 @@ export function useNodes<T>(selector: (nodes: NodesSlice) => T): T {
export function usePendingApprovals(): PendingApprovalRequest[] { export function usePendingApprovals(): PendingApprovalRequest[] {
const approvals = useAppSelector((state) => state.rpc.state.approvalRequests); const approvals = useAppSelector((state) => state.rpc.state.approvalRequests);
return Object.values(approvals).filter((c) => c.state === "Pending"); return Object.values(approvals).filter(
(c) => c.request_status.state === "Pending",
) as PendingApprovalRequest[];
} }
export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalRequest[] { export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalRequest[] {
@ -163,6 +167,11 @@ export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalReque
return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c)); return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c));
} }
export function usePendingSeedSelectionApproval(): PendingSeedSelectionApprovalRequest[] {
const approvals = usePendingApprovals();
return approvals.filter((c) => isPendingSeedSelectionApprovalEvent(c));
}
/// Returns all the pending background processes /// Returns all the pending background processes
/// In the format [id, {componentName, {type: "Pending", content: {consumed, total}}}] /// In the format [id, {componentName, {type: "Pending", content: {consumed, total}}}]
export function usePendingBackgroundProcesses(): [ export function usePendingBackgroundProcesses(): [

View file

@ -8,10 +8,10 @@ use swap::cli::{
data, data,
request::{ request::{
BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, CheckElectrumNodeArgs, BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, CheckElectrumNodeArgs,
CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckSeedArgs,
ExportBitcoinWalletArgs, GetDataDirArgs, GetHistoryArgs, GetLogsArgs, CheckSeedResponse, ExportBitcoinWalletArgs, GetDataDirArgs, GetHistoryArgs,
GetMoneroAddressesArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, GetLogsArgs, GetMoneroAddressesArgs, GetSwapInfoArgs, GetSwapInfosAllArgs,
MoneroRecoveryArgs, RedactArgs, ResolveApprovalArgs, ResumeSwapArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs, ResolveApprovalArgs, ResumeSwapArgs,
SuspendCurrentSwapArgs, WithdrawBtcArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
}, },
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings}, tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
@ -93,12 +93,16 @@ macro_rules! tauri_command {
/// Represents the shared Tauri state. It is accessed by Tauri commands /// Represents the shared Tauri state. It is accessed by Tauri commands
struct State { struct State {
pub context: Option<Arc<Context>>, pub context: Option<Arc<Context>>,
pub handle: TauriHandle,
} }
impl State { impl State {
/// Creates a new State instance with no Context /// Creates a new State instance with no Context
fn new() -> Self { fn new(handle: TauriHandle) -> Self {
Self { context: None } Self {
context: None,
handle,
}
} }
/// Sets the context for the application state /// Sets the context for the application state
@ -141,7 +145,8 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
// We need to set a value for the Tauri state right at the start // We need to set a value for the Tauri state right at the start
// If we don't do this, Tauri commands will panic at runtime if no value is present // If we don't do this, Tauri commands will panic at runtime if no value is present
let state = RwLock::new(State::new()); let handle = TauriHandle::new(app_handle.clone());
let state = RwLock::new(State::new(handle));
app_handle.manage::<RwLock<State>>(state); app_handle.manage::<RwLock<State>>(state);
Ok(()) Ok(())
@ -194,6 +199,7 @@ pub fn run() {
resolve_approval_request, resolve_approval_request,
redact, redact,
save_txt_files, save_txt_files,
check_seed,
]) ])
.setup(setup) .setup(setup)
.build(tauri::generate_context!()) .build(tauri::generate_context!())
@ -234,7 +240,6 @@ tauri_command!(monero_recovery, MoneroRecoveryArgs);
tauri_command!(get_logs, GetLogsArgs); tauri_command!(get_logs, GetLogsArgs);
tauri_command!(list_sellers, ListSellersArgs); tauri_command!(list_sellers, ListSellersArgs);
tauri_command!(cancel_and_refund, CancelAndRefundArgs); tauri_command!(cancel_and_refund, CancelAndRefundArgs);
tauri_command!(resolve_approval_request, ResolveApprovalArgs);
tauri_command!(redact, RedactArgs); tauri_command!(redact, RedactArgs);
// These commands require no arguments // These commands require no arguments
@ -268,6 +273,14 @@ async fn check_electrum_node(
args.request().await.to_string_result() args.request().await.to_string_result()
} }
#[tauri::command]
async fn check_seed(
args: CheckSeedArgs,
_: tauri::State<'_, RwLock<State>>,
) -> Result<CheckSeedResponse, String> {
args.request().await.to_string_result()
}
// Returns the data directory // Returns the data directory
// This is independent of the context to ensure the user can open the directory even if the context cannot // This is independent of the context to ensure the user can open the directory even if the context cannot
// be initialized (for troubleshooting purposes) // be initialized (for troubleshooting purposes)
@ -327,6 +340,23 @@ async fn save_txt_files(
Ok(()) Ok(())
} }
#[tauri::command]
async fn resolve_approval_request(
args: ResolveApprovalArgs,
state: tauri::State<'_, RwLock<State>>,
) -> Result<(), String> {
println!("Resolving approval request");
let lock = state.read().await;
lock.handle
.resolve_approval(args.request_id.parse().unwrap(), args.accept)
.await
.to_string_result()?;
println!("Resolved approval request");
Ok(())
}
/// Tauri command to initialize the Context /// Tauri command to initialize the Context
#[tauri::command] #[tauri::command]
async fn initialize_context( async fn initialize_context(
@ -361,14 +391,13 @@ async fn initialize_context(
} }
} }
// Acquire a write lock on the state
let mut state_write_lock = state
.try_write()
.context("Context is already being initialized")
.to_string_result()?;
// Get app handle and create a Tauri handle // Get app handle and create a Tauri handle
let tauri_handle = TauriHandle::new(app_handle.clone()); let tauri_handle = state
.try_read()
.context("Context is already being initialized")
.to_string_result()?
.handle
.clone();
// Notify frontend that the context is being initialized // Notify frontend that the context is being initialized
tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Initializing); tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Initializing);
@ -388,7 +417,11 @@ async fn initialize_context(
match context_result { match context_result {
Ok(context_instance) => { Ok(context_instance) => {
state_write_lock.set_context(Arc::new(context_instance)); state
.try_write()
.context("Context is already being initialized")
.to_string_result()?
.set_context(Arc::new(context_instance));
tracing::info!("Context initialized"); tracing::info!("Context initialized");

View file

@ -46,6 +46,7 @@ moka = { version = "0.12", features = ["sync", "future"] }
monero = { version = "0.12", features = ["serde_support"] } monero = { version = "0.12", features = ["serde_support"] }
monero-rpc = { path = "../monero-rpc" } monero-rpc = { path = "../monero-rpc" }
monero-rpc-pool = { path = "../monero-rpc-pool" } monero-rpc-pool = { path = "../monero-rpc-pool" }
monero-seed = { version = "0.1.0", path = "../seed" }
monero-sys = { path = "../monero-sys" } monero-sys = { path = "../monero-sys" }
once_cell = "1.19" once_cell = "1.19"
pem = "3.0" pem = "3.0"
@ -86,6 +87,7 @@ unsigned-varint = { version = "0.8.0", features = ["codec", "asynchronous_codec"
url = { version = "2", features = ["serde"] } url = { version = "2", features = ["serde"] }
uuid = { version = "1.9", features = ["serde", "v4"] } uuid = { version = "1.9", features = ["serde", "v4"] }
void = "1" void = "1"
zeroize = "1.8.1"
[target.'cfg(not(windows))'.dependencies] [target.'cfg(not(windows))'.dependencies]
tokio-tar = "0.3" tokio-tar = "0.3"

View file

@ -147,7 +147,7 @@ mod addr_list {
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
let s = Value::deserialize(deserializer)?; let s = Value::deserialize(deserializer)?;
return match s { match s {
Value::String(s) => { Value::String(s) => {
let list: Result<Vec<_>, _> = s let list: Result<Vec<_>, _> = s
.split(',') .split(',')
@ -173,7 +173,7 @@ mod addr_list {
Unexpected::Other(&value.to_string()), Unexpected::Other(&value.to_string()),
&"a string or array", &"a string or array",
)), )),
}; }
} }
} }
@ -188,7 +188,7 @@ mod electrum_urls {
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
let s = Value::deserialize(deserializer)?; let s = Value::deserialize(deserializer)?;
return match s { match s {
Value::String(s) => { Value::String(s) => {
let list: Result<Vec<_>, _> = s let list: Result<Vec<_>, _> = s
.split(',') .split(',')
@ -214,7 +214,7 @@ mod electrum_urls {
Unexpected::Other(&value.to_string()), Unexpected::Other(&value.to_string()),
&"a string or array", &"a string or array",
)), )),
}; }
} }
} }
@ -378,15 +378,19 @@ pub fn query_user_for_initial_config(testnet: bool) -> Result<Config> {
let prompt = format!( let prompt = format!(
"Enter additional Electrum RPC URL ({electrum_number}). Or just hit Enter to continue." "Enter additional Electrum RPC URL ({electrum_number}). Or just hit Enter to continue."
); );
let electrum_url = Input::<Url>::with_theme(&ColorfulTheme::default()) let electrum_url = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt(prompt) .with_prompt(prompt)
.allow_empty(true) .allow_empty(true)
.interact_text()?; .interact_text()?;
if electrum_url.as_str().is_empty() { if electrum_url.as_str().is_empty() {
electrum_done = true; electrum_done = true;
} else if electrum_rpc_urls.contains(&electrum_url) { } else if electrum_rpc_urls
.iter()
.any(|url| url.to_string() == electrum_url)
{
println!("That Electrum URL is already in the list."); println!("That Electrum URL is already in the list.");
} else { } else {
let electrum_url = Url::parse(&electrum_url).context("Invalid Electrum URL")?;
electrum_rpc_urls.push(electrum_url); electrum_rpc_urls.push(electrum_url);
electrum_number += 1; electrum_number += 1;
} }

View file

@ -132,8 +132,9 @@ pub async fn main() -> Result<()> {
)); ));
} }
let seed = let seed = Seed::from_file_or_generate(&config.data.dir, None)
Seed::from_file_or_generate(&config.data.dir).expect("Could not retrieve/initialize seed"); .await
.expect("Could not retrieve/initialize seed");
let db_file = config.data.dir.join("sqlite"); let db_file = config.data.dir.join("sqlite");

View file

@ -952,7 +952,7 @@ impl Wallet {
// //
// At least one chunk is always required. At most total_spks / batch_size or the provided num_chunks (whichever is smaller) // At least one chunk is always required. At most total_spks / batch_size or the provided num_chunks (whichever is smaller)
let num_chunks = max_num_chunks.min(total_spks / batch_size).max(1); let num_chunks = max_num_chunks.min(total_spks / batch_size).max(1);
let chunk_size = (total_spks + num_chunks - 1) / num_chunks; let chunk_size = total_spks.div_ceil(num_chunks);
let mut chunks = Vec::new(); let mut chunks = Vec::new();
@ -2208,10 +2208,7 @@ mod sync_ext {
/// ///
/// Ensures the callback is always invoked when progress reaches 100%. /// Ensures the callback is always invoked when progress reaches 100%.
fn throttle_callback(self, min_percentage_increase: f32) -> InnerSyncCallback { fn throttle_callback(self, min_percentage_increase: f32) -> InnerSyncCallback {
let mut callback = match self { let mut callback = self?;
None => return None,
Some(cb) => cb,
};
let mut last_reported_percentage: f64 = 0.0; let mut last_reported_percentage: f64 = 0.0;
let threshold = min_percentage_increase as f64 / 100.0; let threshold = min_percentage_increase as f64 / 100.0;

View file

@ -281,11 +281,7 @@ impl ContextBuilder {
/// Takes the builder, initializes the context by initializing the wallets and other components and returns the Context. /// Takes the builder, initializes the context by initializing the wallets and other components and returns the Context.
pub async fn build(self) -> Result<Context> { pub async fn build(self) -> Result<Context> {
// These are needed for everything else, and are blocking calls
let data_dir = &data::data_dir_from(self.data, self.is_testnet)?; let data_dir = &data::data_dir_from(self.data, self.is_testnet)?;
let env_config = env_config_from(self.is_testnet);
let seed = &Seed::from_file_or_generate(data_dir.as_path())
.context("Failed to read seed in file")?;
// Initialize logging // Initialize logging
let format = if self.json { Format::Json } else { Format::Raw }; let format = if self.json { Format::Json } else { Format::Raw };
@ -312,6 +308,12 @@ impl ContextBuilder {
); );
}); });
// These are needed for everything else, and are blocking calls
let env_config = env_config_from(self.is_testnet);
let seed = &Seed::from_file_or_generate(data_dir.as_path(), self.tauri_handle.clone())
.await
.context("Failed to read seed in file")?;
// Create the data structure we use to manage the swap lock // Create the data structure we use to manage the swap lock
let swap_lock = Arc::new(SwapLock::new()); let swap_lock = Arc::new(SwapLock::new());
let tasks = PendingTaskList::default().into(); let tasks = PendingTaskList::default().into();
@ -698,14 +700,16 @@ pub mod api_test {
pub const SWAP_ID: &str = "ea030832-3be9-454f-bb98-5ea9a788406b"; pub const SWAP_ID: &str = "ea030832-3be9-454f-bb98-5ea9a788406b";
impl Config { impl Config {
pub fn default( pub async fn default(
is_testnet: bool, is_testnet: bool,
data_dir: Option<PathBuf>, data_dir: Option<PathBuf>,
debug: bool, debug: bool,
json: bool, json: bool,
) -> Self { ) -> Self {
let data_dir = data::data_dir_from(data_dir, is_testnet).unwrap(); let data_dir = data::data_dir_from(data_dir, is_testnet).unwrap();
let seed = Seed::from_file_or_generate(data_dir.as_path()).unwrap(); let seed = Seed::from_file_or_generate(data_dir.as_path(), None)
.await
.unwrap();
let env_config = env_config_from(is_testnet); let env_config = env_config_from(is_testnet);
Self { Self {

View file

@ -19,6 +19,7 @@ use ::monero::Network;
use anyhow::{bail, Context as AnyContext, Result}; use anyhow::{bail, Context as AnyContext, Result};
use libp2p::core::Multiaddr; use libp2p::core::Multiaddr;
use libp2p::PeerId; use libp2p::PeerId;
use monero_seed::{Language, Seed as MoneroSeed};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use qrcode::render::unicode; use qrcode::render::unicode;
use qrcode::QrCode; use qrcode::QrCode;
@ -37,6 +38,7 @@ use tracing::Span;
use typeshare::typeshare; use typeshare::typeshare;
use url::Url; use url::Url;
use uuid::Uuid; use uuid::Uuid;
use zeroize::Zeroizing;
/// This trait is implemented by all types of request args that /// This trait is implemented by all types of request args that
/// the CLI can handle. /// the CLI can handle.
@ -1446,10 +1448,11 @@ impl CheckElectrumNodeArgs {
} }
#[typeshare] #[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ResolveApprovalArgs { pub struct ResolveApprovalArgs {
pub request_id: String, pub request_id: String,
pub accept: bool, #[typeshare(serialized_as = "object")]
pub accept: serde_json::Value,
} }
#[typeshare] #[typeshare]
@ -1458,10 +1461,23 @@ pub struct ResolveApprovalResponse {
pub success: bool, pub success: bool,
} }
impl Request for ResolveApprovalArgs { #[typeshare]
type Response = ResolveApprovalResponse; #[derive(Serialize, Deserialize, Debug)]
pub struct CheckSeedArgs {
pub seed: String,
}
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> { #[typeshare]
resolve_approval_request(self, ctx).await #[derive(Serialize, Deserialize, Debug)]
pub struct CheckSeedResponse {
pub available: bool,
}
impl CheckSeedArgs {
pub async fn request(self) -> Result<CheckSeedResponse> {
let seed = MoneroSeed::from_string(Language::English, Zeroizing::new(self.seed));
Ok(CheckSeedResponse {
available: seed.is_ok(),
})
} }
} }

View file

@ -2,11 +2,13 @@ use super::request::BalanceResponse;
use crate::bitcoin; use crate::bitcoin;
use crate::monero::MoneroAddressPool; use crate::monero::MoneroAddressPool;
use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote}; use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use async_trait::async_trait;
use bitcoin::Txid; use bitcoin::Txid;
use monero_rpc_pool::pool::PoolStatus; use monero_rpc_pool::pool::PoolStatus;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::Display;
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
@ -52,35 +54,49 @@ pub struct LockBitcoinDetails {
#[typeshare] #[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "content")] #[serde(tag = "type", content = "content")]
pub enum ApprovalRequestDetails { pub enum SeedChoice {
RandomSeed,
FromSeed { seed: String },
}
#[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApprovalRequest {
request: ApprovalRequestType,
request_status: RequestStatus,
#[typeshare(serialized_as = "string")]
request_id: Uuid,
}
#[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "content")]
pub enum ApprovalRequestType {
/// Request approval before locking Bitcoin. /// Request approval before locking Bitcoin.
/// Contains specific details for review. /// Contains specific details for review.
LockBitcoin(LockBitcoinDetails), LockBitcoin(LockBitcoinDetails),
/// Request seed selection from user.
/// User can choose between random seed or provide their own.
SeedSelection,
} }
#[typeshare] #[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "state", content = "content")] #[serde(tag = "state", content = "content")]
pub enum ApprovalRequest { pub enum RequestStatus {
Pending { Pending {
request_id: String,
#[typeshare(serialized_as = "number")] #[typeshare(serialized_as = "number")]
expiration_ts: u64, expiration_ts: u64,
details: ApprovalRequestDetails,
}, },
Resolved { Resolved {
request_id: String, #[typeshare(serialized_as = "object")]
details: ApprovalRequestDetails, approve_input: serde_json::Value,
},
Rejected {
request_id: String,
details: ApprovalRequestDetails,
}, },
Rejected,
} }
struct PendingApproval { struct PendingApproval {
responder: Option<oneshot::Sender<bool>>, responder: Option<oneshot::Sender<serde_json::Value>>,
details: ApprovalRequestDetails,
#[allow(dead_code)] #[allow(dead_code)]
expiration_ts: u64, expiration_ts: u64,
} }
@ -136,88 +152,105 @@ impl TauriHandle {
self.emit_unified_event(TauriEvent::Approval(event)) self.emit_unified_event(TauriEvent::Approval(event))
} }
pub async fn request_approval( pub async fn request_approval<Response>(
&self, &self,
request_type: ApprovalRequestDetails, request_type: ApprovalRequestType,
timeout_secs: u64, timeout_secs: Option<u64>,
) -> Result<bool> { ) -> Result<Response>
where
Response: serde::de::DeserializeOwned + Clone,
{
#[cfg(not(feature = "tauri"))] #[cfg(not(feature = "tauri"))]
{ {
return Ok(true); bail!("Tauri feature not enabled");
} }
#[cfg(feature = "tauri")] #[cfg(feature = "tauri")]
{ {
// Compute absolute expiration timestamp, and UUID for the request // Emit the creation of the approval request to the frontend
let request_id = Uuid::new_v4(); // TODO: We need to send a UUID with it here
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time to be after unix epoch (1970-01-01)")
.as_secs();
let expiration_ts = now_secs + timeout_secs;
// Build the approval event let request_id = Uuid::new_v4();
let details = request_type.clone(); // No timeout = one week
let pending_event = ApprovalRequest::Pending { let timeout_secs = timeout_secs.unwrap_or(60 * 60 * 24 * 7);
request_id: request_id.to_string(), let expiration_ts = SystemTime::now()
expiration_ts, .duration_since(UNIX_EPOCH)
details: details.clone(), .unwrap()
.as_secs()
+ timeout_secs;
let request = ApprovalRequest {
request: request_type,
request_status: RequestStatus::Pending { expiration_ts },
request_id,
}; };
// Emit the creation of the approval request to the frontend use anyhow::bail;
self.emit_approval(pending_event.clone()); self.emit_approval(request.clone());
tracing::debug!(%request_id, request=?pending_event, "Emitted approval request event"); tracing::debug!(%request, "Emitted approval request event");
// Construct the data structure we use to internally track the approval request // Construct the data structure we use to internally track the approval request
let (responder, receiver) = oneshot::channel(); let (responder, receiver) = oneshot::channel();
let timeout_duration = Duration::from_secs(timeout_secs); let timeout_duration = Duration::from_secs(timeout_secs);
let pending = PendingApproval { let pending = PendingApproval {
responder: Some(responder), responder: Some(responder),
details: request_type.clone(), expiration_ts: SystemTime::now()
expiration_ts, .duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ timeout_secs,
}; };
// Lock map and insert the pending approval // Lock map and insert the pending approval
{ {
let mut pending_map = self.0.pending_approvals.lock().await; let mut pending_map = self.0.pending_approvals.lock().await;
pending_map.insert(request_id, pending); pending_map.insert(request.request_id, pending);
} }
// Determine if the request will be accepted or rejected // Determine if the request will be accepted or rejected
// Either by being resolved by the user, or by timing out // Either by being resolved by the user, or by timing out
let accepted = tokio::select! { let unparsed_response = tokio::select! {
res = receiver => res.map_err(|_| anyhow!("Approval responder dropped"))?, res = receiver => res.map_err(|_| anyhow!("Approval responder dropped"))?,
_ = tokio::time::sleep(timeout_duration) => { _ = tokio::time::sleep(timeout_duration) => {
tracing::debug!(%request_id, "Approval request timed out and was therefore rejected"); bail!("Approval request timed out and was therefore rejected");
false
}, },
}; };
tracing::debug!(%unparsed_response, "Received approval response");
let response: Result<Response> = serde_json::from_value(unparsed_response.clone())
.context("Failed to parse approval response to expected type");
let mut map = self.0.pending_approvals.lock().await; let mut map = self.0.pending_approvals.lock().await;
if let Some(pending) = map.remove(&request_id) { if let Some(_pending) = map.remove(&request.request_id) {
let event = if accepted { let status = if response.is_ok() {
ApprovalRequest::Resolved { RequestStatus::Resolved {
request_id: request_id.to_string(), approve_input: unparsed_response,
details: pending.details,
} }
} else { } else {
ApprovalRequest::Rejected { RequestStatus::Rejected {}
request_id: request_id.to_string(),
details: pending.details,
}
}; };
self.emit_approval(event); let mut approval = request.clone();
tracing::debug!(%request_id, %accepted, "Resolved approval request"); approval.request_status = status.clone();
tracing::debug!(%approval, "Resolved approval request");
self.emit_approval(approval);
} }
Ok(accepted) tracing::debug!("Returning approval response");
response
} }
} }
pub async fn resolve_approval(&self, request_id: Uuid, accepted: bool) -> Result<()> { pub async fn resolve_approval(
&self,
request_id: Uuid,
response: serde_json::Value,
) -> Result<()> {
#[cfg(not(feature = "tauri"))] #[cfg(not(feature = "tauri"))]
{ {
return Err(anyhow!( return Err(anyhow!(
@ -233,7 +266,7 @@ impl TauriHandle {
.responder .responder
.take() .take()
.context("Approval responder was already consumed")? .context("Approval responder was already consumed")?
.send(accepted); .send(response);
Ok(()) Ok(())
} else { } else {
@ -243,15 +276,24 @@ impl TauriHandle {
} }
} }
impl Display for ApprovalRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.request {
ApprovalRequestType::LockBitcoin(..) => write!(f, "LockBitcoin()"),
ApprovalRequestType::SeedSelection => write!(f, "SeedSelection()"),
}
}
}
#[async_trait]
pub trait TauriEmitter { pub trait TauriEmitter {
fn request_approval<'life0, 'async_trait>( async fn request_bitcoin_approval(
&'life0 self, &self,
request_type: ApprovalRequestDetails, details: LockBitcoinDetails,
timeout_secs: u64, timeout_secs: u64,
) -> Pin<Box<dyn Future<Output = Result<bool>> + Send + 'async_trait>> ) -> Result<bool>;
where
'life0: 'async_trait, async fn request_seed_selection(&self) -> Result<SeedChoice>;
Self: 'async_trait;
fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()>; fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()>;
@ -317,17 +359,25 @@ pub trait TauriEmitter {
) -> TauriBackgroundProgressHandle<T>; ) -> TauriBackgroundProgressHandle<T>;
} }
#[async_trait]
impl TauriEmitter for TauriHandle { impl TauriEmitter for TauriHandle {
fn request_approval<'life0, 'async_trait>( async fn request_bitcoin_approval(
&'life0 self, &self,
request_type: ApprovalRequestDetails, details: LockBitcoinDetails,
timeout_secs: u64, timeout_secs: u64,
) -> Pin<Box<dyn Future<Output = Result<bool>> + Send + 'async_trait>> ) -> Result<bool> {
where Ok(self
'life0: 'async_trait, .request_approval(
Self: 'async_trait, ApprovalRequestType::LockBitcoin(details),
{ Some(timeout_secs),
Box::pin(self.request_approval(request_type, timeout_secs)) )
.await
.unwrap_or(false))
}
async fn request_seed_selection(&self) -> Result<SeedChoice> {
self.request_approval(ApprovalRequestType::SeedSelection, None)
.await
} }
fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> { fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> {
@ -359,6 +409,7 @@ impl TauriEmitter for TauriHandle {
} }
} }
#[async_trait]
impl TauriEmitter for Option<TauriHandle> { impl TauriEmitter for Option<TauriHandle> {
fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> { fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> {
match self { match self {
@ -369,21 +420,22 @@ impl TauriEmitter for Option<TauriHandle> {
} }
} }
fn request_approval<'life0, 'async_trait>( async fn request_bitcoin_approval(
&'life0 self, &self,
request_type: ApprovalRequestDetails, details: LockBitcoinDetails,
timeout_secs: u64, timeout_secs: u64,
) -> Pin<Box<dyn Future<Output = Result<bool>> + Send + 'async_trait>> ) -> Result<bool> {
where
'life0: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move {
match self { match self {
Some(tauri) => tauri.request_approval(request_type, timeout_secs).await, Some(tauri) => tauri.request_bitcoin_approval(details, timeout_secs).await,
None => Ok(true), None => bail!("No Tauri handle available"),
}
}
async fn request_seed_selection(&self) -> Result<SeedChoice> {
match self {
Some(tauri) => tauri.request_seed_selection().await,
None => bail!("No Tauri handle available"),
} }
})
} }
fn new_background_process<T: Clone>( fn new_background_process<T: Clone>(

View file

@ -488,7 +488,7 @@ pub mod monero_private_key {
struct BytesVisitor; struct BytesVisitor;
impl<'de> Visitor<'de> for BytesVisitor { impl Visitor<'_> for BytesVisitor {
type Value = PrivateKey; type Value = PrivateKey;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {

View file

@ -1,9 +1,7 @@
use crate::bitcoin::wallet::ScriptStatus; use crate::bitcoin::wallet::ScriptStatus;
use crate::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund}; use crate::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund};
use crate::cli::api::tauri_bindings::ApprovalRequestDetails; use crate::cli::api::tauri_bindings::LockBitcoinDetails;
use crate::cli::api::tauri_bindings::{ use crate::cli::api::tauri_bindings::{TauriEmitter, TauriHandle, TauriSwapProgressEvent};
LockBitcoinDetails, TauriEmitter, TauriHandle, TauriSwapProgressEvent,
};
use crate::cli::EventLoopHandle; use crate::cli::EventLoopHandle;
use crate::common::retry; use crate::common::retry;
use crate::monero::MoneroAddressPool; use crate::monero::MoneroAddressPool;
@ -166,19 +164,19 @@ async fn next_state(
.context("Failed to get lock amount")? .context("Failed to get lock amount")?
.value; .value;
let request = ApprovalRequestDetails::LockBitcoin(LockBitcoinDetails { let details = LockBitcoinDetails {
btc_lock_amount, btc_lock_amount,
btc_network_fee, btc_network_fee,
xmr_receive_amount, xmr_receive_amount,
monero_receive_pool, monero_receive_pool,
swap_id, swap_id,
}); };
// We request approval before publishing the Bitcoin lock transaction, // We request approval before publishing the Bitcoin lock transaction,
// as the exchange rate determined at this step might be different // as the exchange rate determined at this step might be different
// from the one we previously displayed to the user. // from the one we previously displayed to the user.
let approval_result = event_emitter let approval_result = event_emitter
.request_approval(request, PRE_BTC_LOCK_APPROVAL_TIMEOUT_SECS) .request_bitcoin_approval(details, PRE_BTC_LOCK_APPROVAL_TIMEOUT_SECS)
.await; .await;
match approval_result { match approval_result {

View file

@ -1,3 +1,4 @@
use crate::cli::api::tauri_bindings::{SeedChoice, TauriEmitter, TauriHandle};
use crate::fs::ensure_directory_exists; use crate::fs::ensure_directory_exists;
use ::bitcoin::bip32::Xpriv as ExtendedPrivKey; use ::bitcoin::bip32::Xpriv as ExtendedPrivKey;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@ -5,6 +6,7 @@ use bitcoin::hashes::{sha256, Hash, HashEngine};
use bitcoin::secp256k1::constants::SECRET_KEY_SIZE; use bitcoin::secp256k1::constants::SECRET_KEY_SIZE;
use bitcoin::secp256k1::{self, SecretKey}; use bitcoin::secp256k1::{self, SecretKey};
use libp2p::identity; use libp2p::identity;
use monero_seed::{Language, Seed as MoneroSeed};
use pem::{encode, Pem}; use pem::{encode, Pem};
use rand::prelude::*; use rand::prelude::*;
use std::ffi::OsStr; use std::ffi::OsStr;
@ -12,6 +14,7 @@ use std::fmt;
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
pub const SEED_LENGTH: usize = 32; pub const SEED_LENGTH: usize = 32;
@ -60,20 +63,57 @@ impl Seed {
identity::Keypair::ed25519_from_bytes(bytes).expect("we always pass 32 bytes") identity::Keypair::ed25519_from_bytes(bytes).expect("we always pass 32 bytes")
} }
pub fn from_file_or_generate(data_dir: &Path) -> Result<Self, Error> { pub async fn from_file_or_generate(
data_dir: &Path,
tauri_handle: Option<TauriHandle>,
) -> Result<Self> {
let file_path_buf = data_dir.join("seed.pem"); let file_path_buf = data_dir.join("seed.pem");
let file_path = Path::new(&file_path_buf); let file_path = Path::new(&file_path_buf);
if file_path.exists() { if file_path.exists() {
return Self::from_file(file_path); return Self::from_file(file_path).context("Couldn't get seed from file");
} }
tracing::debug!("No seed file found, creating at {}", file_path.display()); tracing::debug!("No seed file found, creating at {}", file_path.display());
let random_seed = Seed::random()?; // In debug mode, we allow the user to enter a seed manually.
random_seed.write_to(file_path.to_path_buf())?; #[cfg(debug_assertions)]
{
let new_seed = match tauri_handle {
Some(tauri_handle) => {
let seed_choice = tauri_handle.request_seed_selection().await?;
let seed_entered = match seed_choice {
SeedChoice::RandomSeed => Seed::random()?,
SeedChoice::FromSeed { seed } => {
println!("seed: {}", seed);
let monero_seed =
MoneroSeed::from_string(Language::English, Zeroizing::new(seed))
.unwrap();
Seed(*monero_seed.entropy())
}
};
Ok(random_seed) //TODO: Send error type to front end
seed_entered
}
None => {
let random_seed = Seed::random()?;
random_seed
}
};
new_seed.write_to(file_path.to_path_buf())?;
Ok(new_seed)
}
// In release mode, we generate a random seed.
#[cfg(not(debug_assertions))]
{
let new_seed = Seed::random()?;
new_seed.write_to(file_path.to_path_buf())?;
Ok(new_seed)
}
} }
/// Derive a new seed using the given scope. /// Derive a new seed using the given scope.

View file

@ -43,7 +43,7 @@ impl MakeCapturingWriter {
} }
} }
impl<'a> MakeWriter<'a> for MakeCapturingWriter { impl MakeWriter<'_> for MakeCapturingWriter {
type Writer = CapturingWriter; type Writer = CapturingWriter;
fn make_writer(&self) -> Self::Writer { fn make_writer(&self) -> Self::Writer {