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