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 signable = signer.extract_signable_bytes(&tx_bytes)?;
429    let output = signer.sign_transaction(key.expose(), signable)?;
430
431    Ok(SignResult {
432        signature: hex::encode(&output.signature),
433        recovery_id: output.recovery_id,
434    })
435}
436
437/// Sign a message. Returns hex-encoded signature.
438///
439/// The `passphrase` parameter accepts either the owner's passphrase or an
440/// API token (`ows_key_...`).
441pub fn sign_message(
442    wallet: &str,
443    chain: &str,
444    message: &str,
445    passphrase: Option<&str>,
446    encoding: Option<&str>,
447    index: Option<u32>,
448    vault_path: Option<&Path>,
449) -> Result<SignResult, OwsLibError> {
450    let credential = passphrase.unwrap_or("");
451
452    let encoding = encoding.unwrap_or("utf8");
453    let msg_bytes = match encoding {
454        "utf8" => message.as_bytes().to_vec(),
455        "hex" => hex::decode(message)
456            .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex message: {e}")))?,
457        _ => {
458            return Err(OwsLibError::InvalidInput(format!(
459                "unsupported encoding: {encoding} (use 'utf8' or 'hex')"
460            )))
461        }
462    };
463
464    // Agent mode
465    if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
466        let chain = parse_chain(chain)?;
467        return crate::key_ops::sign_message_with_api_key(
468            credential, wallet, &chain, &msg_bytes, index, vault_path,
469        );
470    }
471
472    // Owner mode
473    let chain = parse_chain(chain)?;
474    let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
475    let signer = signer_for_chain(chain.chain_type);
476    let output = signer.sign_message(key.expose(), &msg_bytes)?;
477
478    Ok(SignResult {
479        signature: hex::encode(&output.signature),
480        recovery_id: output.recovery_id,
481    })
482}
483
484/// Sign EIP-712 typed structured data. Returns hex-encoded signature.
485/// Only supported for EVM chains.
486///
487/// Note: API token signing is not supported for typed data (EVM-specific
488/// operation that requires full context). Use `sign_transaction` instead.
489pub fn sign_typed_data(
490    wallet: &str,
491    chain: &str,
492    typed_data_json: &str,
493    passphrase: Option<&str>,
494    index: Option<u32>,
495    vault_path: Option<&Path>,
496) -> Result<SignResult, OwsLibError> {
497    let credential = passphrase.unwrap_or("");
498    let chain = parse_chain(chain)?;
499
500    if chain.chain_type != ows_core::ChainType::Evm {
501        return Err(OwsLibError::InvalidInput(
502            "EIP-712 typed data signing is only supported for EVM chains".into(),
503        ));
504    }
505
506    if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
507        return Err(OwsLibError::InvalidInput(
508            "EIP-712 typed data signing via API key is not yet supported; use sign_transaction"
509                .into(),
510        ));
511    }
512
513    let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
514    let evm_signer = ows_signer::chains::EvmSigner;
515    let output = evm_signer.sign_typed_data(key.expose(), typed_data_json)?;
516
517    Ok(SignResult {
518        signature: hex::encode(&output.signature),
519        recovery_id: output.recovery_id,
520    })
521}
522
523/// Sign and broadcast a transaction. Returns the transaction hash.
524///
525/// The `passphrase` parameter accepts either the owner's passphrase or an
526/// API token (`ows_key_...`). When a token is provided, policy enforcement
527/// occurs before signing.
528pub fn sign_and_send(
529    wallet: &str,
530    chain: &str,
531    tx_hex: &str,
532    passphrase: Option<&str>,
533    index: Option<u32>,
534    rpc_url: Option<&str>,
535    vault_path: Option<&Path>,
536) -> Result<SendResult, OwsLibError> {
537    let credential = passphrase.unwrap_or("");
538
539    let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
540    let tx_bytes = hex::decode(tx_hex_clean)
541        .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
542
543    // Agent mode: enforce policies, decrypt key, then sign + broadcast
544    if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
545        let chain_info = parse_chain(chain)?;
546        let (key, _) = crate::key_ops::enforce_policy_and_decrypt_key(
547            credential,
548            wallet,
549            &chain_info,
550            &tx_bytes,
551            index,
552            vault_path,
553        )?;
554        return sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url);
555    }
556
557    // Owner mode
558    let chain_info = parse_chain(chain)?;
559    let key = decrypt_signing_key(wallet, chain_info.chain_type, credential, index, vault_path)?;
560
561    sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url)
562}
563
564/// Sign, encode, and broadcast a transaction using an already-resolved private key.
565///
566/// This is the shared core of the send-transaction flow. Both the library's
567/// [`sign_and_send`] (which resolves keys from the vault) and the CLI (which
568/// resolves keys via env vars / stdin prompts) delegate here so the
569/// sign → encode → broadcast pipeline is never duplicated.
570pub fn sign_encode_and_broadcast(
571    private_key: &[u8],
572    chain: &str,
573    tx_bytes: &[u8],
574    rpc_url: Option<&str>,
575) -> Result<SendResult, OwsLibError> {
576    let chain = parse_chain(chain)?;
577    let signer = signer_for_chain(chain.chain_type);
578
579    // 1. Extract signable portion (strips signature-slot headers for Solana; no-op for others)
580    let signable = signer.extract_signable_bytes(tx_bytes)?;
581
582    // 2. Sign
583    let output = signer.sign_transaction(private_key, signable)?;
584
585    // 3. Encode the full signed transaction
586    let signed_tx = signer.encode_signed_transaction(tx_bytes, &output)?;
587
588    // 4. Resolve RPC URL using exact chain_id
589    let rpc = resolve_rpc_url(chain.chain_id, chain.chain_type, rpc_url)?;
590
591    // 5. Broadcast the full signed transaction
592    let tx_hash = broadcast(chain.chain_type, &rpc, &signed_tx)?;
593
594    Ok(SendResult { tx_hash })
595}
596
597// --- internal helpers ---
598
599/// Decrypt a wallet and return the private key for the given chain.
600///
601/// This is the single code path for resolving a credential into key material.
602/// Both the library's high-level signing functions and the CLI delegate here.
603pub fn decrypt_signing_key(
604    wallet_name_or_id: &str,
605    chain_type: ChainType,
606    passphrase: &str,
607    index: Option<u32>,
608    vault_path: Option<&Path>,
609) -> Result<SecretBytes, OwsLibError> {
610    let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
611    let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
612    let secret = decrypt(&envelope, passphrase)?;
613
614    match wallet.key_type {
615        KeyType::Mnemonic => {
616            // Use the SecretBytes directly as a &str to avoid un-zeroized String copies.
617            let phrase = std::str::from_utf8(secret.expose()).map_err(|_| {
618                OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
619            })?;
620            let mnemonic = Mnemonic::from_phrase(phrase)?;
621            let signer = signer_for_chain(chain_type);
622            let path = signer.default_derivation_path(index.unwrap_or(0));
623            let curve = signer.curve();
624            Ok(HdDeriver::derive_from_mnemonic_cached(
625                &mnemonic, "", &path, curve,
626            )?)
627        }
628        KeyType::PrivateKey => {
629            // JSON key pair — extract the right key for this chain's curve
630            let keys = KeyPair::from_json_bytes(secret.expose())?;
631            let signer = signer_for_chain(chain_type);
632            Ok(SecretBytes::from_slice(keys.key_for_curve(signer.curve())))
633        }
634    }
635}
636
637/// Resolve the RPC URL: explicit > config override (exact chain_id) > config (namespace) > built-in default.
638fn resolve_rpc_url(
639    chain_id: &str,
640    chain_type: ChainType,
641    explicit: Option<&str>,
642) -> Result<String, OwsLibError> {
643    if let Some(url) = explicit {
644        return Ok(url.to_string());
645    }
646
647    let config = Config::load_or_default();
648    let defaults = Config::default_rpc();
649
650    // Try exact chain_id match first
651    if let Some(url) = config.rpc.get(chain_id) {
652        return Ok(url.clone());
653    }
654    if let Some(url) = defaults.get(chain_id) {
655        return Ok(url.clone());
656    }
657
658    // Fallback to namespace match
659    let namespace = chain_type.namespace();
660    for (key, url) in &config.rpc {
661        if key.starts_with(namespace) {
662            return Ok(url.clone());
663        }
664    }
665    for (key, url) in &defaults {
666        if key.starts_with(namespace) {
667            return Ok(url.clone());
668        }
669    }
670
671    Err(OwsLibError::InvalidInput(format!(
672        "no RPC URL configured for chain '{chain_id}'"
673    )))
674}
675
676/// Broadcast a signed transaction via curl, dispatching per chain type.
677fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
678    match chain {
679        ChainType::Evm => broadcast_evm(rpc_url, signed_bytes),
680        ChainType::Solana => broadcast_solana(rpc_url, signed_bytes),
681        ChainType::Bitcoin => broadcast_bitcoin(rpc_url, signed_bytes),
682        ChainType::Cosmos => broadcast_cosmos(rpc_url, signed_bytes),
683        ChainType::Tron => broadcast_tron(rpc_url, signed_bytes),
684        ChainType::Ton => broadcast_ton(rpc_url, signed_bytes),
685        ChainType::Spark => Err(OwsLibError::InvalidInput(
686            "broadcast not yet supported for Spark".into(),
687        )),
688        ChainType::Filecoin => Err(OwsLibError::InvalidInput(
689            "broadcast not yet supported for Filecoin".into(),
690        )),
691        ChainType::Sui => broadcast_sui(rpc_url, signed_bytes),
692    }
693}
694
695fn broadcast_evm(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
696    let hex_tx = format!("0x{}", hex::encode(signed_bytes));
697    let body = serde_json::json!({
698        "jsonrpc": "2.0",
699        "method": "eth_sendRawTransaction",
700        "params": [hex_tx],
701        "id": 1
702    });
703    let resp = curl_post_json(rpc_url, &body.to_string())?;
704    extract_json_field(&resp, "result")
705}
706
707fn build_solana_rpc_body(signed_bytes: &[u8]) -> serde_json::Value {
708    use base64::Engine;
709    let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
710    serde_json::json!({
711        "jsonrpc": "2.0",
712        "method": "sendTransaction",
713        "params": [b64_tx, {"encoding": "base64"}],
714        "id": 1
715    })
716}
717
718fn broadcast_solana(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
719    let body = build_solana_rpc_body(signed_bytes);
720    let resp = curl_post_json(rpc_url, &body.to_string())?;
721    extract_json_field(&resp, "result")
722}
723
724fn broadcast_bitcoin(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
725    let hex_tx = hex::encode(signed_bytes);
726    let url = format!("{}/tx", rpc_url.trim_end_matches('/'));
727    let output = Command::new("curl")
728        .args([
729            "-fsSL",
730            "-X",
731            "POST",
732            "-H",
733            "Content-Type: text/plain",
734            "-d",
735            &hex_tx,
736            &url,
737        ])
738        .output()
739        .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
740
741    if !output.status.success() {
742        let stderr = String::from_utf8_lossy(&output.stderr);
743        return Err(OwsLibError::BroadcastFailed(format!(
744            "broadcast failed: {stderr}"
745        )));
746    }
747
748    let tx_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
749    if tx_hash.is_empty() {
750        return Err(OwsLibError::BroadcastFailed(
751            "empty response from broadcast".into(),
752        ));
753    }
754    Ok(tx_hash)
755}
756
757fn broadcast_cosmos(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
758    use base64::Engine;
759    let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
760    let url = format!("{}/cosmos/tx/v1beta1/txs", rpc_url.trim_end_matches('/'));
761    let body = serde_json::json!({
762        "tx_bytes": b64_tx,
763        "mode": "BROADCAST_MODE_SYNC"
764    });
765    let resp = curl_post_json(&url, &body.to_string())?;
766    let parsed: serde_json::Value = serde_json::from_str(&resp)?;
767    parsed["tx_response"]["txhash"]
768        .as_str()
769        .map(|s| s.to_string())
770        .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no txhash in response: {resp}")))
771}
772
773fn broadcast_tron(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
774    let hex_tx = hex::encode(signed_bytes);
775    let url = format!("{}/wallet/broadcasthex", rpc_url.trim_end_matches('/'));
776    let body = serde_json::json!({ "transaction": hex_tx });
777    let resp = curl_post_json(&url, &body.to_string())?;
778    extract_json_field(&resp, "txid")
779}
780
781fn broadcast_ton(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
782    use base64::Engine;
783    let b64_boc = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
784    let url = format!("{}/sendBoc", rpc_url.trim_end_matches('/'));
785    let body = serde_json::json!({ "boc": b64_boc });
786    let resp = curl_post_json(&url, &body.to_string())?;
787    let parsed: serde_json::Value = serde_json::from_str(&resp)?;
788    parsed["result"]["hash"]
789        .as_str()
790        .map(|s| s.to_string())
791        .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no hash in response: {resp}")))
792}
793
794fn broadcast_sui(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
795    use ows_signer::chains::sui::WIRE_SIG_LEN;
796
797    if signed_bytes.len() <= WIRE_SIG_LEN {
798        return Err(OwsLibError::InvalidInput(
799            "signed transaction too short to contain tx + signature".into(),
800        ));
801    }
802
803    let split = signed_bytes.len() - WIRE_SIG_LEN;
804    let tx_part = &signed_bytes[..split];
805    let sig_part = &signed_bytes[split..];
806
807    crate::sui_grpc::execute_transaction(rpc_url, tx_part, sig_part)
808}
809
810fn curl_post_json(url: &str, body: &str) -> Result<String, OwsLibError> {
811    let output = Command::new("curl")
812        .args([
813            "-fsSL",
814            "-X",
815            "POST",
816            "-H",
817            "Content-Type: application/json",
818            "-d",
819            body,
820            url,
821        ])
822        .output()
823        .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
824
825    if !output.status.success() {
826        let stderr = String::from_utf8_lossy(&output.stderr);
827        return Err(OwsLibError::BroadcastFailed(format!(
828            "broadcast failed: {stderr}"
829        )));
830    }
831
832    Ok(String::from_utf8_lossy(&output.stdout).to_string())
833}
834
835fn extract_json_field(json_str: &str, field: &str) -> Result<String, OwsLibError> {
836    let parsed: serde_json::Value = serde_json::from_str(json_str)?;
837
838    if let Some(error) = parsed.get("error") {
839        return Err(OwsLibError::BroadcastFailed(format!("RPC error: {error}")));
840    }
841
842    parsed[field]
843        .as_str()
844        .map(|s| s.to_string())
845        .ok_or_else(|| {
846            OwsLibError::BroadcastFailed(format!("no '{field}' in response: {json_str}"))
847        })
848}
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853
854    // ---- helpers ----
855
856    /// Build a private-key wallet directly in the vault, bypassing
857    /// `import_wallet_private_key` (which touches all chains including TON).
858    fn save_privkey_wallet(
859        name: &str,
860        privkey_hex: &str,
861        passphrase: &str,
862        vault: &Path,
863    ) -> WalletInfo {
864        let key_bytes = hex::decode(privkey_hex).unwrap();
865
866        // Generate a random ed25519 key for the other curve
867        let mut ed_key = vec![0u8; 32];
868        getrandom::getrandom(&mut ed_key).unwrap();
869
870        let keys = KeyPair {
871            secp256k1: key_bytes,
872            ed25519: ed_key,
873        };
874        let accounts = derive_all_accounts_from_keys(&keys).unwrap();
875        let payload = keys.to_json_bytes();
876        let crypto_envelope = encrypt(&payload, passphrase).unwrap();
877        let crypto_json = serde_json::to_value(&crypto_envelope).unwrap();
878        let wallet = EncryptedWallet::new(
879            uuid::Uuid::new_v4().to_string(),
880            name.to_string(),
881            accounts,
882            crypto_json,
883            KeyType::PrivateKey,
884        );
885        vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
886        wallet_to_info(&wallet)
887    }
888
889    const TEST_PRIVKEY: &str = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
890
891    // ================================================================
892    // 1. MNEMONIC GENERATION
893    // ================================================================
894
895    #[test]
896    fn mnemonic_12_words() {
897        let phrase = generate_mnemonic(12).unwrap();
898        assert_eq!(phrase.split_whitespace().count(), 12);
899    }
900
901    #[test]
902    fn mnemonic_24_words() {
903        let phrase = generate_mnemonic(24).unwrap();
904        assert_eq!(phrase.split_whitespace().count(), 24);
905    }
906
907    #[test]
908    fn mnemonic_invalid_word_count() {
909        assert!(generate_mnemonic(15).is_err());
910        assert!(generate_mnemonic(0).is_err());
911        assert!(generate_mnemonic(13).is_err());
912    }
913
914    #[test]
915    fn mnemonic_is_unique_each_call() {
916        let a = generate_mnemonic(12).unwrap();
917        let b = generate_mnemonic(12).unwrap();
918        assert_ne!(a, b, "two generated mnemonics should differ");
919    }
920
921    // ================================================================
922    // 2. ADDRESS DERIVATION
923    // ================================================================
924
925    #[test]
926    fn derive_address_all_chains() {
927        let phrase = generate_mnemonic(12).unwrap();
928        let chains = ["evm", "solana", "bitcoin", "cosmos", "tron", "ton", "sui"];
929        for chain in &chains {
930            let addr = derive_address(&phrase, chain, None).unwrap();
931            assert!(!addr.is_empty(), "address should be non-empty for {chain}");
932        }
933    }
934
935    #[test]
936    fn derive_address_evm_format() {
937        let phrase = generate_mnemonic(12).unwrap();
938        let addr = derive_address(&phrase, "evm", None).unwrap();
939        assert!(addr.starts_with("0x"), "EVM address should start with 0x");
940        assert_eq!(addr.len(), 42, "EVM address should be 42 chars");
941    }
942
943    #[test]
944    fn derive_address_deterministic() {
945        let phrase = generate_mnemonic(12).unwrap();
946        let a = derive_address(&phrase, "evm", None).unwrap();
947        let b = derive_address(&phrase, "evm", None).unwrap();
948        assert_eq!(a, b, "same mnemonic should produce same address");
949    }
950
951    #[test]
952    fn derive_address_different_index() {
953        let phrase = generate_mnemonic(12).unwrap();
954        let a = derive_address(&phrase, "evm", Some(0)).unwrap();
955        let b = derive_address(&phrase, "evm", Some(1)).unwrap();
956        assert_ne!(a, b, "different indices should produce different addresses");
957    }
958
959    #[test]
960    fn derive_address_invalid_chain() {
961        let phrase = generate_mnemonic(12).unwrap();
962        assert!(derive_address(&phrase, "nonexistent", None).is_err());
963    }
964
965    #[test]
966    fn derive_address_invalid_mnemonic() {
967        assert!(derive_address("not a valid mnemonic phrase at all", "evm", None).is_err());
968    }
969
970    // ================================================================
971    // 3. MNEMONIC WALLET LIFECYCLE (create → export → import → sign)
972    // ================================================================
973
974    #[test]
975    fn mnemonic_wallet_create_export_reimport() {
976        let v1 = tempfile::tempdir().unwrap();
977        let v2 = tempfile::tempdir().unwrap();
978
979        // Create
980        let w1 = create_wallet("w1", None, None, Some(v1.path())).unwrap();
981        assert!(!w1.accounts.is_empty());
982
983        // Export mnemonic
984        let phrase = export_wallet("w1", None, Some(v1.path())).unwrap();
985        assert_eq!(phrase.split_whitespace().count(), 12);
986
987        // Re-import into fresh vault
988        let w2 = import_wallet_mnemonic("w2", &phrase, None, None, Some(v2.path())).unwrap();
989
990        // Addresses must match exactly
991        assert_eq!(w1.accounts.len(), w2.accounts.len());
992        for (a1, a2) in w1.accounts.iter().zip(w2.accounts.iter()) {
993            assert_eq!(a1.chain_id, a2.chain_id);
994            assert_eq!(
995                a1.address, a2.address,
996                "address mismatch for {}",
997                a1.chain_id
998            );
999        }
1000    }
1001
1002    #[test]
1003    fn mnemonic_wallet_sign_message_all_chains() {
1004        let dir = tempfile::tempdir().unwrap();
1005        let vault = dir.path();
1006        create_wallet("multi-sign", None, None, Some(vault)).unwrap();
1007
1008        let chains = [
1009            "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui",
1010        ];
1011        for chain in &chains {
1012            let result = sign_message(
1013                "multi-sign",
1014                chain,
1015                "test msg",
1016                None,
1017                None,
1018                None,
1019                Some(vault),
1020            );
1021            assert!(
1022                result.is_ok(),
1023                "sign_message should work for {chain}: {:?}",
1024                result.err()
1025            );
1026            let sig = result.unwrap();
1027            assert!(
1028                !sig.signature.is_empty(),
1029                "signature should be non-empty for {chain}"
1030            );
1031        }
1032    }
1033
1034    #[test]
1035    fn mnemonic_wallet_sign_tx_all_chains() {
1036        let dir = tempfile::tempdir().unwrap();
1037        let vault = dir.path();
1038        create_wallet("tx-sign", None, None, Some(vault)).unwrap();
1039
1040        let generic_tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1041        // Solana requires a properly formatted serialized transaction:
1042        // [0x01 num_sigs] [64 zero bytes for sig slot] [message bytes...]
1043        let mut solana_tx = vec![0x01u8]; // 1 signature slot
1044        solana_tx.extend_from_slice(&[0u8; 64]); // placeholder signature
1045        solana_tx.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // message payload
1046        let solana_tx_hex = hex::encode(&solana_tx);
1047
1048        let chains = [
1049            "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui",
1050        ];
1051        for chain in &chains {
1052            let tx = if *chain == "solana" {
1053                &solana_tx_hex
1054            } else {
1055                generic_tx_hex
1056            };
1057            let result = sign_transaction("tx-sign", chain, tx, None, None, Some(vault));
1058            assert!(
1059                result.is_ok(),
1060                "sign_transaction should work for {chain}: {:?}",
1061                result.err()
1062            );
1063        }
1064    }
1065
1066    #[test]
1067    fn mnemonic_wallet_signing_is_deterministic() {
1068        let dir = tempfile::tempdir().unwrap();
1069        let vault = dir.path();
1070        create_wallet("det-sign", None, None, Some(vault)).unwrap();
1071
1072        let s1 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1073        let s2 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1074        assert_eq!(
1075            s1.signature, s2.signature,
1076            "same message should produce same signature"
1077        );
1078    }
1079
1080    #[test]
1081    fn mnemonic_wallet_different_messages_produce_different_sigs() {
1082        let dir = tempfile::tempdir().unwrap();
1083        let vault = dir.path();
1084        create_wallet("diff-msg", None, None, Some(vault)).unwrap();
1085
1086        let s1 = sign_message("diff-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
1087        let s2 = sign_message("diff-msg", "evm", "world", None, None, None, Some(vault)).unwrap();
1088        assert_ne!(s1.signature, s2.signature);
1089    }
1090
1091    // ================================================================
1092    // 4. PRIVATE KEY WALLET LIFECYCLE
1093    // ================================================================
1094
1095    #[test]
1096    fn privkey_wallet_sign_message() {
1097        let dir = tempfile::tempdir().unwrap();
1098        save_privkey_wallet("pk-sign", TEST_PRIVKEY, "", dir.path());
1099
1100        let sig = sign_message(
1101            "pk-sign",
1102            "evm",
1103            "hello",
1104            None,
1105            None,
1106            None,
1107            Some(dir.path()),
1108        )
1109        .unwrap();
1110        assert!(!sig.signature.is_empty());
1111        assert!(sig.recovery_id.is_some());
1112    }
1113
1114    #[test]
1115    fn privkey_wallet_sign_transaction() {
1116        let dir = tempfile::tempdir().unwrap();
1117        save_privkey_wallet("pk-tx", TEST_PRIVKEY, "", dir.path());
1118
1119        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1120        let sig = sign_transaction("pk-tx", "evm", tx, None, None, Some(dir.path())).unwrap();
1121        assert!(!sig.signature.is_empty());
1122    }
1123
1124    #[test]
1125    fn privkey_wallet_export_returns_json() {
1126        let dir = tempfile::tempdir().unwrap();
1127        save_privkey_wallet("pk-export", TEST_PRIVKEY, "", dir.path());
1128
1129        let exported = export_wallet("pk-export", None, Some(dir.path())).unwrap();
1130        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1131        assert_eq!(
1132            obj["secp256k1"].as_str().unwrap(),
1133            TEST_PRIVKEY,
1134            "exported secp256k1 key should match original"
1135        );
1136        assert!(obj["ed25519"].as_str().is_some(), "should have ed25519 key");
1137    }
1138
1139    #[test]
1140    fn privkey_wallet_signing_is_deterministic() {
1141        let dir = tempfile::tempdir().unwrap();
1142        save_privkey_wallet("pk-det", TEST_PRIVKEY, "", dir.path());
1143
1144        let s1 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1145        let s2 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1146        assert_eq!(s1.signature, s2.signature);
1147    }
1148
1149    #[test]
1150    fn privkey_and_mnemonic_wallets_produce_different_sigs() {
1151        let dir = tempfile::tempdir().unwrap();
1152        let vault = dir.path();
1153
1154        create_wallet("mn-w", None, None, Some(vault)).unwrap();
1155        save_privkey_wallet("pk-w", TEST_PRIVKEY, "", vault);
1156
1157        let mn_sig = sign_message("mn-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1158        let pk_sig = sign_message("pk-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1159        assert_ne!(
1160            mn_sig.signature, pk_sig.signature,
1161            "different keys should produce different signatures"
1162        );
1163    }
1164
1165    #[test]
1166    fn privkey_wallet_import_via_api() {
1167        let dir = tempfile::tempdir().unwrap();
1168        let vault = dir.path();
1169
1170        let info = import_wallet_private_key(
1171            "pk-api",
1172            TEST_PRIVKEY,
1173            Some("evm"),
1174            None,
1175            Some(vault),
1176            None,
1177            None,
1178        )
1179        .unwrap();
1180        assert!(
1181            !info.accounts.is_empty(),
1182            "should derive at least one account"
1183        );
1184
1185        // Should be able to sign
1186        let sig = sign_message("pk-api", "evm", "hello", None, None, None, Some(vault)).unwrap();
1187        assert!(!sig.signature.is_empty());
1188
1189        // Export should return JSON key pair with original key
1190        let exported = export_wallet("pk-api", None, Some(vault)).unwrap();
1191        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1192        assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1193    }
1194
1195    #[test]
1196    fn privkey_wallet_import_both_curve_keys() {
1197        let dir = tempfile::tempdir().unwrap();
1198        let vault = dir.path();
1199
1200        let secp_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
1201        let ed_key = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
1202
1203        let info = import_wallet_private_key(
1204            "pk-both",
1205            "",   // ignored when both curve keys provided
1206            None, // chain ignored too
1207            None,
1208            Some(vault),
1209            Some(secp_key),
1210            Some(ed_key),
1211        )
1212        .unwrap();
1213
1214        assert_eq!(
1215            info.accounts.len(),
1216            ALL_CHAIN_TYPES.len(),
1217            "should have one account per chain type"
1218        );
1219
1220        // Sign on EVM (secp256k1)
1221        let sig = sign_message("pk-both", "evm", "hello", None, None, None, Some(vault)).unwrap();
1222        assert!(!sig.signature.is_empty());
1223
1224        // Sign on Solana (ed25519)
1225        let sig =
1226            sign_message("pk-both", "solana", "hello", None, None, None, Some(vault)).unwrap();
1227        assert!(!sig.signature.is_empty());
1228
1229        // Export should return both keys
1230        let exported = export_wallet("pk-both", None, Some(vault)).unwrap();
1231        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1232        assert_eq!(obj["secp256k1"].as_str().unwrap(), secp_key);
1233        assert_eq!(obj["ed25519"].as_str().unwrap(), ed_key);
1234    }
1235
1236    // ================================================================
1237    // 5. PASSPHRASE PROTECTION
1238    // ================================================================
1239
1240    #[test]
1241    fn passphrase_protected_mnemonic_wallet() {
1242        let dir = tempfile::tempdir().unwrap();
1243        let vault = dir.path();
1244
1245        create_wallet("pass-mn", None, Some("s3cret"), Some(vault)).unwrap();
1246
1247        // Sign with correct passphrase
1248        let sig = sign_message(
1249            "pass-mn",
1250            "evm",
1251            "hello",
1252            Some("s3cret"),
1253            None,
1254            None,
1255            Some(vault),
1256        )
1257        .unwrap();
1258        assert!(!sig.signature.is_empty());
1259
1260        // Export with correct passphrase
1261        let phrase = export_wallet("pass-mn", Some("s3cret"), Some(vault)).unwrap();
1262        assert_eq!(phrase.split_whitespace().count(), 12);
1263
1264        // Wrong passphrase should fail
1265        assert!(sign_message(
1266            "pass-mn",
1267            "evm",
1268            "hello",
1269            Some("wrong"),
1270            None,
1271            None,
1272            Some(vault)
1273        )
1274        .is_err());
1275        assert!(export_wallet("pass-mn", Some("wrong"), Some(vault)).is_err());
1276
1277        // No passphrase should fail (defaults to empty string, which is wrong)
1278        assert!(sign_message("pass-mn", "evm", "hello", None, None, None, Some(vault)).is_err());
1279    }
1280
1281    #[test]
1282    fn passphrase_protected_privkey_wallet() {
1283        let dir = tempfile::tempdir().unwrap();
1284        save_privkey_wallet("pass-pk", TEST_PRIVKEY, "mypass", dir.path());
1285
1286        // Correct passphrase
1287        let sig = sign_message(
1288            "pass-pk",
1289            "evm",
1290            "hello",
1291            Some("mypass"),
1292            None,
1293            None,
1294            Some(dir.path()),
1295        )
1296        .unwrap();
1297        assert!(!sig.signature.is_empty());
1298
1299        let exported = export_wallet("pass-pk", Some("mypass"), Some(dir.path())).unwrap();
1300        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1301        assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1302
1303        // Wrong passphrase
1304        assert!(sign_message(
1305            "pass-pk",
1306            "evm",
1307            "hello",
1308            Some("wrong"),
1309            None,
1310            None,
1311            Some(dir.path())
1312        )
1313        .is_err());
1314        assert!(export_wallet("pass-pk", Some("wrong"), Some(dir.path())).is_err());
1315    }
1316
1317    // ================================================================
1318    // 6. SIGNATURE VERIFICATION (prove signatures are cryptographically valid)
1319    // ================================================================
1320
1321    #[test]
1322    fn evm_signature_is_recoverable() {
1323        use sha3::Digest;
1324        let dir = tempfile::tempdir().unwrap();
1325        let vault = dir.path();
1326
1327        let info = create_wallet("verify-evm", None, None, Some(vault)).unwrap();
1328        let evm_addr = info
1329            .accounts
1330            .iter()
1331            .find(|a| a.chain_id.starts_with("eip155:"))
1332            .unwrap()
1333            .address
1334            .clone();
1335
1336        let sig = sign_message(
1337            "verify-evm",
1338            "evm",
1339            "hello world",
1340            None,
1341            None,
1342            None,
1343            Some(vault),
1344        )
1345        .unwrap();
1346
1347        // EVM personal_sign: keccak256("\x19Ethereum Signed Message:\n" + len + msg)
1348        let msg = b"hello world";
1349        let prefix = format!("\x19Ethereum Signed Message:\n{}", msg.len());
1350        let mut prefixed = prefix.into_bytes();
1351        prefixed.extend_from_slice(msg);
1352
1353        let hash = sha3::Keccak256::digest(&prefixed);
1354        let sig_bytes = hex::decode(&sig.signature).unwrap();
1355        assert_eq!(
1356            sig_bytes.len(),
1357            65,
1358            "EVM signature should be 65 bytes (r + s + v)"
1359        );
1360
1361        // Recover public key from signature (v is 27 or 28 per EIP-191)
1362        let v = sig_bytes[64];
1363        assert!(
1364            v == 27 || v == 28,
1365            "EIP-191 v byte should be 27 or 28, got {v}"
1366        );
1367        let recid = k256::ecdsa::RecoveryId::try_from(v - 27).unwrap();
1368        let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
1369        let recovered_key =
1370            k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
1371
1372        // Derive address from recovered key and compare
1373        let pubkey_bytes = recovered_key.to_encoded_point(false);
1374        let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
1375        let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
1376
1377        // Compare case-insensitively (EIP-55 checksum)
1378        assert_eq!(
1379            recovered_addr.to_lowercase(),
1380            evm_addr.to_lowercase(),
1381            "recovered address should match wallet's EVM address"
1382        );
1383    }
1384
1385    // ================================================================
1386    // 7. ERROR HANDLING
1387    // ================================================================
1388
1389    #[test]
1390    fn error_nonexistent_wallet() {
1391        let dir = tempfile::tempdir().unwrap();
1392        assert!(get_wallet("nope", Some(dir.path())).is_err());
1393        assert!(export_wallet("nope", None, Some(dir.path())).is_err());
1394        assert!(sign_message("nope", "evm", "x", None, None, None, Some(dir.path())).is_err());
1395        assert!(delete_wallet("nope", Some(dir.path())).is_err());
1396    }
1397
1398    #[test]
1399    fn error_duplicate_wallet_name() {
1400        let dir = tempfile::tempdir().unwrap();
1401        let vault = dir.path();
1402        create_wallet("dup", None, None, Some(vault)).unwrap();
1403        assert!(create_wallet("dup", None, None, Some(vault)).is_err());
1404    }
1405
1406    #[test]
1407    fn error_invalid_private_key_hex() {
1408        let dir = tempfile::tempdir().unwrap();
1409        assert!(import_wallet_private_key(
1410            "bad",
1411            "not-hex",
1412            Some("evm"),
1413            None,
1414            Some(dir.path()),
1415            None,
1416            None,
1417        )
1418        .is_err());
1419    }
1420
1421    #[test]
1422    fn error_invalid_chain_for_signing() {
1423        let dir = tempfile::tempdir().unwrap();
1424        let vault = dir.path();
1425        create_wallet("chain-err", None, None, Some(vault)).unwrap();
1426        assert!(
1427            sign_message("chain-err", "fakecoin", "hi", None, None, None, Some(vault)).is_err()
1428        );
1429    }
1430
1431    #[test]
1432    fn error_invalid_tx_hex() {
1433        let dir = tempfile::tempdir().unwrap();
1434        let vault = dir.path();
1435        create_wallet("hex-err", None, None, Some(vault)).unwrap();
1436        assert!(
1437            sign_transaction("hex-err", "evm", "not-valid-hex!", None, None, Some(vault)).is_err()
1438        );
1439    }
1440
1441    // ================================================================
1442    // 8. WALLET MANAGEMENT
1443    // ================================================================
1444
1445    #[test]
1446    fn list_wallets_empty_vault() {
1447        let dir = tempfile::tempdir().unwrap();
1448        let wallets = list_wallets(Some(dir.path())).unwrap();
1449        assert!(wallets.is_empty());
1450    }
1451
1452    #[test]
1453    fn get_wallet_by_name_and_id() {
1454        let dir = tempfile::tempdir().unwrap();
1455        let vault = dir.path();
1456        let info = create_wallet("lookup", None, None, Some(vault)).unwrap();
1457
1458        let by_name = get_wallet("lookup", Some(vault)).unwrap();
1459        assert_eq!(by_name.id, info.id);
1460
1461        let by_id = get_wallet(&info.id, Some(vault)).unwrap();
1462        assert_eq!(by_id.name, "lookup");
1463    }
1464
1465    #[test]
1466    fn rename_wallet_works() {
1467        let dir = tempfile::tempdir().unwrap();
1468        let vault = dir.path();
1469        let info = create_wallet("before", None, None, Some(vault)).unwrap();
1470
1471        rename_wallet("before", "after", Some(vault)).unwrap();
1472
1473        assert!(get_wallet("before", Some(vault)).is_err());
1474        let after = get_wallet("after", Some(vault)).unwrap();
1475        assert_eq!(after.id, info.id);
1476    }
1477
1478    #[test]
1479    fn rename_to_existing_name_fails() {
1480        let dir = tempfile::tempdir().unwrap();
1481        let vault = dir.path();
1482        create_wallet("a", None, None, Some(vault)).unwrap();
1483        create_wallet("b", None, None, Some(vault)).unwrap();
1484        assert!(rename_wallet("a", "b", Some(vault)).is_err());
1485    }
1486
1487    #[test]
1488    fn delete_wallet_removes_from_list() {
1489        let dir = tempfile::tempdir().unwrap();
1490        let vault = dir.path();
1491        create_wallet("del-me", None, None, Some(vault)).unwrap();
1492        assert_eq!(list_wallets(Some(vault)).unwrap().len(), 1);
1493
1494        delete_wallet("del-me", Some(vault)).unwrap();
1495        assert_eq!(list_wallets(Some(vault)).unwrap().len(), 0);
1496    }
1497
1498    // ================================================================
1499    // 9. MESSAGE ENCODING
1500    // ================================================================
1501
1502    #[test]
1503    fn sign_message_hex_encoding() {
1504        let dir = tempfile::tempdir().unwrap();
1505        let vault = dir.path();
1506        create_wallet("hex-enc", None, None, Some(vault)).unwrap();
1507
1508        // "hello" in hex
1509        let sig = sign_message(
1510            "hex-enc",
1511            "evm",
1512            "68656c6c6f",
1513            None,
1514            Some("hex"),
1515            None,
1516            Some(vault),
1517        )
1518        .unwrap();
1519        assert!(!sig.signature.is_empty());
1520
1521        // Should match utf8 encoding of the same bytes
1522        let sig2 = sign_message(
1523            "hex-enc",
1524            "evm",
1525            "hello",
1526            None,
1527            Some("utf8"),
1528            None,
1529            Some(vault),
1530        )
1531        .unwrap();
1532        assert_eq!(
1533            sig.signature, sig2.signature,
1534            "hex and utf8 encoding of same bytes should produce same signature"
1535        );
1536    }
1537
1538    #[test]
1539    fn sign_message_invalid_encoding() {
1540        let dir = tempfile::tempdir().unwrap();
1541        let vault = dir.path();
1542        create_wallet("bad-enc", None, None, Some(vault)).unwrap();
1543        assert!(sign_message(
1544            "bad-enc",
1545            "evm",
1546            "hello",
1547            None,
1548            Some("base64"),
1549            None,
1550            Some(vault)
1551        )
1552        .is_err());
1553    }
1554
1555    // ================================================================
1556    // 10. MULTI-WALLET VAULT
1557    // ================================================================
1558
1559    #[test]
1560    fn multiple_wallets_coexist() {
1561        let dir = tempfile::tempdir().unwrap();
1562        let vault = dir.path();
1563
1564        create_wallet("w1", None, None, Some(vault)).unwrap();
1565        create_wallet("w2", None, None, Some(vault)).unwrap();
1566        save_privkey_wallet("w3", TEST_PRIVKEY, "", vault);
1567
1568        let wallets = list_wallets(Some(vault)).unwrap();
1569        assert_eq!(wallets.len(), 3);
1570
1571        // All can sign independently
1572        let s1 = sign_message("w1", "evm", "test", None, None, None, Some(vault)).unwrap();
1573        let s2 = sign_message("w2", "evm", "test", None, None, None, Some(vault)).unwrap();
1574        let s3 = sign_message("w3", "evm", "test", None, None, None, Some(vault)).unwrap();
1575
1576        // All signatures should be different (different keys)
1577        assert_ne!(s1.signature, s2.signature);
1578        assert_ne!(s1.signature, s3.signature);
1579        assert_ne!(s2.signature, s3.signature);
1580
1581        // Delete one, others survive
1582        delete_wallet("w2", Some(vault)).unwrap();
1583        assert_eq!(list_wallets(Some(vault)).unwrap().len(), 2);
1584        assert!(sign_message("w1", "evm", "test", None, None, None, Some(vault)).is_ok());
1585        assert!(sign_message("w3", "evm", "test", None, None, None, Some(vault)).is_ok());
1586    }
1587
1588    // ================================================================
1589    // 11. BUG REGRESSION: CLI send_transaction broadcasts raw signature
1590    // ================================================================
1591
1592    #[test]
1593    fn signed_tx_must_differ_from_raw_signature() {
1594        // BUG TEST: The CLI's send_transaction.rs broadcasts `output.signature`
1595        // (raw 65-byte sig) instead of encoding the full signed transaction via
1596        // signer.encode_signed_transaction(). This test proves the two are different
1597        // — broadcasting the raw signature sends garbage to the RPC node.
1598        //
1599        // The library's sign_and_send correctly calls encode_signed_transaction
1600        // before broadcast (ops.rs:481), but the CLI skips this step
1601        // (send_transaction.rs:43).
1602
1603        let dir = tempfile::tempdir().unwrap();
1604        let vault = dir.path();
1605        save_privkey_wallet("send-bug", TEST_PRIVKEY, "", vault);
1606
1607        // Build a minimal unsigned EIP-1559 transaction
1608        let items: Vec<u8> = [
1609            ows_signer::rlp::encode_bytes(&[1]),          // chain_id = 1
1610            ows_signer::rlp::encode_bytes(&[]),           // nonce = 0
1611            ows_signer::rlp::encode_bytes(&[1]),          // maxPriorityFeePerGas
1612            ows_signer::rlp::encode_bytes(&[100]),        // maxFeePerGas
1613            ows_signer::rlp::encode_bytes(&[0x52, 0x08]), // gasLimit = 21000
1614            ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]), // to (truncated)
1615            ows_signer::rlp::encode_bytes(&[]),           // value = 0
1616            ows_signer::rlp::encode_bytes(&[]),           // data
1617            ows_signer::rlp::encode_list(&[]),            // accessList
1618        ]
1619        .concat();
1620
1621        let mut unsigned_tx = vec![0x02u8];
1622        unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
1623        let tx_hex = hex::encode(&unsigned_tx);
1624
1625        // Sign the transaction via the library
1626        let sign_result =
1627            sign_transaction("send-bug", "evm", &tx_hex, None, None, Some(vault)).unwrap();
1628        let raw_signature = hex::decode(&sign_result.signature).unwrap();
1629
1630        // Now encode the full signed transaction (what the library does correctly)
1631        let key = decrypt_signing_key("send-bug", ChainType::Evm, "", None, Some(vault)).unwrap();
1632        let signer = signer_for_chain(ChainType::Evm);
1633        let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
1634        let full_signed_tx = signer
1635            .encode_signed_transaction(&unsigned_tx, &output)
1636            .unwrap();
1637
1638        // The raw signature (65 bytes) and the full signed tx are completely different.
1639        // Broadcasting the raw signature (as the CLI does) would always fail.
1640        assert_eq!(
1641            raw_signature.len(),
1642            65,
1643            "raw EVM signature should be 65 bytes (r || s || v)"
1644        );
1645        assert!(
1646            full_signed_tx.len() > raw_signature.len(),
1647            "full signed tx ({} bytes) must be larger than raw signature ({} bytes)",
1648            full_signed_tx.len(),
1649            raw_signature.len()
1650        );
1651        assert_ne!(
1652            raw_signature, full_signed_tx,
1653            "raw signature and full signed transaction must differ — \
1654             broadcasting the raw signature (as CLI send_transaction.rs:43 does) is wrong"
1655        );
1656
1657        // The full signed tx should start with the EIP-1559 type byte
1658        assert_eq!(
1659            full_signed_tx[0], 0x02,
1660            "full signed EIP-1559 tx must start with type byte 0x02"
1661        );
1662    }
1663
1664    // ================================================================
1665    // CHARACTERIZATION TESTS: lock down current signing behavior before refactoring
1666    // ================================================================
1667
1668    #[test]
1669    fn char_create_wallet_sign_transaction_with_passphrase() {
1670        let dir = tempfile::tempdir().unwrap();
1671        let vault = dir.path();
1672        create_wallet("char-pass-tx", None, Some("secret"), Some(vault)).unwrap();
1673
1674        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1675        let sig =
1676            sign_transaction("char-pass-tx", "evm", tx, Some("secret"), None, Some(vault)).unwrap();
1677        assert!(!sig.signature.is_empty());
1678        assert!(sig.recovery_id.is_some());
1679    }
1680
1681    #[test]
1682    fn char_create_wallet_sign_transaction_empty_passphrase() {
1683        let dir = tempfile::tempdir().unwrap();
1684        let vault = dir.path();
1685        create_wallet("char-empty-tx", None, None, Some(vault)).unwrap();
1686
1687        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1688        let sig =
1689            sign_transaction("char-empty-tx", "evm", tx, Some(""), None, Some(vault)).unwrap();
1690        assert!(!sig.signature.is_empty());
1691    }
1692
1693    #[test]
1694    fn char_no_passphrase_none_none_sign_transaction() {
1695        // Most common real-world flow: create wallet with no passphrase (None),
1696        // sign with no passphrase (None). Both default to "".
1697        let dir = tempfile::tempdir().unwrap();
1698        let vault = dir.path();
1699        create_wallet("char-none-none", None, None, Some(vault)).unwrap();
1700
1701        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1702        let sig = sign_transaction("char-none-none", "evm", tx, None, None, Some(vault)).unwrap();
1703        assert!(!sig.signature.is_empty());
1704        assert!(sig.recovery_id.is_some());
1705    }
1706
1707    #[test]
1708    fn char_no_passphrase_none_none_sign_message() {
1709        let dir = tempfile::tempdir().unwrap();
1710        let vault = dir.path();
1711        create_wallet("char-none-msg", None, None, Some(vault)).unwrap();
1712
1713        let sig = sign_message(
1714            "char-none-msg",
1715            "evm",
1716            "hello",
1717            None,
1718            None,
1719            None,
1720            Some(vault),
1721        )
1722        .unwrap();
1723        assert!(!sig.signature.is_empty());
1724    }
1725
1726    #[test]
1727    fn char_no_passphrase_none_none_export() {
1728        let dir = tempfile::tempdir().unwrap();
1729        let vault = dir.path();
1730        create_wallet("char-none-exp", None, None, Some(vault)).unwrap();
1731
1732        let phrase = export_wallet("char-none-exp", None, Some(vault)).unwrap();
1733        assert_eq!(phrase.split_whitespace().count(), 12);
1734    }
1735
1736    #[test]
1737    fn char_empty_passphrase_none_and_some_empty_are_equivalent() {
1738        // Verify that None and Some("") produce identical behavior for both
1739        // create and sign — they must be interchangeable.
1740        let dir = tempfile::tempdir().unwrap();
1741        let vault = dir.path();
1742
1743        // Create with None (defaults to "")
1744        create_wallet("char-equiv", None, None, Some(vault)).unwrap();
1745
1746        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1747
1748        // All four combinations of None/Some("") must produce the same signature
1749        let sig_none = sign_transaction("char-equiv", "evm", tx, None, None, Some(vault)).unwrap();
1750        let sig_empty =
1751            sign_transaction("char-equiv", "evm", tx, Some(""), None, Some(vault)).unwrap();
1752
1753        assert_eq!(
1754            sig_none.signature, sig_empty.signature,
1755            "passphrase=None and passphrase=Some(\"\") must produce identical signatures"
1756        );
1757
1758        // Same for sign_message
1759        let msg_none =
1760            sign_message("char-equiv", "evm", "test", None, None, None, Some(vault)).unwrap();
1761        let msg_empty = sign_message(
1762            "char-equiv",
1763            "evm",
1764            "test",
1765            Some(""),
1766            None,
1767            None,
1768            Some(vault),
1769        )
1770        .unwrap();
1771
1772        assert_eq!(
1773            msg_none.signature, msg_empty.signature,
1774            "sign_message: None and Some(\"\") must be equivalent"
1775        );
1776
1777        // Export with both
1778        let export_none = export_wallet("char-equiv", None, Some(vault)).unwrap();
1779        let export_empty = export_wallet("char-equiv", Some(""), Some(vault)).unwrap();
1780        assert_eq!(
1781            export_none, export_empty,
1782            "export_wallet: None and Some(\"\") must return the same mnemonic"
1783        );
1784    }
1785
1786    #[test]
1787    fn char_create_with_some_empty_sign_with_none() {
1788        // Create with explicit Some(""), sign with None — should work
1789        let dir = tempfile::tempdir().unwrap();
1790        let vault = dir.path();
1791        create_wallet("char-some-none", None, Some(""), Some(vault)).unwrap();
1792
1793        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1794        let sig = sign_transaction("char-some-none", "evm", tx, None, None, Some(vault)).unwrap();
1795        assert!(!sig.signature.is_empty());
1796    }
1797
1798    #[test]
1799    fn char_no_passphrase_wallet_rejects_nonempty_passphrase() {
1800        // A wallet created without passphrase must NOT be decryptable with a
1801        // random passphrase — this verifies the empty string is actually used
1802        // as the encryption key, not bypassed.
1803        let dir = tempfile::tempdir().unwrap();
1804        let vault = dir.path();
1805        create_wallet("char-no-pass-reject", None, None, Some(vault)).unwrap();
1806
1807        let result = sign_message(
1808            "char-no-pass-reject",
1809            "evm",
1810            "test",
1811            Some("some-random-passphrase"),
1812            None,
1813            None,
1814            Some(vault),
1815        );
1816        assert!(
1817            result.is_err(),
1818            "non-empty passphrase on empty-passphrase wallet should fail"
1819        );
1820        match result.unwrap_err() {
1821            OwsLibError::Crypto(_) => {} // Expected: decryption failure
1822            other => panic!("expected Crypto error, got: {other}"),
1823        }
1824    }
1825
1826    #[test]
1827    fn char_sign_transaction_wrong_passphrase_returns_crypto_error() {
1828        let dir = tempfile::tempdir().unwrap();
1829        let vault = dir.path();
1830        create_wallet("char-wrong-pass", None, Some("correct"), Some(vault)).unwrap();
1831
1832        let tx = "deadbeef";
1833        let result = sign_transaction(
1834            "char-wrong-pass",
1835            "evm",
1836            tx,
1837            Some("wrong"),
1838            None,
1839            Some(vault),
1840        );
1841        assert!(result.is_err());
1842        match result.unwrap_err() {
1843            OwsLibError::Crypto(_) => {} // Expected
1844            other => panic!("expected Crypto error, got: {other}"),
1845        }
1846    }
1847
1848    #[test]
1849    fn char_sign_transaction_nonexistent_wallet_returns_wallet_not_found() {
1850        let dir = tempfile::tempdir().unwrap();
1851        let result = sign_transaction("ghost", "evm", "deadbeef", None, None, Some(dir.path()));
1852        assert!(result.is_err());
1853        match result.unwrap_err() {
1854            OwsLibError::WalletNotFound(name) => assert_eq!(name, "ghost"),
1855            other => panic!("expected WalletNotFound, got: {other}"),
1856        }
1857    }
1858
1859    #[test]
1860    fn char_sign_and_send_invalid_rpc_returns_broadcast_failed() {
1861        let dir = tempfile::tempdir().unwrap();
1862        let vault = dir.path();
1863        create_wallet("char-rpc-fail", None, None, Some(vault)).unwrap();
1864
1865        // Build a minimal unsigned EIP-1559 transaction
1866        let items: Vec<u8> = [
1867            ows_signer::rlp::encode_bytes(&[1]),          // chain_id = 1
1868            ows_signer::rlp::encode_bytes(&[]),           // nonce = 0
1869            ows_signer::rlp::encode_bytes(&[1]),          // maxPriorityFeePerGas
1870            ows_signer::rlp::encode_bytes(&[100]),        // maxFeePerGas
1871            ows_signer::rlp::encode_bytes(&[0x52, 0x08]), // gasLimit = 21000
1872            ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]), // to (truncated)
1873            ows_signer::rlp::encode_bytes(&[]),           // value = 0
1874            ows_signer::rlp::encode_bytes(&[]),           // data
1875            ows_signer::rlp::encode_list(&[]),            // accessList
1876        ]
1877        .concat();
1878        let mut unsigned_tx = vec![0x02u8];
1879        unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
1880        let tx_hex = hex::encode(&unsigned_tx);
1881
1882        let result = sign_and_send(
1883            "char-rpc-fail",
1884            "evm",
1885            &tx_hex,
1886            None,
1887            None,
1888            Some("http://127.0.0.1:1"), // unreachable RPC
1889            Some(vault),
1890        );
1891        assert!(result.is_err());
1892        match result.unwrap_err() {
1893            OwsLibError::BroadcastFailed(_) => {} // Expected
1894            other => panic!("expected BroadcastFailed, got: {other}"),
1895        }
1896    }
1897
1898    #[test]
1899    fn char_create_sign_rename_sign_with_new_name() {
1900        let dir = tempfile::tempdir().unwrap();
1901        let vault = dir.path();
1902        create_wallet("orig-name", None, None, Some(vault)).unwrap();
1903
1904        // Sign with original name
1905        let sig1 = sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).unwrap();
1906        assert!(!sig1.signature.is_empty());
1907
1908        // Rename
1909        rename_wallet("orig-name", "new-name", Some(vault)).unwrap();
1910
1911        // Old name no longer works
1912        assert!(sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).is_err());
1913
1914        // Sign with new name — should produce same signature (same key)
1915        let sig2 = sign_message("new-name", "evm", "test", None, None, None, Some(vault)).unwrap();
1916        assert_eq!(
1917            sig1.signature, sig2.signature,
1918            "renamed wallet should produce identical signatures"
1919        );
1920    }
1921
1922    #[test]
1923    fn char_create_sign_delete_sign_returns_wallet_not_found() {
1924        let dir = tempfile::tempdir().unwrap();
1925        let vault = dir.path();
1926        create_wallet("del-me-char", None, None, Some(vault)).unwrap();
1927
1928        // Sign succeeds
1929        let sig =
1930            sign_message("del-me-char", "evm", "test", None, None, None, Some(vault)).unwrap();
1931        assert!(!sig.signature.is_empty());
1932
1933        // Delete
1934        delete_wallet("del-me-char", Some(vault)).unwrap();
1935
1936        // Sign after delete fails with WalletNotFound
1937        let result = sign_message("del-me-char", "evm", "test", None, None, None, Some(vault));
1938        assert!(result.is_err());
1939        match result.unwrap_err() {
1940            OwsLibError::WalletNotFound(name) => assert_eq!(name, "del-me-char"),
1941            other => panic!("expected WalletNotFound, got: {other}"),
1942        }
1943    }
1944
1945    #[test]
1946    fn char_import_sign_export_reimport_sign_deterministic() {
1947        let v1 = tempfile::tempdir().unwrap();
1948        let v2 = tempfile::tempdir().unwrap();
1949
1950        // Import with known mnemonic
1951        let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1952        import_wallet_mnemonic("char-det", phrase, None, None, Some(v1.path())).unwrap();
1953
1954        // Sign in vault 1
1955        let sig1 = sign_message(
1956            "char-det",
1957            "evm",
1958            "determinism test",
1959            None,
1960            None,
1961            None,
1962            Some(v1.path()),
1963        )
1964        .unwrap();
1965
1966        // Export
1967        let exported = export_wallet("char-det", None, Some(v1.path())).unwrap();
1968        assert_eq!(exported.trim(), phrase);
1969
1970        // Re-import into vault 2
1971        import_wallet_mnemonic("char-det-2", &exported, None, None, Some(v2.path())).unwrap();
1972
1973        // Sign in vault 2 — must produce identical signature
1974        let sig2 = sign_message(
1975            "char-det-2",
1976            "evm",
1977            "determinism test",
1978            None,
1979            None,
1980            None,
1981            Some(v2.path()),
1982        )
1983        .unwrap();
1984
1985        assert_eq!(
1986            sig1.signature, sig2.signature,
1987            "import→sign→export→reimport→sign must produce identical signatures"
1988        );
1989    }
1990
1991    #[test]
1992    fn char_import_private_key_sign_valid() {
1993        let dir = tempfile::tempdir().unwrap();
1994        let vault = dir.path();
1995
1996        import_wallet_private_key(
1997            "char-pk",
1998            TEST_PRIVKEY,
1999            Some("evm"),
2000            None,
2001            Some(vault),
2002            None,
2003            None,
2004        )
2005        .unwrap();
2006
2007        let sig = sign_transaction("char-pk", "evm", "deadbeef", None, None, Some(vault)).unwrap();
2008        assert!(!sig.signature.is_empty());
2009        assert!(sig.recovery_id.is_some());
2010    }
2011
2012    #[test]
2013    fn char_sign_message_all_chain_families() {
2014        // Verify sign_message works for every chain family (EVM, Solana, Bitcoin, Cosmos, Tron, TON, Sui)
2015        let dir = tempfile::tempdir().unwrap();
2016        let vault = dir.path();
2017        create_wallet("char-all-chains", None, None, Some(vault)).unwrap();
2018
2019        let chains = [
2020            ("evm", true),
2021            ("solana", false),
2022            ("bitcoin", true),
2023            ("cosmos", true),
2024            ("tron", true),
2025            ("ton", false),
2026            ("sui", false),
2027        ];
2028        for (chain, has_recovery_id) in &chains {
2029            let result = sign_message(
2030                "char-all-chains",
2031                chain,
2032                "hello",
2033                None,
2034                None,
2035                None,
2036                Some(vault),
2037            );
2038            assert!(
2039                result.is_ok(),
2040                "sign_message failed for {chain}: {:?}",
2041                result.err()
2042            );
2043            let sig = result.unwrap();
2044            assert!(!sig.signature.is_empty(), "signature empty for {chain}");
2045            if *has_recovery_id {
2046                assert!(
2047                    sig.recovery_id.is_some(),
2048                    "expected recovery_id for {chain}"
2049                );
2050            }
2051        }
2052    }
2053
2054    #[test]
2055    fn char_sign_typed_data_evm_valid_signature() {
2056        let dir = tempfile::tempdir().unwrap();
2057        let vault = dir.path();
2058        create_wallet("char-typed", None, None, Some(vault)).unwrap();
2059
2060        let typed_data = r#"{
2061            "types": {
2062                "EIP712Domain": [
2063                    {"name": "name", "type": "string"},
2064                    {"name": "version", "type": "string"},
2065                    {"name": "chainId", "type": "uint256"}
2066                ],
2067                "Test": [{"name": "value", "type": "uint256"}]
2068            },
2069            "primaryType": "Test",
2070            "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2071            "message": {"value": "42"}
2072        }"#;
2073
2074        let result = sign_typed_data("char-typed", "evm", typed_data, None, None, Some(vault));
2075        assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2076
2077        let sig = result.unwrap();
2078        let sig_bytes = hex::decode(&sig.signature).unwrap();
2079        assert_eq!(sig_bytes.len(), 65, "EIP-712 signature should be 65 bytes");
2080
2081        // v should be 27 or 28 per EIP-712 convention
2082        let v = sig_bytes[64];
2083        assert!(v == 27 || v == 28, "EIP-712 v should be 27 or 28, got {v}");
2084    }
2085
2086    // ================================================================
2087    // CHARACTERIZATION TESTS (wave 2): refactoring-path edge cases
2088    // ================================================================
2089
2090    #[test]
2091    fn char_sign_with_nonzero_account_index() {
2092        // The `index` parameter flows through decrypt_signing_key → HD derivation.
2093        // Verify that index=0 and index=1 produce different signatures via the public API.
2094        let dir = tempfile::tempdir().unwrap();
2095        let vault = dir.path();
2096        create_wallet("char-idx", None, None, Some(vault)).unwrap();
2097
2098        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2099
2100        let sig0 = sign_transaction("char-idx", "evm", tx, None, Some(0), Some(vault)).unwrap();
2101        let sig1 = sign_transaction("char-idx", "evm", tx, None, Some(1), Some(vault)).unwrap();
2102
2103        assert_ne!(
2104            sig0.signature, sig1.signature,
2105            "index 0 and index 1 must produce different signatures (different derived keys)"
2106        );
2107
2108        // Index 0 should match the default (None)
2109        let sig_default = sign_transaction("char-idx", "evm", tx, None, None, Some(vault)).unwrap();
2110        assert_eq!(
2111            sig0.signature, sig_default.signature,
2112            "index=0 should match index=None (default)"
2113        );
2114    }
2115
2116    #[test]
2117    fn char_sign_with_nonzero_index_sign_message() {
2118        let dir = tempfile::tempdir().unwrap();
2119        let vault = dir.path();
2120        create_wallet("char-idx-msg", None, None, Some(vault)).unwrap();
2121
2122        let sig0 = sign_message(
2123            "char-idx-msg",
2124            "evm",
2125            "hello",
2126            None,
2127            None,
2128            Some(0),
2129            Some(vault),
2130        )
2131        .unwrap();
2132        let sig1 = sign_message(
2133            "char-idx-msg",
2134            "evm",
2135            "hello",
2136            None,
2137            None,
2138            Some(1),
2139            Some(vault),
2140        )
2141        .unwrap();
2142
2143        assert_ne!(
2144            sig0.signature, sig1.signature,
2145            "different account indices should yield different signatures"
2146        );
2147    }
2148
2149    #[test]
2150    fn char_sign_transaction_0x_prefix_stripped() {
2151        // sign_transaction strips "0x" prefix from tx_hex. Verify both forms produce
2152        // the same signature.
2153        let dir = tempfile::tempdir().unwrap();
2154        let vault = dir.path();
2155        create_wallet("char-0x", None, None, Some(vault)).unwrap();
2156
2157        let tx_no_prefix = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2158        let tx_with_prefix = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2159
2160        let sig1 =
2161            sign_transaction("char-0x", "evm", tx_no_prefix, None, None, Some(vault)).unwrap();
2162        let sig2 =
2163            sign_transaction("char-0x", "evm", tx_with_prefix, None, None, Some(vault)).unwrap();
2164
2165        assert_eq!(
2166            sig1.signature, sig2.signature,
2167            "0x-prefixed and bare hex should produce identical signatures"
2168        );
2169    }
2170
2171    #[test]
2172    fn char_24_word_mnemonic_wallet_lifecycle() {
2173        // Verify 24-word mnemonics work identically to 12-word through the full lifecycle.
2174        let dir = tempfile::tempdir().unwrap();
2175        let vault = dir.path();
2176
2177        let info = create_wallet("char-24w", Some(24), None, Some(vault)).unwrap();
2178        assert!(!info.accounts.is_empty());
2179
2180        // Export → verify 24 words
2181        let phrase = export_wallet("char-24w", None, Some(vault)).unwrap();
2182        assert_eq!(
2183            phrase.split_whitespace().count(),
2184            24,
2185            "should be a 24-word mnemonic"
2186        );
2187
2188        // Sign transaction
2189        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2190        let sig = sign_transaction("char-24w", "evm", tx, None, None, Some(vault)).unwrap();
2191        assert!(!sig.signature.is_empty());
2192
2193        // Sign message on multiple chains
2194        for chain in &["evm", "solana", "bitcoin", "cosmos"] {
2195            let result = sign_message("char-24w", chain, "test", None, None, None, Some(vault));
2196            assert!(
2197                result.is_ok(),
2198                "24-word wallet sign_message failed for {chain}: {:?}",
2199                result.err()
2200            );
2201        }
2202
2203        // Re-import into separate vault → deterministic
2204        let v2 = tempfile::tempdir().unwrap();
2205        import_wallet_mnemonic("char-24w-2", &phrase, None, None, Some(v2.path())).unwrap();
2206        let sig2 = sign_transaction("char-24w-2", "evm", tx, None, None, Some(v2.path())).unwrap();
2207        assert_eq!(
2208            sig.signature, sig2.signature,
2209            "reimported 24-word wallet must produce identical signature"
2210        );
2211    }
2212
2213    #[test]
2214    fn char_concurrent_signing() {
2215        // Multiple threads signing with the same wallet must all succeed.
2216        // Relevant because agent signing will involve concurrent callers.
2217        use std::sync::Arc;
2218        use std::thread;
2219
2220        let dir = tempfile::tempdir().unwrap();
2221        let vault_path = Arc::new(dir.path().to_path_buf());
2222        create_wallet("char-conc", None, None, Some(&vault_path)).unwrap();
2223
2224        let handles: Vec<_> = (0..8)
2225            .map(|i| {
2226                let vp = Arc::clone(&vault_path);
2227                thread::spawn(move || {
2228                    let msg = format!("thread-{i}");
2229                    let result = sign_message(
2230                        "char-conc",
2231                        "evm",
2232                        &msg,
2233                        None,
2234                        None,
2235                        None,
2236                        Some(vp.as_path()),
2237                    );
2238                    assert!(
2239                        result.is_ok(),
2240                        "concurrent sign_message failed in thread {i}: {:?}",
2241                        result.err()
2242                    );
2243                    result.unwrap()
2244                })
2245            })
2246            .collect();
2247
2248        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2249
2250        // All signatures should be non-empty
2251        for (i, sig) in results.iter().enumerate() {
2252            assert!(
2253                !sig.signature.is_empty(),
2254                "thread {i} produced empty signature"
2255            );
2256        }
2257
2258        // Different messages → different signatures
2259        for i in 0..results.len() {
2260            for j in (i + 1)..results.len() {
2261                assert_ne!(
2262                    results[i].signature, results[j].signature,
2263                    "threads {i} and {j} should produce different signatures (different messages)"
2264                );
2265            }
2266        }
2267    }
2268
2269    #[test]
2270    fn char_evm_sign_transaction_recoverable() {
2271        // Verify that EVM transaction signatures are ecrecover-compatible:
2272        // recover the public key from the signature and compare to the wallet's address.
2273        use sha3::Digest;
2274
2275        let dir = tempfile::tempdir().unwrap();
2276        let vault = dir.path();
2277        let info = create_wallet("char-tx-recover", None, None, Some(vault)).unwrap();
2278        let evm_addr = info
2279            .accounts
2280            .iter()
2281            .find(|a| a.chain_id.starts_with("eip155:"))
2282            .unwrap()
2283            .address
2284            .clone();
2285
2286        let tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2287        let sig =
2288            sign_transaction("char-tx-recover", "evm", tx_hex, None, None, Some(vault)).unwrap();
2289
2290        let sig_bytes = hex::decode(&sig.signature).unwrap();
2291        assert_eq!(sig_bytes.len(), 65);
2292
2293        // EVM sign_transaction: keccak256(tx_bytes) then ecdsaSign
2294        let tx_bytes = hex::decode(tx_hex).unwrap();
2295        let hash = sha3::Keccak256::digest(&tx_bytes);
2296
2297        let v = sig_bytes[64];
2298        let recid = k256::ecdsa::RecoveryId::try_from(v).unwrap();
2299        let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
2300        let recovered_key =
2301            k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
2302
2303        // Derive address from recovered key
2304        let pubkey_bytes = recovered_key.to_encoded_point(false);
2305        let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
2306        let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
2307
2308        assert_eq!(
2309            recovered_addr.to_lowercase(),
2310            evm_addr.to_lowercase(),
2311            "recovered address from tx signature should match wallet's EVM address"
2312        );
2313    }
2314
2315    #[test]
2316    fn char_solana_extract_signable_through_sign_path() {
2317        // Verify that the full Solana signing pipeline (extract_signable → sign → encode)
2318        // works correctly through the library's sign_encode_and_broadcast path (minus broadcast).
2319        // This locks down the Solana-specific header stripping that could regress during
2320        // signing path unification.
2321        let dir = tempfile::tempdir().unwrap();
2322        let vault = dir.path();
2323        create_wallet("char-sol-sig", None, None, Some(vault)).unwrap();
2324
2325        // Build a minimal Solana serialized tx: [1 sig slot] [64 zero bytes] [message]
2326        let message_payload = b"test solana message payload 1234";
2327        let mut tx_bytes = vec![0x01u8]; // 1 signature slot
2328        tx_bytes.extend_from_slice(&[0u8; 64]); // placeholder signature
2329        tx_bytes.extend_from_slice(message_payload);
2330        let tx_hex = hex::encode(&tx_bytes);
2331
2332        // sign_transaction goes through: hex decode → decrypt key → signer.sign_transaction(key, tx_bytes)
2333        // For Solana, sign_transaction signs the raw bytes (callers must pre-extract).
2334        // But sign_and_send does: extract_signable → sign → encode → broadcast.
2335        // Verify the raw sign_transaction path works:
2336        let sig =
2337            sign_transaction("char-sol-sig", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2338        assert_eq!(
2339            hex::decode(&sig.signature).unwrap().len(),
2340            64,
2341            "Solana signature should be 64 bytes (Ed25519)"
2342        );
2343        assert!(sig.recovery_id.is_none(), "Ed25519 has no recovery ID");
2344
2345        // Now verify the sign_encode_and_broadcast pipeline (minus actual broadcast)
2346        // by manually calling the signer's extract/sign/encode chain:
2347        let key =
2348            decrypt_signing_key("char-sol-sig", ChainType::Solana, "", None, Some(vault)).unwrap();
2349        let signer = signer_for_chain(ChainType::Solana);
2350
2351        let signable = signer.extract_signable_bytes(&tx_bytes).unwrap();
2352        assert_eq!(
2353            signable, message_payload,
2354            "extract_signable_bytes should return only the message portion"
2355        );
2356
2357        let output = signer.sign_transaction(key.expose(), signable).unwrap();
2358        let signed_tx = signer
2359            .encode_signed_transaction(&tx_bytes, &output)
2360            .unwrap();
2361
2362        // The signature should be at bytes 1..65 in the signed tx
2363        assert_eq!(&signed_tx[1..65], &output.signature[..]);
2364        // Message portion should be unchanged
2365        assert_eq!(&signed_tx[65..], message_payload);
2366        // Total length unchanged
2367        assert_eq!(signed_tx.len(), tx_bytes.len());
2368
2369        // Verify the signature is valid
2370        let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
2371        let verifying_key = signing_key.verifying_key();
2372        let ed_sig = ed25519_dalek::Signature::from_bytes(&output.signature.try_into().unwrap());
2373        verifying_key
2374            .verify_strict(message_payload, &ed_sig)
2375            .expect("Solana signature should verify against extracted message");
2376    }
2377
2378    #[test]
2379    fn char_library_encodes_before_broadcast() {
2380        // The library's sign_and_send correctly calls encode_signed_transaction
2381        // before broadcasting (unlike a raw sign_transaction call).
2382        // This test verifies the library path by showing that:
2383        // 1. sign_transaction returns a raw 65-byte signature
2384        // 2. The library's internal pipeline produces a full RLP-encoded signed tx
2385        // 3. They are fundamentally different
2386        let dir = tempfile::tempdir().unwrap();
2387        let vault = dir.path();
2388        create_wallet("char-encode", None, None, Some(vault)).unwrap();
2389
2390        // Minimal EIP-1559 tx
2391        let items: Vec<u8> = [
2392            ows_signer::rlp::encode_bytes(&[1]),          // chain_id
2393            ows_signer::rlp::encode_bytes(&[]),           // nonce
2394            ows_signer::rlp::encode_bytes(&[1]),          // maxPriorityFeePerGas
2395            ows_signer::rlp::encode_bytes(&[100]),        // maxFeePerGas
2396            ows_signer::rlp::encode_bytes(&[0x52, 0x08]), // gasLimit = 21000
2397            ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]), // to
2398            ows_signer::rlp::encode_bytes(&[]),           // value
2399            ows_signer::rlp::encode_bytes(&[]),           // data
2400            ows_signer::rlp::encode_list(&[]),            // accessList
2401        ]
2402        .concat();
2403        let mut unsigned_tx = vec![0x02u8];
2404        unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
2405        let tx_hex = hex::encode(&unsigned_tx);
2406
2407        // Path A: sign_transaction (returns raw signature)
2408        let raw_sig =
2409            sign_transaction("char-encode", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2410        let raw_sig_bytes = hex::decode(&raw_sig.signature).unwrap();
2411
2412        // Path B: the internal pipeline (what sign_and_send uses)
2413        let key =
2414            decrypt_signing_key("char-encode", ChainType::Evm, "", None, Some(vault)).unwrap();
2415        let signer = signer_for_chain(ChainType::Evm);
2416        let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
2417        let full_signed_tx = signer
2418            .encode_signed_transaction(&unsigned_tx, &output)
2419            .unwrap();
2420
2421        // Raw sig is 65 bytes (r || s || v)
2422        assert_eq!(raw_sig_bytes.len(), 65);
2423
2424        // Full signed tx is RLP-encoded with type byte prefix
2425        assert!(full_signed_tx.len() > 65);
2426        assert_eq!(
2427            full_signed_tx[0], 0x02,
2428            "should preserve EIP-1559 type byte"
2429        );
2430
2431        // They must be completely different
2432        assert_ne!(raw_sig_bytes, full_signed_tx);
2433
2434        // The full signed tx should contain the r and s values from the signature
2435        // somewhere in its RLP encoding (not at the same offsets)
2436        let r_bytes = &raw_sig_bytes[..32];
2437        let _s_bytes = &raw_sig_bytes[32..64];
2438
2439        // Verify r bytes appear in the full signed tx (they'll be RLP-encoded)
2440        let full_hex = hex::encode(&full_signed_tx);
2441        let r_hex = hex::encode(r_bytes);
2442        assert!(
2443            full_hex.contains(&r_hex),
2444            "full signed tx should contain the r component"
2445        );
2446    }
2447
2448    // ================================================================
2449    // EIP-712 TYPED DATA SIGNING
2450    // ================================================================
2451
2452    #[test]
2453    fn sign_typed_data_rejects_non_evm_chain() {
2454        let tmp = tempfile::tempdir().unwrap();
2455        let vault = tmp.path();
2456
2457        let w = save_privkey_wallet("typed-data-test", TEST_PRIVKEY, "pass", vault);
2458
2459        let typed_data = r#"{
2460            "types": {
2461                "EIP712Domain": [{"name": "name", "type": "string"}],
2462                "Test": [{"name": "value", "type": "uint256"}]
2463            },
2464            "primaryType": "Test",
2465            "domain": {"name": "Test"},
2466            "message": {"value": "1"}
2467        }"#;
2468
2469        let result = sign_typed_data(&w.id, "solana", typed_data, Some("pass"), None, Some(vault));
2470        assert!(result.is_err());
2471        let err_msg = result.unwrap_err().to_string();
2472        assert!(
2473            err_msg.contains("only supported for EVM"),
2474            "expected EVM-only error, got: {err_msg}"
2475        );
2476    }
2477
2478    #[test]
2479    fn sign_typed_data_evm_succeeds() {
2480        let tmp = tempfile::tempdir().unwrap();
2481        let vault = tmp.path();
2482
2483        let w = save_privkey_wallet("typed-data-evm", TEST_PRIVKEY, "pass", vault);
2484
2485        let typed_data = r#"{
2486            "types": {
2487                "EIP712Domain": [
2488                    {"name": "name", "type": "string"},
2489                    {"name": "version", "type": "string"},
2490                    {"name": "chainId", "type": "uint256"}
2491                ],
2492                "Test": [{"name": "value", "type": "uint256"}]
2493            },
2494            "primaryType": "Test",
2495            "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2496            "message": {"value": "42"}
2497        }"#;
2498
2499        let result = sign_typed_data(&w.id, "evm", typed_data, Some("pass"), None, Some(vault));
2500        assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2501
2502        let sign_result = result.unwrap();
2503        assert!(
2504            !sign_result.signature.is_empty(),
2505            "signature should not be empty"
2506        );
2507        assert!(
2508            sign_result.recovery_id.is_some(),
2509            "recovery_id should be present for EVM"
2510        );
2511    }
2512
2513    // ================================================================
2514    // OWNER-MODE REGRESSION: prove the credential branch doesn't alter
2515    // existing behavior for any passphrase variant.
2516    // ================================================================
2517
2518    #[test]
2519    fn regression_owner_path_identical_to_direct_signer() {
2520        // Proves that sign_transaction via the library produces the exact
2521        // same signature as calling decrypt_signing_key → signer directly.
2522        // If the credential branch accidentally altered the owner path,
2523        // these would diverge.
2524        let dir = tempfile::tempdir().unwrap();
2525        let vault = dir.path();
2526        create_wallet("reg-owner", None, None, Some(vault)).unwrap();
2527
2528        let tx_hex = "deadbeefcafebabe";
2529
2530        // Path A: through the public sign_transaction API (has credential branch)
2531        let api_result =
2532            sign_transaction("reg-owner", "evm", tx_hex, None, None, Some(vault)).unwrap();
2533
2534        // Path B: direct signer call (no credential branch)
2535        let key = decrypt_signing_key("reg-owner", ChainType::Evm, "", None, Some(vault)).unwrap();
2536        let signer = signer_for_chain(ChainType::Evm);
2537        let tx_bytes = hex::decode(tx_hex).unwrap();
2538        let direct_output = signer.sign_transaction(key.expose(), &tx_bytes).unwrap();
2539
2540        assert_eq!(
2541            api_result.signature,
2542            hex::encode(&direct_output.signature),
2543            "library API and direct signer must produce identical signatures"
2544        );
2545        assert_eq!(
2546            api_result.recovery_id, direct_output.recovery_id,
2547            "recovery_id must match"
2548        );
2549    }
2550
2551    #[test]
2552    fn regression_owner_passphrase_not_confused_with_token() {
2553        // Prove that a non-token passphrase never enters the agent path.
2554        // If it did, it would fail with ApiKeyNotFound (no such token hash).
2555        let dir = tempfile::tempdir().unwrap();
2556        let vault = dir.path();
2557        create_wallet("reg-pass", Some(12), Some("hunter2"), Some(vault)).unwrap();
2558
2559        let tx_hex = "deadbeef";
2560
2561        // Signing with the correct passphrase must succeed
2562        let result = sign_transaction(
2563            "reg-pass",
2564            "evm",
2565            tx_hex,
2566            Some("hunter2"),
2567            None,
2568            Some(vault),
2569        );
2570        assert!(
2571            result.is_ok(),
2572            "owner-mode signing failed: {:?}",
2573            result.err()
2574        );
2575
2576        // Signing with empty passphrase must fail with CryptoError (wrong passphrase),
2577        // NOT with ApiKeyNotFound (which would mean it entered the agent path)
2578        let bad = sign_transaction("reg-pass", "evm", tx_hex, Some(""), None, Some(vault));
2579        assert!(bad.is_err());
2580        match bad.unwrap_err() {
2581            OwsLibError::Crypto(_) => {} // correct: scrypt decryption failed
2582            other => panic!("expected Crypto error for wrong passphrase, got: {other}"),
2583        }
2584
2585        // Signing with None must also fail with CryptoError
2586        let none_result = sign_transaction("reg-pass", "evm", tx_hex, None, None, Some(vault));
2587        assert!(none_result.is_err());
2588        match none_result.unwrap_err() {
2589            OwsLibError::Crypto(_) => {}
2590            other => panic!("expected Crypto error for None passphrase, got: {other}"),
2591        }
2592    }
2593
2594    #[test]
2595    fn regression_sign_message_owner_path_unchanged() {
2596        let dir = tempfile::tempdir().unwrap();
2597        let vault = dir.path();
2598        create_wallet("reg-msg", None, None, Some(vault)).unwrap();
2599
2600        // Through the public API
2601        let api_result =
2602            sign_message("reg-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
2603
2604        // Direct signer
2605        let key = decrypt_signing_key("reg-msg", ChainType::Evm, "", None, Some(vault)).unwrap();
2606        let signer = signer_for_chain(ChainType::Evm);
2607        let direct = signer.sign_message(key.expose(), b"hello").unwrap();
2608
2609        assert_eq!(
2610            api_result.signature,
2611            hex::encode(&direct.signature),
2612            "sign_message owner path must match direct signer"
2613        );
2614    }
2615
2616    // ================================================================
2617    // SOLANA BROADCAST ENCODING (Issue 1)
2618    // ================================================================
2619
2620    #[test]
2621    fn solana_broadcast_body_includes_encoding_param() {
2622        let dummy_tx = vec![0x01; 100];
2623        let body = build_solana_rpc_body(&dummy_tx);
2624
2625        assert_eq!(body["method"], "sendTransaction");
2626        assert_eq!(
2627            body["params"][1]["encoding"], "base64",
2628            "sendTransaction must specify encoding=base64 so Solana RPC \
2629             does not default to base58"
2630        );
2631    }
2632
2633    #[test]
2634    fn solana_broadcast_body_uses_base64_encoding() {
2635        use base64::Engine;
2636        let dummy_tx = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03];
2637        let body = build_solana_rpc_body(&dummy_tx);
2638
2639        let encoded = body["params"][0].as_str().unwrap();
2640        // Must round-trip through base64
2641        let decoded = base64::engine::general_purpose::STANDARD
2642            .decode(encoded)
2643            .expect("params[0] should be valid base64");
2644        assert_eq!(
2645            decoded, dummy_tx,
2646            "base64 should round-trip to original bytes"
2647        );
2648    }
2649
2650    #[test]
2651    fn solana_broadcast_body_is_not_hex_or_base58() {
2652        // Use bytes that would produce different strings in hex vs base64
2653        let dummy_tx = vec![0xFF; 50];
2654        let body = build_solana_rpc_body(&dummy_tx);
2655
2656        let encoded = body["params"][0].as_str().unwrap();
2657        let hex_encoded = hex::encode(&dummy_tx);
2658        assert_ne!(encoded, hex_encoded, "broadcast should use base64, not hex");
2659        // base58 never contains '+' or '/' but base64 can
2660        // More importantly, verify it's NOT valid base58 for these bytes
2661        assert!(
2662            encoded.contains('/') || encoded.contains('+') || encoded.ends_with('='),
2663            "base64 of 0xFF bytes should contain characters absent from base58"
2664        );
2665    }
2666
2667    #[test]
2668    fn solana_broadcast_body_jsonrpc_structure() {
2669        let body = build_solana_rpc_body(&[0u8; 10]);
2670        assert_eq!(body["jsonrpc"], "2.0");
2671        assert_eq!(body["id"], 1);
2672        assert_eq!(body["method"], "sendTransaction");
2673        assert!(body["params"].is_array());
2674        assert_eq!(
2675            body["params"].as_array().unwrap().len(),
2676            2,
2677            "params should have [tx_data, options_object]"
2678        );
2679    }
2680
2681    // ================================================================
2682    // SOLANA SIGN_TRANSACTION EXTRACTION (Issue 2)
2683    // ================================================================
2684
2685    #[test]
2686    fn solana_sign_transaction_extracts_signable_bytes() {
2687        // After the fix, sign_transaction should automatically extract
2688        // the message portion from a full Solana transaction envelope.
2689        let dir = tempfile::tempdir().unwrap();
2690        let vault = dir.path();
2691        create_wallet("sol-extract", None, None, Some(vault)).unwrap();
2692
2693        let message_payload = b"test solana message for extraction";
2694        let mut full_tx = vec![0x01u8]; // 1 sig slot
2695        full_tx.extend_from_slice(&[0u8; 64]); // placeholder signature
2696        full_tx.extend_from_slice(message_payload);
2697        let tx_hex = hex::encode(&full_tx);
2698
2699        // sign_transaction through the public API (should now extract first)
2700        let sig_result =
2701            sign_transaction("sol-extract", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2702        let sig_bytes = hex::decode(&sig_result.signature).unwrap();
2703
2704        // Verify the signature is over the MESSAGE portion, not the full tx
2705        let key =
2706            decrypt_signing_key("sol-extract", ChainType::Solana, "", None, Some(vault)).unwrap();
2707        let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
2708        let verifying_key = signing_key.verifying_key();
2709        let ed_sig = ed25519_dalek::Signature::from_bytes(&sig_bytes.try_into().unwrap());
2710
2711        verifying_key
2712            .verify_strict(message_payload, &ed_sig)
2713            .expect("sign_transaction should sign the message portion, not the full envelope");
2714    }
2715
2716    #[test]
2717    fn solana_sign_transaction_full_tx_matches_extracted_sign() {
2718        // Signing a full Solana tx via sign_transaction should produce the
2719        // same signature as manually extracting then signing.
2720        let dir = tempfile::tempdir().unwrap();
2721        let vault = dir.path();
2722        create_wallet("sol-match", None, None, Some(vault)).unwrap();
2723
2724        let message_payload = b"matching signatures test";
2725        let mut full_tx = vec![0x01u8];
2726        full_tx.extend_from_slice(&[0u8; 64]);
2727        full_tx.extend_from_slice(message_payload);
2728        let tx_hex = hex::encode(&full_tx);
2729
2730        // Path A: through public sign_transaction API
2731        let api_sig =
2732            sign_transaction("sol-match", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2733
2734        // Path B: manual extract + sign
2735        let key =
2736            decrypt_signing_key("sol-match", ChainType::Solana, "", None, Some(vault)).unwrap();
2737        let signer = signer_for_chain(ChainType::Solana);
2738        let signable = signer.extract_signable_bytes(&full_tx).unwrap();
2739        let direct = signer.sign_transaction(key.expose(), signable).unwrap();
2740
2741        assert_eq!(
2742            api_sig.signature,
2743            hex::encode(&direct.signature),
2744            "sign_transaction API and manual extract+sign must produce the same signature"
2745        );
2746    }
2747
2748    #[test]
2749    fn evm_sign_transaction_unaffected_by_extraction() {
2750        // Regression: EVM's extract_signable_bytes is a no-op, so the fix
2751        // should not change EVM signing behavior.
2752        let dir = tempfile::tempdir().unwrap();
2753        let vault = dir.path();
2754        create_wallet("evm-regress", None, None, Some(vault)).unwrap();
2755
2756        let items: Vec<u8> = [
2757            ows_signer::rlp::encode_bytes(&[1]),
2758            ows_signer::rlp::encode_bytes(&[]),
2759            ows_signer::rlp::encode_bytes(&[1]),
2760            ows_signer::rlp::encode_bytes(&[100]),
2761            ows_signer::rlp::encode_bytes(&[0x52, 0x08]),
2762            ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]),
2763            ows_signer::rlp::encode_bytes(&[]),
2764            ows_signer::rlp::encode_bytes(&[]),
2765            ows_signer::rlp::encode_list(&[]),
2766        ]
2767        .concat();
2768        let mut unsigned_tx = vec![0x02u8];
2769        unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
2770        let tx_hex = hex::encode(&unsigned_tx);
2771
2772        // Sign twice — should be deterministic and work fine
2773        let sig1 =
2774            sign_transaction("evm-regress", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2775        let sig2 =
2776            sign_transaction("evm-regress", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2777        assert_eq!(sig1.signature, sig2.signature);
2778        assert_eq!(hex::decode(&sig1.signature).unwrap().len(), 65);
2779    }
2780
2781    // ================================================================
2782    // SOLANA DEVNET INTEGRATION
2783    // ================================================================
2784
2785    #[test]
2786    #[ignore] // requires network access to Solana devnet
2787    fn solana_devnet_broadcast_encoding_accepted() {
2788        // Send a properly-structured Solana transaction to devnet.
2789        // The account is unfunded so the tx will fail, but the error should
2790        // NOT be about base58 encoding — proving the encoding fix works.
2791
2792        // 1. Fetch a recent blockhash from devnet
2793        let bh_body = serde_json::json!({
2794            "jsonrpc": "2.0",
2795            "method": "getLatestBlockhash",
2796            "params": [],
2797            "id": 1
2798        });
2799        let bh_resp =
2800            curl_post_json("https://api.devnet.solana.com", &bh_body.to_string()).unwrap();
2801        let bh_parsed: serde_json::Value = serde_json::from_str(&bh_resp).unwrap();
2802        let blockhash_b58 = bh_parsed["result"]["value"]["blockhash"]
2803            .as_str()
2804            .expect("devnet should return a blockhash");
2805        let blockhash = bs58::decode(blockhash_b58).into_vec().unwrap();
2806        assert_eq!(blockhash.len(), 32);
2807
2808        // 2. Derive sender pubkey from test key
2809        let privkey =
2810            hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
2811                .unwrap();
2812        let signing_key =
2813            ed25519_dalek::SigningKey::from_bytes(&privkey.clone().try_into().unwrap());
2814        let sender_pubkey = signing_key.verifying_key().to_bytes();
2815
2816        // 3. Build a minimal SOL transfer message
2817        let recipient_pubkey = [0x01; 32]; // arbitrary recipient
2818        let system_program = [0u8; 32]; // 11111..1 in base58 = all zeros
2819
2820        let mut message = vec![
2821            1, // num_required_signatures
2822            0, // num_readonly_signed_accounts
2823            1, // num_readonly_unsigned_accounts
2824            3, // num_account_keys (compact-u16)
2825        ];
2826        message.extend_from_slice(&sender_pubkey);
2827        message.extend_from_slice(&recipient_pubkey);
2828        message.extend_from_slice(&system_program);
2829        // Recent blockhash
2830        message.extend_from_slice(&blockhash);
2831        // Instructions
2832        message.push(1); // num_instructions (compact-u16)
2833        message.push(2); // program_id_index (system program)
2834        message.push(2); // num_accounts
2835        message.push(0); // from
2836        message.push(1); // to
2837        message.push(12); // data_length
2838        message.extend_from_slice(&2u32.to_le_bytes()); // transfer opcode
2839        message.extend_from_slice(&1u64.to_le_bytes()); // 1 lamport
2840
2841        // 4. Build full transaction envelope
2842        let mut tx_bytes = vec![0x01u8]; // 1 signature slot
2843        tx_bytes.extend_from_slice(&[0u8; 64]); // placeholder
2844        tx_bytes.extend_from_slice(&message);
2845
2846        // 5. Sign + encode + broadcast to devnet
2847        let result = sign_encode_and_broadcast(
2848            &privkey,
2849            "solana",
2850            &tx_bytes,
2851            Some("https://api.devnet.solana.com"),
2852        );
2853
2854        // 6. Verify we don't get an encoding error
2855        match result {
2856            Ok(send_result) => {
2857                // Unlikely (unfunded) but fine
2858                assert!(!send_result.tx_hash.is_empty());
2859            }
2860            Err(e) => {
2861                let err_str = format!("{e}");
2862                assert!(
2863                    !err_str.contains("base58"),
2864                    "should not get base58 encoding error: {err_str}"
2865                );
2866                assert!(
2867                    !err_str.contains("InvalidCharacter"),
2868                    "should not get InvalidCharacter error: {err_str}"
2869                );
2870                // We expect errors like "insufficient funds" or simulation failure
2871            }
2872        }
2873    }
2874}