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