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