Skip to main content

ows_lib/
ops.rs

1use std::path::Path;
2use std::process::Command;
3
4use ows_core::{
5    default_chain_for_type, ChainType, Config, EncryptedWallet, KeyType, WalletAccount,
6    ALL_CHAIN_TYPES,
7};
8use ows_signer::{
9    decrypt, encrypt, signer_for_chain, CryptoEnvelope, HdDeriver, Mnemonic, MnemonicStrength,
10    SecretBytes,
11};
12
13use crate::error::OwsLibError;
14use crate::types::{AccountInfo, SendResult, SignResult, WalletInfo};
15use crate::vault;
16
17/// Convert an EncryptedWallet to the binding-friendly WalletInfo.
18fn wallet_to_info(w: &EncryptedWallet) -> WalletInfo {
19    WalletInfo {
20        id: w.id.clone(),
21        name: w.name.clone(),
22        accounts: w
23            .accounts
24            .iter()
25            .map(|a| AccountInfo {
26                chain_id: a.chain_id.clone(),
27                address: a.address.clone(),
28                derivation_path: a.derivation_path.clone(),
29            })
30            .collect(),
31        created_at: w.created_at.clone(),
32    }
33}
34
35fn parse_chain(s: &str) -> Result<ows_core::Chain, OwsLibError> {
36    ows_core::parse_chain(s).map_err(OwsLibError::InvalidInput)
37}
38
39/// Derive accounts for all chain families from a mnemonic at the given index.
40fn derive_all_accounts(mnemonic: &Mnemonic, index: u32) -> Result<Vec<WalletAccount>, OwsLibError> {
41    let mut accounts = Vec::with_capacity(ALL_CHAIN_TYPES.len());
42    for ct in &ALL_CHAIN_TYPES {
43        let chain = default_chain_for_type(*ct);
44        let signer = signer_for_chain(*ct);
45        let path = signer.default_derivation_path(index);
46        let curve = signer.curve();
47        let key = HdDeriver::derive_from_mnemonic(mnemonic, "", &path, curve)?;
48        let address = signer.derive_address(key.expose())?;
49        let account_id = format!("{}:{}", chain.chain_id, address);
50        accounts.push(WalletAccount {
51            account_id,
52            address,
53            chain_id: chain.chain_id.to_string(),
54            derivation_path: path,
55        });
56    }
57    Ok(accounts)
58}
59
60/// A key pair: one key per curve.
61/// Private key material is zeroized on drop.
62struct KeyPair {
63    secp256k1: Vec<u8>,
64    ed25519: Vec<u8>,
65}
66
67impl Drop for KeyPair {
68    fn drop(&mut self) {
69        use zeroize::Zeroize;
70        self.secp256k1.zeroize();
71        self.ed25519.zeroize();
72    }
73}
74
75impl KeyPair {
76    /// Get the key for a given curve.
77    fn key_for_curve(&self, curve: ows_signer::Curve) -> &[u8] {
78        match curve {
79            ows_signer::Curve::Secp256k1 => &self.secp256k1,
80            ows_signer::Curve::Ed25519 => &self.ed25519,
81        }
82    }
83
84    /// Serialize to JSON bytes for encryption.
85    fn to_json_bytes(&self) -> Vec<u8> {
86        let obj = serde_json::json!({
87            "secp256k1": hex::encode(&self.secp256k1),
88            "ed25519": hex::encode(&self.ed25519),
89        });
90        obj.to_string().into_bytes()
91    }
92
93    /// Deserialize from JSON bytes after decryption.
94    fn from_json_bytes(bytes: &[u8]) -> Result<Self, OwsLibError> {
95        let s = String::from_utf8(bytes.to_vec())
96            .map_err(|_| OwsLibError::InvalidInput("invalid key pair data".into()))?;
97        let obj: serde_json::Value = serde_json::from_str(&s)?;
98        let secp = obj["secp256k1"]
99            .as_str()
100            .ok_or_else(|| OwsLibError::InvalidInput("missing secp256k1 key".into()))?;
101        let ed = obj["ed25519"]
102            .as_str()
103            .ok_or_else(|| OwsLibError::InvalidInput("missing ed25519 key".into()))?;
104        Ok(KeyPair {
105            secp256k1: hex::decode(secp)
106                .map_err(|e| OwsLibError::InvalidInput(format!("invalid secp256k1 hex: {e}")))?,
107            ed25519: hex::decode(ed)
108                .map_err(|e| OwsLibError::InvalidInput(format!("invalid ed25519 hex: {e}")))?,
109        })
110    }
111}
112
113/// Derive accounts for all chain families using a key pair (one key per curve).
114fn derive_all_accounts_from_keys(keys: &KeyPair) -> Result<Vec<WalletAccount>, OwsLibError> {
115    let mut accounts = Vec::with_capacity(ALL_CHAIN_TYPES.len());
116    for ct in &ALL_CHAIN_TYPES {
117        let signer = signer_for_chain(*ct);
118        let key = keys.key_for_curve(signer.curve());
119        let address = signer.derive_address(key)?;
120        let chain = default_chain_for_type(*ct);
121        accounts.push(WalletAccount {
122            account_id: format!("{}:{}", chain.chain_id, address),
123            address,
124            chain_id: chain.chain_id.to_string(),
125            derivation_path: String::new(),
126        });
127    }
128    Ok(accounts)
129}
130
131/// Generate a new BIP-39 mnemonic phrase.
132pub fn generate_mnemonic(words: u32) -> Result<String, OwsLibError> {
133    let strength = match words {
134        12 => MnemonicStrength::Words12,
135        24 => MnemonicStrength::Words24,
136        _ => return Err(OwsLibError::InvalidInput("words must be 12 or 24".into())),
137    };
138
139    let mnemonic = Mnemonic::generate(strength)?;
140    let phrase = mnemonic.phrase();
141    String::from_utf8(phrase.expose().to_vec())
142        .map_err(|e| OwsLibError::InvalidInput(format!("invalid UTF-8 in mnemonic: {e}")))
143}
144
145/// Derive an address from a mnemonic phrase for the given chain.
146pub fn derive_address(
147    mnemonic_phrase: &str,
148    chain: &str,
149    index: Option<u32>,
150) -> Result<String, OwsLibError> {
151    let chain = parse_chain(chain)?;
152    let mnemonic = Mnemonic::from_phrase(mnemonic_phrase)?;
153    let signer = signer_for_chain(chain.chain_type);
154    let path = signer.default_derivation_path(index.unwrap_or(0));
155    let curve = signer.curve();
156
157    let key = HdDeriver::derive_from_mnemonic(&mnemonic, "", &path, curve)?;
158    let address = signer.derive_address(key.expose())?;
159    Ok(address)
160}
161
162/// Create a new universal wallet: generates mnemonic, derives addresses for all chains,
163/// encrypts, and saves to vault.
164pub fn create_wallet(
165    name: &str,
166    words: Option<u32>,
167    passphrase: Option<&str>,
168    vault_path: Option<&Path>,
169) -> Result<WalletInfo, OwsLibError> {
170    let passphrase = passphrase.unwrap_or("");
171    let words = words.unwrap_or(12);
172    let strength = match words {
173        12 => MnemonicStrength::Words12,
174        24 => MnemonicStrength::Words24,
175        _ => return Err(OwsLibError::InvalidInput("words must be 12 or 24".into())),
176    };
177
178    if vault::wallet_name_exists(name, vault_path)? {
179        return Err(OwsLibError::WalletNameExists(name.to_string()));
180    }
181
182    let mnemonic = Mnemonic::generate(strength)?;
183    let accounts = derive_all_accounts(&mnemonic, 0)?;
184
185    let phrase = mnemonic.phrase();
186    let crypto_envelope = encrypt(phrase.expose(), passphrase)?;
187    let crypto_json = serde_json::to_value(&crypto_envelope)?;
188
189    let wallet_id = uuid::Uuid::new_v4().to_string();
190
191    let wallet = EncryptedWallet::new(
192        wallet_id,
193        name.to_string(),
194        accounts,
195        crypto_json,
196        KeyType::Mnemonic,
197    );
198
199    vault::save_encrypted_wallet(&wallet, vault_path)?;
200    Ok(wallet_to_info(&wallet))
201}
202
203/// Import a wallet from a mnemonic phrase. Derives addresses for all chains.
204pub fn import_wallet_mnemonic(
205    name: &str,
206    mnemonic_phrase: &str,
207    passphrase: Option<&str>,
208    index: Option<u32>,
209    vault_path: Option<&Path>,
210) -> Result<WalletInfo, OwsLibError> {
211    let passphrase = passphrase.unwrap_or("");
212    let index = index.unwrap_or(0);
213
214    if vault::wallet_name_exists(name, vault_path)? {
215        return Err(OwsLibError::WalletNameExists(name.to_string()));
216    }
217
218    let mnemonic = Mnemonic::from_phrase(mnemonic_phrase)?;
219    let accounts = derive_all_accounts(&mnemonic, index)?;
220
221    let phrase = mnemonic.phrase();
222    let crypto_envelope = encrypt(phrase.expose(), passphrase)?;
223    let crypto_json = serde_json::to_value(&crypto_envelope)?;
224
225    let wallet_id = uuid::Uuid::new_v4().to_string();
226
227    let wallet = EncryptedWallet::new(
228        wallet_id,
229        name.to_string(),
230        accounts,
231        crypto_json,
232        KeyType::Mnemonic,
233    );
234
235    vault::save_encrypted_wallet(&wallet, vault_path)?;
236    Ok(wallet_to_info(&wallet))
237}
238
239/// Decode a hex-encoded key, stripping an optional `0x` prefix.
240fn decode_hex_key(hex_str: &str) -> Result<Vec<u8>, OwsLibError> {
241    let trimmed = hex_str.strip_prefix("0x").unwrap_or(hex_str);
242    hex::decode(trimmed)
243        .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex private key: {e}")))
244}
245
246/// Import a wallet from a hex-encoded private key.
247/// The `chain` parameter specifies which chain the key originates from (e.g. "evm", "solana").
248/// A random key is generated for the other curve so all 6 chains are supported.
249///
250/// Alternatively, provide both `secp256k1_key_hex` and `ed25519_key_hex` to supply
251/// explicit keys for each curve. When both are given, `private_key_hex` and `chain`
252/// are ignored. When only one curve key is given alongside `private_key_hex`, it
253/// overrides the random generation for that curve.
254pub fn import_wallet_private_key(
255    name: &str,
256    private_key_hex: &str,
257    chain: Option<&str>,
258    passphrase: Option<&str>,
259    vault_path: Option<&Path>,
260    secp256k1_key_hex: Option<&str>,
261    ed25519_key_hex: Option<&str>,
262) -> Result<WalletInfo, OwsLibError> {
263    let passphrase = passphrase.unwrap_or("");
264
265    if vault::wallet_name_exists(name, vault_path)? {
266        return Err(OwsLibError::WalletNameExists(name.to_string()));
267    }
268
269    let keys = match (secp256k1_key_hex, ed25519_key_hex) {
270        // Both curve keys explicitly provided — use them directly
271        (Some(secp_hex), Some(ed_hex)) => KeyPair {
272            secp256k1: decode_hex_key(secp_hex)?,
273            ed25519: decode_hex_key(ed_hex)?,
274        },
275        // Existing single-key behavior
276        _ => {
277            let key_bytes = decode_hex_key(private_key_hex)?;
278
279            // Determine curve from the source chain (default: secp256k1)
280            let source_curve = match chain {
281                Some(c) => {
282                    let parsed = parse_chain(c)?;
283                    signer_for_chain(parsed.chain_type).curve()
284                }
285                None => ows_signer::Curve::Secp256k1,
286            };
287
288            // Build key pair: provided key for its curve, random 32 bytes for the other
289            let mut other_key = vec![0u8; 32];
290            getrandom::getrandom(&mut other_key).map_err(|e| {
291                OwsLibError::InvalidInput(format!("failed to generate random key: {e}"))
292            })?;
293
294            match source_curve {
295                ows_signer::Curve::Secp256k1 => KeyPair {
296                    secp256k1: key_bytes,
297                    ed25519: ed25519_key_hex
298                        .map(decode_hex_key)
299                        .transpose()?
300                        .unwrap_or(other_key),
301                },
302                ows_signer::Curve::Ed25519 => KeyPair {
303                    secp256k1: secp256k1_key_hex
304                        .map(decode_hex_key)
305                        .transpose()?
306                        .unwrap_or(other_key),
307                    ed25519: key_bytes,
308                },
309            }
310        }
311    };
312
313    let accounts = derive_all_accounts_from_keys(&keys)?;
314
315    let payload = keys.to_json_bytes();
316    let crypto_envelope = encrypt(&payload, passphrase)?;
317    let crypto_json = serde_json::to_value(&crypto_envelope)?;
318
319    let wallet_id = uuid::Uuid::new_v4().to_string();
320
321    let wallet = EncryptedWallet::new(
322        wallet_id,
323        name.to_string(),
324        accounts,
325        crypto_json,
326        KeyType::PrivateKey,
327    );
328
329    vault::save_encrypted_wallet(&wallet, vault_path)?;
330    Ok(wallet_to_info(&wallet))
331}
332
333/// List all wallets in the vault.
334pub fn list_wallets(vault_path: Option<&Path>) -> Result<Vec<WalletInfo>, OwsLibError> {
335    let wallets = vault::list_encrypted_wallets(vault_path)?;
336    Ok(wallets.iter().map(wallet_to_info).collect())
337}
338
339/// Get a single wallet by name or ID.
340pub fn get_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result<WalletInfo, OwsLibError> {
341    let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
342    Ok(wallet_to_info(&wallet))
343}
344
345/// Delete a wallet from the vault.
346pub fn delete_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibError> {
347    let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
348    vault::delete_wallet_file(&wallet.id, vault_path)?;
349    Ok(())
350}
351
352/// Export a wallet's secret.
353/// Mnemonic wallets return the phrase. Private key wallets return JSON with both keys.
354pub fn export_wallet(
355    name_or_id: &str,
356    passphrase: Option<&str>,
357    vault_path: Option<&Path>,
358) -> Result<String, OwsLibError> {
359    let passphrase = passphrase.unwrap_or("");
360    let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
361    let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
362    let secret = decrypt(&envelope, passphrase)?;
363
364    match wallet.key_type {
365        KeyType::Mnemonic => String::from_utf8(secret.expose().to_vec()).map_err(|_| {
366            OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
367        }),
368        KeyType::PrivateKey => {
369            // Return the JSON key pair as-is
370            String::from_utf8(secret.expose().to_vec())
371                .map_err(|_| OwsLibError::InvalidInput("wallet contains invalid key data".into()))
372        }
373    }
374}
375
376/// Rename a wallet.
377pub fn rename_wallet(
378    name_or_id: &str,
379    new_name: &str,
380    vault_path: Option<&Path>,
381) -> Result<(), OwsLibError> {
382    let mut wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
383
384    if wallet.name == new_name {
385        return Ok(());
386    }
387
388    if vault::wallet_name_exists(new_name, vault_path)? {
389        return Err(OwsLibError::WalletNameExists(new_name.to_string()));
390    }
391
392    wallet.name = new_name.to_string();
393    vault::save_encrypted_wallet(&wallet, vault_path)?;
394    Ok(())
395}
396
397/// Sign a transaction. Returns hex-encoded signature.
398///
399/// The `passphrase` parameter accepts either the owner's passphrase or an
400/// API token (`ows_key_...`). When a token is provided, policy enforcement
401/// kicks in and the mnemonic is decrypted via HKDF instead of scrypt.
402pub fn sign_transaction(
403    wallet: &str,
404    chain: &str,
405    tx_hex: &str,
406    passphrase: Option<&str>,
407    index: Option<u32>,
408    vault_path: Option<&Path>,
409) -> Result<SignResult, OwsLibError> {
410    let credential = passphrase.unwrap_or("");
411
412    let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
413    let tx_bytes = hex::decode(tx_hex_clean)
414        .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
415
416    // Agent mode: token-based signing with policy enforcement
417    if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
418        let chain = parse_chain(chain)?;
419        return crate::key_ops::sign_with_api_key(
420            credential, wallet, &chain, &tx_bytes, index, vault_path,
421        );
422    }
423
424    // Owner mode: existing passphrase-based signing (unchanged)
425    let chain = parse_chain(chain)?;
426    let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
427    let signer = signer_for_chain(chain.chain_type);
428    let output = signer.sign_transaction(key.expose(), &tx_bytes)?;
429
430    Ok(SignResult {
431        signature: hex::encode(&output.signature),
432        recovery_id: output.recovery_id,
433    })
434}
435
436/// Sign a message. Returns hex-encoded signature.
437///
438/// The `passphrase` parameter accepts either the owner's passphrase or an
439/// API token (`ows_key_...`).
440pub fn sign_message(
441    wallet: &str,
442    chain: &str,
443    message: &str,
444    passphrase: Option<&str>,
445    encoding: Option<&str>,
446    index: Option<u32>,
447    vault_path: Option<&Path>,
448) -> Result<SignResult, OwsLibError> {
449    let credential = passphrase.unwrap_or("");
450
451    let encoding = encoding.unwrap_or("utf8");
452    let msg_bytes = match encoding {
453        "utf8" => message.as_bytes().to_vec(),
454        "hex" => hex::decode(message)
455            .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex message: {e}")))?,
456        _ => {
457            return Err(OwsLibError::InvalidInput(format!(
458                "unsupported encoding: {encoding} (use 'utf8' or 'hex')"
459            )))
460        }
461    };
462
463    // Agent mode
464    if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
465        let chain = parse_chain(chain)?;
466        return crate::key_ops::sign_message_with_api_key(
467            credential, wallet, &chain, &msg_bytes, index, vault_path,
468        );
469    }
470
471    // Owner mode
472    let chain = parse_chain(chain)?;
473    let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
474    let signer = signer_for_chain(chain.chain_type);
475    let output = signer.sign_message(key.expose(), &msg_bytes)?;
476
477    Ok(SignResult {
478        signature: hex::encode(&output.signature),
479        recovery_id: output.recovery_id,
480    })
481}
482
483/// Sign EIP-712 typed structured data. Returns hex-encoded signature.
484/// Only supported for EVM chains.
485///
486/// Note: API token signing is not supported for typed data (EVM-specific
487/// operation that requires full context). Use `sign_transaction` instead.
488pub fn sign_typed_data(
489    wallet: &str,
490    chain: &str,
491    typed_data_json: &str,
492    passphrase: Option<&str>,
493    index: Option<u32>,
494    vault_path: Option<&Path>,
495) -> Result<SignResult, OwsLibError> {
496    let credential = passphrase.unwrap_or("");
497    let chain = parse_chain(chain)?;
498
499    if chain.chain_type != ows_core::ChainType::Evm {
500        return Err(OwsLibError::InvalidInput(
501            "EIP-712 typed data signing is only supported for EVM chains".into(),
502        ));
503    }
504
505    if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
506        return Err(OwsLibError::InvalidInput(
507            "EIP-712 typed data signing via API key is not yet supported; use sign_transaction"
508                .into(),
509        ));
510    }
511
512    let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
513    let evm_signer = ows_signer::chains::EvmSigner;
514    let output = evm_signer.sign_typed_data(key.expose(), typed_data_json)?;
515
516    Ok(SignResult {
517        signature: hex::encode(&output.signature),
518        recovery_id: output.recovery_id,
519    })
520}
521
522/// Sign and broadcast a transaction. Returns the transaction hash.
523///
524/// The `passphrase` parameter accepts either the owner's passphrase or an
525/// API token (`ows_key_...`). When a token is provided, policy enforcement
526/// occurs before signing.
527pub fn sign_and_send(
528    wallet: &str,
529    chain: &str,
530    tx_hex: &str,
531    passphrase: Option<&str>,
532    index: Option<u32>,
533    rpc_url: Option<&str>,
534    vault_path: Option<&Path>,
535) -> Result<SendResult, OwsLibError> {
536    let credential = passphrase.unwrap_or("");
537
538    let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
539    let tx_bytes = hex::decode(tx_hex_clean)
540        .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
541
542    // Agent mode: enforce policies, decrypt key, then sign + broadcast
543    if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
544        let chain_info = parse_chain(chain)?;
545        let (key, _) = crate::key_ops::enforce_policy_and_decrypt_key(
546            credential,
547            wallet,
548            &chain_info,
549            &tx_bytes,
550            index,
551            vault_path,
552        )?;
553        return sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url);
554    }
555
556    // Owner mode
557    let chain_info = parse_chain(chain)?;
558    let key = decrypt_signing_key(wallet, chain_info.chain_type, credential, index, vault_path)?;
559
560    sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url)
561}
562
563/// Sign, encode, and broadcast a transaction using an already-resolved private key.
564///
565/// This is the shared core of the send-transaction flow. Both the library's
566/// [`sign_and_send`] (which resolves keys from the vault) and the CLI (which
567/// resolves keys via env vars / stdin prompts) delegate here so the
568/// sign → encode → broadcast pipeline is never duplicated.
569pub fn sign_encode_and_broadcast(
570    private_key: &[u8],
571    chain: &str,
572    tx_bytes: &[u8],
573    rpc_url: Option<&str>,
574) -> Result<SendResult, OwsLibError> {
575    let chain = parse_chain(chain)?;
576    let signer = signer_for_chain(chain.chain_type);
577
578    // 1. Extract signable portion (strips signature-slot headers for Solana; no-op for others)
579    let signable = signer.extract_signable_bytes(tx_bytes)?;
580
581    // 2. Sign
582    let output = signer.sign_transaction(private_key, signable)?;
583
584    // 3. Encode the full signed transaction
585    let signed_tx = signer.encode_signed_transaction(tx_bytes, &output)?;
586
587    // 4. Resolve RPC URL using exact chain_id
588    let rpc = resolve_rpc_url(chain.chain_id, chain.chain_type, rpc_url)?;
589
590    // 5. Broadcast the full signed transaction
591    let tx_hash = broadcast(chain.chain_type, &rpc, &signed_tx)?;
592
593    Ok(SendResult { tx_hash })
594}
595
596// --- internal helpers ---
597
598/// Decrypt a wallet and return the private key for the given chain.
599///
600/// This is the single code path for resolving a credential into key material.
601/// Both the library's high-level signing functions and the CLI delegate here.
602pub fn decrypt_signing_key(
603    wallet_name_or_id: &str,
604    chain_type: ChainType,
605    passphrase: &str,
606    index: Option<u32>,
607    vault_path: Option<&Path>,
608) -> Result<SecretBytes, OwsLibError> {
609    let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
610    let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
611    let secret = decrypt(&envelope, passphrase)?;
612
613    match wallet.key_type {
614        KeyType::Mnemonic => {
615            // Use the SecretBytes directly as a &str to avoid un-zeroized String copies.
616            let phrase = std::str::from_utf8(secret.expose()).map_err(|_| {
617                OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
618            })?;
619            let mnemonic = Mnemonic::from_phrase(phrase)?;
620            let signer = signer_for_chain(chain_type);
621            let path = signer.default_derivation_path(index.unwrap_or(0));
622            let curve = signer.curve();
623            Ok(HdDeriver::derive_from_mnemonic_cached(
624                &mnemonic, "", &path, curve,
625            )?)
626        }
627        KeyType::PrivateKey => {
628            // JSON key pair — extract the right key for this chain's curve
629            let keys = KeyPair::from_json_bytes(secret.expose())?;
630            let signer = signer_for_chain(chain_type);
631            Ok(SecretBytes::from_slice(keys.key_for_curve(signer.curve())))
632        }
633    }
634}
635
636/// Resolve the RPC URL: explicit > config override (exact chain_id) > config (namespace) > built-in default.
637fn resolve_rpc_url(
638    chain_id: &str,
639    chain_type: ChainType,
640    explicit: Option<&str>,
641) -> Result<String, OwsLibError> {
642    if let Some(url) = explicit {
643        return Ok(url.to_string());
644    }
645
646    let config = Config::load_or_default();
647    let defaults = Config::default_rpc();
648
649    // Try exact chain_id match first
650    if let Some(url) = config.rpc.get(chain_id) {
651        return Ok(url.clone());
652    }
653    if let Some(url) = defaults.get(chain_id) {
654        return Ok(url.clone());
655    }
656
657    // Fallback to namespace match
658    let namespace = chain_type.namespace();
659    for (key, url) in &config.rpc {
660        if key.starts_with(namespace) {
661            return Ok(url.clone());
662        }
663    }
664    for (key, url) in &defaults {
665        if key.starts_with(namespace) {
666            return Ok(url.clone());
667        }
668    }
669
670    Err(OwsLibError::InvalidInput(format!(
671        "no RPC URL configured for chain '{chain_id}'"
672    )))
673}
674
675/// Broadcast a signed transaction via curl, dispatching per chain type.
676fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
677    match chain {
678        ChainType::Evm => broadcast_evm(rpc_url, signed_bytes),
679        ChainType::Solana => broadcast_solana(rpc_url, signed_bytes),
680        ChainType::Bitcoin => broadcast_bitcoin(rpc_url, signed_bytes),
681        ChainType::Cosmos => broadcast_cosmos(rpc_url, signed_bytes),
682        ChainType::Tron => broadcast_tron(rpc_url, signed_bytes),
683        ChainType::Ton => broadcast_ton(rpc_url, signed_bytes),
684        ChainType::Spark => Err(OwsLibError::InvalidInput(
685            "broadcast not yet supported for Spark".into(),
686        )),
687        ChainType::Filecoin => Err(OwsLibError::InvalidInput(
688            "broadcast not yet supported for Filecoin".into(),
689        )),
690        ChainType::Sui => broadcast_sui(rpc_url, signed_bytes),
691    }
692}
693
694fn broadcast_evm(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
695    let hex_tx = format!("0x{}", hex::encode(signed_bytes));
696    let body = serde_json::json!({
697        "jsonrpc": "2.0",
698        "method": "eth_sendRawTransaction",
699        "params": [hex_tx],
700        "id": 1
701    });
702    let resp = curl_post_json(rpc_url, &body.to_string())?;
703    extract_json_field(&resp, "result")
704}
705
706fn broadcast_solana(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
707    use base64::Engine;
708    let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
709    let body = serde_json::json!({
710        "jsonrpc": "2.0",
711        "method": "sendTransaction",
712        "params": [b64_tx],
713        "id": 1
714    });
715    let resp = curl_post_json(rpc_url, &body.to_string())?;
716    extract_json_field(&resp, "result")
717}
718
719fn broadcast_bitcoin(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
720    let hex_tx = hex::encode(signed_bytes);
721    let url = format!("{}/tx", rpc_url.trim_end_matches('/'));
722    let output = Command::new("curl")
723        .args([
724            "-fsSL",
725            "-X",
726            "POST",
727            "-H",
728            "Content-Type: text/plain",
729            "-d",
730            &hex_tx,
731            &url,
732        ])
733        .output()
734        .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
735
736    if !output.status.success() {
737        let stderr = String::from_utf8_lossy(&output.stderr);
738        return Err(OwsLibError::BroadcastFailed(format!(
739            "broadcast failed: {stderr}"
740        )));
741    }
742
743    let tx_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
744    if tx_hash.is_empty() {
745        return Err(OwsLibError::BroadcastFailed(
746            "empty response from broadcast".into(),
747        ));
748    }
749    Ok(tx_hash)
750}
751
752fn broadcast_cosmos(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
753    use base64::Engine;
754    let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
755    let url = format!("{}/cosmos/tx/v1beta1/txs", rpc_url.trim_end_matches('/'));
756    let body = serde_json::json!({
757        "tx_bytes": b64_tx,
758        "mode": "BROADCAST_MODE_SYNC"
759    });
760    let resp = curl_post_json(&url, &body.to_string())?;
761    let parsed: serde_json::Value = serde_json::from_str(&resp)?;
762    parsed["tx_response"]["txhash"]
763        .as_str()
764        .map(|s| s.to_string())
765        .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no txhash in response: {resp}")))
766}
767
768fn broadcast_tron(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
769    let hex_tx = hex::encode(signed_bytes);
770    let url = format!("{}/wallet/broadcasthex", rpc_url.trim_end_matches('/'));
771    let body = serde_json::json!({ "transaction": hex_tx });
772    let resp = curl_post_json(&url, &body.to_string())?;
773    extract_json_field(&resp, "txid")
774}
775
776fn broadcast_ton(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
777    use base64::Engine;
778    let b64_boc = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
779    let url = format!("{}/sendBoc", rpc_url.trim_end_matches('/'));
780    let body = serde_json::json!({ "boc": b64_boc });
781    let resp = curl_post_json(&url, &body.to_string())?;
782    let parsed: serde_json::Value = serde_json::from_str(&resp)?;
783    parsed["result"]["hash"]
784        .as_str()
785        .map(|s| s.to_string())
786        .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no hash in response: {resp}")))
787}
788
789fn broadcast_sui(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
790    use ows_signer::chains::sui::WIRE_SIG_LEN;
791
792    if signed_bytes.len() <= WIRE_SIG_LEN {
793        return Err(OwsLibError::InvalidInput(
794            "signed transaction too short to contain tx + signature".into(),
795        ));
796    }
797
798    let split = signed_bytes.len() - WIRE_SIG_LEN;
799    let tx_part = &signed_bytes[..split];
800    let sig_part = &signed_bytes[split..];
801
802    crate::sui_grpc::execute_transaction(rpc_url, tx_part, sig_part)
803}
804
805fn curl_post_json(url: &str, body: &str) -> Result<String, OwsLibError> {
806    let output = Command::new("curl")
807        .args([
808            "-fsSL",
809            "-X",
810            "POST",
811            "-H",
812            "Content-Type: application/json",
813            "-d",
814            body,
815            url,
816        ])
817        .output()
818        .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
819
820    if !output.status.success() {
821        let stderr = String::from_utf8_lossy(&output.stderr);
822        return Err(OwsLibError::BroadcastFailed(format!(
823            "broadcast failed: {stderr}"
824        )));
825    }
826
827    Ok(String::from_utf8_lossy(&output.stdout).to_string())
828}
829
830fn extract_json_field(json_str: &str, field: &str) -> Result<String, OwsLibError> {
831    let parsed: serde_json::Value = serde_json::from_str(json_str)?;
832
833    if let Some(error) = parsed.get("error") {
834        return Err(OwsLibError::BroadcastFailed(format!("RPC error: {error}")));
835    }
836
837    parsed[field]
838        .as_str()
839        .map(|s| s.to_string())
840        .ok_or_else(|| {
841            OwsLibError::BroadcastFailed(format!("no '{field}' in response: {json_str}"))
842        })
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    // ---- helpers ----
850
851    /// Build a private-key wallet directly in the vault, bypassing
852    /// `import_wallet_private_key` (which touches all chains including TON).
853    fn save_privkey_wallet(
854        name: &str,
855        privkey_hex: &str,
856        passphrase: &str,
857        vault: &Path,
858    ) -> WalletInfo {
859        let key_bytes = hex::decode(privkey_hex).unwrap();
860
861        // Generate a random ed25519 key for the other curve
862        let mut ed_key = vec![0u8; 32];
863        getrandom::getrandom(&mut ed_key).unwrap();
864
865        let keys = KeyPair {
866            secp256k1: key_bytes,
867            ed25519: ed_key,
868        };
869        let accounts = derive_all_accounts_from_keys(&keys).unwrap();
870        let payload = keys.to_json_bytes();
871        let crypto_envelope = encrypt(&payload, passphrase).unwrap();
872        let crypto_json = serde_json::to_value(&crypto_envelope).unwrap();
873        let wallet = EncryptedWallet::new(
874            uuid::Uuid::new_v4().to_string(),
875            name.to_string(),
876            accounts,
877            crypto_json,
878            KeyType::PrivateKey,
879        );
880        vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
881        wallet_to_info(&wallet)
882    }
883
884    const TEST_PRIVKEY: &str = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
885
886    // ================================================================
887    // 1. MNEMONIC GENERATION
888    // ================================================================
889
890    #[test]
891    fn mnemonic_12_words() {
892        let phrase = generate_mnemonic(12).unwrap();
893        assert_eq!(phrase.split_whitespace().count(), 12);
894    }
895
896    #[test]
897    fn mnemonic_24_words() {
898        let phrase = generate_mnemonic(24).unwrap();
899        assert_eq!(phrase.split_whitespace().count(), 24);
900    }
901
902    #[test]
903    fn mnemonic_invalid_word_count() {
904        assert!(generate_mnemonic(15).is_err());
905        assert!(generate_mnemonic(0).is_err());
906        assert!(generate_mnemonic(13).is_err());
907    }
908
909    #[test]
910    fn mnemonic_is_unique_each_call() {
911        let a = generate_mnemonic(12).unwrap();
912        let b = generate_mnemonic(12).unwrap();
913        assert_ne!(a, b, "two generated mnemonics should differ");
914    }
915
916    // ================================================================
917    // 2. ADDRESS DERIVATION
918    // ================================================================
919
920    #[test]
921    fn derive_address_all_chains() {
922        let phrase = generate_mnemonic(12).unwrap();
923        let chains = ["evm", "solana", "bitcoin", "cosmos", "tron", "ton", "sui"];
924        for chain in &chains {
925            let addr = derive_address(&phrase, chain, None).unwrap();
926            assert!(!addr.is_empty(), "address should be non-empty for {chain}");
927        }
928    }
929
930    #[test]
931    fn derive_address_evm_format() {
932        let phrase = generate_mnemonic(12).unwrap();
933        let addr = derive_address(&phrase, "evm", None).unwrap();
934        assert!(addr.starts_with("0x"), "EVM address should start with 0x");
935        assert_eq!(addr.len(), 42, "EVM address should be 42 chars");
936    }
937
938    #[test]
939    fn derive_address_deterministic() {
940        let phrase = generate_mnemonic(12).unwrap();
941        let a = derive_address(&phrase, "evm", None).unwrap();
942        let b = derive_address(&phrase, "evm", None).unwrap();
943        assert_eq!(a, b, "same mnemonic should produce same address");
944    }
945
946    #[test]
947    fn derive_address_different_index() {
948        let phrase = generate_mnemonic(12).unwrap();
949        let a = derive_address(&phrase, "evm", Some(0)).unwrap();
950        let b = derive_address(&phrase, "evm", Some(1)).unwrap();
951        assert_ne!(a, b, "different indices should produce different addresses");
952    }
953
954    #[test]
955    fn derive_address_invalid_chain() {
956        let phrase = generate_mnemonic(12).unwrap();
957        assert!(derive_address(&phrase, "nonexistent", None).is_err());
958    }
959
960    #[test]
961    fn derive_address_invalid_mnemonic() {
962        assert!(derive_address("not a valid mnemonic phrase at all", "evm", None).is_err());
963    }
964
965    // ================================================================
966    // 3. MNEMONIC WALLET LIFECYCLE (create → export → import → sign)
967    // ================================================================
968
969    #[test]
970    fn mnemonic_wallet_create_export_reimport() {
971        let v1 = tempfile::tempdir().unwrap();
972        let v2 = tempfile::tempdir().unwrap();
973
974        // Create
975        let w1 = create_wallet("w1", None, None, Some(v1.path())).unwrap();
976        assert!(!w1.accounts.is_empty());
977
978        // Export mnemonic
979        let phrase = export_wallet("w1", None, Some(v1.path())).unwrap();
980        assert_eq!(phrase.split_whitespace().count(), 12);
981
982        // Re-import into fresh vault
983        let w2 = import_wallet_mnemonic("w2", &phrase, None, None, Some(v2.path())).unwrap();
984
985        // Addresses must match exactly
986        assert_eq!(w1.accounts.len(), w2.accounts.len());
987        for (a1, a2) in w1.accounts.iter().zip(w2.accounts.iter()) {
988            assert_eq!(a1.chain_id, a2.chain_id);
989            assert_eq!(
990                a1.address, a2.address,
991                "address mismatch for {}",
992                a1.chain_id
993            );
994        }
995    }
996
997    #[test]
998    fn mnemonic_wallet_sign_message_all_chains() {
999        let dir = tempfile::tempdir().unwrap();
1000        let vault = dir.path();
1001        create_wallet("multi-sign", None, None, Some(vault)).unwrap();
1002
1003        let chains = [
1004            "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui",
1005        ];
1006        for chain in &chains {
1007            let result = sign_message(
1008                "multi-sign",
1009                chain,
1010                "test msg",
1011                None,
1012                None,
1013                None,
1014                Some(vault),
1015            );
1016            assert!(
1017                result.is_ok(),
1018                "sign_message should work for {chain}: {:?}",
1019                result.err()
1020            );
1021            let sig = result.unwrap();
1022            assert!(
1023                !sig.signature.is_empty(),
1024                "signature should be non-empty for {chain}"
1025            );
1026        }
1027    }
1028
1029    #[test]
1030    fn mnemonic_wallet_sign_tx_all_chains() {
1031        let dir = tempfile::tempdir().unwrap();
1032        let vault = dir.path();
1033        create_wallet("tx-sign", None, None, Some(vault)).unwrap();
1034
1035        let generic_tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1036        // Solana requires a properly formatted serialized transaction:
1037        // [0x01 num_sigs] [64 zero bytes for sig slot] [message bytes...]
1038        let mut solana_tx = vec![0x01u8]; // 1 signature slot
1039        solana_tx.extend_from_slice(&[0u8; 64]); // placeholder signature
1040        solana_tx.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // message payload
1041        let solana_tx_hex = hex::encode(&solana_tx);
1042
1043        let chains = [
1044            "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui",
1045        ];
1046        for chain in &chains {
1047            let tx = if *chain == "solana" {
1048                &solana_tx_hex
1049            } else {
1050                generic_tx_hex
1051            };
1052            let result = sign_transaction("tx-sign", chain, tx, None, None, Some(vault));
1053            assert!(
1054                result.is_ok(),
1055                "sign_transaction should work for {chain}: {:?}",
1056                result.err()
1057            );
1058        }
1059    }
1060
1061    #[test]
1062    fn mnemonic_wallet_signing_is_deterministic() {
1063        let dir = tempfile::tempdir().unwrap();
1064        let vault = dir.path();
1065        create_wallet("det-sign", None, None, Some(vault)).unwrap();
1066
1067        let s1 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1068        let s2 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1069        assert_eq!(
1070            s1.signature, s2.signature,
1071            "same message should produce same signature"
1072        );
1073    }
1074
1075    #[test]
1076    fn mnemonic_wallet_different_messages_produce_different_sigs() {
1077        let dir = tempfile::tempdir().unwrap();
1078        let vault = dir.path();
1079        create_wallet("diff-msg", None, None, Some(vault)).unwrap();
1080
1081        let s1 = sign_message("diff-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
1082        let s2 = sign_message("diff-msg", "evm", "world", None, None, None, Some(vault)).unwrap();
1083        assert_ne!(s1.signature, s2.signature);
1084    }
1085
1086    // ================================================================
1087    // 4. PRIVATE KEY WALLET LIFECYCLE
1088    // ================================================================
1089
1090    #[test]
1091    fn privkey_wallet_sign_message() {
1092        let dir = tempfile::tempdir().unwrap();
1093        save_privkey_wallet("pk-sign", TEST_PRIVKEY, "", dir.path());
1094
1095        let sig = sign_message(
1096            "pk-sign",
1097            "evm",
1098            "hello",
1099            None,
1100            None,
1101            None,
1102            Some(dir.path()),
1103        )
1104        .unwrap();
1105        assert!(!sig.signature.is_empty());
1106        assert!(sig.recovery_id.is_some());
1107    }
1108
1109    #[test]
1110    fn privkey_wallet_sign_transaction() {
1111        let dir = tempfile::tempdir().unwrap();
1112        save_privkey_wallet("pk-tx", TEST_PRIVKEY, "", dir.path());
1113
1114        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1115        let sig = sign_transaction("pk-tx", "evm", tx, None, None, Some(dir.path())).unwrap();
1116        assert!(!sig.signature.is_empty());
1117    }
1118
1119    #[test]
1120    fn privkey_wallet_export_returns_json() {
1121        let dir = tempfile::tempdir().unwrap();
1122        save_privkey_wallet("pk-export", TEST_PRIVKEY, "", dir.path());
1123
1124        let exported = export_wallet("pk-export", None, Some(dir.path())).unwrap();
1125        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1126        assert_eq!(
1127            obj["secp256k1"].as_str().unwrap(),
1128            TEST_PRIVKEY,
1129            "exported secp256k1 key should match original"
1130        );
1131        assert!(obj["ed25519"].as_str().is_some(), "should have ed25519 key");
1132    }
1133
1134    #[test]
1135    fn privkey_wallet_signing_is_deterministic() {
1136        let dir = tempfile::tempdir().unwrap();
1137        save_privkey_wallet("pk-det", TEST_PRIVKEY, "", dir.path());
1138
1139        let s1 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1140        let s2 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1141        assert_eq!(s1.signature, s2.signature);
1142    }
1143
1144    #[test]
1145    fn privkey_and_mnemonic_wallets_produce_different_sigs() {
1146        let dir = tempfile::tempdir().unwrap();
1147        let vault = dir.path();
1148
1149        create_wallet("mn-w", None, None, Some(vault)).unwrap();
1150        save_privkey_wallet("pk-w", TEST_PRIVKEY, "", vault);
1151
1152        let mn_sig = sign_message("mn-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1153        let pk_sig = sign_message("pk-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1154        assert_ne!(
1155            mn_sig.signature, pk_sig.signature,
1156            "different keys should produce different signatures"
1157        );
1158    }
1159
1160    #[test]
1161    fn privkey_wallet_import_via_api() {
1162        let dir = tempfile::tempdir().unwrap();
1163        let vault = dir.path();
1164
1165        let info = import_wallet_private_key(
1166            "pk-api",
1167            TEST_PRIVKEY,
1168            Some("evm"),
1169            None,
1170            Some(vault),
1171            None,
1172            None,
1173        )
1174        .unwrap();
1175        assert!(
1176            !info.accounts.is_empty(),
1177            "should derive at least one account"
1178        );
1179
1180        // Should be able to sign
1181        let sig = sign_message("pk-api", "evm", "hello", None, None, None, Some(vault)).unwrap();
1182        assert!(!sig.signature.is_empty());
1183
1184        // Export should return JSON key pair with original key
1185        let exported = export_wallet("pk-api", None, Some(vault)).unwrap();
1186        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1187        assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1188    }
1189
1190    #[test]
1191    fn privkey_wallet_import_both_curve_keys() {
1192        let dir = tempfile::tempdir().unwrap();
1193        let vault = dir.path();
1194
1195        let secp_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
1196        let ed_key = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
1197
1198        let info = import_wallet_private_key(
1199            "pk-both",
1200            "",   // ignored when both curve keys provided
1201            None, // chain ignored too
1202            None,
1203            Some(vault),
1204            Some(secp_key),
1205            Some(ed_key),
1206        )
1207        .unwrap();
1208
1209        assert_eq!(
1210            info.accounts.len(),
1211            ALL_CHAIN_TYPES.len(),
1212            "should have one account per chain type"
1213        );
1214
1215        // Sign on EVM (secp256k1)
1216        let sig = sign_message("pk-both", "evm", "hello", None, None, None, Some(vault)).unwrap();
1217        assert!(!sig.signature.is_empty());
1218
1219        // Sign on Solana (ed25519)
1220        let sig =
1221            sign_message("pk-both", "solana", "hello", None, None, None, Some(vault)).unwrap();
1222        assert!(!sig.signature.is_empty());
1223
1224        // Export should return both keys
1225        let exported = export_wallet("pk-both", None, Some(vault)).unwrap();
1226        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1227        assert_eq!(obj["secp256k1"].as_str().unwrap(), secp_key);
1228        assert_eq!(obj["ed25519"].as_str().unwrap(), ed_key);
1229    }
1230
1231    // ================================================================
1232    // 5. PASSPHRASE PROTECTION
1233    // ================================================================
1234
1235    #[test]
1236    fn passphrase_protected_mnemonic_wallet() {
1237        let dir = tempfile::tempdir().unwrap();
1238        let vault = dir.path();
1239
1240        create_wallet("pass-mn", None, Some("s3cret"), Some(vault)).unwrap();
1241
1242        // Sign with correct passphrase
1243        let sig = sign_message(
1244            "pass-mn",
1245            "evm",
1246            "hello",
1247            Some("s3cret"),
1248            None,
1249            None,
1250            Some(vault),
1251        )
1252        .unwrap();
1253        assert!(!sig.signature.is_empty());
1254
1255        // Export with correct passphrase
1256        let phrase = export_wallet("pass-mn", Some("s3cret"), Some(vault)).unwrap();
1257        assert_eq!(phrase.split_whitespace().count(), 12);
1258
1259        // Wrong passphrase should fail
1260        assert!(sign_message(
1261            "pass-mn",
1262            "evm",
1263            "hello",
1264            Some("wrong"),
1265            None,
1266            None,
1267            Some(vault)
1268        )
1269        .is_err());
1270        assert!(export_wallet("pass-mn", Some("wrong"), Some(vault)).is_err());
1271
1272        // No passphrase should fail (defaults to empty string, which is wrong)
1273        assert!(sign_message("pass-mn", "evm", "hello", None, None, None, Some(vault)).is_err());
1274    }
1275
1276    #[test]
1277    fn passphrase_protected_privkey_wallet() {
1278        let dir = tempfile::tempdir().unwrap();
1279        save_privkey_wallet("pass-pk", TEST_PRIVKEY, "mypass", dir.path());
1280
1281        // Correct passphrase
1282        let sig = sign_message(
1283            "pass-pk",
1284            "evm",
1285            "hello",
1286            Some("mypass"),
1287            None,
1288            None,
1289            Some(dir.path()),
1290        )
1291        .unwrap();
1292        assert!(!sig.signature.is_empty());
1293
1294        let exported = export_wallet("pass-pk", Some("mypass"), Some(dir.path())).unwrap();
1295        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1296        assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1297
1298        // Wrong passphrase
1299        assert!(sign_message(
1300            "pass-pk",
1301            "evm",
1302            "hello",
1303            Some("wrong"),
1304            None,
1305            None,
1306            Some(dir.path())
1307        )
1308        .is_err());
1309        assert!(export_wallet("pass-pk", Some("wrong"), Some(dir.path())).is_err());
1310    }
1311
1312    // ================================================================
1313    // 6. SIGNATURE VERIFICATION (prove signatures are cryptographically valid)
1314    // ================================================================
1315
1316    #[test]
1317    fn evm_signature_is_recoverable() {
1318        use sha3::Digest;
1319        let dir = tempfile::tempdir().unwrap();
1320        let vault = dir.path();
1321
1322        let info = create_wallet("verify-evm", None, None, Some(vault)).unwrap();
1323        let evm_addr = info
1324            .accounts
1325            .iter()
1326            .find(|a| a.chain_id.starts_with("eip155:"))
1327            .unwrap()
1328            .address
1329            .clone();
1330
1331        let sig = sign_message(
1332            "verify-evm",
1333            "evm",
1334            "hello world",
1335            None,
1336            None,
1337            None,
1338            Some(vault),
1339        )
1340        .unwrap();
1341
1342        // EVM personal_sign: keccak256("\x19Ethereum Signed Message:\n" + len + msg)
1343        let msg = b"hello world";
1344        let prefix = format!("\x19Ethereum Signed Message:\n{}", msg.len());
1345        let mut prefixed = prefix.into_bytes();
1346        prefixed.extend_from_slice(msg);
1347
1348        let hash = sha3::Keccak256::digest(&prefixed);
1349        let sig_bytes = hex::decode(&sig.signature).unwrap();
1350        assert_eq!(
1351            sig_bytes.len(),
1352            65,
1353            "EVM signature should be 65 bytes (r + s + v)"
1354        );
1355
1356        // Recover public key from signature (v is 27 or 28 per EIP-191)
1357        let v = sig_bytes[64];
1358        assert!(
1359            v == 27 || v == 28,
1360            "EIP-191 v byte should be 27 or 28, got {v}"
1361        );
1362        let recid = k256::ecdsa::RecoveryId::try_from(v - 27).unwrap();
1363        let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
1364        let recovered_key =
1365            k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
1366
1367        // Derive address from recovered key and compare
1368        let pubkey_bytes = recovered_key.to_encoded_point(false);
1369        let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
1370        let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
1371
1372        // Compare case-insensitively (EIP-55 checksum)
1373        assert_eq!(
1374            recovered_addr.to_lowercase(),
1375            evm_addr.to_lowercase(),
1376            "recovered address should match wallet's EVM address"
1377        );
1378    }
1379
1380    // ================================================================
1381    // 7. ERROR HANDLING
1382    // ================================================================
1383
1384    #[test]
1385    fn error_nonexistent_wallet() {
1386        let dir = tempfile::tempdir().unwrap();
1387        assert!(get_wallet("nope", Some(dir.path())).is_err());
1388        assert!(export_wallet("nope", None, Some(dir.path())).is_err());
1389        assert!(sign_message("nope", "evm", "x", None, None, None, Some(dir.path())).is_err());
1390        assert!(delete_wallet("nope", Some(dir.path())).is_err());
1391    }
1392
1393    #[test]
1394    fn error_duplicate_wallet_name() {
1395        let dir = tempfile::tempdir().unwrap();
1396        let vault = dir.path();
1397        create_wallet("dup", None, None, Some(vault)).unwrap();
1398        assert!(create_wallet("dup", None, None, Some(vault)).is_err());
1399    }
1400
1401    #[test]
1402    fn error_invalid_private_key_hex() {
1403        let dir = tempfile::tempdir().unwrap();
1404        assert!(import_wallet_private_key(
1405            "bad",
1406            "not-hex",
1407            Some("evm"),
1408            None,
1409            Some(dir.path()),
1410            None,
1411            None,
1412        )
1413        .is_err());
1414    }
1415
1416    #[test]
1417    fn error_invalid_chain_for_signing() {
1418        let dir = tempfile::tempdir().unwrap();
1419        let vault = dir.path();
1420        create_wallet("chain-err", None, None, Some(vault)).unwrap();
1421        assert!(
1422            sign_message("chain-err", "fakecoin", "hi", None, None, None, Some(vault)).is_err()
1423        );
1424    }
1425
1426    #[test]
1427    fn error_invalid_tx_hex() {
1428        let dir = tempfile::tempdir().unwrap();
1429        let vault = dir.path();
1430        create_wallet("hex-err", None, None, Some(vault)).unwrap();
1431        assert!(
1432            sign_transaction("hex-err", "evm", "not-valid-hex!", None, None, Some(vault)).is_err()
1433        );
1434    }
1435
1436    // ================================================================
1437    // 8. WALLET MANAGEMENT
1438    // ================================================================
1439
1440    #[test]
1441    fn list_wallets_empty_vault() {
1442        let dir = tempfile::tempdir().unwrap();
1443        let wallets = list_wallets(Some(dir.path())).unwrap();
1444        assert!(wallets.is_empty());
1445    }
1446
1447    #[test]
1448    fn get_wallet_by_name_and_id() {
1449        let dir = tempfile::tempdir().unwrap();
1450        let vault = dir.path();
1451        let info = create_wallet("lookup", None, None, Some(vault)).unwrap();
1452
1453        let by_name = get_wallet("lookup", Some(vault)).unwrap();
1454        assert_eq!(by_name.id, info.id);
1455
1456        let by_id = get_wallet(&info.id, Some(vault)).unwrap();
1457        assert_eq!(by_id.name, "lookup");
1458    }
1459
1460    #[test]
1461    fn rename_wallet_works() {
1462        let dir = tempfile::tempdir().unwrap();
1463        let vault = dir.path();
1464        let info = create_wallet("before", None, None, Some(vault)).unwrap();
1465
1466        rename_wallet("before", "after", Some(vault)).unwrap();
1467
1468        assert!(get_wallet("before", Some(vault)).is_err());
1469        let after = get_wallet("after", Some(vault)).unwrap();
1470        assert_eq!(after.id, info.id);
1471    }
1472
1473    #[test]
1474    fn rename_to_existing_name_fails() {
1475        let dir = tempfile::tempdir().unwrap();
1476        let vault = dir.path();
1477        create_wallet("a", None, None, Some(vault)).unwrap();
1478        create_wallet("b", None, None, Some(vault)).unwrap();
1479        assert!(rename_wallet("a", "b", Some(vault)).is_err());
1480    }
1481
1482    #[test]
1483    fn delete_wallet_removes_from_list() {
1484        let dir = tempfile::tempdir().unwrap();
1485        let vault = dir.path();
1486        create_wallet("del-me", None, None, Some(vault)).unwrap();
1487        assert_eq!(list_wallets(Some(vault)).unwrap().len(), 1);
1488
1489        delete_wallet("del-me", Some(vault)).unwrap();
1490        assert_eq!(list_wallets(Some(vault)).unwrap().len(), 0);
1491    }
1492
1493    // ================================================================
1494    // 9. MESSAGE ENCODING
1495    // ================================================================
1496
1497    #[test]
1498    fn sign_message_hex_encoding() {
1499        let dir = tempfile::tempdir().unwrap();
1500        let vault = dir.path();
1501        create_wallet("hex-enc", None, None, Some(vault)).unwrap();
1502
1503        // "hello" in hex
1504        let sig = sign_message(
1505            "hex-enc",
1506            "evm",
1507            "68656c6c6f",
1508            None,
1509            Some("hex"),
1510            None,
1511            Some(vault),
1512        )
1513        .unwrap();
1514        assert!(!sig.signature.is_empty());
1515
1516        // Should match utf8 encoding of the same bytes
1517        let sig2 = sign_message(
1518            "hex-enc",
1519            "evm",
1520            "hello",
1521            None,
1522            Some("utf8"),
1523            None,
1524            Some(vault),
1525        )
1526        .unwrap();
1527        assert_eq!(
1528            sig.signature, sig2.signature,
1529            "hex and utf8 encoding of same bytes should produce same signature"
1530        );
1531    }
1532
1533    #[test]
1534    fn sign_message_invalid_encoding() {
1535        let dir = tempfile::tempdir().unwrap();
1536        let vault = dir.path();
1537        create_wallet("bad-enc", None, None, Some(vault)).unwrap();
1538        assert!(sign_message(
1539            "bad-enc",
1540            "evm",
1541            "hello",
1542            None,
1543            Some("base64"),
1544            None,
1545            Some(vault)
1546        )
1547        .is_err());
1548    }
1549
1550    // ================================================================
1551    // 10. MULTI-WALLET VAULT
1552    // ================================================================
1553
1554    #[test]
1555    fn multiple_wallets_coexist() {
1556        let dir = tempfile::tempdir().unwrap();
1557        let vault = dir.path();
1558
1559        create_wallet("w1", None, None, Some(vault)).unwrap();
1560        create_wallet("w2", None, None, Some(vault)).unwrap();
1561        save_privkey_wallet("w3", TEST_PRIVKEY, "", vault);
1562
1563        let wallets = list_wallets(Some(vault)).unwrap();
1564        assert_eq!(wallets.len(), 3);
1565
1566        // All can sign independently
1567        let s1 = sign_message("w1", "evm", "test", None, None, None, Some(vault)).unwrap();
1568        let s2 = sign_message("w2", "evm", "test", None, None, None, Some(vault)).unwrap();
1569        let s3 = sign_message("w3", "evm", "test", None, None, None, Some(vault)).unwrap();
1570
1571        // All signatures should be different (different keys)
1572        assert_ne!(s1.signature, s2.signature);
1573        assert_ne!(s1.signature, s3.signature);
1574        assert_ne!(s2.signature, s3.signature);
1575
1576        // Delete one, others survive
1577        delete_wallet("w2", Some(vault)).unwrap();
1578        assert_eq!(list_wallets(Some(vault)).unwrap().len(), 2);
1579        assert!(sign_message("w1", "evm", "test", None, None, None, Some(vault)).is_ok());
1580        assert!(sign_message("w3", "evm", "test", None, None, None, Some(vault)).is_ok());
1581    }
1582
1583    // ================================================================
1584    // 11. BUG REGRESSION: CLI send_transaction broadcasts raw signature
1585    // ================================================================
1586
1587    #[test]
1588    fn signed_tx_must_differ_from_raw_signature() {
1589        // BUG TEST: The CLI's send_transaction.rs broadcasts `output.signature`
1590        // (raw 65-byte sig) instead of encoding the full signed transaction via
1591        // signer.encode_signed_transaction(). This test proves the two are different
1592        // — broadcasting the raw signature sends garbage to the RPC node.
1593        //
1594        // The library's sign_and_send correctly calls encode_signed_transaction
1595        // before broadcast (ops.rs:481), but the CLI skips this step
1596        // (send_transaction.rs:43).
1597
1598        let dir = tempfile::tempdir().unwrap();
1599        let vault = dir.path();
1600        save_privkey_wallet("send-bug", TEST_PRIVKEY, "", vault);
1601
1602        // Build a minimal unsigned EIP-1559 transaction
1603        let items: Vec<u8> = [
1604            ows_signer::rlp::encode_bytes(&[1]),          // chain_id = 1
1605            ows_signer::rlp::encode_bytes(&[]),           // nonce = 0
1606            ows_signer::rlp::encode_bytes(&[1]),          // maxPriorityFeePerGas
1607            ows_signer::rlp::encode_bytes(&[100]),        // maxFeePerGas
1608            ows_signer::rlp::encode_bytes(&[0x52, 0x08]), // gasLimit = 21000
1609            ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]), // to (truncated)
1610            ows_signer::rlp::encode_bytes(&[]),           // value = 0
1611            ows_signer::rlp::encode_bytes(&[]),           // data
1612            ows_signer::rlp::encode_list(&[]),            // accessList
1613        ]
1614        .concat();
1615
1616        let mut unsigned_tx = vec![0x02u8];
1617        unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
1618        let tx_hex = hex::encode(&unsigned_tx);
1619
1620        // Sign the transaction via the library
1621        let sign_result =
1622            sign_transaction("send-bug", "evm", &tx_hex, None, None, Some(vault)).unwrap();
1623        let raw_signature = hex::decode(&sign_result.signature).unwrap();
1624
1625        // Now encode the full signed transaction (what the library does correctly)
1626        let key = decrypt_signing_key("send-bug", ChainType::Evm, "", None, Some(vault)).unwrap();
1627        let signer = signer_for_chain(ChainType::Evm);
1628        let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
1629        let full_signed_tx = signer
1630            .encode_signed_transaction(&unsigned_tx, &output)
1631            .unwrap();
1632
1633        // The raw signature (65 bytes) and the full signed tx are completely different.
1634        // Broadcasting the raw signature (as the CLI does) would always fail.
1635        assert_eq!(
1636            raw_signature.len(),
1637            65,
1638            "raw EVM signature should be 65 bytes (r || s || v)"
1639        );
1640        assert!(
1641            full_signed_tx.len() > raw_signature.len(),
1642            "full signed tx ({} bytes) must be larger than raw signature ({} bytes)",
1643            full_signed_tx.len(),
1644            raw_signature.len()
1645        );
1646        assert_ne!(
1647            raw_signature, full_signed_tx,
1648            "raw signature and full signed transaction must differ — \
1649             broadcasting the raw signature (as CLI send_transaction.rs:43 does) is wrong"
1650        );
1651
1652        // The full signed tx should start with the EIP-1559 type byte
1653        assert_eq!(
1654            full_signed_tx[0], 0x02,
1655            "full signed EIP-1559 tx must start with type byte 0x02"
1656        );
1657    }
1658
1659    // ================================================================
1660    // CHARACTERIZATION TESTS: lock down current signing behavior before refactoring
1661    // ================================================================
1662
1663    #[test]
1664    fn char_create_wallet_sign_transaction_with_passphrase() {
1665        let dir = tempfile::tempdir().unwrap();
1666        let vault = dir.path();
1667        create_wallet("char-pass-tx", None, Some("secret"), Some(vault)).unwrap();
1668
1669        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1670        let sig =
1671            sign_transaction("char-pass-tx", "evm", tx, Some("secret"), None, Some(vault)).unwrap();
1672        assert!(!sig.signature.is_empty());
1673        assert!(sig.recovery_id.is_some());
1674    }
1675
1676    #[test]
1677    fn char_create_wallet_sign_transaction_empty_passphrase() {
1678        let dir = tempfile::tempdir().unwrap();
1679        let vault = dir.path();
1680        create_wallet("char-empty-tx", None, None, Some(vault)).unwrap();
1681
1682        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1683        let sig =
1684            sign_transaction("char-empty-tx", "evm", tx, Some(""), None, Some(vault)).unwrap();
1685        assert!(!sig.signature.is_empty());
1686    }
1687
1688    #[test]
1689    fn char_no_passphrase_none_none_sign_transaction() {
1690        // Most common real-world flow: create wallet with no passphrase (None),
1691        // sign with no passphrase (None). Both default to "".
1692        let dir = tempfile::tempdir().unwrap();
1693        let vault = dir.path();
1694        create_wallet("char-none-none", None, None, Some(vault)).unwrap();
1695
1696        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1697        let sig = sign_transaction("char-none-none", "evm", tx, None, None, Some(vault)).unwrap();
1698        assert!(!sig.signature.is_empty());
1699        assert!(sig.recovery_id.is_some());
1700    }
1701
1702    #[test]
1703    fn char_no_passphrase_none_none_sign_message() {
1704        let dir = tempfile::tempdir().unwrap();
1705        let vault = dir.path();
1706        create_wallet("char-none-msg", None, None, Some(vault)).unwrap();
1707
1708        let sig = sign_message(
1709            "char-none-msg",
1710            "evm",
1711            "hello",
1712            None,
1713            None,
1714            None,
1715            Some(vault),
1716        )
1717        .unwrap();
1718        assert!(!sig.signature.is_empty());
1719    }
1720
1721    #[test]
1722    fn char_no_passphrase_none_none_export() {
1723        let dir = tempfile::tempdir().unwrap();
1724        let vault = dir.path();
1725        create_wallet("char-none-exp", None, None, Some(vault)).unwrap();
1726
1727        let phrase = export_wallet("char-none-exp", None, Some(vault)).unwrap();
1728        assert_eq!(phrase.split_whitespace().count(), 12);
1729    }
1730
1731    #[test]
1732    fn char_empty_passphrase_none_and_some_empty_are_equivalent() {
1733        // Verify that None and Some("") produce identical behavior for both
1734        // create and sign — they must be interchangeable.
1735        let dir = tempfile::tempdir().unwrap();
1736        let vault = dir.path();
1737
1738        // Create with None (defaults to "")
1739        create_wallet("char-equiv", None, None, Some(vault)).unwrap();
1740
1741        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1742
1743        // All four combinations of None/Some("") must produce the same signature
1744        let sig_none = sign_transaction("char-equiv", "evm", tx, None, None, Some(vault)).unwrap();
1745        let sig_empty =
1746            sign_transaction("char-equiv", "evm", tx, Some(""), None, Some(vault)).unwrap();
1747
1748        assert_eq!(
1749            sig_none.signature, sig_empty.signature,
1750            "passphrase=None and passphrase=Some(\"\") must produce identical signatures"
1751        );
1752
1753        // Same for sign_message
1754        let msg_none =
1755            sign_message("char-equiv", "evm", "test", None, None, None, Some(vault)).unwrap();
1756        let msg_empty = sign_message(
1757            "char-equiv",
1758            "evm",
1759            "test",
1760            Some(""),
1761            None,
1762            None,
1763            Some(vault),
1764        )
1765        .unwrap();
1766
1767        assert_eq!(
1768            msg_none.signature, msg_empty.signature,
1769            "sign_message: None and Some(\"\") must be equivalent"
1770        );
1771
1772        // Export with both
1773        let export_none = export_wallet("char-equiv", None, Some(vault)).unwrap();
1774        let export_empty = export_wallet("char-equiv", Some(""), Some(vault)).unwrap();
1775        assert_eq!(
1776            export_none, export_empty,
1777            "export_wallet: None and Some(\"\") must return the same mnemonic"
1778        );
1779    }
1780
1781    #[test]
1782    fn char_create_with_some_empty_sign_with_none() {
1783        // Create with explicit Some(""), sign with None — should work
1784        let dir = tempfile::tempdir().unwrap();
1785        let vault = dir.path();
1786        create_wallet("char-some-none", None, Some(""), Some(vault)).unwrap();
1787
1788        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1789        let sig = sign_transaction("char-some-none", "evm", tx, None, None, Some(vault)).unwrap();
1790        assert!(!sig.signature.is_empty());
1791    }
1792
1793    #[test]
1794    fn char_no_passphrase_wallet_rejects_nonempty_passphrase() {
1795        // A wallet created without passphrase must NOT be decryptable with a
1796        // random passphrase — this verifies the empty string is actually used
1797        // as the encryption key, not bypassed.
1798        let dir = tempfile::tempdir().unwrap();
1799        let vault = dir.path();
1800        create_wallet("char-no-pass-reject", None, None, Some(vault)).unwrap();
1801
1802        let result = sign_message(
1803            "char-no-pass-reject",
1804            "evm",
1805            "test",
1806            Some("some-random-passphrase"),
1807            None,
1808            None,
1809            Some(vault),
1810        );
1811        assert!(
1812            result.is_err(),
1813            "non-empty passphrase on empty-passphrase wallet should fail"
1814        );
1815        match result.unwrap_err() {
1816            OwsLibError::Crypto(_) => {} // Expected: decryption failure
1817            other => panic!("expected Crypto error, got: {other}"),
1818        }
1819    }
1820
1821    #[test]
1822    fn char_sign_transaction_wrong_passphrase_returns_crypto_error() {
1823        let dir = tempfile::tempdir().unwrap();
1824        let vault = dir.path();
1825        create_wallet("char-wrong-pass", None, Some("correct"), Some(vault)).unwrap();
1826
1827        let tx = "deadbeef";
1828        let result = sign_transaction(
1829            "char-wrong-pass",
1830            "evm",
1831            tx,
1832            Some("wrong"),
1833            None,
1834            Some(vault),
1835        );
1836        assert!(result.is_err());
1837        match result.unwrap_err() {
1838            OwsLibError::Crypto(_) => {} // Expected
1839            other => panic!("expected Crypto error, got: {other}"),
1840        }
1841    }
1842
1843    #[test]
1844    fn char_sign_transaction_nonexistent_wallet_returns_wallet_not_found() {
1845        let dir = tempfile::tempdir().unwrap();
1846        let result = sign_transaction("ghost", "evm", "deadbeef", None, None, Some(dir.path()));
1847        assert!(result.is_err());
1848        match result.unwrap_err() {
1849            OwsLibError::WalletNotFound(name) => assert_eq!(name, "ghost"),
1850            other => panic!("expected WalletNotFound, got: {other}"),
1851        }
1852    }
1853
1854    #[test]
1855    fn char_sign_and_send_invalid_rpc_returns_broadcast_failed() {
1856        let dir = tempfile::tempdir().unwrap();
1857        let vault = dir.path();
1858        create_wallet("char-rpc-fail", None, None, Some(vault)).unwrap();
1859
1860        // Build a minimal unsigned EIP-1559 transaction
1861        let items: Vec<u8> = [
1862            ows_signer::rlp::encode_bytes(&[1]),          // chain_id = 1
1863            ows_signer::rlp::encode_bytes(&[]),           // nonce = 0
1864            ows_signer::rlp::encode_bytes(&[1]),          // maxPriorityFeePerGas
1865            ows_signer::rlp::encode_bytes(&[100]),        // maxFeePerGas
1866            ows_signer::rlp::encode_bytes(&[0x52, 0x08]), // gasLimit = 21000
1867            ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]), // to (truncated)
1868            ows_signer::rlp::encode_bytes(&[]),           // value = 0
1869            ows_signer::rlp::encode_bytes(&[]),           // data
1870            ows_signer::rlp::encode_list(&[]),            // accessList
1871        ]
1872        .concat();
1873        let mut unsigned_tx = vec![0x02u8];
1874        unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
1875        let tx_hex = hex::encode(&unsigned_tx);
1876
1877        let result = sign_and_send(
1878            "char-rpc-fail",
1879            "evm",
1880            &tx_hex,
1881            None,
1882            None,
1883            Some("http://127.0.0.1:1"), // unreachable RPC
1884            Some(vault),
1885        );
1886        assert!(result.is_err());
1887        match result.unwrap_err() {
1888            OwsLibError::BroadcastFailed(_) => {} // Expected
1889            other => panic!("expected BroadcastFailed, got: {other}"),
1890        }
1891    }
1892
1893    #[test]
1894    fn char_create_sign_rename_sign_with_new_name() {
1895        let dir = tempfile::tempdir().unwrap();
1896        let vault = dir.path();
1897        create_wallet("orig-name", None, None, Some(vault)).unwrap();
1898
1899        // Sign with original name
1900        let sig1 = sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).unwrap();
1901        assert!(!sig1.signature.is_empty());
1902
1903        // Rename
1904        rename_wallet("orig-name", "new-name", Some(vault)).unwrap();
1905
1906        // Old name no longer works
1907        assert!(sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).is_err());
1908
1909        // Sign with new name — should produce same signature (same key)
1910        let sig2 = sign_message("new-name", "evm", "test", None, None, None, Some(vault)).unwrap();
1911        assert_eq!(
1912            sig1.signature, sig2.signature,
1913            "renamed wallet should produce identical signatures"
1914        );
1915    }
1916
1917    #[test]
1918    fn char_create_sign_delete_sign_returns_wallet_not_found() {
1919        let dir = tempfile::tempdir().unwrap();
1920        let vault = dir.path();
1921        create_wallet("del-me-char", None, None, Some(vault)).unwrap();
1922
1923        // Sign succeeds
1924        let sig =
1925            sign_message("del-me-char", "evm", "test", None, None, None, Some(vault)).unwrap();
1926        assert!(!sig.signature.is_empty());
1927
1928        // Delete
1929        delete_wallet("del-me-char", Some(vault)).unwrap();
1930
1931        // Sign after delete fails with WalletNotFound
1932        let result = sign_message("del-me-char", "evm", "test", None, None, None, Some(vault));
1933        assert!(result.is_err());
1934        match result.unwrap_err() {
1935            OwsLibError::WalletNotFound(name) => assert_eq!(name, "del-me-char"),
1936            other => panic!("expected WalletNotFound, got: {other}"),
1937        }
1938    }
1939
1940    #[test]
1941    fn char_import_sign_export_reimport_sign_deterministic() {
1942        let v1 = tempfile::tempdir().unwrap();
1943        let v2 = tempfile::tempdir().unwrap();
1944
1945        // Import with known mnemonic
1946        let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1947        import_wallet_mnemonic("char-det", phrase, None, None, Some(v1.path())).unwrap();
1948
1949        // Sign in vault 1
1950        let sig1 = sign_message(
1951            "char-det",
1952            "evm",
1953            "determinism test",
1954            None,
1955            None,
1956            None,
1957            Some(v1.path()),
1958        )
1959        .unwrap();
1960
1961        // Export
1962        let exported = export_wallet("char-det", None, Some(v1.path())).unwrap();
1963        assert_eq!(exported.trim(), phrase);
1964
1965        // Re-import into vault 2
1966        import_wallet_mnemonic("char-det-2", &exported, None, None, Some(v2.path())).unwrap();
1967
1968        // Sign in vault 2 — must produce identical signature
1969        let sig2 = sign_message(
1970            "char-det-2",
1971            "evm",
1972            "determinism test",
1973            None,
1974            None,
1975            None,
1976            Some(v2.path()),
1977        )
1978        .unwrap();
1979
1980        assert_eq!(
1981            sig1.signature, sig2.signature,
1982            "import→sign→export→reimport→sign must produce identical signatures"
1983        );
1984    }
1985
1986    #[test]
1987    fn char_import_private_key_sign_valid() {
1988        let dir = tempfile::tempdir().unwrap();
1989        let vault = dir.path();
1990
1991        import_wallet_private_key(
1992            "char-pk",
1993            TEST_PRIVKEY,
1994            Some("evm"),
1995            None,
1996            Some(vault),
1997            None,
1998            None,
1999        )
2000        .unwrap();
2001
2002        let sig = sign_transaction("char-pk", "evm", "deadbeef", None, None, Some(vault)).unwrap();
2003        assert!(!sig.signature.is_empty());
2004        assert!(sig.recovery_id.is_some());
2005    }
2006
2007    #[test]
2008    fn char_sign_message_all_chain_families() {
2009        // Verify sign_message works for every chain family (EVM, Solana, Bitcoin, Cosmos, Tron, TON, Sui)
2010        let dir = tempfile::tempdir().unwrap();
2011        let vault = dir.path();
2012        create_wallet("char-all-chains", None, None, Some(vault)).unwrap();
2013
2014        let chains = [
2015            ("evm", true),
2016            ("solana", false),
2017            ("bitcoin", true),
2018            ("cosmos", true),
2019            ("tron", true),
2020            ("ton", false),
2021            ("sui", false),
2022        ];
2023        for (chain, has_recovery_id) in &chains {
2024            let result = sign_message(
2025                "char-all-chains",
2026                chain,
2027                "hello",
2028                None,
2029                None,
2030                None,
2031                Some(vault),
2032            );
2033            assert!(
2034                result.is_ok(),
2035                "sign_message failed for {chain}: {:?}",
2036                result.err()
2037            );
2038            let sig = result.unwrap();
2039            assert!(!sig.signature.is_empty(), "signature empty for {chain}");
2040            if *has_recovery_id {
2041                assert!(
2042                    sig.recovery_id.is_some(),
2043                    "expected recovery_id for {chain}"
2044                );
2045            }
2046        }
2047    }
2048
2049    #[test]
2050    fn char_sign_typed_data_evm_valid_signature() {
2051        let dir = tempfile::tempdir().unwrap();
2052        let vault = dir.path();
2053        create_wallet("char-typed", None, None, Some(vault)).unwrap();
2054
2055        let typed_data = r#"{
2056            "types": {
2057                "EIP712Domain": [
2058                    {"name": "name", "type": "string"},
2059                    {"name": "version", "type": "string"},
2060                    {"name": "chainId", "type": "uint256"}
2061                ],
2062                "Test": [{"name": "value", "type": "uint256"}]
2063            },
2064            "primaryType": "Test",
2065            "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2066            "message": {"value": "42"}
2067        }"#;
2068
2069        let result = sign_typed_data("char-typed", "evm", typed_data, None, None, Some(vault));
2070        assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2071
2072        let sig = result.unwrap();
2073        let sig_bytes = hex::decode(&sig.signature).unwrap();
2074        assert_eq!(sig_bytes.len(), 65, "EIP-712 signature should be 65 bytes");
2075
2076        // v should be 27 or 28 per EIP-712 convention
2077        let v = sig_bytes[64];
2078        assert!(v == 27 || v == 28, "EIP-712 v should be 27 or 28, got {v}");
2079    }
2080
2081    // ================================================================
2082    // CHARACTERIZATION TESTS (wave 2): refactoring-path edge cases
2083    // ================================================================
2084
2085    #[test]
2086    fn char_sign_with_nonzero_account_index() {
2087        // The `index` parameter flows through decrypt_signing_key → HD derivation.
2088        // Verify that index=0 and index=1 produce different signatures via the public API.
2089        let dir = tempfile::tempdir().unwrap();
2090        let vault = dir.path();
2091        create_wallet("char-idx", None, None, Some(vault)).unwrap();
2092
2093        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2094
2095        let sig0 = sign_transaction("char-idx", "evm", tx, None, Some(0), Some(vault)).unwrap();
2096        let sig1 = sign_transaction("char-idx", "evm", tx, None, Some(1), Some(vault)).unwrap();
2097
2098        assert_ne!(
2099            sig0.signature, sig1.signature,
2100            "index 0 and index 1 must produce different signatures (different derived keys)"
2101        );
2102
2103        // Index 0 should match the default (None)
2104        let sig_default = sign_transaction("char-idx", "evm", tx, None, None, Some(vault)).unwrap();
2105        assert_eq!(
2106            sig0.signature, sig_default.signature,
2107            "index=0 should match index=None (default)"
2108        );
2109    }
2110
2111    #[test]
2112    fn char_sign_with_nonzero_index_sign_message() {
2113        let dir = tempfile::tempdir().unwrap();
2114        let vault = dir.path();
2115        create_wallet("char-idx-msg", None, None, Some(vault)).unwrap();
2116
2117        let sig0 = sign_message(
2118            "char-idx-msg",
2119            "evm",
2120            "hello",
2121            None,
2122            None,
2123            Some(0),
2124            Some(vault),
2125        )
2126        .unwrap();
2127        let sig1 = sign_message(
2128            "char-idx-msg",
2129            "evm",
2130            "hello",
2131            None,
2132            None,
2133            Some(1),
2134            Some(vault),
2135        )
2136        .unwrap();
2137
2138        assert_ne!(
2139            sig0.signature, sig1.signature,
2140            "different account indices should yield different signatures"
2141        );
2142    }
2143
2144    #[test]
2145    fn char_sign_transaction_0x_prefix_stripped() {
2146        // sign_transaction strips "0x" prefix from tx_hex. Verify both forms produce
2147        // the same signature.
2148        let dir = tempfile::tempdir().unwrap();
2149        let vault = dir.path();
2150        create_wallet("char-0x", None, None, Some(vault)).unwrap();
2151
2152        let tx_no_prefix = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2153        let tx_with_prefix = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2154
2155        let sig1 =
2156            sign_transaction("char-0x", "evm", tx_no_prefix, None, None, Some(vault)).unwrap();
2157        let sig2 =
2158            sign_transaction("char-0x", "evm", tx_with_prefix, None, None, Some(vault)).unwrap();
2159
2160        assert_eq!(
2161            sig1.signature, sig2.signature,
2162            "0x-prefixed and bare hex should produce identical signatures"
2163        );
2164    }
2165
2166    #[test]
2167    fn char_24_word_mnemonic_wallet_lifecycle() {
2168        // Verify 24-word mnemonics work identically to 12-word through the full lifecycle.
2169        let dir = tempfile::tempdir().unwrap();
2170        let vault = dir.path();
2171
2172        let info = create_wallet("char-24w", Some(24), None, Some(vault)).unwrap();
2173        assert!(!info.accounts.is_empty());
2174
2175        // Export → verify 24 words
2176        let phrase = export_wallet("char-24w", None, Some(vault)).unwrap();
2177        assert_eq!(
2178            phrase.split_whitespace().count(),
2179            24,
2180            "should be a 24-word mnemonic"
2181        );
2182
2183        // Sign transaction
2184        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2185        let sig = sign_transaction("char-24w", "evm", tx, None, None, Some(vault)).unwrap();
2186        assert!(!sig.signature.is_empty());
2187
2188        // Sign message on multiple chains
2189        for chain in &["evm", "solana", "bitcoin", "cosmos"] {
2190            let result = sign_message("char-24w", chain, "test", None, None, None, Some(vault));
2191            assert!(
2192                result.is_ok(),
2193                "24-word wallet sign_message failed for {chain}: {:?}",
2194                result.err()
2195            );
2196        }
2197
2198        // Re-import into separate vault → deterministic
2199        let v2 = tempfile::tempdir().unwrap();
2200        import_wallet_mnemonic("char-24w-2", &phrase, None, None, Some(v2.path())).unwrap();
2201        let sig2 = sign_transaction("char-24w-2", "evm", tx, None, None, Some(v2.path())).unwrap();
2202        assert_eq!(
2203            sig.signature, sig2.signature,
2204            "reimported 24-word wallet must produce identical signature"
2205        );
2206    }
2207
2208    #[test]
2209    fn char_concurrent_signing() {
2210        // Multiple threads signing with the same wallet must all succeed.
2211        // Relevant because agent signing will involve concurrent callers.
2212        use std::sync::Arc;
2213        use std::thread;
2214
2215        let dir = tempfile::tempdir().unwrap();
2216        let vault_path = Arc::new(dir.path().to_path_buf());
2217        create_wallet("char-conc", None, None, Some(&vault_path)).unwrap();
2218
2219        let handles: Vec<_> = (0..8)
2220            .map(|i| {
2221                let vp = Arc::clone(&vault_path);
2222                thread::spawn(move || {
2223                    let msg = format!("thread-{i}");
2224                    let result = sign_message(
2225                        "char-conc",
2226                        "evm",
2227                        &msg,
2228                        None,
2229                        None,
2230                        None,
2231                        Some(vp.as_path()),
2232                    );
2233                    assert!(
2234                        result.is_ok(),
2235                        "concurrent sign_message failed in thread {i}: {:?}",
2236                        result.err()
2237                    );
2238                    result.unwrap()
2239                })
2240            })
2241            .collect();
2242
2243        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2244
2245        // All signatures should be non-empty
2246        for (i, sig) in results.iter().enumerate() {
2247            assert!(
2248                !sig.signature.is_empty(),
2249                "thread {i} produced empty signature"
2250            );
2251        }
2252
2253        // Different messages → different signatures
2254        for i in 0..results.len() {
2255            for j in (i + 1)..results.len() {
2256                assert_ne!(
2257                    results[i].signature, results[j].signature,
2258                    "threads {i} and {j} should produce different signatures (different messages)"
2259                );
2260            }
2261        }
2262    }
2263
2264    #[test]
2265    fn char_evm_sign_transaction_recoverable() {
2266        // Verify that EVM transaction signatures are ecrecover-compatible:
2267        // recover the public key from the signature and compare to the wallet's address.
2268        use sha3::Digest;
2269
2270        let dir = tempfile::tempdir().unwrap();
2271        let vault = dir.path();
2272        let info = create_wallet("char-tx-recover", None, None, Some(vault)).unwrap();
2273        let evm_addr = info
2274            .accounts
2275            .iter()
2276            .find(|a| a.chain_id.starts_with("eip155:"))
2277            .unwrap()
2278            .address
2279            .clone();
2280
2281        let tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2282        let sig =
2283            sign_transaction("char-tx-recover", "evm", tx_hex, None, None, Some(vault)).unwrap();
2284
2285        let sig_bytes = hex::decode(&sig.signature).unwrap();
2286        assert_eq!(sig_bytes.len(), 65);
2287
2288        // EVM sign_transaction: keccak256(tx_bytes) then ecdsaSign
2289        let tx_bytes = hex::decode(tx_hex).unwrap();
2290        let hash = sha3::Keccak256::digest(&tx_bytes);
2291
2292        let v = sig_bytes[64];
2293        let recid = k256::ecdsa::RecoveryId::try_from(v).unwrap();
2294        let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
2295        let recovered_key =
2296            k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
2297
2298        // Derive address from recovered key
2299        let pubkey_bytes = recovered_key.to_encoded_point(false);
2300        let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
2301        let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
2302
2303        assert_eq!(
2304            recovered_addr.to_lowercase(),
2305            evm_addr.to_lowercase(),
2306            "recovered address from tx signature should match wallet's EVM address"
2307        );
2308    }
2309
2310    #[test]
2311    fn char_solana_extract_signable_through_sign_path() {
2312        // Verify that the full Solana signing pipeline (extract_signable → sign → encode)
2313        // works correctly through the library's sign_encode_and_broadcast path (minus broadcast).
2314        // This locks down the Solana-specific header stripping that could regress during
2315        // signing path unification.
2316        let dir = tempfile::tempdir().unwrap();
2317        let vault = dir.path();
2318        create_wallet("char-sol-sig", None, None, Some(vault)).unwrap();
2319
2320        // Build a minimal Solana serialized tx: [1 sig slot] [64 zero bytes] [message]
2321        let message_payload = b"test solana message payload 1234";
2322        let mut tx_bytes = vec![0x01u8]; // 1 signature slot
2323        tx_bytes.extend_from_slice(&[0u8; 64]); // placeholder signature
2324        tx_bytes.extend_from_slice(message_payload);
2325        let tx_hex = hex::encode(&tx_bytes);
2326
2327        // sign_transaction goes through: hex decode → decrypt key → signer.sign_transaction(key, tx_bytes)
2328        // For Solana, sign_transaction signs the raw bytes (callers must pre-extract).
2329        // But sign_and_send does: extract_signable → sign → encode → broadcast.
2330        // Verify the raw sign_transaction path works:
2331        let sig =
2332            sign_transaction("char-sol-sig", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2333        assert_eq!(
2334            hex::decode(&sig.signature).unwrap().len(),
2335            64,
2336            "Solana signature should be 64 bytes (Ed25519)"
2337        );
2338        assert!(sig.recovery_id.is_none(), "Ed25519 has no recovery ID");
2339
2340        // Now verify the sign_encode_and_broadcast pipeline (minus actual broadcast)
2341        // by manually calling the signer's extract/sign/encode chain:
2342        let key =
2343            decrypt_signing_key("char-sol-sig", ChainType::Solana, "", None, Some(vault)).unwrap();
2344        let signer = signer_for_chain(ChainType::Solana);
2345
2346        let signable = signer.extract_signable_bytes(&tx_bytes).unwrap();
2347        assert_eq!(
2348            signable, message_payload,
2349            "extract_signable_bytes should return only the message portion"
2350        );
2351
2352        let output = signer.sign_transaction(key.expose(), signable).unwrap();
2353        let signed_tx = signer
2354            .encode_signed_transaction(&tx_bytes, &output)
2355            .unwrap();
2356
2357        // The signature should be at bytes 1..65 in the signed tx
2358        assert_eq!(&signed_tx[1..65], &output.signature[..]);
2359        // Message portion should be unchanged
2360        assert_eq!(&signed_tx[65..], message_payload);
2361        // Total length unchanged
2362        assert_eq!(signed_tx.len(), tx_bytes.len());
2363
2364        // Verify the signature is valid
2365        let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
2366        let verifying_key = signing_key.verifying_key();
2367        let ed_sig = ed25519_dalek::Signature::from_bytes(&output.signature.try_into().unwrap());
2368        verifying_key
2369            .verify_strict(message_payload, &ed_sig)
2370            .expect("Solana signature should verify against extracted message");
2371    }
2372
2373    #[test]
2374    fn char_library_encodes_before_broadcast() {
2375        // The library's sign_and_send correctly calls encode_signed_transaction
2376        // before broadcasting (unlike a raw sign_transaction call).
2377        // This test verifies the library path by showing that:
2378        // 1. sign_transaction returns a raw 65-byte signature
2379        // 2. The library's internal pipeline produces a full RLP-encoded signed tx
2380        // 3. They are fundamentally different
2381        let dir = tempfile::tempdir().unwrap();
2382        let vault = dir.path();
2383        create_wallet("char-encode", None, None, Some(vault)).unwrap();
2384
2385        // Minimal EIP-1559 tx
2386        let items: Vec<u8> = [
2387            ows_signer::rlp::encode_bytes(&[1]),          // chain_id
2388            ows_signer::rlp::encode_bytes(&[]),           // nonce
2389            ows_signer::rlp::encode_bytes(&[1]),          // maxPriorityFeePerGas
2390            ows_signer::rlp::encode_bytes(&[100]),        // maxFeePerGas
2391            ows_signer::rlp::encode_bytes(&[0x52, 0x08]), // gasLimit = 21000
2392            ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]), // to
2393            ows_signer::rlp::encode_bytes(&[]),           // value
2394            ows_signer::rlp::encode_bytes(&[]),           // data
2395            ows_signer::rlp::encode_list(&[]),            // accessList
2396        ]
2397        .concat();
2398        let mut unsigned_tx = vec![0x02u8];
2399        unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
2400        let tx_hex = hex::encode(&unsigned_tx);
2401
2402        // Path A: sign_transaction (returns raw signature)
2403        let raw_sig =
2404            sign_transaction("char-encode", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2405        let raw_sig_bytes = hex::decode(&raw_sig.signature).unwrap();
2406
2407        // Path B: the internal pipeline (what sign_and_send uses)
2408        let key =
2409            decrypt_signing_key("char-encode", ChainType::Evm, "", None, Some(vault)).unwrap();
2410        let signer = signer_for_chain(ChainType::Evm);
2411        let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
2412        let full_signed_tx = signer
2413            .encode_signed_transaction(&unsigned_tx, &output)
2414            .unwrap();
2415
2416        // Raw sig is 65 bytes (r || s || v)
2417        assert_eq!(raw_sig_bytes.len(), 65);
2418
2419        // Full signed tx is RLP-encoded with type byte prefix
2420        assert!(full_signed_tx.len() > 65);
2421        assert_eq!(
2422            full_signed_tx[0], 0x02,
2423            "should preserve EIP-1559 type byte"
2424        );
2425
2426        // They must be completely different
2427        assert_ne!(raw_sig_bytes, full_signed_tx);
2428
2429        // The full signed tx should contain the r and s values from the signature
2430        // somewhere in its RLP encoding (not at the same offsets)
2431        let r_bytes = &raw_sig_bytes[..32];
2432        let _s_bytes = &raw_sig_bytes[32..64];
2433
2434        // Verify r bytes appear in the full signed tx (they'll be RLP-encoded)
2435        let full_hex = hex::encode(&full_signed_tx);
2436        let r_hex = hex::encode(r_bytes);
2437        assert!(
2438            full_hex.contains(&r_hex),
2439            "full signed tx should contain the r component"
2440        );
2441    }
2442
2443    // ================================================================
2444    // EIP-712 TYPED DATA SIGNING
2445    // ================================================================
2446
2447    #[test]
2448    fn sign_typed_data_rejects_non_evm_chain() {
2449        let tmp = tempfile::tempdir().unwrap();
2450        let vault = tmp.path();
2451
2452        let w = save_privkey_wallet("typed-data-test", TEST_PRIVKEY, "pass", vault);
2453
2454        let typed_data = r#"{
2455            "types": {
2456                "EIP712Domain": [{"name": "name", "type": "string"}],
2457                "Test": [{"name": "value", "type": "uint256"}]
2458            },
2459            "primaryType": "Test",
2460            "domain": {"name": "Test"},
2461            "message": {"value": "1"}
2462        }"#;
2463
2464        let result = sign_typed_data(&w.id, "solana", typed_data, Some("pass"), None, Some(vault));
2465        assert!(result.is_err());
2466        let err_msg = result.unwrap_err().to_string();
2467        assert!(
2468            err_msg.contains("only supported for EVM"),
2469            "expected EVM-only error, got: {err_msg}"
2470        );
2471    }
2472
2473    #[test]
2474    fn sign_typed_data_evm_succeeds() {
2475        let tmp = tempfile::tempdir().unwrap();
2476        let vault = tmp.path();
2477
2478        let w = save_privkey_wallet("typed-data-evm", TEST_PRIVKEY, "pass", vault);
2479
2480        let typed_data = r#"{
2481            "types": {
2482                "EIP712Domain": [
2483                    {"name": "name", "type": "string"},
2484                    {"name": "version", "type": "string"},
2485                    {"name": "chainId", "type": "uint256"}
2486                ],
2487                "Test": [{"name": "value", "type": "uint256"}]
2488            },
2489            "primaryType": "Test",
2490            "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2491            "message": {"value": "42"}
2492        }"#;
2493
2494        let result = sign_typed_data(&w.id, "evm", typed_data, Some("pass"), None, Some(vault));
2495        assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2496
2497        let sign_result = result.unwrap();
2498        assert!(
2499            !sign_result.signature.is_empty(),
2500            "signature should not be empty"
2501        );
2502        assert!(
2503            sign_result.recovery_id.is_some(),
2504            "recovery_id should be present for EVM"
2505        );
2506    }
2507
2508    // ================================================================
2509    // OWNER-MODE REGRESSION: prove the credential branch doesn't alter
2510    // existing behavior for any passphrase variant.
2511    // ================================================================
2512
2513    #[test]
2514    fn regression_owner_path_identical_to_direct_signer() {
2515        // Proves that sign_transaction via the library produces the exact
2516        // same signature as calling decrypt_signing_key → signer directly.
2517        // If the credential branch accidentally altered the owner path,
2518        // these would diverge.
2519        let dir = tempfile::tempdir().unwrap();
2520        let vault = dir.path();
2521        create_wallet("reg-owner", None, None, Some(vault)).unwrap();
2522
2523        let tx_hex = "deadbeefcafebabe";
2524
2525        // Path A: through the public sign_transaction API (has credential branch)
2526        let api_result =
2527            sign_transaction("reg-owner", "evm", tx_hex, None, None, Some(vault)).unwrap();
2528
2529        // Path B: direct signer call (no credential branch)
2530        let key = decrypt_signing_key("reg-owner", ChainType::Evm, "", None, Some(vault)).unwrap();
2531        let signer = signer_for_chain(ChainType::Evm);
2532        let tx_bytes = hex::decode(tx_hex).unwrap();
2533        let direct_output = signer.sign_transaction(key.expose(), &tx_bytes).unwrap();
2534
2535        assert_eq!(
2536            api_result.signature,
2537            hex::encode(&direct_output.signature),
2538            "library API and direct signer must produce identical signatures"
2539        );
2540        assert_eq!(
2541            api_result.recovery_id, direct_output.recovery_id,
2542            "recovery_id must match"
2543        );
2544    }
2545
2546    #[test]
2547    fn regression_owner_passphrase_not_confused_with_token() {
2548        // Prove that a non-token passphrase never enters the agent path.
2549        // If it did, it would fail with ApiKeyNotFound (no such token hash).
2550        let dir = tempfile::tempdir().unwrap();
2551        let vault = dir.path();
2552        create_wallet("reg-pass", Some(12), Some("hunter2"), Some(vault)).unwrap();
2553
2554        let tx_hex = "deadbeef";
2555
2556        // Signing with the correct passphrase must succeed
2557        let result = sign_transaction(
2558            "reg-pass",
2559            "evm",
2560            tx_hex,
2561            Some("hunter2"),
2562            None,
2563            Some(vault),
2564        );
2565        assert!(
2566            result.is_ok(),
2567            "owner-mode signing failed: {:?}",
2568            result.err()
2569        );
2570
2571        // Signing with empty passphrase must fail with CryptoError (wrong passphrase),
2572        // NOT with ApiKeyNotFound (which would mean it entered the agent path)
2573        let bad = sign_transaction("reg-pass", "evm", tx_hex, Some(""), None, Some(vault));
2574        assert!(bad.is_err());
2575        match bad.unwrap_err() {
2576            OwsLibError::Crypto(_) => {} // correct: scrypt decryption failed
2577            other => panic!("expected Crypto error for wrong passphrase, got: {other}"),
2578        }
2579
2580        // Signing with None must also fail with CryptoError
2581        let none_result = sign_transaction("reg-pass", "evm", tx_hex, None, None, Some(vault));
2582        assert!(none_result.is_err());
2583        match none_result.unwrap_err() {
2584            OwsLibError::Crypto(_) => {}
2585            other => panic!("expected Crypto error for None passphrase, got: {other}"),
2586        }
2587    }
2588
2589    #[test]
2590    fn regression_sign_message_owner_path_unchanged() {
2591        let dir = tempfile::tempdir().unwrap();
2592        let vault = dir.path();
2593        create_wallet("reg-msg", None, None, Some(vault)).unwrap();
2594
2595        // Through the public API
2596        let api_result =
2597            sign_message("reg-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
2598
2599        // Direct signer
2600        let key = decrypt_signing_key("reg-msg", ChainType::Evm, "", None, Some(vault)).unwrap();
2601        let signer = signer_for_chain(ChainType::Evm);
2602        let direct = signer.sign_message(key.expose(), b"hello").unwrap();
2603
2604        assert_eq!(
2605            api_result.signature,
2606            hex::encode(&direct.signature),
2607            "sign_message owner path must match direct signer"
2608        );
2609    }
2610}