iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

wallet_store.rs (29405B)


      1 use std::{
      2     fs::{self, File, OpenOptions},
      3     io::Write,
      4     path::Path,
      5 };
      6 
      7 use anyhow::{Context, Result, anyhow, bail};
      8 use bip39::{Language, Mnemonic};
      9 use chacha20poly1305::{
     10     ChaCha20Poly1305, KeyInit, Nonce,
     11     aead::{Aead, Payload},
     12 };
     13 use pbkdf2::pbkdf2_hmac;
     14 use serde::{Deserialize, Serialize};
     15 use sha2::Sha256;
     16 
     17 use crate::domain::{OwnedBlindedTransaction, Wallet};
     18 
     19 const WALLET_FILE_VERSION: u32 = 3;
     20 const PLAINTEXT_WALLET_FILE_VERSION: u32 = 2;
     21 const WALLET_ENCRYPTION_ALGORITHM: &str = "chacha20poly1305";
     22 const WALLET_ENCRYPTION_KDF: &str = "pbkdf2-sha256";
     23 const WALLET_ENCRYPTION_ITERATIONS: u32 = 210_000;
     24 const GENERATED_SEED_WORDS: usize = 24;
     25 const BIP39_SEED_ENTROPY_BYTES: usize = 32;
     26 
     27 #[derive(Debug, Serialize, Deserialize)]
     28 struct WalletFile {
     29     version: u32,
     30     #[serde(default, skip_serializing_if = "Option::is_none")]
     31     seed: Option<String>,
     32     address: String,
     33     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     34     owned_blinded_transactions: Vec<OwnedBlindedTransaction>,
     35     #[serde(default, skip_serializing_if = "Option::is_none")]
     36     encryption: Option<EncryptedWalletSeed>,
     37 }
     38 
     39 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
     40 struct WalletData {
     41     seed: String,
     42     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     43     owned_blinded_transactions: Vec<OwnedBlindedTransaction>,
     44 }
     45 
     46 #[derive(Clone, Debug, Eq, PartialEq)]
     47 pub struct WalletMetadata {
     48     pub address: String,
     49     pub encrypted: bool,
     50 }
     51 
     52 #[derive(Debug, Serialize, Deserialize)]
     53 struct EncryptedWalletSeed {
     54     algorithm: String,
     55     kdf: String,
     56     kdf_iterations: u32,
     57     salt: String,
     58     nonce: String,
     59     ciphertext: String,
     60 }
     61 
     62 pub fn load_or_create(path: &Path) -> Result<Wallet> {
     63     if path.exists() {
     64         return load(path);
     65     }
     66 
     67     let seed = generate_seed_phrase()?;
     68     let wallet = Wallet::from_seed(&seed);
     69     let mut file = create_wallet_file(path)?;
     70     write_wallet_file(&mut file, seed, wallet.address())
     71         .with_context(|| format!("failed to write wallet file {}", path.display()))?;
     72 
     73     Ok(wallet)
     74 }
     75 
     76 pub fn replace_with_generated_seed_phrase(path: &Path) -> Result<(Wallet, String)> {
     77     let seed = generate_seed_phrase()?;
     78     let wallet = write_wallet(path, seed.clone(), WalletFileMode::Replace)?;
     79     Ok((wallet, seed))
     80 }
     81 
     82 pub fn replace_with_generated_seed_phrase_encrypted(
     83     path: &Path,
     84     password: &str,
     85 ) -> Result<(Wallet, String)> {
     86     let seed = generate_seed_phrase()?;
     87     let wallet = write_wallet_encrypted(path, seed.clone(), password, WalletFileMode::Replace)?;
     88     Ok((wallet, seed))
     89 }
     90 
     91 pub fn replace_with_imported_seed_phrase(path: &Path, seed_phrase: &str) -> Result<Wallet> {
     92     let seed = normalize_seed_phrase(seed_phrase)?;
     93     write_wallet(path, seed, WalletFileMode::Replace)
     94 }
     95 
     96 pub fn replace_with_imported_seed_phrase_encrypted(
     97     path: &Path,
     98     seed_phrase: &str,
     99     password: &str,
    100 ) -> Result<Wallet> {
    101     let seed = normalize_seed_phrase(seed_phrase)?;
    102     write_wallet_encrypted(path, seed, password, WalletFileMode::Replace)
    103 }
    104 
    105 pub fn setup_seed_phrase(path: &Path) -> Result<Option<String>> {
    106     setup_seed_phrase_with_password(path, None)
    107 }
    108 
    109 pub fn setup_seed_phrase_with_password(
    110     path: &Path,
    111     password: Option<&str>,
    112 ) -> Result<Option<String>> {
    113     if !path.exists() {
    114         return Ok(None);
    115     }
    116     let stored = read_wallet_file(path)?;
    117     let seed = match wallet_seed(&stored, password) {
    118         Ok(seed) => seed,
    119         Err(_) => return Ok(None),
    120     };
    121     let normalized = match normalize_seed_phrase(&seed) {
    122         Ok(seed) => seed,
    123         Err(_) => return Ok(None),
    124     };
    125     if normalized == seed {
    126         Ok(Some(normalized))
    127     } else {
    128         Ok(None)
    129     }
    130 }
    131 
    132 pub fn metadata(path: &Path) -> Result<Option<WalletMetadata>> {
    133     if !path.exists() {
    134         return Ok(None);
    135     }
    136     let stored = read_wallet_file(path)?;
    137     Ok(Some(WalletMetadata {
    138         address: stored.address,
    139         encrypted: stored.encryption.is_some(),
    140     }))
    141 }
    142 
    143 pub fn load_with_password(path: &Path, password: &str) -> Result<Wallet> {
    144     load_encrypted_or_plaintext(path, Some(password))
    145 }
    146 
    147 pub fn load_owned_blinded_transactions(
    148     path: &Path,
    149     password: Option<&str>,
    150 ) -> Result<Vec<OwnedBlindedTransaction>> {
    151     let stored = read_wallet_file(path)?;
    152     Ok(wallet_data(&stored, password)?.owned_blinded_transactions)
    153 }
    154 
    155 pub fn replace_owned_blinded_transactions(
    156     path: &Path,
    157     password: Option<&str>,
    158     owned_blinded_transactions: Vec<OwnedBlindedTransaction>,
    159 ) -> Result<()> {
    160     if !path.exists() {
    161         return Ok(());
    162     }
    163     let stored = read_wallet_file(path)?;
    164     let mut data = wallet_data(&stored, password)?;
    165     data.owned_blinded_transactions = owned_blinded_transactions;
    166     let mut file = open_wallet_file(path, WalletFileMode::Replace)?;
    167     if stored.encryption.is_some() {
    168         let password = password
    169             .context("wallet is encrypted; unlock it before persisting blinded transactions")?;
    170         write_encrypted_wallet_data_file(&mut file, data, &stored.address, password)
    171     } else {
    172         write_wallet_data_file(&mut file, data, &stored.address)
    173     }
    174     .with_context(|| format!("failed to update wallet file {}", path.display()))
    175 }
    176 
    177 pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()> {
    178     if !path.exists() {
    179         return Ok(());
    180     }
    181     let stored = read_wallet_file(path)?;
    182     if stored.encryption.is_some() {
    183         let _ = wallet_from_stored(&stored, Some(password))?;
    184         return Ok(());
    185     }
    186     let data = wallet_data(&stored, None)?;
    187     let seed = data.seed;
    188     let seed = normalize_seed_phrase(&seed).unwrap_or(seed);
    189     let wallet = Wallet::from_seed(&seed);
    190     if wallet.address() != stored.address {
    191         bail!(
    192             "wallet file has address {}, but its seed derives {}",
    193             stored.address,
    194             wallet.address()
    195         );
    196     }
    197     let mut file = open_wallet_file(path, WalletFileMode::Replace)?;
    198     write_encrypted_wallet_data_file(
    199         &mut file,
    200         WalletData {
    201             seed,
    202             owned_blinded_transactions: data.owned_blinded_transactions,
    203         },
    204         wallet.address(),
    205         password,
    206     )
    207     .with_context(|| format!("failed to encrypt wallet file {}", path.display()))
    208 }
    209 
    210 pub fn reencrypt_with_password(
    211     path: &Path,
    212     current_password: &str,
    213     new_password: &str,
    214 ) -> Result<Wallet> {
    215     let stored = read_wallet_file(path)?;
    216     let data = wallet_data(&stored, Some(current_password))?;
    217     let seed = data.seed;
    218     let seed = normalize_seed_phrase(&seed).unwrap_or(seed);
    219     let wallet = Wallet::from_seed(&seed);
    220     if wallet.address() != stored.address {
    221         bail!(
    222             "wallet file has address {}, but its seed derives {}",
    223             stored.address,
    224             wallet.address()
    225         );
    226     }
    227     let mut file = open_wallet_file(path, WalletFileMode::Replace)?;
    228     write_encrypted_wallet_data_file(
    229         &mut file,
    230         WalletData {
    231             seed,
    232             owned_blinded_transactions: data.owned_blinded_transactions,
    233         },
    234         wallet.address(),
    235         new_password,
    236     )
    237     .with_context(|| format!("failed to re-encrypt wallet file {}", path.display()))?;
    238     Ok(wallet)
    239 }
    240 
    241 fn load(path: &Path) -> Result<Wallet> {
    242     load_encrypted_or_plaintext(path, None)
    243 }
    244 
    245 fn load_encrypted_or_plaintext(path: &Path, password: Option<&str>) -> Result<Wallet> {
    246     let stored = read_wallet_file(path)?;
    247     let wallet = wallet_from_stored(&stored, password)?;
    248     if stored.version == 1 {
    249         let mut file = OpenOptions::new()
    250             .write(true)
    251             .truncate(true)
    252             .open(path)
    253             .with_context(|| format!("failed to migrate wallet file {}", path.display()))?;
    254         let seed = stored
    255             .seed
    256             .context("legacy wallet file does not contain a seed")?;
    257         write_wallet_file(&mut file, seed, wallet.address())
    258             .with_context(|| format!("failed to migrate wallet file {}", path.display()))?;
    259         return Ok(wallet);
    260     }
    261     if stored.version != WALLET_FILE_VERSION && stored.version != PLAINTEXT_WALLET_FILE_VERSION {
    262         bail!(
    263             "unsupported wallet file version {} in {}",
    264             stored.version,
    265             path.display()
    266         );
    267     }
    268     if wallet.address() != stored.address {
    269         bail!(
    270             "wallet file {} has address {}, but its seed derives {}",
    271             path.display(),
    272             stored.address,
    273             wallet.address()
    274         );
    275     }
    276 
    277     Ok(wallet)
    278 }
    279 
    280 fn wallet_from_stored(stored: &WalletFile, password: Option<&str>) -> Result<Wallet> {
    281     let seed = wallet_seed(stored, password)?;
    282     Ok(Wallet::from_seed(&seed))
    283 }
    284 
    285 fn wallet_seed(stored: &WalletFile, password: Option<&str>) -> Result<String> {
    286     if let Some(encryption) = &stored.encryption {
    287         let password = password.context("wallet is encrypted; unlock it with the UI password")?;
    288         return decrypt_seed(encryption, &stored.address, password);
    289     }
    290     stored
    291         .seed
    292         .clone()
    293         .context("wallet file does not contain a seed")
    294 }
    295 
    296 fn read_wallet_file(path: &Path) -> Result<WalletFile> {
    297     let bytes =
    298         fs::read(path).with_context(|| format!("failed to read wallet file {}", path.display()))?;
    299     serde_json::from_slice(&bytes)
    300         .with_context(|| format!("failed to parse wallet file {}", path.display()))
    301 }
    302 
    303 enum WalletFileMode {
    304     CreateNew,
    305     Replace,
    306 }
    307 
    308 fn write_wallet(path: &Path, seed: String, mode: WalletFileMode) -> Result<Wallet> {
    309     let wallet = Wallet::from_seed(&seed);
    310     let mut file = open_wallet_file(path, mode)?;
    311     write_wallet_file(&mut file, seed, wallet.address())
    312         .with_context(|| format!("failed to write wallet file {}", path.display()))?;
    313     Ok(wallet)
    314 }
    315 
    316 fn write_wallet_encrypted(
    317     path: &Path,
    318     seed: String,
    319     password: &str,
    320     mode: WalletFileMode,
    321 ) -> Result<Wallet> {
    322     let wallet = Wallet::from_seed(&seed);
    323     let mut file = open_wallet_file(path, mode)?;
    324     write_encrypted_wallet_file(&mut file, seed, wallet.address(), password)
    325         .with_context(|| format!("failed to write wallet file {}", path.display()))?;
    326     Ok(wallet)
    327 }
    328 
    329 fn write_wallet_file(file: &mut File, seed: String, address: &str) -> Result<()> {
    330     write_wallet_data_file(
    331         file,
    332         WalletData {
    333             seed,
    334             owned_blinded_transactions: Vec::new(),
    335         },
    336         address,
    337     )
    338 }
    339 
    340 fn write_wallet_data_file(file: &mut File, data: WalletData, address: &str) -> Result<()> {
    341     let stored = WalletFile {
    342         version: PLAINTEXT_WALLET_FILE_VERSION,
    343         seed: Some(data.seed),
    344         address: address.to_string(),
    345         owned_blinded_transactions: data.owned_blinded_transactions,
    346         encryption: None,
    347     };
    348     let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?;
    349     file.write_all(&bytes)?;
    350     file.write_all(b"\n")?;
    351     Ok(())
    352 }
    353 
    354 fn write_encrypted_wallet_file(
    355     file: &mut File,
    356     seed: String,
    357     address: &str,
    358     password: &str,
    359 ) -> Result<()> {
    360     write_encrypted_wallet_data_file(
    361         file,
    362         WalletData {
    363             seed,
    364             owned_blinded_transactions: Vec::new(),
    365         },
    366         address,
    367         password,
    368     )
    369 }
    370 
    371 fn write_encrypted_wallet_data_file(
    372     file: &mut File,
    373     data: WalletData,
    374     address: &str,
    375     password: &str,
    376 ) -> Result<()> {
    377     let encryption = encrypt_wallet_data(&data, address, password)?;
    378     let stored = WalletFile {
    379         version: WALLET_FILE_VERSION,
    380         seed: None,
    381         address: address.to_string(),
    382         owned_blinded_transactions: Vec::new(),
    383         encryption: Some(encryption),
    384     };
    385     let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?;
    386     file.write_all(&bytes)?;
    387     file.write_all(b"\n")?;
    388     Ok(())
    389 }
    390 
    391 fn wallet_data(stored: &WalletFile, password: Option<&str>) -> Result<WalletData> {
    392     if let Some(encryption) = &stored.encryption {
    393         let password = password.context("wallet is encrypted; unlock it with the UI password")?;
    394         return decrypt_wallet_data(encryption, &stored.address, password);
    395     }
    396     let seed = stored
    397         .seed
    398         .clone()
    399         .context("wallet file does not contain a seed")?;
    400     Ok(WalletData {
    401         seed,
    402         owned_blinded_transactions: stored.owned_blinded_transactions.clone(),
    403     })
    404 }
    405 
    406 fn encrypt_wallet_data(
    407     data: &WalletData,
    408     address: &str,
    409     password: &str,
    410 ) -> Result<EncryptedWalletSeed> {
    411     let salt = random_bytes::<16>()?;
    412     let nonce = random_bytes::<12>()?;
    413     let key = wallet_encryption_key(password, &salt, WALLET_ENCRYPTION_ITERATIONS);
    414     let cipher = ChaCha20Poly1305::new((&key).into());
    415     let plaintext =
    416         serde_json::to_vec(data).context("failed to serialize encrypted wallet data")?;
    417     let ciphertext = cipher
    418         .encrypt(
    419             Nonce::from_slice(&nonce),
    420             Payload {
    421                 msg: &plaintext,
    422                 aad: address.as_bytes(),
    423             },
    424         )
    425         .map_err(|_| anyhow!("failed to encrypt wallet seed"))?;
    426     Ok(EncryptedWalletSeed {
    427         algorithm: WALLET_ENCRYPTION_ALGORITHM.to_string(),
    428         kdf: WALLET_ENCRYPTION_KDF.to_string(),
    429         kdf_iterations: WALLET_ENCRYPTION_ITERATIONS,
    430         salt: hex_encode(salt),
    431         nonce: hex_encode(nonce),
    432         ciphertext: hex_encode(ciphertext),
    433     })
    434 }
    435 
    436 fn decrypt_seed(encryption: &EncryptedWalletSeed, address: &str, password: &str) -> Result<String> {
    437     Ok(decrypt_wallet_data(encryption, address, password)?.seed)
    438 }
    439 
    440 fn decrypt_wallet_data(
    441     encryption: &EncryptedWalletSeed,
    442     address: &str,
    443     password: &str,
    444 ) -> Result<WalletData> {
    445     if encryption.algorithm != WALLET_ENCRYPTION_ALGORITHM {
    446         bail!("unsupported wallet encryption algorithm");
    447     }
    448     if encryption.kdf != WALLET_ENCRYPTION_KDF {
    449         bail!("unsupported wallet encryption kdf");
    450     }
    451     let salt = decode_hex(&encryption.salt).context("invalid wallet encryption salt")?;
    452     let nonce = decode_hex(&encryption.nonce).context("invalid wallet encryption nonce")?;
    453     let ciphertext = decode_hex(&encryption.ciphertext).context("invalid wallet encrypted seed")?;
    454     if nonce.len() != 12 {
    455         bail!("invalid wallet encryption nonce length");
    456     }
    457     let key = wallet_encryption_key(password, &salt, encryption.kdf_iterations);
    458     let cipher = ChaCha20Poly1305::new((&key).into());
    459     let plaintext = cipher
    460         .decrypt(
    461             Nonce::from_slice(&nonce),
    462             Payload {
    463                 msg: &ciphertext,
    464                 aad: address.as_bytes(),
    465             },
    466         )
    467         .map_err(|_| anyhow!("invalid wallet password"))?;
    468     match serde_json::from_slice::<WalletData>(&plaintext) {
    469         Ok(data) => Ok(data),
    470         Err(_) => Ok(WalletData {
    471             seed: String::from_utf8(plaintext).context("wallet seed is not valid utf-8")?,
    472             owned_blinded_transactions: Vec::new(),
    473         }),
    474     }
    475 }
    476 
    477 fn wallet_encryption_key(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] {
    478     let mut key = [0_u8; 32];
    479     pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut key);
    480     key
    481 }
    482 
    483 fn random_bytes<const N: usize>() -> Result<[u8; N]> {
    484     let mut bytes = [0_u8; N];
    485     getrandom::getrandom(&mut bytes)
    486         .map_err(|error| anyhow!("failed to read system randomness: {error:?}"))?;
    487     Ok(bytes)
    488 }
    489 
    490 fn generate_seed_phrase() -> Result<String> {
    491     let mut entropy = [0_u8; BIP39_SEED_ENTROPY_BYTES];
    492     getrandom::getrandom(&mut entropy)
    493         .map_err(|error| anyhow!("failed to read system randomness: {error:?}"))?;
    494     let mnemonic = Mnemonic::from_entropy_in(Language::English, &entropy)
    495         .context("failed to generate BIP-39 seed phrase")?;
    496     Ok(mnemonic.to_string())
    497 }
    498 
    499 fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
    500     const HEX: &[u8; 16] = b"0123456789abcdef";
    501     let mut encoded = String::with_capacity(bytes.as_ref().len() * 2);
    502     for byte in bytes.as_ref() {
    503         encoded.push(HEX[(byte >> 4) as usize] as char);
    504         encoded.push(HEX[(byte & 0x0f) as usize] as char);
    505     }
    506     encoded
    507 }
    508 
    509 fn decode_hex(input: &str) -> Result<Vec<u8>> {
    510     if input.len() % 2 != 0 {
    511         bail!("hex string has odd length");
    512     }
    513     let mut bytes = Vec::with_capacity(input.len() / 2);
    514     for pair in input.as_bytes().chunks_exact(2) {
    515         let high = decode_hex_nibble(pair[0])?;
    516         let low = decode_hex_nibble(pair[1])?;
    517         bytes.push((high << 4) | low);
    518     }
    519     Ok(bytes)
    520 }
    521 
    522 fn decode_hex_nibble(byte: u8) -> Result<u8> {
    523     match byte {
    524         b'0'..=b'9' => Ok(byte - b'0'),
    525         b'a'..=b'f' => Ok(byte - b'a' + 10),
    526         b'A'..=b'F' => Ok(byte - b'A' + 10),
    527         _ => bail!("invalid hex character"),
    528     }
    529 }
    530 
    531 fn normalize_seed_phrase(seed_phrase: &str) -> Result<String> {
    532     let normalized = seed_phrase
    533         .split_whitespace()
    534         .map(|word| word.trim().to_ascii_lowercase())
    535         .filter(|word| !word.is_empty())
    536         .collect::<Vec<_>>()
    537         .join(" ");
    538     if normalized.split_whitespace().count() != GENERATED_SEED_WORDS {
    539         bail!("seed phrase must contain 24 words");
    540     }
    541     for word in normalized.split_whitespace() {
    542         if !word.chars().all(|ch| ch.is_ascii_lowercase()) {
    543             bail!("seed phrase words must contain only letters");
    544         }
    545     }
    546     let mnemonic = Mnemonic::parse_in_normalized(Language::English, &normalized)
    547         .context("invalid BIP-39 seed phrase")?;
    548     Ok(mnemonic.to_string())
    549 }
    550 
    551 fn create_wallet_file(path: &Path) -> Result<File> {
    552     open_wallet_file(path, WalletFileMode::CreateNew)
    553 }
    554 
    555 fn open_wallet_file(path: &Path, mode: WalletFileMode) -> Result<File> {
    556     if let Some(parent) = path.parent() {
    557         fs::create_dir_all(parent)
    558             .with_context(|| format!("failed to create wallet directory {}", parent.display()))?;
    559     }
    560 
    561     let mut options = OpenOptions::new();
    562     options.write(true);
    563     match mode {
    564         WalletFileMode::CreateNew => {
    565             options.create_new(true);
    566         }
    567         WalletFileMode::Replace => {
    568             options.create(true).truncate(true);
    569         }
    570     }
    571 
    572     #[cfg(unix)]
    573     {
    574         use std::os::unix::fs::OpenOptionsExt;
    575         options.mode(0o600);
    576     }
    577 
    578     options
    579         .open(path)
    580         .with_context(|| format!("failed to create wallet file {}", path.display()))
    581 }
    582 
    583 #[cfg(test)]
    584 mod tests {
    585     use std::fs;
    586 
    587     use bip39::{Language, Mnemonic};
    588     use tempfile::tempdir;
    589 
    590     use crate::domain::{
    591         BlindedReveal, BlindedTransaction, OwnedBlindedTransaction, Transaction, TxInput, TxOutput,
    592     };
    593 
    594     use super::{
    595         encrypt_existing_with_password, load_or_create, load_owned_blinded_transactions,
    596         load_with_password, metadata, replace_owned_blinded_transactions,
    597         replace_with_generated_seed_phrase, replace_with_generated_seed_phrase_encrypted,
    598         replace_with_imported_seed_phrase, setup_seed_phrase, setup_seed_phrase_with_password,
    599     };
    600 
    601     #[test]
    602     fn creates_and_reuses_wallet_file() {
    603         let dir = tempdir().unwrap();
    604         let path = dir.path().join("wallet.json");
    605 
    606         let first = load_or_create(&path).unwrap();
    607         let second = load_or_create(&path).unwrap();
    608 
    609         assert_eq!(first.address(), second.address());
    610         let stored = fs::read_to_string(path).unwrap();
    611         assert!(stored.contains(first.address()));
    612         assert!(!stored.contains("dev-wallet"));
    613         assert!(
    614             setup_seed_phrase(&dir.path().join("wallet.json"))
    615                 .unwrap()
    616                 .is_some()
    617         );
    618     }
    619 
    620     #[test]
    621     fn generated_wallet_uses_recovery_phrase() {
    622         let dir = tempdir().unwrap();
    623         let path = dir.path().join("wallet.json");
    624 
    625         let (_wallet, seed_phrase) = replace_with_generated_seed_phrase(&path).unwrap();
    626         let words = seed_phrase.split_whitespace().collect::<Vec<_>>();
    627 
    628         assert_eq!(words.len(), 24);
    629         assert!(Mnemonic::parse_in_normalized(Language::English, &seed_phrase).is_ok());
    630         assert_eq!(
    631             setup_seed_phrase(&path).unwrap().as_deref(),
    632             Some(seed_phrase.as_str())
    633         );
    634     }
    635 
    636     #[test]
    637     fn generated_verified_phrase_imports_to_same_wallet() {
    638         let dir = tempdir().unwrap();
    639         let generated_path = dir.path().join("generated-wallet.json");
    640         let imported_path = dir.path().join("imported-wallet.json");
    641 
    642         let (generated_wallet, seed_phrase) =
    643             replace_with_generated_seed_phrase(&generated_path).unwrap();
    644         assert_recovery_words_verify(&seed_phrase, &[0, 6, 13, 23]);
    645 
    646         let generated_loaded = load_or_create(&generated_path).unwrap();
    647         assert_eq!(generated_wallet.address(), generated_loaded.address());
    648 
    649         let imported_wallet =
    650             replace_with_imported_seed_phrase(&imported_path, &seed_phrase).unwrap();
    651         assert_recovery_words_verify(
    652             setup_seed_phrase(&imported_path)
    653                 .unwrap()
    654                 .as_deref()
    655                 .unwrap(),
    656             &[0, 6, 13, 23],
    657         );
    658 
    659         let imported_loaded = load_or_create(&imported_path).unwrap();
    660         assert_eq!(generated_wallet.address(), imported_wallet.address());
    661         assert_eq!(generated_wallet.address(), imported_loaded.address());
    662         assert_eq!(
    663             setup_seed_phrase(&generated_path).unwrap(),
    664             setup_seed_phrase(&imported_path).unwrap()
    665         );
    666     }
    667 
    668     #[test]
    669     fn encrypted_generated_wallet_hides_seed_and_requires_password() {
    670         let dir = tempdir().unwrap();
    671         let path = dir.path().join("wallet.json");
    672 
    673         let (wallet, seed_phrase) =
    674             replace_with_generated_seed_phrase_encrypted(&path, "correct horse battery staple")
    675                 .unwrap();
    676         let stored = fs::read_to_string(&path).unwrap();
    677 
    678         assert!(stored.contains("\"version\": 3"));
    679         assert!(stored.contains("\"encryption\""));
    680         assert!(!stored.contains(&seed_phrase));
    681         assert_eq!(metadata(&path).unwrap().unwrap().address, wallet.address());
    682         assert!(metadata(&path).unwrap().unwrap().encrypted);
    683         assert!(
    684             load_or_create(&path)
    685                 .unwrap_err()
    686                 .to_string()
    687                 .contains("encrypted")
    688         );
    689         assert!(load_with_password(&path, "wrong password").is_err());
    690 
    691         let loaded = load_with_password(&path, "correct horse battery staple").unwrap();
    692         assert_eq!(loaded.address(), wallet.address());
    693         assert_eq!(
    694             setup_seed_phrase_with_password(&path, Some("correct horse battery staple"))
    695                 .unwrap()
    696                 .as_deref(),
    697             Some(seed_phrase.as_str())
    698         );
    699         assert!(setup_seed_phrase(&path).unwrap().is_none());
    700     }
    701 
    702     #[test]
    703     fn plaintext_wallet_can_be_encrypted_in_place() {
    704         let dir = tempdir().unwrap();
    705         let path = dir.path().join("wallet.json");
    706 
    707         let (wallet, seed_phrase) = replace_with_generated_seed_phrase(&path).unwrap();
    708         encrypt_existing_with_password(&path, "correct horse battery staple").unwrap();
    709         let stored = fs::read_to_string(&path).unwrap();
    710 
    711         assert!(stored.contains("\"version\": 3"));
    712         assert!(!stored.contains(&seed_phrase));
    713         assert_eq!(
    714             load_with_password(&path, "correct horse battery staple")
    715                 .unwrap()
    716                 .address(),
    717             wallet.address()
    718         );
    719     }
    720 
    721     #[test]
    722     fn plaintext_wallet_persists_owned_blinded_transactions() {
    723         let dir = tempdir().unwrap();
    724         let path = dir.path().join("wallet.json");
    725         replace_with_generated_seed_phrase(&path).unwrap();
    726         let owned = sample_owned_blinded_transaction();
    727 
    728         replace_owned_blinded_transactions(&path, None, vec![owned.clone()]).unwrap();
    729 
    730         assert_eq!(
    731             load_owned_blinded_transactions(&path, None).unwrap(),
    732             vec![owned]
    733         );
    734     }
    735 
    736     #[test]
    737     fn encrypted_wallet_persists_owned_blinded_transactions_without_plaintext() {
    738         let dir = tempdir().unwrap();
    739         let path = dir.path().join("wallet.json");
    740         replace_with_generated_seed_phrase_encrypted(&path, "correct horse battery staple")
    741             .unwrap();
    742         let owned = sample_owned_blinded_transaction();
    743 
    744         replace_owned_blinded_transactions(
    745             &path,
    746             Some("correct horse battery staple"),
    747             vec![owned.clone()],
    748         )
    749         .unwrap();
    750 
    751         let stored = fs::read_to_string(&path).unwrap();
    752         assert!(!stored.contains(&owned.payload.signature().to_string()));
    753         assert!(!stored.contains(&owned.reveal.key));
    754         assert_eq!(
    755             load_owned_blinded_transactions(&path, Some("correct horse battery staple")).unwrap(),
    756             vec![owned]
    757         );
    758         assert!(load_owned_blinded_transactions(&path, Some("wrong password")).is_err());
    759     }
    760 
    761     #[test]
    762     fn imports_normalized_seed_phrase() {
    763         let dir = tempdir().unwrap();
    764         let path = dir.path().join("wallet.json");
    765 
    766         let wallet = replace_with_imported_seed_phrase(
    767             &path,
    768             " ABANDON  abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art ",
    769         )
    770         .unwrap();
    771         let loaded = load_or_create(&path).unwrap();
    772 
    773         assert_eq!(wallet.address(), loaded.address());
    774         assert_eq!(
    775             setup_seed_phrase(&path).unwrap().as_deref(),
    776             Some(
    777                 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"
    778             )
    779         );
    780     }
    781 
    782     #[test]
    783     fn rejects_invalid_imported_seed_phrase() {
    784         let dir = tempdir().unwrap();
    785         let path = dir.path().join("wallet.json");
    786 
    787         let error = replace_with_imported_seed_phrase(&path, "too few words").unwrap_err();
    788 
    789         assert!(error.to_string().contains("24 words"));
    790     }
    791 
    792     #[test]
    793     fn rejects_imported_seed_phrase_with_invalid_checksum() {
    794         let dir = tempdir().unwrap();
    795         let path = dir.path().join("wallet.json");
    796         let invalid_checksum = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon";
    797 
    798         let error = replace_with_imported_seed_phrase(&path, invalid_checksum).unwrap_err();
    799 
    800         assert!(error.to_string().contains("BIP-39"));
    801     }
    802 
    803     fn assert_recovery_words_verify(seed_phrase: &str, indexes: &[usize]) {
    804         let words = seed_phrase.split_whitespace().collect::<Vec<_>>();
    805         assert_eq!(words.len(), 24);
    806         for index in indexes {
    807             let answer = words[*index].to_ascii_uppercase();
    808             assert_eq!(
    809                 answer.trim().to_ascii_lowercase(),
    810                 words[*index],
    811                 "word {} should verify case-insensitively",
    812                 index + 1
    813             );
    814         }
    815     }
    816 
    817     fn sample_owned_blinded_transaction() -> OwnedBlindedTransaction {
    818         let payload = Transaction::Transfer {
    819             inputs: vec![TxInput {
    820                 outpoint: crate::domain::OutPoint {
    821                     txid: "a".repeat(64),
    822                     index: 0,
    823                 },
    824                 owner: "mv_sample_owner".to_string(),
    825                 signature: "b".repeat(64),
    826             }],
    827             outputs: vec![TxOutput {
    828                 address: "mv_sample_recipient".to_string(),
    829                 amount: 1,
    830             }],
    831             fee: 1,
    832             signature: "c".repeat(64),
    833         };
    834         OwnedBlindedTransaction {
    835             transaction: BlindedTransaction {
    836                 commitment: "d".repeat(64),
    837                 inputs: Vec::new(),
    838                 fee: 1,
    839                 encrypted_size: 42,
    840                 expires_at_height: 10,
    841                 nonce: "e".repeat(24),
    842                 ciphertext: "f".repeat(84),
    843                 payload_hash: "1".repeat(64),
    844             },
    845             payload,
    846             reveal: BlindedReveal {
    847                 commitment: "d".repeat(64),
    848                 key: "2".repeat(64),
    849             },
    850         }
    851     }
    852 
    853     #[test]
    854     fn migrates_v1_wallet_file_to_current_address() {
    855         let dir = tempdir().unwrap();
    856         let path = dir.path().join("wallet.json");
    857         fs::write(&path, r#"{"version":1,"seed":"alice","address":"old"}"#).unwrap();
    858 
    859         let wallet = load_or_create(&path).unwrap();
    860         let stored = fs::read_to_string(path).unwrap();
    861 
    862         assert!(stored.contains("\"version\": 2"));
    863         assert!(stored.contains(wallet.address()));
    864     }
    865 
    866     #[test]
    867     fn rejects_seed_address_mismatch() {
    868         let dir = tempdir().unwrap();
    869         let path = dir.path().join("wallet.json");
    870         fs::write(
    871             &path,
    872             r#"{"version":2,"seed":"alice","address":"mv_wrong"}"#,
    873         )
    874         .unwrap();
    875 
    876         let error = load_or_create(&path).unwrap_err();
    877 
    878         assert!(error.to_string().contains("seed derives"));
    879     }
    880 }