Skip to main content

ows_lib/
ops.rs

1use std::path::Path;
2use std::process::Command;
3
4use ows_core::{
5    default_chain_for_type, ChainType, Config, EncryptedWallet, KeyType, WalletAccount,
6    ALL_CHAIN_TYPES,
7};
8use ows_signer::{
9    decrypt, encrypt, signer_for_chain, CryptoEnvelope, HdDeriver, Mnemonic, MnemonicStrength,
10    SecretBytes,
11};
12
13use crate::error::OwsLibError;
14use crate::types::{AccountInfo, SendResult, SignResult, WalletInfo};
15use crate::vault;
16
17/// Convert an EncryptedWallet to the binding-friendly WalletInfo.
18fn wallet_to_info(w: &EncryptedWallet) -> WalletInfo {
19    WalletInfo {
20        id: w.id.clone(),
21        name: w.name.clone(),
22        accounts: w
23            .accounts
24            .iter()
25            .map(|a| AccountInfo {
26                chain_id: a.chain_id.clone(),
27                address: a.address.clone(),
28                derivation_path: a.derivation_path.clone(),
29            })
30            .collect(),
31        created_at: w.created_at.clone(),
32    }
33}
34
35fn parse_chain(s: &str) -> Result<ows_core::Chain, OwsLibError> {
36    ows_core::parse_chain(s).map_err(OwsLibError::InvalidInput)
37}
38
39/// Derive accounts for all chain families from a mnemonic at the given index.
40fn derive_all_accounts(mnemonic: &Mnemonic, index: u32) -> Result<Vec<WalletAccount>, OwsLibError> {
41    let mut accounts = Vec::with_capacity(ALL_CHAIN_TYPES.len());
42    for ct in &ALL_CHAIN_TYPES {
43        let chain = default_chain_for_type(*ct);
44        let signer = signer_for_chain(*ct);
45        let path = signer.default_derivation_path(index);
46        let curve = signer.curve();
47        let key = HdDeriver::derive_from_mnemonic(mnemonic, "", &path, curve)?;
48        let address = signer.derive_address(key.expose())?;
49        let account_id = format!("{}:{}", chain.chain_id, address);
50        accounts.push(WalletAccount {
51            account_id,
52            address,
53            chain_id: chain.chain_id.to_string(),
54            derivation_path: path,
55        });
56    }
57    Ok(accounts)
58}
59
60/// A key pair: one key per curve.
61/// Private key material is zeroized on drop.
62struct KeyPair {
63    secp256k1: Vec<u8>,
64    ed25519: Vec<u8>,
65}
66
67impl Drop for KeyPair {
68    fn drop(&mut self) {
69        use zeroize::Zeroize;
70        self.secp256k1.zeroize();
71        self.ed25519.zeroize();
72    }
73}
74
75impl KeyPair {
76    /// Get the key for a given curve.
77    fn key_for_curve(&self, curve: ows_signer::Curve) -> &[u8] {
78        match curve {
79            ows_signer::Curve::Secp256k1 => &self.secp256k1,
80            ows_signer::Curve::Ed25519 => &self.ed25519,
81        }
82    }
83
84    /// Serialize to JSON bytes for encryption.
85    fn to_json_bytes(&self) -> Vec<u8> {
86        let obj = serde_json::json!({
87            "secp256k1": hex::encode(&self.secp256k1),
88            "ed25519": hex::encode(&self.ed25519),
89        });
90        obj.to_string().into_bytes()
91    }
92
93    /// Deserialize from JSON bytes after decryption.
94    fn from_json_bytes(bytes: &[u8]) -> Result<Self, OwsLibError> {
95        let s = String::from_utf8(bytes.to_vec())
96            .map_err(|_| OwsLibError::InvalidInput("invalid key pair data".into()))?;
97        let obj: serde_json::Value = serde_json::from_str(&s)?;
98        let secp = obj["secp256k1"]
99            .as_str()
100            .ok_or_else(|| OwsLibError::InvalidInput("missing secp256k1 key".into()))?;
101        let ed = obj["ed25519"]
102            .as_str()
103            .ok_or_else(|| OwsLibError::InvalidInput("missing ed25519 key".into()))?;
104        Ok(KeyPair {
105            secp256k1: hex::decode(secp)
106                .map_err(|e| OwsLibError::InvalidInput(format!("invalid secp256k1 hex: {e}")))?,
107            ed25519: hex::decode(ed)
108                .map_err(|e| OwsLibError::InvalidInput(format!("invalid ed25519 hex: {e}")))?,
109        })
110    }
111}
112
113/// Derive accounts for all chain families using a key pair (one key per curve).
114fn derive_all_accounts_from_keys(keys: &KeyPair) -> Result<Vec<WalletAccount>, OwsLibError> {
115    let mut accounts = Vec::with_capacity(ALL_CHAIN_TYPES.len());
116    for ct in &ALL_CHAIN_TYPES {
117        let signer = signer_for_chain(*ct);
118        let key = keys.key_for_curve(signer.curve());
119        let address = signer.derive_address(key)?;
120        let chain = default_chain_for_type(*ct);
121        accounts.push(WalletAccount {
122            account_id: format!("{}:{}", chain.chain_id, address),
123            address,
124            chain_id: chain.chain_id.to_string(),
125            derivation_path: String::new(),
126        });
127    }
128    Ok(accounts)
129}
130
131/// Generate a new BIP-39 mnemonic phrase.
132pub fn generate_mnemonic(words: u32) -> Result<String, OwsLibError> {
133    let strength = match words {
134        12 => MnemonicStrength::Words12,
135        24 => MnemonicStrength::Words24,
136        _ => return Err(OwsLibError::InvalidInput("words must be 12 or 24".into())),
137    };
138
139    let mnemonic = Mnemonic::generate(strength)?;
140    let phrase = mnemonic.phrase();
141    String::from_utf8(phrase.expose().to_vec())
142        .map_err(|e| OwsLibError::InvalidInput(format!("invalid UTF-8 in mnemonic: {e}")))
143}
144
145/// Derive an address from a mnemonic phrase for the given chain.
146pub fn derive_address(
147    mnemonic_phrase: &str,
148    chain: &str,
149    index: Option<u32>,
150) -> Result<String, OwsLibError> {
151    let chain = parse_chain(chain)?;
152    let mnemonic = Mnemonic::from_phrase(mnemonic_phrase)?;
153    let signer = signer_for_chain(chain.chain_type);
154    let path = signer.default_derivation_path(index.unwrap_or(0));
155    let curve = signer.curve();
156
157    let key = HdDeriver::derive_from_mnemonic(&mnemonic, "", &path, curve)?;
158    let address = signer.derive_address(key.expose())?;
159    Ok(address)
160}
161
162/// Create a new universal wallet: generates mnemonic, derives addresses for all chains,
163/// encrypts, and saves to vault.
164pub fn create_wallet(
165    name: &str,
166    words: Option<u32>,
167    passphrase: Option<&str>,
168    vault_path: Option<&Path>,
169) -> Result<WalletInfo, OwsLibError> {
170    let passphrase = passphrase.unwrap_or("");
171    let words = words.unwrap_or(12);
172    let strength = match words {
173        12 => MnemonicStrength::Words12,
174        24 => MnemonicStrength::Words24,
175        _ => return Err(OwsLibError::InvalidInput("words must be 12 or 24".into())),
176    };
177
178    if vault::wallet_name_exists(name, vault_path)? {
179        return Err(OwsLibError::WalletNameExists(name.to_string()));
180    }
181
182    let mnemonic = Mnemonic::generate(strength)?;
183    let accounts = derive_all_accounts(&mnemonic, 0)?;
184
185    let phrase = mnemonic.phrase();
186    let crypto_envelope = encrypt(phrase.expose(), passphrase)?;
187    let crypto_json = serde_json::to_value(&crypto_envelope)?;
188
189    let wallet_id = uuid::Uuid::new_v4().to_string();
190
191    let wallet = EncryptedWallet::new(
192        wallet_id,
193        name.to_string(),
194        accounts,
195        crypto_json,
196        KeyType::Mnemonic,
197    );
198
199    vault::save_encrypted_wallet(&wallet, vault_path)?;
200    Ok(wallet_to_info(&wallet))
201}
202
203/// Import a wallet from a mnemonic phrase. Derives addresses for all chains.
204pub fn import_wallet_mnemonic(
205    name: &str,
206    mnemonic_phrase: &str,
207    passphrase: Option<&str>,
208    index: Option<u32>,
209    vault_path: Option<&Path>,
210) -> Result<WalletInfo, OwsLibError> {
211    let passphrase = passphrase.unwrap_or("");
212    let index = index.unwrap_or(0);
213
214    if vault::wallet_name_exists(name, vault_path)? {
215        return Err(OwsLibError::WalletNameExists(name.to_string()));
216    }
217
218    let mnemonic = Mnemonic::from_phrase(mnemonic_phrase)?;
219    let accounts = derive_all_accounts(&mnemonic, index)?;
220
221    let phrase = mnemonic.phrase();
222    let crypto_envelope = encrypt(phrase.expose(), passphrase)?;
223    let crypto_json = serde_json::to_value(&crypto_envelope)?;
224
225    let wallet_id = uuid::Uuid::new_v4().to_string();
226
227    let wallet = EncryptedWallet::new(
228        wallet_id,
229        name.to_string(),
230        accounts,
231        crypto_json,
232        KeyType::Mnemonic,
233    );
234
235    vault::save_encrypted_wallet(&wallet, vault_path)?;
236    Ok(wallet_to_info(&wallet))
237}
238
239/// Decode a hex-encoded key, stripping an optional `0x` prefix.
240fn decode_hex_key(hex_str: &str) -> Result<Vec<u8>, OwsLibError> {
241    let trimmed = hex_str.strip_prefix("0x").unwrap_or(hex_str);
242    hex::decode(trimmed)
243        .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex private key: {e}")))
244}
245
246/// Import a wallet from a hex-encoded private key.
247/// The `chain` parameter specifies which chain the key originates from (e.g. "evm", "solana").
248/// A random key is generated for the other curve so all 6 chains are supported.
249///
250/// Alternatively, provide both `secp256k1_key_hex` and `ed25519_key_hex` to supply
251/// explicit keys for each curve. When both are given, `private_key_hex` and `chain`
252/// are ignored. When only one curve key is given alongside `private_key_hex`, it
253/// overrides the random generation for that curve.
254pub fn import_wallet_private_key(
255    name: &str,
256    private_key_hex: &str,
257    chain: Option<&str>,
258    passphrase: Option<&str>,
259    vault_path: Option<&Path>,
260    secp256k1_key_hex: Option<&str>,
261    ed25519_key_hex: Option<&str>,
262) -> Result<WalletInfo, OwsLibError> {
263    let passphrase = passphrase.unwrap_or("");
264
265    if vault::wallet_name_exists(name, vault_path)? {
266        return Err(OwsLibError::WalletNameExists(name.to_string()));
267    }
268
269    let keys = match (secp256k1_key_hex, ed25519_key_hex) {
270        // Both curve keys explicitly provided — use them directly
271        (Some(secp_hex), Some(ed_hex)) => KeyPair {
272            secp256k1: decode_hex_key(secp_hex)?,
273            ed25519: decode_hex_key(ed_hex)?,
274        },
275        // Existing single-key behavior
276        _ => {
277            let key_bytes = decode_hex_key(private_key_hex)?;
278
279            // Determine curve from the source chain (default: secp256k1)
280            let source_curve = match chain {
281                Some(c) => {
282                    let parsed = parse_chain(c)?;
283                    signer_for_chain(parsed.chain_type).curve()
284                }
285                None => ows_signer::Curve::Secp256k1,
286            };
287
288            // Build key pair: provided key for its curve, random 32 bytes for the other
289            let mut other_key = vec![0u8; 32];
290            getrandom::getrandom(&mut other_key).map_err(|e| {
291                OwsLibError::InvalidInput(format!("failed to generate random key: {e}"))
292            })?;
293
294            match source_curve {
295                ows_signer::Curve::Secp256k1 => KeyPair {
296                    secp256k1: key_bytes,
297                    ed25519: ed25519_key_hex
298                        .map(decode_hex_key)
299                        .transpose()?
300                        .unwrap_or(other_key),
301                },
302                ows_signer::Curve::Ed25519 => KeyPair {
303                    secp256k1: secp256k1_key_hex
304                        .map(decode_hex_key)
305                        .transpose()?
306                        .unwrap_or(other_key),
307                    ed25519: key_bytes,
308                },
309            }
310        }
311    };
312
313    let accounts = derive_all_accounts_from_keys(&keys)?;
314
315    let payload = keys.to_json_bytes();
316    let crypto_envelope = encrypt(&payload, passphrase)?;
317    let crypto_json = serde_json::to_value(&crypto_envelope)?;
318
319    let wallet_id = uuid::Uuid::new_v4().to_string();
320
321    let wallet = EncryptedWallet::new(
322        wallet_id,
323        name.to_string(),
324        accounts,
325        crypto_json,
326        KeyType::PrivateKey,
327    );
328
329    vault::save_encrypted_wallet(&wallet, vault_path)?;
330    Ok(wallet_to_info(&wallet))
331}
332
333/// List all wallets in the vault.
334pub fn list_wallets(vault_path: Option<&Path>) -> Result<Vec<WalletInfo>, OwsLibError> {
335    let wallets = vault::list_encrypted_wallets(vault_path)?;
336    Ok(wallets.iter().map(wallet_to_info).collect())
337}
338
339/// Get a single wallet by name or ID.
340pub fn get_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result<WalletInfo, OwsLibError> {
341    let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
342    Ok(wallet_to_info(&wallet))
343}
344
345/// Delete a wallet from the vault.
346pub fn delete_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibError> {
347    let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
348    vault::delete_wallet_file(&wallet.id, vault_path)?;
349    Ok(())
350}
351
352/// Export a wallet's secret.
353/// Mnemonic wallets return the phrase. Private key wallets return JSON with both keys.
354pub fn export_wallet(
355    name_or_id: &str,
356    passphrase: Option<&str>,
357    vault_path: Option<&Path>,
358) -> Result<String, OwsLibError> {
359    let passphrase = passphrase.unwrap_or("");
360    let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
361    let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
362    let secret = decrypt(&envelope, passphrase)?;
363
364    match wallet.key_type {
365        KeyType::Mnemonic => String::from_utf8(secret.expose().to_vec()).map_err(|_| {
366            OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
367        }),
368        KeyType::PrivateKey => {
369            // Return the JSON key pair as-is
370            String::from_utf8(secret.expose().to_vec())
371                .map_err(|_| OwsLibError::InvalidInput("wallet contains invalid key data".into()))
372        }
373    }
374}
375
376/// Rename a wallet.
377pub fn rename_wallet(
378    name_or_id: &str,
379    new_name: &str,
380    vault_path: Option<&Path>,
381) -> Result<(), OwsLibError> {
382    let mut wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
383
384    if wallet.name == new_name {
385        return Ok(());
386    }
387
388    if vault::wallet_name_exists(new_name, vault_path)? {
389        return Err(OwsLibError::WalletNameExists(new_name.to_string()));
390    }
391
392    wallet.name = new_name.to_string();
393    vault::save_encrypted_wallet(&wallet, vault_path)?;
394    Ok(())
395}
396
397/// Sign a transaction. Returns hex-encoded signature.
398pub fn sign_transaction(
399    wallet: &str,
400    chain: &str,
401    tx_hex: &str,
402    passphrase: Option<&str>,
403    index: Option<u32>,
404    vault_path: Option<&Path>,
405) -> Result<SignResult, OwsLibError> {
406    let passphrase = passphrase.unwrap_or("");
407    let chain = parse_chain(chain)?;
408
409    let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
410    let tx_bytes = hex::decode(tx_hex_clean)
411        .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
412
413    let key = decrypt_signing_key(wallet, chain.chain_type, passphrase, index, vault_path)?;
414    let signer = signer_for_chain(chain.chain_type);
415    let output = signer.sign_transaction(key.expose(), &tx_bytes)?;
416
417    Ok(SignResult {
418        signature: hex::encode(&output.signature),
419        recovery_id: output.recovery_id,
420    })
421}
422
423/// Sign a message. Returns hex-encoded signature.
424pub fn sign_message(
425    wallet: &str,
426    chain: &str,
427    message: &str,
428    passphrase: Option<&str>,
429    encoding: Option<&str>,
430    index: Option<u32>,
431    vault_path: Option<&Path>,
432) -> Result<SignResult, OwsLibError> {
433    let passphrase = passphrase.unwrap_or("");
434    let chain = parse_chain(chain)?;
435
436    let encoding = encoding.unwrap_or("utf8");
437    let msg_bytes = match encoding {
438        "utf8" => message.as_bytes().to_vec(),
439        "hex" => hex::decode(message)
440            .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex message: {e}")))?,
441        _ => {
442            return Err(OwsLibError::InvalidInput(format!(
443                "unsupported encoding: {encoding} (use 'utf8' or 'hex')"
444            )))
445        }
446    };
447
448    let key = decrypt_signing_key(wallet, chain.chain_type, passphrase, index, vault_path)?;
449    let signer = signer_for_chain(chain.chain_type);
450    let output = signer.sign_message(key.expose(), &msg_bytes)?;
451
452    Ok(SignResult {
453        signature: hex::encode(&output.signature),
454        recovery_id: output.recovery_id,
455    })
456}
457
458/// Sign EIP-712 typed structured data. Returns hex-encoded signature.
459/// Only supported for EVM chains.
460pub fn sign_typed_data(
461    wallet: &str,
462    chain: &str,
463    typed_data_json: &str,
464    passphrase: Option<&str>,
465    index: Option<u32>,
466    vault_path: Option<&Path>,
467) -> Result<SignResult, OwsLibError> {
468    let passphrase = passphrase.unwrap_or("");
469    let chain = parse_chain(chain)?;
470
471    if chain.chain_type != ows_core::ChainType::Evm {
472        return Err(OwsLibError::InvalidInput(
473            "EIP-712 typed data signing is only supported for EVM chains".into(),
474        ));
475    }
476
477    let key = decrypt_signing_key(wallet, chain.chain_type, passphrase, index, vault_path)?;
478    let evm_signer = ows_signer::chains::EvmSigner;
479    let output = evm_signer.sign_typed_data(key.expose(), typed_data_json)?;
480
481    Ok(SignResult {
482        signature: hex::encode(&output.signature),
483        recovery_id: output.recovery_id,
484    })
485}
486
487/// Sign and broadcast a transaction. Returns the transaction hash.
488pub fn sign_and_send(
489    wallet: &str,
490    chain: &str,
491    tx_hex: &str,
492    passphrase: Option<&str>,
493    index: Option<u32>,
494    rpc_url: Option<&str>,
495    vault_path: Option<&Path>,
496) -> Result<SendResult, OwsLibError> {
497    let passphrase = passphrase.unwrap_or("");
498    let chain_info = parse_chain(chain)?;
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    let key = decrypt_signing_key(wallet, chain_info.chain_type, passphrase, index, vault_path)?;
505
506    sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url)
507}
508
509/// Sign, encode, and broadcast a transaction using an already-resolved private key.
510///
511/// This is the shared core of the send-transaction flow. Both the library's
512/// [`sign_and_send`] (which resolves keys from the vault) and the CLI (which
513/// resolves keys via env vars / stdin prompts) delegate here so the
514/// sign → encode → broadcast pipeline is never duplicated.
515pub fn sign_encode_and_broadcast(
516    private_key: &[u8],
517    chain: &str,
518    tx_bytes: &[u8],
519    rpc_url: Option<&str>,
520) -> Result<SendResult, OwsLibError> {
521    let chain = parse_chain(chain)?;
522    let signer = signer_for_chain(chain.chain_type);
523
524    // 1. Extract signable portion (strips signature-slot headers for Solana; no-op for others)
525    let signable = signer.extract_signable_bytes(tx_bytes)?;
526
527    // 2. Sign
528    let output = signer.sign_transaction(private_key, signable)?;
529
530    // 3. Encode the full signed transaction
531    let signed_tx = signer.encode_signed_transaction(tx_bytes, &output)?;
532
533    // 4. Resolve RPC URL using exact chain_id
534    let rpc = resolve_rpc_url(chain.chain_id, chain.chain_type, rpc_url)?;
535
536    // 5. Broadcast the full signed transaction
537    let tx_hash = broadcast(chain.chain_type, &rpc, &signed_tx)?;
538
539    Ok(SendResult { tx_hash })
540}
541
542// --- internal helpers ---
543
544/// Decrypt a wallet and return the private key for the given chain.
545fn decrypt_signing_key(
546    wallet_name_or_id: &str,
547    chain_type: ChainType,
548    passphrase: &str,
549    index: Option<u32>,
550    vault_path: Option<&Path>,
551) -> Result<SecretBytes, OwsLibError> {
552    let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
553    let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
554    let secret = decrypt(&envelope, passphrase)?;
555
556    match wallet.key_type {
557        KeyType::Mnemonic => {
558            // Use the SecretBytes directly as a &str to avoid un-zeroized String copies.
559            let phrase = std::str::from_utf8(secret.expose()).map_err(|_| {
560                OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
561            })?;
562            let mnemonic = Mnemonic::from_phrase(phrase)?;
563            let signer = signer_for_chain(chain_type);
564            let path = signer.default_derivation_path(index.unwrap_or(0));
565            let curve = signer.curve();
566            Ok(HdDeriver::derive_from_mnemonic(
567                &mnemonic, "", &path, curve,
568            )?)
569        }
570        KeyType::PrivateKey => {
571            // JSON key pair — extract the right key for this chain's curve
572            let keys = KeyPair::from_json_bytes(secret.expose())?;
573            let signer = signer_for_chain(chain_type);
574            Ok(SecretBytes::from_slice(keys.key_for_curve(signer.curve())))
575        }
576    }
577}
578
579/// Resolve the RPC URL: explicit > config override (exact chain_id) > config (namespace) > built-in default.
580fn resolve_rpc_url(
581    chain_id: &str,
582    chain_type: ChainType,
583    explicit: Option<&str>,
584) -> Result<String, OwsLibError> {
585    if let Some(url) = explicit {
586        return Ok(url.to_string());
587    }
588
589    let config = Config::load_or_default();
590    let defaults = Config::default_rpc();
591
592    // Try exact chain_id match first
593    if let Some(url) = config.rpc.get(chain_id) {
594        return Ok(url.clone());
595    }
596    if let Some(url) = defaults.get(chain_id) {
597        return Ok(url.clone());
598    }
599
600    // Fallback to namespace match
601    let namespace = chain_type.namespace();
602    for (key, url) in &config.rpc {
603        if key.starts_with(namespace) {
604            return Ok(url.clone());
605        }
606    }
607    for (key, url) in &defaults {
608        if key.starts_with(namespace) {
609            return Ok(url.clone());
610        }
611    }
612
613    Err(OwsLibError::InvalidInput(format!(
614        "no RPC URL configured for chain '{chain_id}'"
615    )))
616}
617
618/// Broadcast a signed transaction via curl, dispatching per chain type.
619fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
620    match chain {
621        ChainType::Evm => broadcast_evm(rpc_url, signed_bytes),
622        ChainType::Solana => broadcast_solana(rpc_url, signed_bytes),
623        ChainType::Bitcoin => broadcast_bitcoin(rpc_url, signed_bytes),
624        ChainType::Cosmos => broadcast_cosmos(rpc_url, signed_bytes),
625        ChainType::Tron => broadcast_tron(rpc_url, signed_bytes),
626        ChainType::Ton => broadcast_ton(rpc_url, signed_bytes),
627        ChainType::Spark => Err(OwsLibError::InvalidInput(
628            "broadcast not yet supported for Spark".into(),
629        )),
630        ChainType::Filecoin => Err(OwsLibError::InvalidInput(
631            "broadcast not yet supported for Filecoin".into(),
632        )),
633    }
634}
635
636fn broadcast_evm(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
637    let hex_tx = format!("0x{}", hex::encode(signed_bytes));
638    let body = serde_json::json!({
639        "jsonrpc": "2.0",
640        "method": "eth_sendRawTransaction",
641        "params": [hex_tx],
642        "id": 1
643    });
644    let resp = curl_post_json(rpc_url, &body.to_string())?;
645    extract_json_field(&resp, "result")
646}
647
648fn broadcast_solana(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
649    use base64::Engine;
650    let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
651    let body = serde_json::json!({
652        "jsonrpc": "2.0",
653        "method": "sendTransaction",
654        "params": [b64_tx],
655        "id": 1
656    });
657    let resp = curl_post_json(rpc_url, &body.to_string())?;
658    extract_json_field(&resp, "result")
659}
660
661fn broadcast_bitcoin(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
662    let hex_tx = hex::encode(signed_bytes);
663    let url = format!("{}/tx", rpc_url.trim_end_matches('/'));
664    let output = Command::new("curl")
665        .args([
666            "-fsSL",
667            "-X",
668            "POST",
669            "-H",
670            "Content-Type: text/plain",
671            "-d",
672            &hex_tx,
673            &url,
674        ])
675        .output()
676        .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
677
678    if !output.status.success() {
679        let stderr = String::from_utf8_lossy(&output.stderr);
680        return Err(OwsLibError::BroadcastFailed(format!(
681            "broadcast failed: {stderr}"
682        )));
683    }
684
685    let tx_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
686    if tx_hash.is_empty() {
687        return Err(OwsLibError::BroadcastFailed(
688            "empty response from broadcast".into(),
689        ));
690    }
691    Ok(tx_hash)
692}
693
694fn broadcast_cosmos(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
695    use base64::Engine;
696    let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
697    let url = format!("{}/cosmos/tx/v1beta1/txs", rpc_url.trim_end_matches('/'));
698    let body = serde_json::json!({
699        "tx_bytes": b64_tx,
700        "mode": "BROADCAST_MODE_SYNC"
701    });
702    let resp = curl_post_json(&url, &body.to_string())?;
703    let parsed: serde_json::Value = serde_json::from_str(&resp)?;
704    parsed["tx_response"]["txhash"]
705        .as_str()
706        .map(|s| s.to_string())
707        .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no txhash in response: {resp}")))
708}
709
710fn broadcast_tron(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
711    let hex_tx = hex::encode(signed_bytes);
712    let url = format!("{}/wallet/broadcasthex", rpc_url.trim_end_matches('/'));
713    let body = serde_json::json!({ "transaction": hex_tx });
714    let resp = curl_post_json(&url, &body.to_string())?;
715    extract_json_field(&resp, "txid")
716}
717
718fn broadcast_ton(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
719    use base64::Engine;
720    let b64_boc = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
721    let url = format!("{}/sendBoc", rpc_url.trim_end_matches('/'));
722    let body = serde_json::json!({ "boc": b64_boc });
723    let resp = curl_post_json(&url, &body.to_string())?;
724    let parsed: serde_json::Value = serde_json::from_str(&resp)?;
725    parsed["result"]["hash"]
726        .as_str()
727        .map(|s| s.to_string())
728        .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no hash in response: {resp}")))
729}
730
731fn curl_post_json(url: &str, body: &str) -> Result<String, OwsLibError> {
732    let output = Command::new("curl")
733        .args([
734            "-fsSL",
735            "-X",
736            "POST",
737            "-H",
738            "Content-Type: application/json",
739            "-d",
740            body,
741            url,
742        ])
743        .output()
744        .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
745
746    if !output.status.success() {
747        let stderr = String::from_utf8_lossy(&output.stderr);
748        return Err(OwsLibError::BroadcastFailed(format!(
749            "broadcast failed: {stderr}"
750        )));
751    }
752
753    Ok(String::from_utf8_lossy(&output.stdout).to_string())
754}
755
756fn extract_json_field(json_str: &str, field: &str) -> Result<String, OwsLibError> {
757    let parsed: serde_json::Value = serde_json::from_str(json_str)?;
758
759    if let Some(error) = parsed.get("error") {
760        return Err(OwsLibError::BroadcastFailed(format!("RPC error: {error}")));
761    }
762
763    parsed[field]
764        .as_str()
765        .map(|s| s.to_string())
766        .ok_or_else(|| {
767            OwsLibError::BroadcastFailed(format!("no '{field}' in response: {json_str}"))
768        })
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    // ---- helpers ----
776
777    /// Build a private-key wallet directly in the vault, bypassing
778    /// `import_wallet_private_key` (which touches all chains including TON).
779    fn save_privkey_wallet(
780        name: &str,
781        privkey_hex: &str,
782        passphrase: &str,
783        vault: &Path,
784    ) -> WalletInfo {
785        let key_bytes = hex::decode(privkey_hex).unwrap();
786
787        // Generate a random ed25519 key for the other curve
788        let mut ed_key = vec![0u8; 32];
789        getrandom::getrandom(&mut ed_key).unwrap();
790
791        let keys = KeyPair {
792            secp256k1: key_bytes,
793            ed25519: ed_key,
794        };
795        let accounts = derive_all_accounts_from_keys(&keys).unwrap();
796        let payload = keys.to_json_bytes();
797        let crypto_envelope = encrypt(&payload, passphrase).unwrap();
798        let crypto_json = serde_json::to_value(&crypto_envelope).unwrap();
799        let wallet = EncryptedWallet::new(
800            uuid::Uuid::new_v4().to_string(),
801            name.to_string(),
802            accounts,
803            crypto_json,
804            KeyType::PrivateKey,
805        );
806        vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
807        wallet_to_info(&wallet)
808    }
809
810    const TEST_PRIVKEY: &str = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
811
812    // ================================================================
813    // 1. MNEMONIC GENERATION
814    // ================================================================
815
816    #[test]
817    fn mnemonic_12_words() {
818        let phrase = generate_mnemonic(12).unwrap();
819        assert_eq!(phrase.split_whitespace().count(), 12);
820    }
821
822    #[test]
823    fn mnemonic_24_words() {
824        let phrase = generate_mnemonic(24).unwrap();
825        assert_eq!(phrase.split_whitespace().count(), 24);
826    }
827
828    #[test]
829    fn mnemonic_invalid_word_count() {
830        assert!(generate_mnemonic(15).is_err());
831        assert!(generate_mnemonic(0).is_err());
832        assert!(generate_mnemonic(13).is_err());
833    }
834
835    #[test]
836    fn mnemonic_is_unique_each_call() {
837        let a = generate_mnemonic(12).unwrap();
838        let b = generate_mnemonic(12).unwrap();
839        assert_ne!(a, b, "two generated mnemonics should differ");
840    }
841
842    // ================================================================
843    // 2. ADDRESS DERIVATION
844    // ================================================================
845
846    #[test]
847    fn derive_address_all_chains() {
848        let phrase = generate_mnemonic(12).unwrap();
849        let chains = ["evm", "solana", "bitcoin", "cosmos", "tron", "ton"];
850        for chain in &chains {
851            let addr = derive_address(&phrase, chain, None).unwrap();
852            assert!(!addr.is_empty(), "address should be non-empty for {chain}");
853        }
854    }
855
856    #[test]
857    fn derive_address_evm_format() {
858        let phrase = generate_mnemonic(12).unwrap();
859        let addr = derive_address(&phrase, "evm", None).unwrap();
860        assert!(addr.starts_with("0x"), "EVM address should start with 0x");
861        assert_eq!(addr.len(), 42, "EVM address should be 42 chars");
862    }
863
864    #[test]
865    fn derive_address_deterministic() {
866        let phrase = generate_mnemonic(12).unwrap();
867        let a = derive_address(&phrase, "evm", None).unwrap();
868        let b = derive_address(&phrase, "evm", None).unwrap();
869        assert_eq!(a, b, "same mnemonic should produce same address");
870    }
871
872    #[test]
873    fn derive_address_different_index() {
874        let phrase = generate_mnemonic(12).unwrap();
875        let a = derive_address(&phrase, "evm", Some(0)).unwrap();
876        let b = derive_address(&phrase, "evm", Some(1)).unwrap();
877        assert_ne!(a, b, "different indices should produce different addresses");
878    }
879
880    #[test]
881    fn derive_address_invalid_chain() {
882        let phrase = generate_mnemonic(12).unwrap();
883        assert!(derive_address(&phrase, "nonexistent", None).is_err());
884    }
885
886    #[test]
887    fn derive_address_invalid_mnemonic() {
888        assert!(derive_address("not a valid mnemonic phrase at all", "evm", None).is_err());
889    }
890
891    // ================================================================
892    // 3. MNEMONIC WALLET LIFECYCLE (create → export → import → sign)
893    // ================================================================
894
895    #[test]
896    fn mnemonic_wallet_create_export_reimport() {
897        let v1 = tempfile::tempdir().unwrap();
898        let v2 = tempfile::tempdir().unwrap();
899
900        // Create
901        let w1 = create_wallet("w1", None, None, Some(v1.path())).unwrap();
902        assert!(!w1.accounts.is_empty());
903
904        // Export mnemonic
905        let phrase = export_wallet("w1", None, Some(v1.path())).unwrap();
906        assert_eq!(phrase.split_whitespace().count(), 12);
907
908        // Re-import into fresh vault
909        let w2 = import_wallet_mnemonic("w2", &phrase, None, None, Some(v2.path())).unwrap();
910
911        // Addresses must match exactly
912        assert_eq!(w1.accounts.len(), w2.accounts.len());
913        for (a1, a2) in w1.accounts.iter().zip(w2.accounts.iter()) {
914            assert_eq!(a1.chain_id, a2.chain_id);
915            assert_eq!(
916                a1.address, a2.address,
917                "address mismatch for {}",
918                a1.chain_id
919            );
920        }
921    }
922
923    #[test]
924    fn mnemonic_wallet_sign_message_all_chains() {
925        let dir = tempfile::tempdir().unwrap();
926        let vault = dir.path();
927        create_wallet("multi-sign", None, None, Some(vault)).unwrap();
928
929        let chains = ["evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark"];
930        for chain in &chains {
931            let result = sign_message(
932                "multi-sign",
933                chain,
934                "test msg",
935                None,
936                None,
937                None,
938                Some(vault),
939            );
940            assert!(
941                result.is_ok(),
942                "sign_message should work for {chain}: {:?}",
943                result.err()
944            );
945            let sig = result.unwrap();
946            assert!(
947                !sig.signature.is_empty(),
948                "signature should be non-empty for {chain}"
949            );
950        }
951    }
952
953    #[test]
954    fn mnemonic_wallet_sign_tx_all_chains() {
955        let dir = tempfile::tempdir().unwrap();
956        let vault = dir.path();
957        create_wallet("tx-sign", None, None, Some(vault)).unwrap();
958
959        let generic_tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
960        // Solana requires a properly formatted serialized transaction:
961        // [0x01 num_sigs] [64 zero bytes for sig slot] [message bytes...]
962        let mut solana_tx = vec![0x01u8]; // 1 signature slot
963        solana_tx.extend_from_slice(&[0u8; 64]); // placeholder signature
964        solana_tx.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // message payload
965        let solana_tx_hex = hex::encode(&solana_tx);
966
967        let chains = ["evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark"];
968        for chain in &chains {
969            let tx = if *chain == "solana" {
970                &solana_tx_hex
971            } else {
972                generic_tx_hex
973            };
974            let result = sign_transaction("tx-sign", chain, tx, None, None, Some(vault));
975            assert!(
976                result.is_ok(),
977                "sign_transaction should work for {chain}: {:?}",
978                result.err()
979            );
980        }
981    }
982
983    #[test]
984    fn mnemonic_wallet_signing_is_deterministic() {
985        let dir = tempfile::tempdir().unwrap();
986        let vault = dir.path();
987        create_wallet("det-sign", None, None, Some(vault)).unwrap();
988
989        let s1 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
990        let s2 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
991        assert_eq!(
992            s1.signature, s2.signature,
993            "same message should produce same signature"
994        );
995    }
996
997    #[test]
998    fn mnemonic_wallet_different_messages_produce_different_sigs() {
999        let dir = tempfile::tempdir().unwrap();
1000        let vault = dir.path();
1001        create_wallet("diff-msg", None, None, Some(vault)).unwrap();
1002
1003        let s1 = sign_message("diff-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
1004        let s2 = sign_message("diff-msg", "evm", "world", None, None, None, Some(vault)).unwrap();
1005        assert_ne!(s1.signature, s2.signature);
1006    }
1007
1008    // ================================================================
1009    // 4. PRIVATE KEY WALLET LIFECYCLE
1010    // ================================================================
1011
1012    #[test]
1013    fn privkey_wallet_sign_message() {
1014        let dir = tempfile::tempdir().unwrap();
1015        save_privkey_wallet("pk-sign", TEST_PRIVKEY, "", dir.path());
1016
1017        let sig = sign_message(
1018            "pk-sign",
1019            "evm",
1020            "hello",
1021            None,
1022            None,
1023            None,
1024            Some(dir.path()),
1025        )
1026        .unwrap();
1027        assert!(!sig.signature.is_empty());
1028        assert!(sig.recovery_id.is_some());
1029    }
1030
1031    #[test]
1032    fn privkey_wallet_sign_transaction() {
1033        let dir = tempfile::tempdir().unwrap();
1034        save_privkey_wallet("pk-tx", TEST_PRIVKEY, "", dir.path());
1035
1036        let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1037        let sig = sign_transaction("pk-tx", "evm", tx, None, None, Some(dir.path())).unwrap();
1038        assert!(!sig.signature.is_empty());
1039    }
1040
1041    #[test]
1042    fn privkey_wallet_export_returns_json() {
1043        let dir = tempfile::tempdir().unwrap();
1044        save_privkey_wallet("pk-export", TEST_PRIVKEY, "", dir.path());
1045
1046        let exported = export_wallet("pk-export", None, Some(dir.path())).unwrap();
1047        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1048        assert_eq!(
1049            obj["secp256k1"].as_str().unwrap(),
1050            TEST_PRIVKEY,
1051            "exported secp256k1 key should match original"
1052        );
1053        assert!(obj["ed25519"].as_str().is_some(), "should have ed25519 key");
1054    }
1055
1056    #[test]
1057    fn privkey_wallet_signing_is_deterministic() {
1058        let dir = tempfile::tempdir().unwrap();
1059        save_privkey_wallet("pk-det", TEST_PRIVKEY, "", dir.path());
1060
1061        let s1 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1062        let s2 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1063        assert_eq!(s1.signature, s2.signature);
1064    }
1065
1066    #[test]
1067    fn privkey_and_mnemonic_wallets_produce_different_sigs() {
1068        let dir = tempfile::tempdir().unwrap();
1069        let vault = dir.path();
1070
1071        create_wallet("mn-w", None, None, Some(vault)).unwrap();
1072        save_privkey_wallet("pk-w", TEST_PRIVKEY, "", vault);
1073
1074        let mn_sig = sign_message("mn-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1075        let pk_sig = sign_message("pk-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1076        assert_ne!(
1077            mn_sig.signature, pk_sig.signature,
1078            "different keys should produce different signatures"
1079        );
1080    }
1081
1082    #[test]
1083    fn privkey_wallet_import_via_api() {
1084        let dir = tempfile::tempdir().unwrap();
1085        let vault = dir.path();
1086
1087        let info = import_wallet_private_key(
1088            "pk-api",
1089            TEST_PRIVKEY,
1090            Some("evm"),
1091            None,
1092            Some(vault),
1093            None,
1094            None,
1095        )
1096        .unwrap();
1097        assert!(
1098            !info.accounts.is_empty(),
1099            "should derive at least one account"
1100        );
1101
1102        // Should be able to sign
1103        let sig = sign_message("pk-api", "evm", "hello", None, None, None, Some(vault)).unwrap();
1104        assert!(!sig.signature.is_empty());
1105
1106        // Export should return JSON key pair with original key
1107        let exported = export_wallet("pk-api", None, Some(vault)).unwrap();
1108        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1109        assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1110    }
1111
1112    #[test]
1113    fn privkey_wallet_import_both_curve_keys() {
1114        let dir = tempfile::tempdir().unwrap();
1115        let vault = dir.path();
1116
1117        let secp_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
1118        let ed_key = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
1119
1120        let info = import_wallet_private_key(
1121            "pk-both",
1122            "",   // ignored when both curve keys provided
1123            None, // chain ignored too
1124            None,
1125            Some(vault),
1126            Some(secp_key),
1127            Some(ed_key),
1128        )
1129        .unwrap();
1130
1131        assert_eq!(
1132            info.accounts.len(),
1133            ALL_CHAIN_TYPES.len(),
1134            "should have one account per chain type"
1135        );
1136
1137        // Sign on EVM (secp256k1)
1138        let sig = sign_message("pk-both", "evm", "hello", None, None, None, Some(vault)).unwrap();
1139        assert!(!sig.signature.is_empty());
1140
1141        // Sign on Solana (ed25519)
1142        let sig =
1143            sign_message("pk-both", "solana", "hello", None, None, None, Some(vault)).unwrap();
1144        assert!(!sig.signature.is_empty());
1145
1146        // Export should return both keys
1147        let exported = export_wallet("pk-both", None, Some(vault)).unwrap();
1148        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1149        assert_eq!(obj["secp256k1"].as_str().unwrap(), secp_key);
1150        assert_eq!(obj["ed25519"].as_str().unwrap(), ed_key);
1151    }
1152
1153    // ================================================================
1154    // 5. PASSPHRASE PROTECTION
1155    // ================================================================
1156
1157    #[test]
1158    fn passphrase_protected_mnemonic_wallet() {
1159        let dir = tempfile::tempdir().unwrap();
1160        let vault = dir.path();
1161
1162        create_wallet("pass-mn", None, Some("s3cret"), Some(vault)).unwrap();
1163
1164        // Sign with correct passphrase
1165        let sig = sign_message(
1166            "pass-mn",
1167            "evm",
1168            "hello",
1169            Some("s3cret"),
1170            None,
1171            None,
1172            Some(vault),
1173        )
1174        .unwrap();
1175        assert!(!sig.signature.is_empty());
1176
1177        // Export with correct passphrase
1178        let phrase = export_wallet("pass-mn", Some("s3cret"), Some(vault)).unwrap();
1179        assert_eq!(phrase.split_whitespace().count(), 12);
1180
1181        // Wrong passphrase should fail
1182        assert!(sign_message(
1183            "pass-mn",
1184            "evm",
1185            "hello",
1186            Some("wrong"),
1187            None,
1188            None,
1189            Some(vault)
1190        )
1191        .is_err());
1192        assert!(export_wallet("pass-mn", Some("wrong"), Some(vault)).is_err());
1193
1194        // No passphrase should fail (defaults to empty string, which is wrong)
1195        assert!(sign_message("pass-mn", "evm", "hello", None, None, None, Some(vault)).is_err());
1196    }
1197
1198    #[test]
1199    fn passphrase_protected_privkey_wallet() {
1200        let dir = tempfile::tempdir().unwrap();
1201        save_privkey_wallet("pass-pk", TEST_PRIVKEY, "mypass", dir.path());
1202
1203        // Correct passphrase
1204        let sig = sign_message(
1205            "pass-pk",
1206            "evm",
1207            "hello",
1208            Some("mypass"),
1209            None,
1210            None,
1211            Some(dir.path()),
1212        )
1213        .unwrap();
1214        assert!(!sig.signature.is_empty());
1215
1216        let exported = export_wallet("pass-pk", Some("mypass"), Some(dir.path())).unwrap();
1217        let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1218        assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1219
1220        // Wrong passphrase
1221        assert!(sign_message(
1222            "pass-pk",
1223            "evm",
1224            "hello",
1225            Some("wrong"),
1226            None,
1227            None,
1228            Some(dir.path())
1229        )
1230        .is_err());
1231        assert!(export_wallet("pass-pk", Some("wrong"), Some(dir.path())).is_err());
1232    }
1233
1234    // ================================================================
1235    // 6. SIGNATURE VERIFICATION (prove signatures are cryptographically valid)
1236    // ================================================================
1237
1238    #[test]
1239    fn evm_signature_is_recoverable() {
1240        use sha3::Digest;
1241        let dir = tempfile::tempdir().unwrap();
1242        let vault = dir.path();
1243
1244        let info = create_wallet("verify-evm", None, None, Some(vault)).unwrap();
1245        let evm_addr = info
1246            .accounts
1247            .iter()
1248            .find(|a| a.chain_id.starts_with("eip155:"))
1249            .unwrap()
1250            .address
1251            .clone();
1252
1253        let sig = sign_message(
1254            "verify-evm",
1255            "evm",
1256            "hello world",
1257            None,
1258            None,
1259            None,
1260            Some(vault),
1261        )
1262        .unwrap();
1263
1264        // EVM personal_sign: keccak256("\x19Ethereum Signed Message:\n" + len + msg)
1265        let msg = b"hello world";
1266        let prefix = format!("\x19Ethereum Signed Message:\n{}", msg.len());
1267        let mut prefixed = prefix.into_bytes();
1268        prefixed.extend_from_slice(msg);
1269
1270        let hash = sha3::Keccak256::digest(&prefixed);
1271        let sig_bytes = hex::decode(&sig.signature).unwrap();
1272        assert_eq!(
1273            sig_bytes.len(),
1274            65,
1275            "EVM signature should be 65 bytes (r + s + v)"
1276        );
1277
1278        // Recover public key from signature (v is 27 or 28 per EIP-191)
1279        let v = sig_bytes[64];
1280        assert!(
1281            v == 27 || v == 28,
1282            "EIP-191 v byte should be 27 or 28, got {v}"
1283        );
1284        let recid = k256::ecdsa::RecoveryId::try_from(v - 27).unwrap();
1285        let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
1286        let recovered_key =
1287            k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
1288
1289        // Derive address from recovered key and compare
1290        let pubkey_bytes = recovered_key.to_encoded_point(false);
1291        let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
1292        let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
1293
1294        // Compare case-insensitively (EIP-55 checksum)
1295        assert_eq!(
1296            recovered_addr.to_lowercase(),
1297            evm_addr.to_lowercase(),
1298            "recovered address should match wallet's EVM address"
1299        );
1300    }
1301
1302    // ================================================================
1303    // 7. ERROR HANDLING
1304    // ================================================================
1305
1306    #[test]
1307    fn error_nonexistent_wallet() {
1308        let dir = tempfile::tempdir().unwrap();
1309        assert!(get_wallet("nope", Some(dir.path())).is_err());
1310        assert!(export_wallet("nope", None, Some(dir.path())).is_err());
1311        assert!(sign_message("nope", "evm", "x", None, None, None, Some(dir.path())).is_err());
1312        assert!(delete_wallet("nope", Some(dir.path())).is_err());
1313    }
1314
1315    #[test]
1316    fn error_duplicate_wallet_name() {
1317        let dir = tempfile::tempdir().unwrap();
1318        let vault = dir.path();
1319        create_wallet("dup", None, None, Some(vault)).unwrap();
1320        assert!(create_wallet("dup", None, None, Some(vault)).is_err());
1321    }
1322
1323    #[test]
1324    fn error_invalid_private_key_hex() {
1325        let dir = tempfile::tempdir().unwrap();
1326        assert!(import_wallet_private_key(
1327            "bad",
1328            "not-hex",
1329            Some("evm"),
1330            None,
1331            Some(dir.path()),
1332            None,
1333            None,
1334        )
1335        .is_err());
1336    }
1337
1338    #[test]
1339    fn error_invalid_chain_for_signing() {
1340        let dir = tempfile::tempdir().unwrap();
1341        let vault = dir.path();
1342        create_wallet("chain-err", None, None, Some(vault)).unwrap();
1343        assert!(
1344            sign_message("chain-err", "fakecoin", "hi", None, None, None, Some(vault)).is_err()
1345        );
1346    }
1347
1348    #[test]
1349    fn error_invalid_tx_hex() {
1350        let dir = tempfile::tempdir().unwrap();
1351        let vault = dir.path();
1352        create_wallet("hex-err", None, None, Some(vault)).unwrap();
1353        assert!(
1354            sign_transaction("hex-err", "evm", "not-valid-hex!", None, None, Some(vault)).is_err()
1355        );
1356    }
1357
1358    // ================================================================
1359    // 8. WALLET MANAGEMENT
1360    // ================================================================
1361
1362    #[test]
1363    fn list_wallets_empty_vault() {
1364        let dir = tempfile::tempdir().unwrap();
1365        let wallets = list_wallets(Some(dir.path())).unwrap();
1366        assert!(wallets.is_empty());
1367    }
1368
1369    #[test]
1370    fn get_wallet_by_name_and_id() {
1371        let dir = tempfile::tempdir().unwrap();
1372        let vault = dir.path();
1373        let info = create_wallet("lookup", None, None, Some(vault)).unwrap();
1374
1375        let by_name = get_wallet("lookup", Some(vault)).unwrap();
1376        assert_eq!(by_name.id, info.id);
1377
1378        let by_id = get_wallet(&info.id, Some(vault)).unwrap();
1379        assert_eq!(by_id.name, "lookup");
1380    }
1381
1382    #[test]
1383    fn rename_wallet_works() {
1384        let dir = tempfile::tempdir().unwrap();
1385        let vault = dir.path();
1386        let info = create_wallet("before", None, None, Some(vault)).unwrap();
1387
1388        rename_wallet("before", "after", Some(vault)).unwrap();
1389
1390        assert!(get_wallet("before", Some(vault)).is_err());
1391        let after = get_wallet("after", Some(vault)).unwrap();
1392        assert_eq!(after.id, info.id);
1393    }
1394
1395    #[test]
1396    fn rename_to_existing_name_fails() {
1397        let dir = tempfile::tempdir().unwrap();
1398        let vault = dir.path();
1399        create_wallet("a", None, None, Some(vault)).unwrap();
1400        create_wallet("b", None, None, Some(vault)).unwrap();
1401        assert!(rename_wallet("a", "b", Some(vault)).is_err());
1402    }
1403
1404    #[test]
1405    fn delete_wallet_removes_from_list() {
1406        let dir = tempfile::tempdir().unwrap();
1407        let vault = dir.path();
1408        create_wallet("del-me", None, None, Some(vault)).unwrap();
1409        assert_eq!(list_wallets(Some(vault)).unwrap().len(), 1);
1410
1411        delete_wallet("del-me", Some(vault)).unwrap();
1412        assert_eq!(list_wallets(Some(vault)).unwrap().len(), 0);
1413    }
1414
1415    // ================================================================
1416    // 9. MESSAGE ENCODING
1417    // ================================================================
1418
1419    #[test]
1420    fn sign_message_hex_encoding() {
1421        let dir = tempfile::tempdir().unwrap();
1422        let vault = dir.path();
1423        create_wallet("hex-enc", None, None, Some(vault)).unwrap();
1424
1425        // "hello" in hex
1426        let sig = sign_message(
1427            "hex-enc",
1428            "evm",
1429            "68656c6c6f",
1430            None,
1431            Some("hex"),
1432            None,
1433            Some(vault),
1434        )
1435        .unwrap();
1436        assert!(!sig.signature.is_empty());
1437
1438        // Should match utf8 encoding of the same bytes
1439        let sig2 = sign_message(
1440            "hex-enc",
1441            "evm",
1442            "hello",
1443            None,
1444            Some("utf8"),
1445            None,
1446            Some(vault),
1447        )
1448        .unwrap();
1449        assert_eq!(
1450            sig.signature, sig2.signature,
1451            "hex and utf8 encoding of same bytes should produce same signature"
1452        );
1453    }
1454
1455    #[test]
1456    fn sign_message_invalid_encoding() {
1457        let dir = tempfile::tempdir().unwrap();
1458        let vault = dir.path();
1459        create_wallet("bad-enc", None, None, Some(vault)).unwrap();
1460        assert!(sign_message(
1461            "bad-enc",
1462            "evm",
1463            "hello",
1464            None,
1465            Some("base64"),
1466            None,
1467            Some(vault)
1468        )
1469        .is_err());
1470    }
1471
1472    // ================================================================
1473    // 10. MULTI-WALLET VAULT
1474    // ================================================================
1475
1476    #[test]
1477    fn multiple_wallets_coexist() {
1478        let dir = tempfile::tempdir().unwrap();
1479        let vault = dir.path();
1480
1481        create_wallet("w1", None, None, Some(vault)).unwrap();
1482        create_wallet("w2", None, None, Some(vault)).unwrap();
1483        save_privkey_wallet("w3", TEST_PRIVKEY, "", vault);
1484
1485        let wallets = list_wallets(Some(vault)).unwrap();
1486        assert_eq!(wallets.len(), 3);
1487
1488        // All can sign independently
1489        let s1 = sign_message("w1", "evm", "test", None, None, None, Some(vault)).unwrap();
1490        let s2 = sign_message("w2", "evm", "test", None, None, None, Some(vault)).unwrap();
1491        let s3 = sign_message("w3", "evm", "test", None, None, None, Some(vault)).unwrap();
1492
1493        // All signatures should be different (different keys)
1494        assert_ne!(s1.signature, s2.signature);
1495        assert_ne!(s1.signature, s3.signature);
1496        assert_ne!(s2.signature, s3.signature);
1497
1498        // Delete one, others survive
1499        delete_wallet("w2", Some(vault)).unwrap();
1500        assert_eq!(list_wallets(Some(vault)).unwrap().len(), 2);
1501        assert!(sign_message("w1", "evm", "test", None, None, None, Some(vault)).is_ok());
1502        assert!(sign_message("w3", "evm", "test", None, None, None, Some(vault)).is_ok());
1503    }
1504
1505    // ================================================================
1506    // 11. BUG REGRESSION: CLI send_transaction broadcasts raw signature
1507    // ================================================================
1508
1509    #[test]
1510    fn signed_tx_must_differ_from_raw_signature() {
1511        // BUG TEST: The CLI's send_transaction.rs broadcasts `output.signature`
1512        // (raw 65-byte sig) instead of encoding the full signed transaction via
1513        // signer.encode_signed_transaction(). This test proves the two are different
1514        // — broadcasting the raw signature sends garbage to the RPC node.
1515        //
1516        // The library's sign_and_send correctly calls encode_signed_transaction
1517        // before broadcast (ops.rs:481), but the CLI skips this step
1518        // (send_transaction.rs:43).
1519
1520        let dir = tempfile::tempdir().unwrap();
1521        let vault = dir.path();
1522        save_privkey_wallet("send-bug", TEST_PRIVKEY, "", vault);
1523
1524        // Build a minimal unsigned EIP-1559 transaction
1525        let items: Vec<u8> = [
1526            ows_signer::rlp::encode_bytes(&[1]),          // chain_id = 1
1527            ows_signer::rlp::encode_bytes(&[]),           // nonce = 0
1528            ows_signer::rlp::encode_bytes(&[1]),          // maxPriorityFeePerGas
1529            ows_signer::rlp::encode_bytes(&[100]),        // maxFeePerGas
1530            ows_signer::rlp::encode_bytes(&[0x52, 0x08]), // gasLimit = 21000
1531            ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]), // to (truncated)
1532            ows_signer::rlp::encode_bytes(&[]),           // value = 0
1533            ows_signer::rlp::encode_bytes(&[]),           // data
1534            ows_signer::rlp::encode_list(&[]),            // accessList
1535        ]
1536        .concat();
1537
1538        let mut unsigned_tx = vec![0x02u8];
1539        unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
1540        let tx_hex = hex::encode(&unsigned_tx);
1541
1542        // Sign the transaction via the library
1543        let sign_result =
1544            sign_transaction("send-bug", "evm", &tx_hex, None, None, Some(vault)).unwrap();
1545        let raw_signature = hex::decode(&sign_result.signature).unwrap();
1546
1547        // Now encode the full signed transaction (what the library does correctly)
1548        let key = decrypt_signing_key("send-bug", ChainType::Evm, "", None, Some(vault)).unwrap();
1549        let signer = signer_for_chain(ChainType::Evm);
1550        let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
1551        let full_signed_tx = signer
1552            .encode_signed_transaction(&unsigned_tx, &output)
1553            .unwrap();
1554
1555        // The raw signature (65 bytes) and the full signed tx are completely different.
1556        // Broadcasting the raw signature (as the CLI does) would always fail.
1557        assert_eq!(
1558            raw_signature.len(),
1559            65,
1560            "raw EVM signature should be 65 bytes (r || s || v)"
1561        );
1562        assert!(
1563            full_signed_tx.len() > raw_signature.len(),
1564            "full signed tx ({} bytes) must be larger than raw signature ({} bytes)",
1565            full_signed_tx.len(),
1566            raw_signature.len()
1567        );
1568        assert_ne!(
1569            raw_signature, full_signed_tx,
1570            "raw signature and full signed transaction must differ — \
1571             broadcasting the raw signature (as CLI send_transaction.rs:43 does) is wrong"
1572        );
1573
1574        // The full signed tx should start with the EIP-1559 type byte
1575        assert_eq!(
1576            full_signed_tx[0], 0x02,
1577            "full signed EIP-1559 tx must start with type byte 0x02"
1578        );
1579    }
1580
1581    // ================================================================
1582    // EIP-712 TYPED DATA SIGNING
1583    // ================================================================
1584
1585    #[test]
1586    fn sign_typed_data_rejects_non_evm_chain() {
1587        let tmp = tempfile::tempdir().unwrap();
1588        let vault = tmp.path();
1589
1590        let w = save_privkey_wallet("typed-data-test", TEST_PRIVKEY, "pass", vault);
1591
1592        let typed_data = r#"{
1593            "types": {
1594                "EIP712Domain": [{"name": "name", "type": "string"}],
1595                "Test": [{"name": "value", "type": "uint256"}]
1596            },
1597            "primaryType": "Test",
1598            "domain": {"name": "Test"},
1599            "message": {"value": "1"}
1600        }"#;
1601
1602        let result = sign_typed_data(&w.id, "solana", typed_data, Some("pass"), None, Some(vault));
1603        assert!(result.is_err());
1604        let err_msg = result.unwrap_err().to_string();
1605        assert!(
1606            err_msg.contains("only supported for EVM"),
1607            "expected EVM-only error, got: {err_msg}"
1608        );
1609    }
1610
1611    #[test]
1612    fn sign_typed_data_evm_succeeds() {
1613        let tmp = tempfile::tempdir().unwrap();
1614        let vault = tmp.path();
1615
1616        let w = save_privkey_wallet("typed-data-evm", TEST_PRIVKEY, "pass", vault);
1617
1618        let typed_data = r#"{
1619            "types": {
1620                "EIP712Domain": [
1621                    {"name": "name", "type": "string"},
1622                    {"name": "version", "type": "string"},
1623                    {"name": "chainId", "type": "uint256"}
1624                ],
1625                "Test": [{"name": "value", "type": "uint256"}]
1626            },
1627            "primaryType": "Test",
1628            "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
1629            "message": {"value": "42"}
1630        }"#;
1631
1632        let result = sign_typed_data(&w.id, "evm", typed_data, Some("pass"), None, Some(vault));
1633        assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
1634
1635        let sign_result = result.unwrap();
1636        assert!(
1637            !sign_result.signature.is_empty(),
1638            "signature should not be empty"
1639        );
1640        assert!(
1641            sign_result.recovery_id.is_some(),
1642            "recovery_id should be present for EVM"
1643        );
1644    }
1645}