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(
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
423pub 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
458pub 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
487pub 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
509pub 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 let signable = signer.extract_signable_bytes(tx_bytes)?;
526
527 let output = signer.sign_transaction(private_key, signable)?;
529
530 let signed_tx = signer.encode_signed_transaction(tx_bytes, &output)?;
532
533 let rpc = resolve_rpc_url(chain.chain_id, chain.chain_type, rpc_url)?;
535
536 let tx_hash = broadcast(chain.chain_type, &rpc, &signed_tx)?;
538
539 Ok(SendResult { tx_hash })
540}
541
542fn 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 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 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
579fn 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 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 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
618fn 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 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 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 #[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 #[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 #[test]
896 fn mnemonic_wallet_create_export_reimport() {
897 let v1 = tempfile::tempdir().unwrap();
898 let v2 = tempfile::tempdir().unwrap();
899
900 let w1 = create_wallet("w1", None, None, Some(v1.path())).unwrap();
902 assert!(!w1.accounts.is_empty());
903
904 let phrase = export_wallet("w1", None, Some(v1.path())).unwrap();
906 assert_eq!(phrase.split_whitespace().count(), 12);
907
908 let w2 = import_wallet_mnemonic("w2", &phrase, None, None, Some(v2.path())).unwrap();
910
911 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 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);
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 #[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 let sig = sign_message("pk-api", "evm", "hello", None, None, None, Some(vault)).unwrap();
1104 assert!(!sig.signature.is_empty());
1105
1106 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 "", None, 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 let sig = sign_message("pk-both", "evm", "hello", None, None, None, Some(vault)).unwrap();
1139 assert!(!sig.signature.is_empty());
1140
1141 let sig =
1143 sign_message("pk-both", "solana", "hello", None, None, None, Some(vault)).unwrap();
1144 assert!(!sig.signature.is_empty());
1145
1146 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 #[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 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 let phrase = export_wallet("pass-mn", Some("s3cret"), Some(vault)).unwrap();
1179 assert_eq!(phrase.split_whitespace().count(), 12);
1180
1181 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 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 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 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 #[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 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 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 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 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 #[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 #[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 #[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 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 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 #[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 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 assert_ne!(s1.signature, s2.signature);
1495 assert_ne!(s1.signature, s3.signature);
1496 assert_ne!(s2.signature, s3.signature);
1497
1498 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 #[test]
1510 fn signed_tx_must_differ_from_raw_signature() {
1511 let dir = tempfile::tempdir().unwrap();
1521 let vault = dir.path();
1522 save_privkey_wallet("send-bug", TEST_PRIVKEY, "", vault);
1523
1524 let items: Vec<u8> = [
1526 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(&[]), ]
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 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 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 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 assert_eq!(
1576 full_signed_tx[0], 0x02,
1577 "full signed EIP-1559 tx must start with type byte 0x02"
1578 );
1579 }
1580
1581 #[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}