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
17fn 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
39fn 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
60struct 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 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 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 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
113fn 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 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
145pub 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
162pub 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
203pub 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
239fn 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
246pub 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 (Some(secp_hex), Some(ed_hex)) => KeyPair {
272 secp256k1: decode_hex_key(secp_hex)?,
273 ed25519: decode_hex_key(ed_hex)?,
274 },
275 _ => {
277 let key_bytes = decode_hex_key(private_key_hex)?;
278
279 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 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
333pub 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
339pub 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
345pub 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
352pub 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 String::from_utf8(secret.expose().to_vec())
371 .map_err(|_| OwsLibError::InvalidInput("wallet contains invalid key data".into()))
372 }
373 }
374}
375
376pub 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
397pub fn sign_transaction(
403 wallet: &str,
404 chain: &str,
405 tx_hex: &str,
406 passphrase: Option<&str>,
407 index: Option<u32>,
408 vault_path: Option<&Path>,
409) -> Result<SignResult, OwsLibError> {
410 let credential = passphrase.unwrap_or("");
411
412 let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
413 let tx_bytes = hex::decode(tx_hex_clean)
414 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
415
416 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
418 let chain = parse_chain(chain)?;
419 return crate::key_ops::sign_with_api_key(
420 credential, wallet, &chain, &tx_bytes, index, vault_path,
421 );
422 }
423
424 let chain = parse_chain(chain)?;
426 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
427 let signer = signer_for_chain(chain.chain_type);
428 let output = signer.sign_transaction(key.expose(), &tx_bytes)?;
429
430 Ok(SignResult {
431 signature: hex::encode(&output.signature),
432 recovery_id: output.recovery_id,
433 })
434}
435
436pub fn sign_message(
441 wallet: &str,
442 chain: &str,
443 message: &str,
444 passphrase: Option<&str>,
445 encoding: Option<&str>,
446 index: Option<u32>,
447 vault_path: Option<&Path>,
448) -> Result<SignResult, OwsLibError> {
449 let credential = passphrase.unwrap_or("");
450
451 let encoding = encoding.unwrap_or("utf8");
452 let msg_bytes = match encoding {
453 "utf8" => message.as_bytes().to_vec(),
454 "hex" => hex::decode(message)
455 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex message: {e}")))?,
456 _ => {
457 return Err(OwsLibError::InvalidInput(format!(
458 "unsupported encoding: {encoding} (use 'utf8' or 'hex')"
459 )))
460 }
461 };
462
463 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
465 let chain = parse_chain(chain)?;
466 return crate::key_ops::sign_message_with_api_key(
467 credential, wallet, &chain, &msg_bytes, index, vault_path,
468 );
469 }
470
471 let chain = parse_chain(chain)?;
473 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
474 let signer = signer_for_chain(chain.chain_type);
475 let output = signer.sign_message(key.expose(), &msg_bytes)?;
476
477 Ok(SignResult {
478 signature: hex::encode(&output.signature),
479 recovery_id: output.recovery_id,
480 })
481}
482
483pub fn sign_typed_data(
489 wallet: &str,
490 chain: &str,
491 typed_data_json: &str,
492 passphrase: Option<&str>,
493 index: Option<u32>,
494 vault_path: Option<&Path>,
495) -> Result<SignResult, OwsLibError> {
496 let credential = passphrase.unwrap_or("");
497 let chain = parse_chain(chain)?;
498
499 if chain.chain_type != ows_core::ChainType::Evm {
500 return Err(OwsLibError::InvalidInput(
501 "EIP-712 typed data signing is only supported for EVM chains".into(),
502 ));
503 }
504
505 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
506 return Err(OwsLibError::InvalidInput(
507 "EIP-712 typed data signing via API key is not yet supported; use sign_transaction"
508 .into(),
509 ));
510 }
511
512 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
513 let evm_signer = ows_signer::chains::EvmSigner;
514 let output = evm_signer.sign_typed_data(key.expose(), typed_data_json)?;
515
516 Ok(SignResult {
517 signature: hex::encode(&output.signature),
518 recovery_id: output.recovery_id,
519 })
520}
521
522pub fn sign_and_send(
528 wallet: &str,
529 chain: &str,
530 tx_hex: &str,
531 passphrase: Option<&str>,
532 index: Option<u32>,
533 rpc_url: Option<&str>,
534 vault_path: Option<&Path>,
535) -> Result<SendResult, OwsLibError> {
536 let credential = passphrase.unwrap_or("");
537
538 let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
539 let tx_bytes = hex::decode(tx_hex_clean)
540 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
541
542 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
544 let chain_info = parse_chain(chain)?;
545 let (key, _) = crate::key_ops::enforce_policy_and_decrypt_key(
546 credential,
547 wallet,
548 &chain_info,
549 &tx_bytes,
550 index,
551 vault_path,
552 )?;
553 return sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url);
554 }
555
556 let chain_info = parse_chain(chain)?;
558 let key = decrypt_signing_key(wallet, chain_info.chain_type, credential, index, vault_path)?;
559
560 sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url)
561}
562
563pub fn sign_encode_and_broadcast(
570 private_key: &[u8],
571 chain: &str,
572 tx_bytes: &[u8],
573 rpc_url: Option<&str>,
574) -> Result<SendResult, OwsLibError> {
575 let chain = parse_chain(chain)?;
576 let signer = signer_for_chain(chain.chain_type);
577
578 let signable = signer.extract_signable_bytes(tx_bytes)?;
580
581 let output = signer.sign_transaction(private_key, signable)?;
583
584 let signed_tx = signer.encode_signed_transaction(tx_bytes, &output)?;
586
587 let rpc = resolve_rpc_url(chain.chain_id, chain.chain_type, rpc_url)?;
589
590 let tx_hash = broadcast(chain.chain_type, &rpc, &signed_tx)?;
592
593 Ok(SendResult { tx_hash })
594}
595
596pub fn decrypt_signing_key(
603 wallet_name_or_id: &str,
604 chain_type: ChainType,
605 passphrase: &str,
606 index: Option<u32>,
607 vault_path: Option<&Path>,
608) -> Result<SecretBytes, OwsLibError> {
609 let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
610 let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
611 let secret = decrypt(&envelope, passphrase)?;
612
613 match wallet.key_type {
614 KeyType::Mnemonic => {
615 let phrase = std::str::from_utf8(secret.expose()).map_err(|_| {
617 OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
618 })?;
619 let mnemonic = Mnemonic::from_phrase(phrase)?;
620 let signer = signer_for_chain(chain_type);
621 let path = signer.default_derivation_path(index.unwrap_or(0));
622 let curve = signer.curve();
623 Ok(HdDeriver::derive_from_mnemonic_cached(
624 &mnemonic, "", &path, curve,
625 )?)
626 }
627 KeyType::PrivateKey => {
628 let keys = KeyPair::from_json_bytes(secret.expose())?;
630 let signer = signer_for_chain(chain_type);
631 Ok(SecretBytes::from_slice(keys.key_for_curve(signer.curve())))
632 }
633 }
634}
635
636fn resolve_rpc_url(
638 chain_id: &str,
639 chain_type: ChainType,
640 explicit: Option<&str>,
641) -> Result<String, OwsLibError> {
642 if let Some(url) = explicit {
643 return Ok(url.to_string());
644 }
645
646 let config = Config::load_or_default();
647 let defaults = Config::default_rpc();
648
649 if let Some(url) = config.rpc.get(chain_id) {
651 return Ok(url.clone());
652 }
653 if let Some(url) = defaults.get(chain_id) {
654 return Ok(url.clone());
655 }
656
657 let namespace = chain_type.namespace();
659 for (key, url) in &config.rpc {
660 if key.starts_with(namespace) {
661 return Ok(url.clone());
662 }
663 }
664 for (key, url) in &defaults {
665 if key.starts_with(namespace) {
666 return Ok(url.clone());
667 }
668 }
669
670 Err(OwsLibError::InvalidInput(format!(
671 "no RPC URL configured for chain '{chain_id}'"
672 )))
673}
674
675fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
677 match chain {
678 ChainType::Evm => broadcast_evm(rpc_url, signed_bytes),
679 ChainType::Solana => broadcast_solana(rpc_url, signed_bytes),
680 ChainType::Bitcoin => broadcast_bitcoin(rpc_url, signed_bytes),
681 ChainType::Cosmos => broadcast_cosmos(rpc_url, signed_bytes),
682 ChainType::Tron => broadcast_tron(rpc_url, signed_bytes),
683 ChainType::Ton => broadcast_ton(rpc_url, signed_bytes),
684 ChainType::Spark => Err(OwsLibError::InvalidInput(
685 "broadcast not yet supported for Spark".into(),
686 )),
687 ChainType::Filecoin => Err(OwsLibError::InvalidInput(
688 "broadcast not yet supported for Filecoin".into(),
689 )),
690 ChainType::Sui => broadcast_sui(rpc_url, signed_bytes),
691 }
692}
693
694fn broadcast_evm(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
695 let hex_tx = format!("0x{}", hex::encode(signed_bytes));
696 let body = serde_json::json!({
697 "jsonrpc": "2.0",
698 "method": "eth_sendRawTransaction",
699 "params": [hex_tx],
700 "id": 1
701 });
702 let resp = curl_post_json(rpc_url, &body.to_string())?;
703 extract_json_field(&resp, "result")
704}
705
706fn broadcast_solana(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
707 use base64::Engine;
708 let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
709 let body = serde_json::json!({
710 "jsonrpc": "2.0",
711 "method": "sendTransaction",
712 "params": [b64_tx],
713 "id": 1
714 });
715 let resp = curl_post_json(rpc_url, &body.to_string())?;
716 extract_json_field(&resp, "result")
717}
718
719fn broadcast_bitcoin(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
720 let hex_tx = hex::encode(signed_bytes);
721 let url = format!("{}/tx", rpc_url.trim_end_matches('/'));
722 let output = Command::new("curl")
723 .args([
724 "-fsSL",
725 "-X",
726 "POST",
727 "-H",
728 "Content-Type: text/plain",
729 "-d",
730 &hex_tx,
731 &url,
732 ])
733 .output()
734 .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
735
736 if !output.status.success() {
737 let stderr = String::from_utf8_lossy(&output.stderr);
738 return Err(OwsLibError::BroadcastFailed(format!(
739 "broadcast failed: {stderr}"
740 )));
741 }
742
743 let tx_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
744 if tx_hash.is_empty() {
745 return Err(OwsLibError::BroadcastFailed(
746 "empty response from broadcast".into(),
747 ));
748 }
749 Ok(tx_hash)
750}
751
752fn broadcast_cosmos(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
753 use base64::Engine;
754 let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
755 let url = format!("{}/cosmos/tx/v1beta1/txs", rpc_url.trim_end_matches('/'));
756 let body = serde_json::json!({
757 "tx_bytes": b64_tx,
758 "mode": "BROADCAST_MODE_SYNC"
759 });
760 let resp = curl_post_json(&url, &body.to_string())?;
761 let parsed: serde_json::Value = serde_json::from_str(&resp)?;
762 parsed["tx_response"]["txhash"]
763 .as_str()
764 .map(|s| s.to_string())
765 .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no txhash in response: {resp}")))
766}
767
768fn broadcast_tron(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
769 let hex_tx = hex::encode(signed_bytes);
770 let url = format!("{}/wallet/broadcasthex", rpc_url.trim_end_matches('/'));
771 let body = serde_json::json!({ "transaction": hex_tx });
772 let resp = curl_post_json(&url, &body.to_string())?;
773 extract_json_field(&resp, "txid")
774}
775
776fn broadcast_ton(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
777 use base64::Engine;
778 let b64_boc = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
779 let url = format!("{}/sendBoc", rpc_url.trim_end_matches('/'));
780 let body = serde_json::json!({ "boc": b64_boc });
781 let resp = curl_post_json(&url, &body.to_string())?;
782 let parsed: serde_json::Value = serde_json::from_str(&resp)?;
783 parsed["result"]["hash"]
784 .as_str()
785 .map(|s| s.to_string())
786 .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no hash in response: {resp}")))
787}
788
789fn broadcast_sui(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
790 use ows_signer::chains::sui::WIRE_SIG_LEN;
791
792 if signed_bytes.len() <= WIRE_SIG_LEN {
793 return Err(OwsLibError::InvalidInput(
794 "signed transaction too short to contain tx + signature".into(),
795 ));
796 }
797
798 let split = signed_bytes.len() - WIRE_SIG_LEN;
799 let tx_part = &signed_bytes[..split];
800 let sig_part = &signed_bytes[split..];
801
802 crate::sui_grpc::execute_transaction(rpc_url, tx_part, sig_part)
803}
804
805fn curl_post_json(url: &str, body: &str) -> Result<String, OwsLibError> {
806 let output = Command::new("curl")
807 .args([
808 "-fsSL",
809 "-X",
810 "POST",
811 "-H",
812 "Content-Type: application/json",
813 "-d",
814 body,
815 url,
816 ])
817 .output()
818 .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
819
820 if !output.status.success() {
821 let stderr = String::from_utf8_lossy(&output.stderr);
822 return Err(OwsLibError::BroadcastFailed(format!(
823 "broadcast failed: {stderr}"
824 )));
825 }
826
827 Ok(String::from_utf8_lossy(&output.stdout).to_string())
828}
829
830fn extract_json_field(json_str: &str, field: &str) -> Result<String, OwsLibError> {
831 let parsed: serde_json::Value = serde_json::from_str(json_str)?;
832
833 if let Some(error) = parsed.get("error") {
834 return Err(OwsLibError::BroadcastFailed(format!("RPC error: {error}")));
835 }
836
837 parsed[field]
838 .as_str()
839 .map(|s| s.to_string())
840 .ok_or_else(|| {
841 OwsLibError::BroadcastFailed(format!("no '{field}' in response: {json_str}"))
842 })
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848
849 fn save_privkey_wallet(
854 name: &str,
855 privkey_hex: &str,
856 passphrase: &str,
857 vault: &Path,
858 ) -> WalletInfo {
859 let key_bytes = hex::decode(privkey_hex).unwrap();
860
861 let mut ed_key = vec![0u8; 32];
863 getrandom::getrandom(&mut ed_key).unwrap();
864
865 let keys = KeyPair {
866 secp256k1: key_bytes,
867 ed25519: ed_key,
868 };
869 let accounts = derive_all_accounts_from_keys(&keys).unwrap();
870 let payload = keys.to_json_bytes();
871 let crypto_envelope = encrypt(&payload, passphrase).unwrap();
872 let crypto_json = serde_json::to_value(&crypto_envelope).unwrap();
873 let wallet = EncryptedWallet::new(
874 uuid::Uuid::new_v4().to_string(),
875 name.to_string(),
876 accounts,
877 crypto_json,
878 KeyType::PrivateKey,
879 );
880 vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
881 wallet_to_info(&wallet)
882 }
883
884 const TEST_PRIVKEY: &str = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
885
886 #[test]
891 fn mnemonic_12_words() {
892 let phrase = generate_mnemonic(12).unwrap();
893 assert_eq!(phrase.split_whitespace().count(), 12);
894 }
895
896 #[test]
897 fn mnemonic_24_words() {
898 let phrase = generate_mnemonic(24).unwrap();
899 assert_eq!(phrase.split_whitespace().count(), 24);
900 }
901
902 #[test]
903 fn mnemonic_invalid_word_count() {
904 assert!(generate_mnemonic(15).is_err());
905 assert!(generate_mnemonic(0).is_err());
906 assert!(generate_mnemonic(13).is_err());
907 }
908
909 #[test]
910 fn mnemonic_is_unique_each_call() {
911 let a = generate_mnemonic(12).unwrap();
912 let b = generate_mnemonic(12).unwrap();
913 assert_ne!(a, b, "two generated mnemonics should differ");
914 }
915
916 #[test]
921 fn derive_address_all_chains() {
922 let phrase = generate_mnemonic(12).unwrap();
923 let chains = ["evm", "solana", "bitcoin", "cosmos", "tron", "ton", "sui"];
924 for chain in &chains {
925 let addr = derive_address(&phrase, chain, None).unwrap();
926 assert!(!addr.is_empty(), "address should be non-empty for {chain}");
927 }
928 }
929
930 #[test]
931 fn derive_address_evm_format() {
932 let phrase = generate_mnemonic(12).unwrap();
933 let addr = derive_address(&phrase, "evm", None).unwrap();
934 assert!(addr.starts_with("0x"), "EVM address should start with 0x");
935 assert_eq!(addr.len(), 42, "EVM address should be 42 chars");
936 }
937
938 #[test]
939 fn derive_address_deterministic() {
940 let phrase = generate_mnemonic(12).unwrap();
941 let a = derive_address(&phrase, "evm", None).unwrap();
942 let b = derive_address(&phrase, "evm", None).unwrap();
943 assert_eq!(a, b, "same mnemonic should produce same address");
944 }
945
946 #[test]
947 fn derive_address_different_index() {
948 let phrase = generate_mnemonic(12).unwrap();
949 let a = derive_address(&phrase, "evm", Some(0)).unwrap();
950 let b = derive_address(&phrase, "evm", Some(1)).unwrap();
951 assert_ne!(a, b, "different indices should produce different addresses");
952 }
953
954 #[test]
955 fn derive_address_invalid_chain() {
956 let phrase = generate_mnemonic(12).unwrap();
957 assert!(derive_address(&phrase, "nonexistent", None).is_err());
958 }
959
960 #[test]
961 fn derive_address_invalid_mnemonic() {
962 assert!(derive_address("not a valid mnemonic phrase at all", "evm", None).is_err());
963 }
964
965 #[test]
970 fn mnemonic_wallet_create_export_reimport() {
971 let v1 = tempfile::tempdir().unwrap();
972 let v2 = tempfile::tempdir().unwrap();
973
974 let w1 = create_wallet("w1", None, None, Some(v1.path())).unwrap();
976 assert!(!w1.accounts.is_empty());
977
978 let phrase = export_wallet("w1", None, Some(v1.path())).unwrap();
980 assert_eq!(phrase.split_whitespace().count(), 12);
981
982 let w2 = import_wallet_mnemonic("w2", &phrase, None, None, Some(v2.path())).unwrap();
984
985 assert_eq!(w1.accounts.len(), w2.accounts.len());
987 for (a1, a2) in w1.accounts.iter().zip(w2.accounts.iter()) {
988 assert_eq!(a1.chain_id, a2.chain_id);
989 assert_eq!(
990 a1.address, a2.address,
991 "address mismatch for {}",
992 a1.chain_id
993 );
994 }
995 }
996
997 #[test]
998 fn mnemonic_wallet_sign_message_all_chains() {
999 let dir = tempfile::tempdir().unwrap();
1000 let vault = dir.path();
1001 create_wallet("multi-sign", None, None, Some(vault)).unwrap();
1002
1003 let chains = [
1004 "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui",
1005 ];
1006 for chain in &chains {
1007 let result = sign_message(
1008 "multi-sign",
1009 chain,
1010 "test msg",
1011 None,
1012 None,
1013 None,
1014 Some(vault),
1015 );
1016 assert!(
1017 result.is_ok(),
1018 "sign_message should work for {chain}: {:?}",
1019 result.err()
1020 );
1021 let sig = result.unwrap();
1022 assert!(
1023 !sig.signature.is_empty(),
1024 "signature should be non-empty for {chain}"
1025 );
1026 }
1027 }
1028
1029 #[test]
1030 fn mnemonic_wallet_sign_tx_all_chains() {
1031 let dir = tempfile::tempdir().unwrap();
1032 let vault = dir.path();
1033 create_wallet("tx-sign", None, None, Some(vault)).unwrap();
1034
1035 let generic_tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1036 let mut solana_tx = vec![0x01u8]; solana_tx.extend_from_slice(&[0u8; 64]); solana_tx.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); let solana_tx_hex = hex::encode(&solana_tx);
1042
1043 let chains = [
1044 "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui",
1045 ];
1046 for chain in &chains {
1047 let tx = if *chain == "solana" {
1048 &solana_tx_hex
1049 } else {
1050 generic_tx_hex
1051 };
1052 let result = sign_transaction("tx-sign", chain, tx, None, None, Some(vault));
1053 assert!(
1054 result.is_ok(),
1055 "sign_transaction should work for {chain}: {:?}",
1056 result.err()
1057 );
1058 }
1059 }
1060
1061 #[test]
1062 fn mnemonic_wallet_signing_is_deterministic() {
1063 let dir = tempfile::tempdir().unwrap();
1064 let vault = dir.path();
1065 create_wallet("det-sign", None, None, Some(vault)).unwrap();
1066
1067 let s1 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1068 let s2 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1069 assert_eq!(
1070 s1.signature, s2.signature,
1071 "same message should produce same signature"
1072 );
1073 }
1074
1075 #[test]
1076 fn mnemonic_wallet_different_messages_produce_different_sigs() {
1077 let dir = tempfile::tempdir().unwrap();
1078 let vault = dir.path();
1079 create_wallet("diff-msg", None, None, Some(vault)).unwrap();
1080
1081 let s1 = sign_message("diff-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
1082 let s2 = sign_message("diff-msg", "evm", "world", None, None, None, Some(vault)).unwrap();
1083 assert_ne!(s1.signature, s2.signature);
1084 }
1085
1086 #[test]
1091 fn privkey_wallet_sign_message() {
1092 let dir = tempfile::tempdir().unwrap();
1093 save_privkey_wallet("pk-sign", TEST_PRIVKEY, "", dir.path());
1094
1095 let sig = sign_message(
1096 "pk-sign",
1097 "evm",
1098 "hello",
1099 None,
1100 None,
1101 None,
1102 Some(dir.path()),
1103 )
1104 .unwrap();
1105 assert!(!sig.signature.is_empty());
1106 assert!(sig.recovery_id.is_some());
1107 }
1108
1109 #[test]
1110 fn privkey_wallet_sign_transaction() {
1111 let dir = tempfile::tempdir().unwrap();
1112 save_privkey_wallet("pk-tx", TEST_PRIVKEY, "", dir.path());
1113
1114 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1115 let sig = sign_transaction("pk-tx", "evm", tx, None, None, Some(dir.path())).unwrap();
1116 assert!(!sig.signature.is_empty());
1117 }
1118
1119 #[test]
1120 fn privkey_wallet_export_returns_json() {
1121 let dir = tempfile::tempdir().unwrap();
1122 save_privkey_wallet("pk-export", TEST_PRIVKEY, "", dir.path());
1123
1124 let exported = export_wallet("pk-export", None, Some(dir.path())).unwrap();
1125 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1126 assert_eq!(
1127 obj["secp256k1"].as_str().unwrap(),
1128 TEST_PRIVKEY,
1129 "exported secp256k1 key should match original"
1130 );
1131 assert!(obj["ed25519"].as_str().is_some(), "should have ed25519 key");
1132 }
1133
1134 #[test]
1135 fn privkey_wallet_signing_is_deterministic() {
1136 let dir = tempfile::tempdir().unwrap();
1137 save_privkey_wallet("pk-det", TEST_PRIVKEY, "", dir.path());
1138
1139 let s1 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1140 let s2 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1141 assert_eq!(s1.signature, s2.signature);
1142 }
1143
1144 #[test]
1145 fn privkey_and_mnemonic_wallets_produce_different_sigs() {
1146 let dir = tempfile::tempdir().unwrap();
1147 let vault = dir.path();
1148
1149 create_wallet("mn-w", None, None, Some(vault)).unwrap();
1150 save_privkey_wallet("pk-w", TEST_PRIVKEY, "", vault);
1151
1152 let mn_sig = sign_message("mn-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1153 let pk_sig = sign_message("pk-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1154 assert_ne!(
1155 mn_sig.signature, pk_sig.signature,
1156 "different keys should produce different signatures"
1157 );
1158 }
1159
1160 #[test]
1161 fn privkey_wallet_import_via_api() {
1162 let dir = tempfile::tempdir().unwrap();
1163 let vault = dir.path();
1164
1165 let info = import_wallet_private_key(
1166 "pk-api",
1167 TEST_PRIVKEY,
1168 Some("evm"),
1169 None,
1170 Some(vault),
1171 None,
1172 None,
1173 )
1174 .unwrap();
1175 assert!(
1176 !info.accounts.is_empty(),
1177 "should derive at least one account"
1178 );
1179
1180 let sig = sign_message("pk-api", "evm", "hello", None, None, None, Some(vault)).unwrap();
1182 assert!(!sig.signature.is_empty());
1183
1184 let exported = export_wallet("pk-api", None, Some(vault)).unwrap();
1186 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1187 assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1188 }
1189
1190 #[test]
1191 fn privkey_wallet_import_both_curve_keys() {
1192 let dir = tempfile::tempdir().unwrap();
1193 let vault = dir.path();
1194
1195 let secp_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
1196 let ed_key = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
1197
1198 let info = import_wallet_private_key(
1199 "pk-both",
1200 "", None, None,
1203 Some(vault),
1204 Some(secp_key),
1205 Some(ed_key),
1206 )
1207 .unwrap();
1208
1209 assert_eq!(
1210 info.accounts.len(),
1211 ALL_CHAIN_TYPES.len(),
1212 "should have one account per chain type"
1213 );
1214
1215 let sig = sign_message("pk-both", "evm", "hello", None, None, None, Some(vault)).unwrap();
1217 assert!(!sig.signature.is_empty());
1218
1219 let sig =
1221 sign_message("pk-both", "solana", "hello", None, None, None, Some(vault)).unwrap();
1222 assert!(!sig.signature.is_empty());
1223
1224 let exported = export_wallet("pk-both", None, Some(vault)).unwrap();
1226 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1227 assert_eq!(obj["secp256k1"].as_str().unwrap(), secp_key);
1228 assert_eq!(obj["ed25519"].as_str().unwrap(), ed_key);
1229 }
1230
1231 #[test]
1236 fn passphrase_protected_mnemonic_wallet() {
1237 let dir = tempfile::tempdir().unwrap();
1238 let vault = dir.path();
1239
1240 create_wallet("pass-mn", None, Some("s3cret"), Some(vault)).unwrap();
1241
1242 let sig = sign_message(
1244 "pass-mn",
1245 "evm",
1246 "hello",
1247 Some("s3cret"),
1248 None,
1249 None,
1250 Some(vault),
1251 )
1252 .unwrap();
1253 assert!(!sig.signature.is_empty());
1254
1255 let phrase = export_wallet("pass-mn", Some("s3cret"), Some(vault)).unwrap();
1257 assert_eq!(phrase.split_whitespace().count(), 12);
1258
1259 assert!(sign_message(
1261 "pass-mn",
1262 "evm",
1263 "hello",
1264 Some("wrong"),
1265 None,
1266 None,
1267 Some(vault)
1268 )
1269 .is_err());
1270 assert!(export_wallet("pass-mn", Some("wrong"), Some(vault)).is_err());
1271
1272 assert!(sign_message("pass-mn", "evm", "hello", None, None, None, Some(vault)).is_err());
1274 }
1275
1276 #[test]
1277 fn passphrase_protected_privkey_wallet() {
1278 let dir = tempfile::tempdir().unwrap();
1279 save_privkey_wallet("pass-pk", TEST_PRIVKEY, "mypass", dir.path());
1280
1281 let sig = sign_message(
1283 "pass-pk",
1284 "evm",
1285 "hello",
1286 Some("mypass"),
1287 None,
1288 None,
1289 Some(dir.path()),
1290 )
1291 .unwrap();
1292 assert!(!sig.signature.is_empty());
1293
1294 let exported = export_wallet("pass-pk", Some("mypass"), Some(dir.path())).unwrap();
1295 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1296 assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1297
1298 assert!(sign_message(
1300 "pass-pk",
1301 "evm",
1302 "hello",
1303 Some("wrong"),
1304 None,
1305 None,
1306 Some(dir.path())
1307 )
1308 .is_err());
1309 assert!(export_wallet("pass-pk", Some("wrong"), Some(dir.path())).is_err());
1310 }
1311
1312 #[test]
1317 fn evm_signature_is_recoverable() {
1318 use sha3::Digest;
1319 let dir = tempfile::tempdir().unwrap();
1320 let vault = dir.path();
1321
1322 let info = create_wallet("verify-evm", None, None, Some(vault)).unwrap();
1323 let evm_addr = info
1324 .accounts
1325 .iter()
1326 .find(|a| a.chain_id.starts_with("eip155:"))
1327 .unwrap()
1328 .address
1329 .clone();
1330
1331 let sig = sign_message(
1332 "verify-evm",
1333 "evm",
1334 "hello world",
1335 None,
1336 None,
1337 None,
1338 Some(vault),
1339 )
1340 .unwrap();
1341
1342 let msg = b"hello world";
1344 let prefix = format!("\x19Ethereum Signed Message:\n{}", msg.len());
1345 let mut prefixed = prefix.into_bytes();
1346 prefixed.extend_from_slice(msg);
1347
1348 let hash = sha3::Keccak256::digest(&prefixed);
1349 let sig_bytes = hex::decode(&sig.signature).unwrap();
1350 assert_eq!(
1351 sig_bytes.len(),
1352 65,
1353 "EVM signature should be 65 bytes (r + s + v)"
1354 );
1355
1356 let v = sig_bytes[64];
1358 assert!(
1359 v == 27 || v == 28,
1360 "EIP-191 v byte should be 27 or 28, got {v}"
1361 );
1362 let recid = k256::ecdsa::RecoveryId::try_from(v - 27).unwrap();
1363 let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
1364 let recovered_key =
1365 k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
1366
1367 let pubkey_bytes = recovered_key.to_encoded_point(false);
1369 let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
1370 let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
1371
1372 assert_eq!(
1374 recovered_addr.to_lowercase(),
1375 evm_addr.to_lowercase(),
1376 "recovered address should match wallet's EVM address"
1377 );
1378 }
1379
1380 #[test]
1385 fn error_nonexistent_wallet() {
1386 let dir = tempfile::tempdir().unwrap();
1387 assert!(get_wallet("nope", Some(dir.path())).is_err());
1388 assert!(export_wallet("nope", None, Some(dir.path())).is_err());
1389 assert!(sign_message("nope", "evm", "x", None, None, None, Some(dir.path())).is_err());
1390 assert!(delete_wallet("nope", Some(dir.path())).is_err());
1391 }
1392
1393 #[test]
1394 fn error_duplicate_wallet_name() {
1395 let dir = tempfile::tempdir().unwrap();
1396 let vault = dir.path();
1397 create_wallet("dup", None, None, Some(vault)).unwrap();
1398 assert!(create_wallet("dup", None, None, Some(vault)).is_err());
1399 }
1400
1401 #[test]
1402 fn error_invalid_private_key_hex() {
1403 let dir = tempfile::tempdir().unwrap();
1404 assert!(import_wallet_private_key(
1405 "bad",
1406 "not-hex",
1407 Some("evm"),
1408 None,
1409 Some(dir.path()),
1410 None,
1411 None,
1412 )
1413 .is_err());
1414 }
1415
1416 #[test]
1417 fn error_invalid_chain_for_signing() {
1418 let dir = tempfile::tempdir().unwrap();
1419 let vault = dir.path();
1420 create_wallet("chain-err", None, None, Some(vault)).unwrap();
1421 assert!(
1422 sign_message("chain-err", "fakecoin", "hi", None, None, None, Some(vault)).is_err()
1423 );
1424 }
1425
1426 #[test]
1427 fn error_invalid_tx_hex() {
1428 let dir = tempfile::tempdir().unwrap();
1429 let vault = dir.path();
1430 create_wallet("hex-err", None, None, Some(vault)).unwrap();
1431 assert!(
1432 sign_transaction("hex-err", "evm", "not-valid-hex!", None, None, Some(vault)).is_err()
1433 );
1434 }
1435
1436 #[test]
1441 fn list_wallets_empty_vault() {
1442 let dir = tempfile::tempdir().unwrap();
1443 let wallets = list_wallets(Some(dir.path())).unwrap();
1444 assert!(wallets.is_empty());
1445 }
1446
1447 #[test]
1448 fn get_wallet_by_name_and_id() {
1449 let dir = tempfile::tempdir().unwrap();
1450 let vault = dir.path();
1451 let info = create_wallet("lookup", None, None, Some(vault)).unwrap();
1452
1453 let by_name = get_wallet("lookup", Some(vault)).unwrap();
1454 assert_eq!(by_name.id, info.id);
1455
1456 let by_id = get_wallet(&info.id, Some(vault)).unwrap();
1457 assert_eq!(by_id.name, "lookup");
1458 }
1459
1460 #[test]
1461 fn rename_wallet_works() {
1462 let dir = tempfile::tempdir().unwrap();
1463 let vault = dir.path();
1464 let info = create_wallet("before", None, None, Some(vault)).unwrap();
1465
1466 rename_wallet("before", "after", Some(vault)).unwrap();
1467
1468 assert!(get_wallet("before", Some(vault)).is_err());
1469 let after = get_wallet("after", Some(vault)).unwrap();
1470 assert_eq!(after.id, info.id);
1471 }
1472
1473 #[test]
1474 fn rename_to_existing_name_fails() {
1475 let dir = tempfile::tempdir().unwrap();
1476 let vault = dir.path();
1477 create_wallet("a", None, None, Some(vault)).unwrap();
1478 create_wallet("b", None, None, Some(vault)).unwrap();
1479 assert!(rename_wallet("a", "b", Some(vault)).is_err());
1480 }
1481
1482 #[test]
1483 fn delete_wallet_removes_from_list() {
1484 let dir = tempfile::tempdir().unwrap();
1485 let vault = dir.path();
1486 create_wallet("del-me", None, None, Some(vault)).unwrap();
1487 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 1);
1488
1489 delete_wallet("del-me", Some(vault)).unwrap();
1490 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 0);
1491 }
1492
1493 #[test]
1498 fn sign_message_hex_encoding() {
1499 let dir = tempfile::tempdir().unwrap();
1500 let vault = dir.path();
1501 create_wallet("hex-enc", None, None, Some(vault)).unwrap();
1502
1503 let sig = sign_message(
1505 "hex-enc",
1506 "evm",
1507 "68656c6c6f",
1508 None,
1509 Some("hex"),
1510 None,
1511 Some(vault),
1512 )
1513 .unwrap();
1514 assert!(!sig.signature.is_empty());
1515
1516 let sig2 = sign_message(
1518 "hex-enc",
1519 "evm",
1520 "hello",
1521 None,
1522 Some("utf8"),
1523 None,
1524 Some(vault),
1525 )
1526 .unwrap();
1527 assert_eq!(
1528 sig.signature, sig2.signature,
1529 "hex and utf8 encoding of same bytes should produce same signature"
1530 );
1531 }
1532
1533 #[test]
1534 fn sign_message_invalid_encoding() {
1535 let dir = tempfile::tempdir().unwrap();
1536 let vault = dir.path();
1537 create_wallet("bad-enc", None, None, Some(vault)).unwrap();
1538 assert!(sign_message(
1539 "bad-enc",
1540 "evm",
1541 "hello",
1542 None,
1543 Some("base64"),
1544 None,
1545 Some(vault)
1546 )
1547 .is_err());
1548 }
1549
1550 #[test]
1555 fn multiple_wallets_coexist() {
1556 let dir = tempfile::tempdir().unwrap();
1557 let vault = dir.path();
1558
1559 create_wallet("w1", None, None, Some(vault)).unwrap();
1560 create_wallet("w2", None, None, Some(vault)).unwrap();
1561 save_privkey_wallet("w3", TEST_PRIVKEY, "", vault);
1562
1563 let wallets = list_wallets(Some(vault)).unwrap();
1564 assert_eq!(wallets.len(), 3);
1565
1566 let s1 = sign_message("w1", "evm", "test", None, None, None, Some(vault)).unwrap();
1568 let s2 = sign_message("w2", "evm", "test", None, None, None, Some(vault)).unwrap();
1569 let s3 = sign_message("w3", "evm", "test", None, None, None, Some(vault)).unwrap();
1570
1571 assert_ne!(s1.signature, s2.signature);
1573 assert_ne!(s1.signature, s3.signature);
1574 assert_ne!(s2.signature, s3.signature);
1575
1576 delete_wallet("w2", Some(vault)).unwrap();
1578 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 2);
1579 assert!(sign_message("w1", "evm", "test", None, None, None, Some(vault)).is_ok());
1580 assert!(sign_message("w3", "evm", "test", None, None, None, Some(vault)).is_ok());
1581 }
1582
1583 #[test]
1588 fn signed_tx_must_differ_from_raw_signature() {
1589 let dir = tempfile::tempdir().unwrap();
1599 let vault = dir.path();
1600 save_privkey_wallet("send-bug", TEST_PRIVKEY, "", vault);
1601
1602 let items: Vec<u8> = [
1604 ows_signer::rlp::encode_bytes(&[1]), ows_signer::rlp::encode_bytes(&[]), ows_signer::rlp::encode_bytes(&[1]), ows_signer::rlp::encode_bytes(&[100]), ows_signer::rlp::encode_bytes(&[0x52, 0x08]), ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]), ows_signer::rlp::encode_bytes(&[]), ows_signer::rlp::encode_bytes(&[]), ows_signer::rlp::encode_list(&[]), ]
1614 .concat();
1615
1616 let mut unsigned_tx = vec![0x02u8];
1617 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
1618 let tx_hex = hex::encode(&unsigned_tx);
1619
1620 let sign_result =
1622 sign_transaction("send-bug", "evm", &tx_hex, None, None, Some(vault)).unwrap();
1623 let raw_signature = hex::decode(&sign_result.signature).unwrap();
1624
1625 let key = decrypt_signing_key("send-bug", ChainType::Evm, "", None, Some(vault)).unwrap();
1627 let signer = signer_for_chain(ChainType::Evm);
1628 let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
1629 let full_signed_tx = signer
1630 .encode_signed_transaction(&unsigned_tx, &output)
1631 .unwrap();
1632
1633 assert_eq!(
1636 raw_signature.len(),
1637 65,
1638 "raw EVM signature should be 65 bytes (r || s || v)"
1639 );
1640 assert!(
1641 full_signed_tx.len() > raw_signature.len(),
1642 "full signed tx ({} bytes) must be larger than raw signature ({} bytes)",
1643 full_signed_tx.len(),
1644 raw_signature.len()
1645 );
1646 assert_ne!(
1647 raw_signature, full_signed_tx,
1648 "raw signature and full signed transaction must differ — \
1649 broadcasting the raw signature (as CLI send_transaction.rs:43 does) is wrong"
1650 );
1651
1652 assert_eq!(
1654 full_signed_tx[0], 0x02,
1655 "full signed EIP-1559 tx must start with type byte 0x02"
1656 );
1657 }
1658
1659 #[test]
1664 fn char_create_wallet_sign_transaction_with_passphrase() {
1665 let dir = tempfile::tempdir().unwrap();
1666 let vault = dir.path();
1667 create_wallet("char-pass-tx", None, Some("secret"), Some(vault)).unwrap();
1668
1669 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1670 let sig =
1671 sign_transaction("char-pass-tx", "evm", tx, Some("secret"), None, Some(vault)).unwrap();
1672 assert!(!sig.signature.is_empty());
1673 assert!(sig.recovery_id.is_some());
1674 }
1675
1676 #[test]
1677 fn char_create_wallet_sign_transaction_empty_passphrase() {
1678 let dir = tempfile::tempdir().unwrap();
1679 let vault = dir.path();
1680 create_wallet("char-empty-tx", None, None, Some(vault)).unwrap();
1681
1682 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1683 let sig =
1684 sign_transaction("char-empty-tx", "evm", tx, Some(""), None, Some(vault)).unwrap();
1685 assert!(!sig.signature.is_empty());
1686 }
1687
1688 #[test]
1689 fn char_no_passphrase_none_none_sign_transaction() {
1690 let dir = tempfile::tempdir().unwrap();
1693 let vault = dir.path();
1694 create_wallet("char-none-none", None, None, Some(vault)).unwrap();
1695
1696 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1697 let sig = sign_transaction("char-none-none", "evm", tx, None, None, Some(vault)).unwrap();
1698 assert!(!sig.signature.is_empty());
1699 assert!(sig.recovery_id.is_some());
1700 }
1701
1702 #[test]
1703 fn char_no_passphrase_none_none_sign_message() {
1704 let dir = tempfile::tempdir().unwrap();
1705 let vault = dir.path();
1706 create_wallet("char-none-msg", None, None, Some(vault)).unwrap();
1707
1708 let sig = sign_message(
1709 "char-none-msg",
1710 "evm",
1711 "hello",
1712 None,
1713 None,
1714 None,
1715 Some(vault),
1716 )
1717 .unwrap();
1718 assert!(!sig.signature.is_empty());
1719 }
1720
1721 #[test]
1722 fn char_no_passphrase_none_none_export() {
1723 let dir = tempfile::tempdir().unwrap();
1724 let vault = dir.path();
1725 create_wallet("char-none-exp", None, None, Some(vault)).unwrap();
1726
1727 let phrase = export_wallet("char-none-exp", None, Some(vault)).unwrap();
1728 assert_eq!(phrase.split_whitespace().count(), 12);
1729 }
1730
1731 #[test]
1732 fn char_empty_passphrase_none_and_some_empty_are_equivalent() {
1733 let dir = tempfile::tempdir().unwrap();
1736 let vault = dir.path();
1737
1738 create_wallet("char-equiv", None, None, Some(vault)).unwrap();
1740
1741 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1742
1743 let sig_none = sign_transaction("char-equiv", "evm", tx, None, None, Some(vault)).unwrap();
1745 let sig_empty =
1746 sign_transaction("char-equiv", "evm", tx, Some(""), None, Some(vault)).unwrap();
1747
1748 assert_eq!(
1749 sig_none.signature, sig_empty.signature,
1750 "passphrase=None and passphrase=Some(\"\") must produce identical signatures"
1751 );
1752
1753 let msg_none =
1755 sign_message("char-equiv", "evm", "test", None, None, None, Some(vault)).unwrap();
1756 let msg_empty = sign_message(
1757 "char-equiv",
1758 "evm",
1759 "test",
1760 Some(""),
1761 None,
1762 None,
1763 Some(vault),
1764 )
1765 .unwrap();
1766
1767 assert_eq!(
1768 msg_none.signature, msg_empty.signature,
1769 "sign_message: None and Some(\"\") must be equivalent"
1770 );
1771
1772 let export_none = export_wallet("char-equiv", None, Some(vault)).unwrap();
1774 let export_empty = export_wallet("char-equiv", Some(""), Some(vault)).unwrap();
1775 assert_eq!(
1776 export_none, export_empty,
1777 "export_wallet: None and Some(\"\") must return the same mnemonic"
1778 );
1779 }
1780
1781 #[test]
1782 fn char_create_with_some_empty_sign_with_none() {
1783 let dir = tempfile::tempdir().unwrap();
1785 let vault = dir.path();
1786 create_wallet("char-some-none", None, Some(""), Some(vault)).unwrap();
1787
1788 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1789 let sig = sign_transaction("char-some-none", "evm", tx, None, None, Some(vault)).unwrap();
1790 assert!(!sig.signature.is_empty());
1791 }
1792
1793 #[test]
1794 fn char_no_passphrase_wallet_rejects_nonempty_passphrase() {
1795 let dir = tempfile::tempdir().unwrap();
1799 let vault = dir.path();
1800 create_wallet("char-no-pass-reject", None, None, Some(vault)).unwrap();
1801
1802 let result = sign_message(
1803 "char-no-pass-reject",
1804 "evm",
1805 "test",
1806 Some("some-random-passphrase"),
1807 None,
1808 None,
1809 Some(vault),
1810 );
1811 assert!(
1812 result.is_err(),
1813 "non-empty passphrase on empty-passphrase wallet should fail"
1814 );
1815 match result.unwrap_err() {
1816 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error, got: {other}"),
1818 }
1819 }
1820
1821 #[test]
1822 fn char_sign_transaction_wrong_passphrase_returns_crypto_error() {
1823 let dir = tempfile::tempdir().unwrap();
1824 let vault = dir.path();
1825 create_wallet("char-wrong-pass", None, Some("correct"), Some(vault)).unwrap();
1826
1827 let tx = "deadbeef";
1828 let result = sign_transaction(
1829 "char-wrong-pass",
1830 "evm",
1831 tx,
1832 Some("wrong"),
1833 None,
1834 Some(vault),
1835 );
1836 assert!(result.is_err());
1837 match result.unwrap_err() {
1838 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error, got: {other}"),
1840 }
1841 }
1842
1843 #[test]
1844 fn char_sign_transaction_nonexistent_wallet_returns_wallet_not_found() {
1845 let dir = tempfile::tempdir().unwrap();
1846 let result = sign_transaction("ghost", "evm", "deadbeef", None, None, Some(dir.path()));
1847 assert!(result.is_err());
1848 match result.unwrap_err() {
1849 OwsLibError::WalletNotFound(name) => assert_eq!(name, "ghost"),
1850 other => panic!("expected WalletNotFound, got: {other}"),
1851 }
1852 }
1853
1854 #[test]
1855 fn char_sign_and_send_invalid_rpc_returns_broadcast_failed() {
1856 let dir = tempfile::tempdir().unwrap();
1857 let vault = dir.path();
1858 create_wallet("char-rpc-fail", None, None, Some(vault)).unwrap();
1859
1860 let items: Vec<u8> = [
1862 ows_signer::rlp::encode_bytes(&[1]), ows_signer::rlp::encode_bytes(&[]), ows_signer::rlp::encode_bytes(&[1]), ows_signer::rlp::encode_bytes(&[100]), ows_signer::rlp::encode_bytes(&[0x52, 0x08]), ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]), ows_signer::rlp::encode_bytes(&[]), ows_signer::rlp::encode_bytes(&[]), ows_signer::rlp::encode_list(&[]), ]
1872 .concat();
1873 let mut unsigned_tx = vec![0x02u8];
1874 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
1875 let tx_hex = hex::encode(&unsigned_tx);
1876
1877 let result = sign_and_send(
1878 "char-rpc-fail",
1879 "evm",
1880 &tx_hex,
1881 None,
1882 None,
1883 Some("http://127.0.0.1:1"), Some(vault),
1885 );
1886 assert!(result.is_err());
1887 match result.unwrap_err() {
1888 OwsLibError::BroadcastFailed(_) => {} other => panic!("expected BroadcastFailed, got: {other}"),
1890 }
1891 }
1892
1893 #[test]
1894 fn char_create_sign_rename_sign_with_new_name() {
1895 let dir = tempfile::tempdir().unwrap();
1896 let vault = dir.path();
1897 create_wallet("orig-name", None, None, Some(vault)).unwrap();
1898
1899 let sig1 = sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).unwrap();
1901 assert!(!sig1.signature.is_empty());
1902
1903 rename_wallet("orig-name", "new-name", Some(vault)).unwrap();
1905
1906 assert!(sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).is_err());
1908
1909 let sig2 = sign_message("new-name", "evm", "test", None, None, None, Some(vault)).unwrap();
1911 assert_eq!(
1912 sig1.signature, sig2.signature,
1913 "renamed wallet should produce identical signatures"
1914 );
1915 }
1916
1917 #[test]
1918 fn char_create_sign_delete_sign_returns_wallet_not_found() {
1919 let dir = tempfile::tempdir().unwrap();
1920 let vault = dir.path();
1921 create_wallet("del-me-char", None, None, Some(vault)).unwrap();
1922
1923 let sig =
1925 sign_message("del-me-char", "evm", "test", None, None, None, Some(vault)).unwrap();
1926 assert!(!sig.signature.is_empty());
1927
1928 delete_wallet("del-me-char", Some(vault)).unwrap();
1930
1931 let result = sign_message("del-me-char", "evm", "test", None, None, None, Some(vault));
1933 assert!(result.is_err());
1934 match result.unwrap_err() {
1935 OwsLibError::WalletNotFound(name) => assert_eq!(name, "del-me-char"),
1936 other => panic!("expected WalletNotFound, got: {other}"),
1937 }
1938 }
1939
1940 #[test]
1941 fn char_import_sign_export_reimport_sign_deterministic() {
1942 let v1 = tempfile::tempdir().unwrap();
1943 let v2 = tempfile::tempdir().unwrap();
1944
1945 let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1947 import_wallet_mnemonic("char-det", phrase, None, None, Some(v1.path())).unwrap();
1948
1949 let sig1 = sign_message(
1951 "char-det",
1952 "evm",
1953 "determinism test",
1954 None,
1955 None,
1956 None,
1957 Some(v1.path()),
1958 )
1959 .unwrap();
1960
1961 let exported = export_wallet("char-det", None, Some(v1.path())).unwrap();
1963 assert_eq!(exported.trim(), phrase);
1964
1965 import_wallet_mnemonic("char-det-2", &exported, None, None, Some(v2.path())).unwrap();
1967
1968 let sig2 = sign_message(
1970 "char-det-2",
1971 "evm",
1972 "determinism test",
1973 None,
1974 None,
1975 None,
1976 Some(v2.path()),
1977 )
1978 .unwrap();
1979
1980 assert_eq!(
1981 sig1.signature, sig2.signature,
1982 "import→sign→export→reimport→sign must produce identical signatures"
1983 );
1984 }
1985
1986 #[test]
1987 fn char_import_private_key_sign_valid() {
1988 let dir = tempfile::tempdir().unwrap();
1989 let vault = dir.path();
1990
1991 import_wallet_private_key(
1992 "char-pk",
1993 TEST_PRIVKEY,
1994 Some("evm"),
1995 None,
1996 Some(vault),
1997 None,
1998 None,
1999 )
2000 .unwrap();
2001
2002 let sig = sign_transaction("char-pk", "evm", "deadbeef", None, None, Some(vault)).unwrap();
2003 assert!(!sig.signature.is_empty());
2004 assert!(sig.recovery_id.is_some());
2005 }
2006
2007 #[test]
2008 fn char_sign_message_all_chain_families() {
2009 let dir = tempfile::tempdir().unwrap();
2011 let vault = dir.path();
2012 create_wallet("char-all-chains", None, None, Some(vault)).unwrap();
2013
2014 let chains = [
2015 ("evm", true),
2016 ("solana", false),
2017 ("bitcoin", true),
2018 ("cosmos", true),
2019 ("tron", true),
2020 ("ton", false),
2021 ("sui", false),
2022 ];
2023 for (chain, has_recovery_id) in &chains {
2024 let result = sign_message(
2025 "char-all-chains",
2026 chain,
2027 "hello",
2028 None,
2029 None,
2030 None,
2031 Some(vault),
2032 );
2033 assert!(
2034 result.is_ok(),
2035 "sign_message failed for {chain}: {:?}",
2036 result.err()
2037 );
2038 let sig = result.unwrap();
2039 assert!(!sig.signature.is_empty(), "signature empty for {chain}");
2040 if *has_recovery_id {
2041 assert!(
2042 sig.recovery_id.is_some(),
2043 "expected recovery_id for {chain}"
2044 );
2045 }
2046 }
2047 }
2048
2049 #[test]
2050 fn char_sign_typed_data_evm_valid_signature() {
2051 let dir = tempfile::tempdir().unwrap();
2052 let vault = dir.path();
2053 create_wallet("char-typed", None, None, Some(vault)).unwrap();
2054
2055 let typed_data = r#"{
2056 "types": {
2057 "EIP712Domain": [
2058 {"name": "name", "type": "string"},
2059 {"name": "version", "type": "string"},
2060 {"name": "chainId", "type": "uint256"}
2061 ],
2062 "Test": [{"name": "value", "type": "uint256"}]
2063 },
2064 "primaryType": "Test",
2065 "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2066 "message": {"value": "42"}
2067 }"#;
2068
2069 let result = sign_typed_data("char-typed", "evm", typed_data, None, None, Some(vault));
2070 assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2071
2072 let sig = result.unwrap();
2073 let sig_bytes = hex::decode(&sig.signature).unwrap();
2074 assert_eq!(sig_bytes.len(), 65, "EIP-712 signature should be 65 bytes");
2075
2076 let v = sig_bytes[64];
2078 assert!(v == 27 || v == 28, "EIP-712 v should be 27 or 28, got {v}");
2079 }
2080
2081 #[test]
2086 fn char_sign_with_nonzero_account_index() {
2087 let dir = tempfile::tempdir().unwrap();
2090 let vault = dir.path();
2091 create_wallet("char-idx", None, None, Some(vault)).unwrap();
2092
2093 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2094
2095 let sig0 = sign_transaction("char-idx", "evm", tx, None, Some(0), Some(vault)).unwrap();
2096 let sig1 = sign_transaction("char-idx", "evm", tx, None, Some(1), Some(vault)).unwrap();
2097
2098 assert_ne!(
2099 sig0.signature, sig1.signature,
2100 "index 0 and index 1 must produce different signatures (different derived keys)"
2101 );
2102
2103 let sig_default = sign_transaction("char-idx", "evm", tx, None, None, Some(vault)).unwrap();
2105 assert_eq!(
2106 sig0.signature, sig_default.signature,
2107 "index=0 should match index=None (default)"
2108 );
2109 }
2110
2111 #[test]
2112 fn char_sign_with_nonzero_index_sign_message() {
2113 let dir = tempfile::tempdir().unwrap();
2114 let vault = dir.path();
2115 create_wallet("char-idx-msg", None, None, Some(vault)).unwrap();
2116
2117 let sig0 = sign_message(
2118 "char-idx-msg",
2119 "evm",
2120 "hello",
2121 None,
2122 None,
2123 Some(0),
2124 Some(vault),
2125 )
2126 .unwrap();
2127 let sig1 = sign_message(
2128 "char-idx-msg",
2129 "evm",
2130 "hello",
2131 None,
2132 None,
2133 Some(1),
2134 Some(vault),
2135 )
2136 .unwrap();
2137
2138 assert_ne!(
2139 sig0.signature, sig1.signature,
2140 "different account indices should yield different signatures"
2141 );
2142 }
2143
2144 #[test]
2145 fn char_sign_transaction_0x_prefix_stripped() {
2146 let dir = tempfile::tempdir().unwrap();
2149 let vault = dir.path();
2150 create_wallet("char-0x", None, None, Some(vault)).unwrap();
2151
2152 let tx_no_prefix = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2153 let tx_with_prefix = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2154
2155 let sig1 =
2156 sign_transaction("char-0x", "evm", tx_no_prefix, None, None, Some(vault)).unwrap();
2157 let sig2 =
2158 sign_transaction("char-0x", "evm", tx_with_prefix, None, None, Some(vault)).unwrap();
2159
2160 assert_eq!(
2161 sig1.signature, sig2.signature,
2162 "0x-prefixed and bare hex should produce identical signatures"
2163 );
2164 }
2165
2166 #[test]
2167 fn char_24_word_mnemonic_wallet_lifecycle() {
2168 let dir = tempfile::tempdir().unwrap();
2170 let vault = dir.path();
2171
2172 let info = create_wallet("char-24w", Some(24), None, Some(vault)).unwrap();
2173 assert!(!info.accounts.is_empty());
2174
2175 let phrase = export_wallet("char-24w", None, Some(vault)).unwrap();
2177 assert_eq!(
2178 phrase.split_whitespace().count(),
2179 24,
2180 "should be a 24-word mnemonic"
2181 );
2182
2183 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2185 let sig = sign_transaction("char-24w", "evm", tx, None, None, Some(vault)).unwrap();
2186 assert!(!sig.signature.is_empty());
2187
2188 for chain in &["evm", "solana", "bitcoin", "cosmos"] {
2190 let result = sign_message("char-24w", chain, "test", None, None, None, Some(vault));
2191 assert!(
2192 result.is_ok(),
2193 "24-word wallet sign_message failed for {chain}: {:?}",
2194 result.err()
2195 );
2196 }
2197
2198 let v2 = tempfile::tempdir().unwrap();
2200 import_wallet_mnemonic("char-24w-2", &phrase, None, None, Some(v2.path())).unwrap();
2201 let sig2 = sign_transaction("char-24w-2", "evm", tx, None, None, Some(v2.path())).unwrap();
2202 assert_eq!(
2203 sig.signature, sig2.signature,
2204 "reimported 24-word wallet must produce identical signature"
2205 );
2206 }
2207
2208 #[test]
2209 fn char_concurrent_signing() {
2210 use std::sync::Arc;
2213 use std::thread;
2214
2215 let dir = tempfile::tempdir().unwrap();
2216 let vault_path = Arc::new(dir.path().to_path_buf());
2217 create_wallet("char-conc", None, None, Some(&vault_path)).unwrap();
2218
2219 let handles: Vec<_> = (0..8)
2220 .map(|i| {
2221 let vp = Arc::clone(&vault_path);
2222 thread::spawn(move || {
2223 let msg = format!("thread-{i}");
2224 let result = sign_message(
2225 "char-conc",
2226 "evm",
2227 &msg,
2228 None,
2229 None,
2230 None,
2231 Some(vp.as_path()),
2232 );
2233 assert!(
2234 result.is_ok(),
2235 "concurrent sign_message failed in thread {i}: {:?}",
2236 result.err()
2237 );
2238 result.unwrap()
2239 })
2240 })
2241 .collect();
2242
2243 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2244
2245 for (i, sig) in results.iter().enumerate() {
2247 assert!(
2248 !sig.signature.is_empty(),
2249 "thread {i} produced empty signature"
2250 );
2251 }
2252
2253 for i in 0..results.len() {
2255 for j in (i + 1)..results.len() {
2256 assert_ne!(
2257 results[i].signature, results[j].signature,
2258 "threads {i} and {j} should produce different signatures (different messages)"
2259 );
2260 }
2261 }
2262 }
2263
2264 #[test]
2265 fn char_evm_sign_transaction_recoverable() {
2266 use sha3::Digest;
2269
2270 let dir = tempfile::tempdir().unwrap();
2271 let vault = dir.path();
2272 let info = create_wallet("char-tx-recover", None, None, Some(vault)).unwrap();
2273 let evm_addr = info
2274 .accounts
2275 .iter()
2276 .find(|a| a.chain_id.starts_with("eip155:"))
2277 .unwrap()
2278 .address
2279 .clone();
2280
2281 let tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2282 let sig =
2283 sign_transaction("char-tx-recover", "evm", tx_hex, None, None, Some(vault)).unwrap();
2284
2285 let sig_bytes = hex::decode(&sig.signature).unwrap();
2286 assert_eq!(sig_bytes.len(), 65);
2287
2288 let tx_bytes = hex::decode(tx_hex).unwrap();
2290 let hash = sha3::Keccak256::digest(&tx_bytes);
2291
2292 let v = sig_bytes[64];
2293 let recid = k256::ecdsa::RecoveryId::try_from(v).unwrap();
2294 let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
2295 let recovered_key =
2296 k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
2297
2298 let pubkey_bytes = recovered_key.to_encoded_point(false);
2300 let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
2301 let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
2302
2303 assert_eq!(
2304 recovered_addr.to_lowercase(),
2305 evm_addr.to_lowercase(),
2306 "recovered address from tx signature should match wallet's EVM address"
2307 );
2308 }
2309
2310 #[test]
2311 fn char_solana_extract_signable_through_sign_path() {
2312 let dir = tempfile::tempdir().unwrap();
2317 let vault = dir.path();
2318 create_wallet("char-sol-sig", None, None, Some(vault)).unwrap();
2319
2320 let message_payload = b"test solana message payload 1234";
2322 let mut tx_bytes = vec![0x01u8]; tx_bytes.extend_from_slice(&[0u8; 64]); tx_bytes.extend_from_slice(message_payload);
2325 let tx_hex = hex::encode(&tx_bytes);
2326
2327 let sig =
2332 sign_transaction("char-sol-sig", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2333 assert_eq!(
2334 hex::decode(&sig.signature).unwrap().len(),
2335 64,
2336 "Solana signature should be 64 bytes (Ed25519)"
2337 );
2338 assert!(sig.recovery_id.is_none(), "Ed25519 has no recovery ID");
2339
2340 let key =
2343 decrypt_signing_key("char-sol-sig", ChainType::Solana, "", None, Some(vault)).unwrap();
2344 let signer = signer_for_chain(ChainType::Solana);
2345
2346 let signable = signer.extract_signable_bytes(&tx_bytes).unwrap();
2347 assert_eq!(
2348 signable, message_payload,
2349 "extract_signable_bytes should return only the message portion"
2350 );
2351
2352 let output = signer.sign_transaction(key.expose(), signable).unwrap();
2353 let signed_tx = signer
2354 .encode_signed_transaction(&tx_bytes, &output)
2355 .unwrap();
2356
2357 assert_eq!(&signed_tx[1..65], &output.signature[..]);
2359 assert_eq!(&signed_tx[65..], message_payload);
2361 assert_eq!(signed_tx.len(), tx_bytes.len());
2363
2364 let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
2366 let verifying_key = signing_key.verifying_key();
2367 let ed_sig = ed25519_dalek::Signature::from_bytes(&output.signature.try_into().unwrap());
2368 verifying_key
2369 .verify_strict(message_payload, &ed_sig)
2370 .expect("Solana signature should verify against extracted message");
2371 }
2372
2373 #[test]
2374 fn char_library_encodes_before_broadcast() {
2375 let dir = tempfile::tempdir().unwrap();
2382 let vault = dir.path();
2383 create_wallet("char-encode", None, None, Some(vault)).unwrap();
2384
2385 let items: Vec<u8> = [
2387 ows_signer::rlp::encode_bytes(&[1]), ows_signer::rlp::encode_bytes(&[]), ows_signer::rlp::encode_bytes(&[1]), ows_signer::rlp::encode_bytes(&[100]), ows_signer::rlp::encode_bytes(&[0x52, 0x08]), ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]), ows_signer::rlp::encode_bytes(&[]), ows_signer::rlp::encode_bytes(&[]), ows_signer::rlp::encode_list(&[]), ]
2397 .concat();
2398 let mut unsigned_tx = vec![0x02u8];
2399 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
2400 let tx_hex = hex::encode(&unsigned_tx);
2401
2402 let raw_sig =
2404 sign_transaction("char-encode", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2405 let raw_sig_bytes = hex::decode(&raw_sig.signature).unwrap();
2406
2407 let key =
2409 decrypt_signing_key("char-encode", ChainType::Evm, "", None, Some(vault)).unwrap();
2410 let signer = signer_for_chain(ChainType::Evm);
2411 let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
2412 let full_signed_tx = signer
2413 .encode_signed_transaction(&unsigned_tx, &output)
2414 .unwrap();
2415
2416 assert_eq!(raw_sig_bytes.len(), 65);
2418
2419 assert!(full_signed_tx.len() > 65);
2421 assert_eq!(
2422 full_signed_tx[0], 0x02,
2423 "should preserve EIP-1559 type byte"
2424 );
2425
2426 assert_ne!(raw_sig_bytes, full_signed_tx);
2428
2429 let r_bytes = &raw_sig_bytes[..32];
2432 let _s_bytes = &raw_sig_bytes[32..64];
2433
2434 let full_hex = hex::encode(&full_signed_tx);
2436 let r_hex = hex::encode(r_bytes);
2437 assert!(
2438 full_hex.contains(&r_hex),
2439 "full signed tx should contain the r component"
2440 );
2441 }
2442
2443 #[test]
2448 fn sign_typed_data_rejects_non_evm_chain() {
2449 let tmp = tempfile::tempdir().unwrap();
2450 let vault = tmp.path();
2451
2452 let w = save_privkey_wallet("typed-data-test", TEST_PRIVKEY, "pass", vault);
2453
2454 let typed_data = r#"{
2455 "types": {
2456 "EIP712Domain": [{"name": "name", "type": "string"}],
2457 "Test": [{"name": "value", "type": "uint256"}]
2458 },
2459 "primaryType": "Test",
2460 "domain": {"name": "Test"},
2461 "message": {"value": "1"}
2462 }"#;
2463
2464 let result = sign_typed_data(&w.id, "solana", typed_data, Some("pass"), None, Some(vault));
2465 assert!(result.is_err());
2466 let err_msg = result.unwrap_err().to_string();
2467 assert!(
2468 err_msg.contains("only supported for EVM"),
2469 "expected EVM-only error, got: {err_msg}"
2470 );
2471 }
2472
2473 #[test]
2474 fn sign_typed_data_evm_succeeds() {
2475 let tmp = tempfile::tempdir().unwrap();
2476 let vault = tmp.path();
2477
2478 let w = save_privkey_wallet("typed-data-evm", TEST_PRIVKEY, "pass", vault);
2479
2480 let typed_data = r#"{
2481 "types": {
2482 "EIP712Domain": [
2483 {"name": "name", "type": "string"},
2484 {"name": "version", "type": "string"},
2485 {"name": "chainId", "type": "uint256"}
2486 ],
2487 "Test": [{"name": "value", "type": "uint256"}]
2488 },
2489 "primaryType": "Test",
2490 "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2491 "message": {"value": "42"}
2492 }"#;
2493
2494 let result = sign_typed_data(&w.id, "evm", typed_data, Some("pass"), None, Some(vault));
2495 assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2496
2497 let sign_result = result.unwrap();
2498 assert!(
2499 !sign_result.signature.is_empty(),
2500 "signature should not be empty"
2501 );
2502 assert!(
2503 sign_result.recovery_id.is_some(),
2504 "recovery_id should be present for EVM"
2505 );
2506 }
2507
2508 #[test]
2514 fn regression_owner_path_identical_to_direct_signer() {
2515 let dir = tempfile::tempdir().unwrap();
2520 let vault = dir.path();
2521 create_wallet("reg-owner", None, None, Some(vault)).unwrap();
2522
2523 let tx_hex = "deadbeefcafebabe";
2524
2525 let api_result =
2527 sign_transaction("reg-owner", "evm", tx_hex, None, None, Some(vault)).unwrap();
2528
2529 let key = decrypt_signing_key("reg-owner", ChainType::Evm, "", None, Some(vault)).unwrap();
2531 let signer = signer_for_chain(ChainType::Evm);
2532 let tx_bytes = hex::decode(tx_hex).unwrap();
2533 let direct_output = signer.sign_transaction(key.expose(), &tx_bytes).unwrap();
2534
2535 assert_eq!(
2536 api_result.signature,
2537 hex::encode(&direct_output.signature),
2538 "library API and direct signer must produce identical signatures"
2539 );
2540 assert_eq!(
2541 api_result.recovery_id, direct_output.recovery_id,
2542 "recovery_id must match"
2543 );
2544 }
2545
2546 #[test]
2547 fn regression_owner_passphrase_not_confused_with_token() {
2548 let dir = tempfile::tempdir().unwrap();
2551 let vault = dir.path();
2552 create_wallet("reg-pass", Some(12), Some("hunter2"), Some(vault)).unwrap();
2553
2554 let tx_hex = "deadbeef";
2555
2556 let result = sign_transaction(
2558 "reg-pass",
2559 "evm",
2560 tx_hex,
2561 Some("hunter2"),
2562 None,
2563 Some(vault),
2564 );
2565 assert!(
2566 result.is_ok(),
2567 "owner-mode signing failed: {:?}",
2568 result.err()
2569 );
2570
2571 let bad = sign_transaction("reg-pass", "evm", tx_hex, Some(""), None, Some(vault));
2574 assert!(bad.is_err());
2575 match bad.unwrap_err() {
2576 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error for wrong passphrase, got: {other}"),
2578 }
2579
2580 let none_result = sign_transaction("reg-pass", "evm", tx_hex, None, None, Some(vault));
2582 assert!(none_result.is_err());
2583 match none_result.unwrap_err() {
2584 OwsLibError::Crypto(_) => {}
2585 other => panic!("expected Crypto error for None passphrase, got: {other}"),
2586 }
2587 }
2588
2589 #[test]
2590 fn regression_sign_message_owner_path_unchanged() {
2591 let dir = tempfile::tempdir().unwrap();
2592 let vault = dir.path();
2593 create_wallet("reg-msg", None, None, Some(vault)).unwrap();
2594
2595 let api_result =
2597 sign_message("reg-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
2598
2599 let key = decrypt_signing_key("reg-msg", ChainType::Evm, "", None, Some(vault)).unwrap();
2601 let signer = signer_for_chain(ChainType::Evm);
2602 let direct = signer.sign_message(key.expose(), b"hello").unwrap();
2603
2604 assert_eq!(
2605 api_result.signature,
2606 hex::encode(&direct.signature),
2607 "sign_message owner path must match direct signer"
2608 );
2609 }
2610}