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