1use std::path::Path;
2use std::process::Command;
3
4use ows_core::{
5 default_chain_for_type, ChainType, Config, EncryptedWallet, KeyType, WalletAccount,
6 ALL_CHAIN_TYPES,
7};
8use ows_signer::{
9 decrypt, encrypt, signer_for_chain, CryptoEnvelope, Curve, HdDeriver, Mnemonic,
10 MnemonicStrength, SecretBytes,
11};
12
13use crate::error::OwsLibError;
14use crate::types::{AccountInfo, SendResult, SignResult, WalletInfo};
15use crate::vault;
16
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(crate) fn secret_to_signing_key(
132 secret: &SecretBytes,
133 key_type: &KeyType,
134 chain_type: ChainType,
135 index: Option<u32>,
136) -> Result<SecretBytes, OwsLibError> {
137 match key_type {
138 KeyType::Mnemonic => {
139 let phrase = std::str::from_utf8(secret.expose()).map_err(|_| {
141 OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
142 })?;
143 let mnemonic = Mnemonic::from_phrase(phrase)?;
144 let signer = signer_for_chain(chain_type);
145 let path = signer.default_derivation_path(index.unwrap_or(0));
146 let curve = signer.curve();
147 Ok(HdDeriver::derive_from_mnemonic_cached(
148 &mnemonic, "", &path, curve,
149 )?)
150 }
151 KeyType::PrivateKey => {
152 let keys = KeyPair::from_json_bytes(secret.expose())?;
154 let signer = signer_for_chain(chain_type);
155 Ok(SecretBytes::from_slice(keys.key_for_curve(signer.curve())))
156 }
157 }
158}
159
160pub fn generate_mnemonic(words: u32) -> Result<String, OwsLibError> {
162 let strength = match words {
163 12 => MnemonicStrength::Words12,
164 24 => MnemonicStrength::Words24,
165 _ => return Err(OwsLibError::InvalidInput("words must be 12 or 24".into())),
166 };
167
168 let mnemonic = Mnemonic::generate(strength)?;
169 let phrase = mnemonic.phrase();
170 String::from_utf8(phrase.expose().to_vec())
171 .map_err(|e| OwsLibError::InvalidInput(format!("invalid UTF-8 in mnemonic: {e}")))
172}
173
174pub fn derive_address(
176 mnemonic_phrase: &str,
177 chain: &str,
178 index: Option<u32>,
179) -> Result<String, OwsLibError> {
180 let chain = parse_chain(chain)?;
181 let mnemonic = Mnemonic::from_phrase(mnemonic_phrase)?;
182 let signer = signer_for_chain(chain.chain_type);
183 let path = signer.default_derivation_path(index.unwrap_or(0));
184 let curve = signer.curve();
185
186 let key = HdDeriver::derive_from_mnemonic(&mnemonic, "", &path, curve)?;
187 let address = signer.derive_address(key.expose())?;
188 Ok(address)
189}
190
191pub fn create_wallet(
194 name: &str,
195 words: Option<u32>,
196 passphrase: Option<&str>,
197 vault_path: Option<&Path>,
198) -> Result<WalletInfo, OwsLibError> {
199 let passphrase = passphrase.unwrap_or("");
200 let words = words.unwrap_or(12);
201 let strength = match words {
202 12 => MnemonicStrength::Words12,
203 24 => MnemonicStrength::Words24,
204 _ => return Err(OwsLibError::InvalidInput("words must be 12 or 24".into())),
205 };
206
207 if vault::wallet_name_exists(name, vault_path)? {
208 return Err(OwsLibError::WalletNameExists(name.to_string()));
209 }
210
211 let mnemonic = Mnemonic::generate(strength)?;
212 let accounts = derive_all_accounts(&mnemonic, 0)?;
213
214 let phrase = mnemonic.phrase();
215 let crypto_envelope = encrypt(phrase.expose(), passphrase)?;
216 let crypto_json = serde_json::to_value(&crypto_envelope)?;
217
218 let wallet_id = uuid::Uuid::new_v4().to_string();
219
220 let wallet = EncryptedWallet::new(
221 wallet_id,
222 name.to_string(),
223 accounts,
224 crypto_json,
225 KeyType::Mnemonic,
226 );
227
228 vault::save_encrypted_wallet(&wallet, vault_path)?;
229 Ok(wallet_to_info(&wallet))
230}
231
232pub fn import_wallet_mnemonic(
234 name: &str,
235 mnemonic_phrase: &str,
236 passphrase: Option<&str>,
237 index: Option<u32>,
238 vault_path: Option<&Path>,
239) -> Result<WalletInfo, OwsLibError> {
240 let passphrase = passphrase.unwrap_or("");
241 let index = index.unwrap_or(0);
242
243 if vault::wallet_name_exists(name, vault_path)? {
244 return Err(OwsLibError::WalletNameExists(name.to_string()));
245 }
246
247 let mnemonic = Mnemonic::from_phrase(mnemonic_phrase)?;
248 let accounts = derive_all_accounts(&mnemonic, index)?;
249
250 let phrase = mnemonic.phrase();
251 let crypto_envelope = encrypt(phrase.expose(), passphrase)?;
252 let crypto_json = serde_json::to_value(&crypto_envelope)?;
253
254 let wallet_id = uuid::Uuid::new_v4().to_string();
255
256 let wallet = EncryptedWallet::new(
257 wallet_id,
258 name.to_string(),
259 accounts,
260 crypto_json,
261 KeyType::Mnemonic,
262 );
263
264 vault::save_encrypted_wallet(&wallet, vault_path)?;
265 Ok(wallet_to_info(&wallet))
266}
267
268fn decode_hex_key(hex_str: &str) -> Result<Vec<u8>, OwsLibError> {
270 let trimmed = hex_str.strip_prefix("0x").unwrap_or(hex_str);
271 hex::decode(trimmed)
272 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex private key: {e}")))
273}
274
275pub fn import_wallet_private_key(
284 name: &str,
285 private_key_hex: &str,
286 chain: Option<&str>,
287 passphrase: Option<&str>,
288 vault_path: Option<&Path>,
289 secp256k1_key_hex: Option<&str>,
290 ed25519_key_hex: Option<&str>,
291) -> Result<WalletInfo, OwsLibError> {
292 let passphrase = passphrase.unwrap_or("");
293
294 if vault::wallet_name_exists(name, vault_path)? {
295 return Err(OwsLibError::WalletNameExists(name.to_string()));
296 }
297
298 let keys = match (secp256k1_key_hex, ed25519_key_hex) {
299 (Some(secp_hex), Some(ed_hex)) => KeyPair {
301 secp256k1: decode_hex_key(secp_hex)?,
302 ed25519: decode_hex_key(ed_hex)?,
303 },
304 _ => {
306 let key_bytes = decode_hex_key(private_key_hex)?;
307
308 let source_curve = match chain {
310 Some(c) => {
311 let parsed = parse_chain(c)?;
312 signer_for_chain(parsed.chain_type).curve()
313 }
314 None => ows_signer::Curve::Secp256k1,
315 };
316
317 let mut other_key = vec![0u8; 32];
319 getrandom::getrandom(&mut other_key).map_err(|e| {
320 OwsLibError::InvalidInput(format!("failed to generate random key: {e}"))
321 })?;
322
323 match source_curve {
324 ows_signer::Curve::Secp256k1 => KeyPair {
325 secp256k1: key_bytes,
326 ed25519: ed25519_key_hex
327 .map(decode_hex_key)
328 .transpose()?
329 .unwrap_or(other_key),
330 },
331 ows_signer::Curve::Ed25519 => KeyPair {
332 secp256k1: secp256k1_key_hex
333 .map(decode_hex_key)
334 .transpose()?
335 .unwrap_or(other_key),
336 ed25519: key_bytes,
337 },
338 }
339 }
340 };
341
342 let accounts = derive_all_accounts_from_keys(&keys)?;
343
344 let payload = keys.to_json_bytes();
345 let crypto_envelope = encrypt(&payload, passphrase)?;
346 let crypto_json = serde_json::to_value(&crypto_envelope)?;
347
348 let wallet_id = uuid::Uuid::new_v4().to_string();
349
350 let wallet = EncryptedWallet::new(
351 wallet_id,
352 name.to_string(),
353 accounts,
354 crypto_json,
355 KeyType::PrivateKey,
356 );
357
358 vault::save_encrypted_wallet(&wallet, vault_path)?;
359 Ok(wallet_to_info(&wallet))
360}
361
362pub fn list_wallets(vault_path: Option<&Path>) -> Result<Vec<WalletInfo>, OwsLibError> {
364 let wallets = vault::list_encrypted_wallets(vault_path)?;
365 Ok(wallets.iter().map(wallet_to_info).collect())
366}
367
368pub fn get_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result<WalletInfo, OwsLibError> {
370 let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
371 Ok(wallet_to_info(&wallet))
372}
373
374pub fn delete_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibError> {
376 let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
377 vault::delete_wallet_file(&wallet.id, vault_path)?;
378 Ok(())
379}
380
381pub fn export_wallet(
384 name_or_id: &str,
385 passphrase: Option<&str>,
386 vault_path: Option<&Path>,
387) -> Result<String, OwsLibError> {
388 let passphrase = passphrase.unwrap_or("");
389 let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
390 let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
391 let secret = decrypt(&envelope, passphrase)?;
392
393 match wallet.key_type {
394 KeyType::Mnemonic => String::from_utf8(secret.expose().to_vec()).map_err(|_| {
395 OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
396 }),
397 KeyType::PrivateKey => {
398 String::from_utf8(secret.expose().to_vec())
400 .map_err(|_| OwsLibError::InvalidInput("wallet contains invalid key data".into()))
401 }
402 }
403}
404
405pub fn rename_wallet(
407 name_or_id: &str,
408 new_name: &str,
409 vault_path: Option<&Path>,
410) -> Result<(), OwsLibError> {
411 let mut wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
412
413 if wallet.name == new_name {
414 return Ok(());
415 }
416
417 if vault::wallet_name_exists(new_name, vault_path)? {
418 return Err(OwsLibError::WalletNameExists(new_name.to_string()));
419 }
420
421 wallet.name = new_name.to_string();
422 vault::save_encrypted_wallet(&wallet, vault_path)?;
423 Ok(())
424}
425
426fn decode_hash_hex(hash_hex: &str) -> Result<Vec<u8>, OwsLibError> {
427 let hash_hex = hash_hex.strip_prefix("0x").unwrap_or(hash_hex);
428 let hash = hex::decode(hash_hex)
429 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex hash: {e}")))?;
430
431 if hash.len() != 32 {
432 return Err(OwsLibError::InvalidInput(format!(
433 "raw hash signing requires exactly 32 bytes, got {}",
434 hash.len()
435 )));
436 }
437
438 Ok(hash)
439}
440
441fn sign_hash_with_credential(
442 wallet: &str,
443 chain: &ows_core::Chain,
444 policy_bytes: &[u8],
445 hash_bytes: &[u8],
446 credential: &str,
447 index: Option<u32>,
448 vault_path: Option<&Path>,
449) -> Result<SignResult, OwsLibError> {
450 let signer = signer_for_chain(chain.chain_type);
451 if signer.curve() != Curve::Secp256k1 {
452 return Err(OwsLibError::InvalidInput(
453 "raw hash signing is only supported for secp256k1-backed chains".into(),
454 ));
455 }
456
457 if hash_bytes.len() != 32 {
458 return Err(OwsLibError::InvalidInput(format!(
459 "raw hash signing requires exactly 32 bytes, got {}",
460 hash_bytes.len()
461 )));
462 }
463
464 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
465 return crate::key_ops::sign_hash_with_api_key(
466 credential,
467 wallet,
468 chain,
469 policy_bytes,
470 hash_bytes,
471 index,
472 vault_path,
473 );
474 }
475
476 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
477 let output = signer.sign(key.expose(), hash_bytes)?;
478
479 Ok(SignResult {
480 signature: hex::encode(&output.signature),
481 recovery_id: output.recovery_id,
482 })
483}
484
485pub fn sign_transaction(
491 wallet: &str,
492 chain: &str,
493 tx_hex: &str,
494 passphrase: Option<&str>,
495 index: Option<u32>,
496 vault_path: Option<&Path>,
497) -> Result<SignResult, OwsLibError> {
498 let credential = passphrase.unwrap_or("");
499
500 let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
501 let tx_bytes = hex::decode(tx_hex_clean)
502 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
503
504 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
506 let chain = parse_chain(chain)?;
507 return crate::key_ops::sign_with_api_key(
508 credential, wallet, &chain, &tx_bytes, index, vault_path,
509 );
510 }
511
512 let chain = parse_chain(chain)?;
514 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
515 let signer = signer_for_chain(chain.chain_type);
516 let signable = signer.extract_signable_bytes(&tx_bytes)?;
517 let output = signer.sign_transaction(key.expose(), signable)?;
518
519 Ok(SignResult {
520 signature: hex::encode(&output.signature),
521 recovery_id: output.recovery_id,
522 })
523}
524
525pub fn sign_hash(
531 wallet: &str,
532 chain: &str,
533 hash_hex: &str,
534 passphrase: Option<&str>,
535 index: Option<u32>,
536 vault_path: Option<&Path>,
537) -> Result<SignResult, OwsLibError> {
538 let credential = passphrase.unwrap_or("");
539 let chain = parse_chain(chain)?;
540 let hash = decode_hash_hex(hash_hex)?;
541
542 sign_hash_with_credential(wallet, &chain, &hash, &hash, credential, index, vault_path)
543}
544
545pub fn sign_authorization(
550 wallet: &str,
551 chain: &str,
552 address: &str,
553 nonce: &str,
554 passphrase: Option<&str>,
555 index: Option<u32>,
556 vault_path: Option<&Path>,
557) -> Result<SignResult, OwsLibError> {
558 let credential = passphrase.unwrap_or("");
559 let chain = parse_chain(chain)?;
560 if chain.chain_type != ChainType::Evm {
561 return Err(OwsLibError::InvalidInput(
562 "EIP-7702 authorization signing is only supported for EVM chains".into(),
563 ));
564 }
565
566 let authorization_chain_id = chain.chain_id.strip_prefix("eip155:").ok_or_else(|| {
567 OwsLibError::InvalidInput(format!(
568 "EVM chain '{}' is missing an eip155 reference",
569 chain.chain_id
570 ))
571 })?;
572
573 let evm_signer = ows_signer::chains::EvmSigner;
574 let payload = evm_signer.authorization_payload(authorization_chain_id, address, nonce)?;
575 let hash = evm_signer.authorization_hash(authorization_chain_id, address, nonce)?;
576
577 sign_hash_with_credential(
578 wallet, &chain, &payload, &hash, credential, index, vault_path,
579 )
580}
581
582pub fn sign_message(
587 wallet: &str,
588 chain: &str,
589 message: &str,
590 passphrase: Option<&str>,
591 encoding: Option<&str>,
592 index: Option<u32>,
593 vault_path: Option<&Path>,
594) -> Result<SignResult, OwsLibError> {
595 let credential = passphrase.unwrap_or("");
596
597 let encoding = encoding.unwrap_or("utf8");
598 let msg_bytes = match encoding {
599 "utf8" => message.as_bytes().to_vec(),
600 "hex" => hex::decode(message)
601 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex message: {e}")))?,
602 _ => {
603 return Err(OwsLibError::InvalidInput(format!(
604 "unsupported encoding: {encoding} (use 'utf8' or 'hex')"
605 )))
606 }
607 };
608
609 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
611 let chain = parse_chain(chain)?;
612 return crate::key_ops::sign_message_with_api_key(
613 credential, wallet, &chain, &msg_bytes, index, vault_path,
614 );
615 }
616
617 let chain = parse_chain(chain)?;
619 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
620 let signer = signer_for_chain(chain.chain_type);
621 let output = signer.sign_message(key.expose(), &msg_bytes)?;
622
623 Ok(SignResult {
624 signature: hex::encode(&output.signature),
625 recovery_id: output.recovery_id,
626 })
627}
628
629pub fn sign_typed_data(
635 wallet: &str,
636 chain: &str,
637 typed_data_json: &str,
638 passphrase: Option<&str>,
639 index: Option<u32>,
640 vault_path: Option<&Path>,
641) -> Result<SignResult, OwsLibError> {
642 let credential = passphrase.unwrap_or("");
643 let chain = parse_chain(chain)?;
644
645 if chain.chain_type != ows_core::ChainType::Evm {
646 return Err(OwsLibError::InvalidInput(
647 "EIP-712 typed data signing is only supported for EVM chains".into(),
648 ));
649 }
650
651 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
652 return crate::key_ops::sign_typed_data_with_api_key(
653 credential,
654 wallet,
655 &chain,
656 typed_data_json,
657 index,
658 vault_path,
659 );
660 }
661
662 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
663 let evm_signer = ows_signer::chains::EvmSigner;
664 let output = evm_signer.sign_typed_data(key.expose(), typed_data_json)?;
665
666 Ok(SignResult {
667 signature: hex::encode(&output.signature),
668 recovery_id: output.recovery_id,
669 })
670}
671
672pub fn sign_and_send(
678 wallet: &str,
679 chain: &str,
680 tx_hex: &str,
681 passphrase: Option<&str>,
682 index: Option<u32>,
683 rpc_url: Option<&str>,
684 vault_path: Option<&Path>,
685) -> Result<SendResult, OwsLibError> {
686 let credential = passphrase.unwrap_or("");
687
688 let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
689 let tx_bytes = hex::decode(tx_hex_clean)
690 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
691
692 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
694 let chain_info = parse_chain(chain)?;
695 let (key, _) = crate::key_ops::enforce_policy_and_decrypt_key(
696 credential,
697 wallet,
698 &chain_info,
699 &tx_bytes,
700 index,
701 vault_path,
702 )?;
703 return sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url);
704 }
705
706 let chain_info = parse_chain(chain)?;
708 let key = decrypt_signing_key(wallet, chain_info.chain_type, credential, index, vault_path)?;
709
710 sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url)
711}
712
713pub fn sign_encode_and_broadcast(
720 private_key: &[u8],
721 chain: &str,
722 tx_bytes: &[u8],
723 rpc_url: Option<&str>,
724) -> Result<SendResult, OwsLibError> {
725 let chain = parse_chain(chain)?;
726 let signer = signer_for_chain(chain.chain_type);
727
728 let signable = signer.extract_signable_bytes(tx_bytes)?;
730
731 let output = signer.sign_transaction(private_key, signable)?;
733
734 let signed_tx = signer.encode_signed_transaction(tx_bytes, &output)?;
736
737 let rpc = resolve_rpc_url(chain.chain_id, chain.chain_type, rpc_url)?;
739
740 let tx_hash = broadcast(chain.chain_type, &rpc, &signed_tx)?;
742
743 Ok(SendResult { tx_hash })
744}
745
746pub fn decrypt_signing_key(
753 wallet_name_or_id: &str,
754 chain_type: ChainType,
755 passphrase: &str,
756 index: Option<u32>,
757 vault_path: Option<&Path>,
758) -> Result<SecretBytes, OwsLibError> {
759 let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
760 let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
761 let secret = decrypt(&envelope, passphrase)?;
762 secret_to_signing_key(&secret, &wallet.key_type, chain_type, index)
763}
764
765fn resolve_rpc_url(
767 chain_id: &str,
768 chain_type: ChainType,
769 explicit: Option<&str>,
770) -> Result<String, OwsLibError> {
771 if let Some(url) = explicit {
772 return Ok(url.to_string());
773 }
774
775 let config = Config::load_or_default();
776 let defaults = Config::default_rpc();
777
778 if let Some(url) = config.rpc.get(chain_id) {
780 return Ok(url.clone());
781 }
782 if let Some(url) = defaults.get(chain_id) {
783 return Ok(url.clone());
784 }
785
786 let namespace = chain_type.namespace();
788 for (key, url) in &config.rpc {
789 if key.starts_with(namespace) {
790 return Ok(url.clone());
791 }
792 }
793 for (key, url) in &defaults {
794 if key.starts_with(namespace) {
795 return Ok(url.clone());
796 }
797 }
798
799 Err(OwsLibError::InvalidInput(format!(
800 "no RPC URL configured for chain '{chain_id}'"
801 )))
802}
803
804fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
806 match chain {
807 ChainType::Evm => broadcast_evm(rpc_url, signed_bytes),
808 ChainType::Solana => broadcast_solana(rpc_url, signed_bytes),
809 ChainType::Bitcoin => broadcast_bitcoin(rpc_url, signed_bytes),
810 ChainType::Cosmos => broadcast_cosmos(rpc_url, signed_bytes),
811 ChainType::Tron => broadcast_tron(rpc_url, signed_bytes),
812 ChainType::Ton => broadcast_ton(rpc_url, signed_bytes),
813 ChainType::Spark => Err(OwsLibError::InvalidInput(
814 "broadcast not yet supported for Spark".into(),
815 )),
816 ChainType::Filecoin => Err(OwsLibError::InvalidInput(
817 "broadcast not yet supported for Filecoin".into(),
818 )),
819 ChainType::Sui => broadcast_sui(rpc_url, signed_bytes),
820 ChainType::Xrpl => broadcast_xrpl(rpc_url, signed_bytes),
821 ChainType::Nano => broadcast_nano(rpc_url, signed_bytes),
822 ChainType::Near => crate::near_rpc::broadcast_tx_commit(rpc_url, signed_bytes),
823 }
824}
825
826fn broadcast_xrpl(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
827 let tx_blob = hex::encode_upper(signed_bytes);
828 let body = serde_json::json!({
829 "method": "submit",
830 "params": [{ "tx_blob": tx_blob }]
831 });
832 let resp_str = curl_post_json(rpc_url, &body.to_string())?;
833 let resp: serde_json::Value = serde_json::from_str(&resp_str)?;
834
835 let engine_result = resp["result"]["engine_result"].as_str().unwrap_or("");
837 if !engine_result.starts_with("tes") {
838 let msg = resp["result"]["engine_result_message"]
839 .as_str()
840 .unwrap_or(engine_result);
841 return Err(OwsLibError::BroadcastFailed(format!(
842 "XRPL submit failed ({engine_result}): {msg}"
843 )));
844 }
845
846 resp["result"]["tx_json"]["hash"]
847 .as_str()
848 .map(|s| s.to_string())
849 .ok_or_else(|| {
850 OwsLibError::BroadcastFailed(format!("no hash in XRPL response: {resp_str}"))
851 })
852}
853
854fn broadcast_evm(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
855 let hex_tx = format!("0x{}", hex::encode(signed_bytes));
856 let body = serde_json::json!({
857 "jsonrpc": "2.0",
858 "method": "eth_sendRawTransaction",
859 "params": [hex_tx],
860 "id": 1
861 });
862 let resp = curl_post_json(rpc_url, &body.to_string())?;
863 extract_json_field(&resp, "result")
864}
865
866fn build_solana_rpc_body(signed_bytes: &[u8]) -> serde_json::Value {
867 use base64::Engine;
868 let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
869 serde_json::json!({
870 "jsonrpc": "2.0",
871 "method": "sendTransaction",
872 "params": [b64_tx, {"encoding": "base64"}],
873 "id": 1
874 })
875}
876
877fn broadcast_solana(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
878 let body = build_solana_rpc_body(signed_bytes);
879 let resp = curl_post_json(rpc_url, &body.to_string())?;
880 extract_json_field(&resp, "result")
881}
882
883fn broadcast_bitcoin(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
884 let hex_tx = hex::encode(signed_bytes);
885 let url = format!("{}/tx", rpc_url.trim_end_matches('/'));
886 let output = Command::new("curl")
887 .args([
888 "-fsSL",
889 "-X",
890 "POST",
891 "-H",
892 "Content-Type: text/plain",
893 "-d",
894 &hex_tx,
895 &url,
896 ])
897 .output()
898 .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
899
900 if !output.status.success() {
901 let stderr = String::from_utf8_lossy(&output.stderr);
902 return Err(OwsLibError::BroadcastFailed(format!(
903 "broadcast failed: {stderr}"
904 )));
905 }
906
907 let tx_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
908 if tx_hash.is_empty() {
909 return Err(OwsLibError::BroadcastFailed(
910 "empty response from broadcast".into(),
911 ));
912 }
913 Ok(tx_hash)
914}
915
916fn broadcast_cosmos(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
917 use base64::Engine;
918 let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
919 let url = format!("{}/cosmos/tx/v1beta1/txs", rpc_url.trim_end_matches('/'));
920 let body = serde_json::json!({
921 "tx_bytes": b64_tx,
922 "mode": "BROADCAST_MODE_SYNC"
923 });
924 let resp = curl_post_json(&url, &body.to_string())?;
925 let parsed: serde_json::Value = serde_json::from_str(&resp)?;
926 parsed["tx_response"]["txhash"]
927 .as_str()
928 .map(|s| s.to_string())
929 .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no txhash in response: {resp}")))
930}
931
932fn broadcast_tron(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
933 let hex_tx = hex::encode(signed_bytes);
934 let url = format!("{}/wallet/broadcasthex", rpc_url.trim_end_matches('/'));
935 let body = serde_json::json!({ "transaction": hex_tx });
936 let resp = curl_post_json(&url, &body.to_string())?;
937 extract_json_field(&resp, "txid")
938}
939
940fn broadcast_ton(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
941 use base64::Engine;
942 let b64_boc = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
943 let url = format!("{}/sendBoc", rpc_url.trim_end_matches('/'));
944 let body = serde_json::json!({ "boc": b64_boc });
945 let resp = curl_post_json(&url, &body.to_string())?;
946 let parsed: serde_json::Value = serde_json::from_str(&resp)?;
947 parsed["result"]["hash"]
948 .as_str()
949 .map(|s| s.to_string())
950 .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no hash in response: {resp}")))
951}
952
953fn broadcast_sui(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
954 use ows_signer::chains::sui::WIRE_SIG_LEN;
955
956 if signed_bytes.len() <= WIRE_SIG_LEN {
957 return Err(OwsLibError::InvalidInput(
958 "signed transaction too short to contain tx + signature".into(),
959 ));
960 }
961
962 let split = signed_bytes.len() - WIRE_SIG_LEN;
963 let tx_part = &signed_bytes[..split];
964 let sig_part = &signed_bytes[split..];
965
966 crate::sui_grpc::execute_transaction(rpc_url, tx_part, sig_part)
967}
968
969fn broadcast_nano(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
970 const STATE_BLOCK_LEN: usize = 176;
971 const SIGNATURE_LEN: usize = 64;
972 const SIGNED_BLOCK_LEN: usize = STATE_BLOCK_LEN + SIGNATURE_LEN;
973
974 if signed_bytes.len() != SIGNED_BLOCK_LEN {
975 return Err(OwsLibError::InvalidInput(format!(
976 "Nano signed block must be {} bytes ({} block + {} sig), got {}",
977 SIGNED_BLOCK_LEN,
978 STATE_BLOCK_LEN,
979 SIGNATURE_LEN,
980 signed_bytes.len()
981 )));
982 }
983
984 let block_bytes = &signed_bytes[..STATE_BLOCK_LEN];
985 let signature = &signed_bytes[STATE_BLOCK_LEN..SIGNED_BLOCK_LEN];
986
987 let account: [u8; 32] = block_bytes[32..64]
989 .try_into()
990 .map_err(|_| OwsLibError::InvalidInput("invalid account bytes in block".into()))?;
991 let previous = &block_bytes[64..96];
992 let representative: [u8; 32] = block_bytes[96..128]
993 .try_into()
994 .map_err(|_| OwsLibError::InvalidInput("invalid representative bytes in block".into()))?;
995 let balance_bytes: [u8; 16] = block_bytes[128..144]
996 .try_into()
997 .map_err(|_| OwsLibError::InvalidInput("invalid balance bytes in block".into()))?;
998 let balance = u128::from_be_bytes(balance_bytes);
999 let link = &block_bytes[144..STATE_BLOCK_LEN];
1000
1001 let previous_is_zero = previous == [0u8; 32];
1002
1003 let account_address = ows_signer::chains::nano::nano_address(&account);
1004
1005 let subtype = if previous_is_zero {
1007 "open"
1008 } else {
1009 match crate::nano_rpc::account_info(rpc_url, &account_address)? {
1010 Some(info) => {
1011 let prev_balance: u128 = info.balance.parse().unwrap_or(0);
1012 if balance < prev_balance {
1013 "send"
1014 } else {
1015 "receive"
1016 }
1017 }
1018 None => "open",
1019 }
1020 };
1021
1022 let difficulty = match subtype {
1023 "send" => crate::nano_rpc::SEND_DIFFICULTY,
1024 _ => crate::nano_rpc::RECEIVE_DIFFICULTY,
1025 };
1026
1027 let work_root = if previous_is_zero {
1029 hex::encode(account)
1030 } else {
1031 hex::encode(previous)
1032 };
1033
1034 let work = crate::nano_rpc::work_generate(rpc_url, &work_root, difficulty)?;
1035
1036 let block_json = serde_json::json!({
1037 "type": "state",
1038 "account": account_address,
1039 "previous": hex::encode(previous),
1040 "representative": ows_signer::chains::nano::nano_address(&representative),
1041 "balance": balance.to_string(),
1042 "link": hex::encode(link),
1043 "signature": hex::encode(signature),
1044 "work": work
1045 });
1046
1047 crate::nano_rpc::process_block(rpc_url, &block_json, subtype)
1048}
1049
1050fn curl_post_json(url: &str, body: &str) -> Result<String, OwsLibError> {
1051 let output = Command::new("curl")
1052 .args([
1053 "-fsSL",
1054 "-X",
1055 "POST",
1056 "-H",
1057 "Content-Type: application/json",
1058 "-d",
1059 body,
1060 url,
1061 ])
1062 .output()
1063 .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
1064
1065 if !output.status.success() {
1066 let stderr = String::from_utf8_lossy(&output.stderr);
1067 return Err(OwsLibError::BroadcastFailed(format!(
1068 "broadcast failed: {stderr}"
1069 )));
1070 }
1071
1072 Ok(String::from_utf8_lossy(&output.stdout).to_string())
1073}
1074
1075fn extract_json_field(json_str: &str, field: &str) -> Result<String, OwsLibError> {
1076 let parsed: serde_json::Value = serde_json::from_str(json_str)?;
1077
1078 if let Some(error) = parsed.get("error") {
1079 return Err(OwsLibError::BroadcastFailed(format!("RPC error: {error}")));
1080 }
1081
1082 parsed[field]
1083 .as_str()
1084 .map(|s| s.to_string())
1085 .ok_or_else(|| {
1086 OwsLibError::BroadcastFailed(format!("no '{field}' in response: {json_str}"))
1087 })
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093 use ows_core::OwsError;
1094
1095 fn save_privkey_wallet(
1100 name: &str,
1101 privkey_hex: &str,
1102 passphrase: &str,
1103 vault: &Path,
1104 ) -> WalletInfo {
1105 let key_bytes = hex::decode(privkey_hex).unwrap();
1106
1107 let mut ed_key = vec![0u8; 32];
1109 getrandom::getrandom(&mut ed_key).unwrap();
1110
1111 let keys = KeyPair {
1112 secp256k1: key_bytes,
1113 ed25519: ed_key,
1114 };
1115 let accounts = derive_all_accounts_from_keys(&keys).unwrap();
1116 let payload = keys.to_json_bytes();
1117 let crypto_envelope = encrypt(&payload, passphrase).unwrap();
1118 let crypto_json = serde_json::to_value(&crypto_envelope).unwrap();
1119 let wallet = EncryptedWallet::new(
1120 uuid::Uuid::new_v4().to_string(),
1121 name.to_string(),
1122 accounts,
1123 crypto_json,
1124 KeyType::PrivateKey,
1125 );
1126 vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
1127 wallet_to_info(&wallet)
1128 }
1129
1130 const TEST_PRIVKEY: &str = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
1131
1132 fn save_allowed_chains_policy(vault: &Path, id: &str, chain_ids: Vec<String>) {
1133 let policy = ows_core::Policy {
1134 id: id.to_string(),
1135 name: format!("{id} policy"),
1136 version: 1,
1137 created_at: "2026-03-22T00:00:00Z".to_string(),
1138 rules: vec![ows_core::PolicyRule::AllowedChains { chain_ids }],
1139 executable: None,
1140 config: None,
1141 action: ows_core::PolicyAction::Deny,
1142 };
1143
1144 crate::policy_store::save_policy(&policy, Some(vault)).unwrap();
1145 }
1146
1147 #[test]
1152 fn mnemonic_12_words() {
1153 let phrase = generate_mnemonic(12).unwrap();
1154 assert_eq!(phrase.split_whitespace().count(), 12);
1155 }
1156
1157 #[test]
1158 fn mnemonic_24_words() {
1159 let phrase = generate_mnemonic(24).unwrap();
1160 assert_eq!(phrase.split_whitespace().count(), 24);
1161 }
1162
1163 #[test]
1164 fn mnemonic_invalid_word_count() {
1165 assert!(generate_mnemonic(15).is_err());
1166 assert!(generate_mnemonic(0).is_err());
1167 assert!(generate_mnemonic(13).is_err());
1168 }
1169
1170 #[test]
1171 fn mnemonic_is_unique_each_call() {
1172 let a = generate_mnemonic(12).unwrap();
1173 let b = generate_mnemonic(12).unwrap();
1174 assert_ne!(a, b, "two generated mnemonics should differ");
1175 }
1176
1177 #[test]
1182 fn derive_address_all_chains() {
1183 let phrase = generate_mnemonic(12).unwrap();
1184 let chains = [
1185 "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "sui", "xrpl", "nano", "near",
1186 ];
1187 for chain in &chains {
1188 let addr = derive_address(&phrase, chain, None).unwrap();
1189 assert!(!addr.is_empty(), "address should be non-empty for {chain}");
1190 }
1191 }
1192
1193 #[test]
1194 fn derive_address_evm_format() {
1195 let phrase = generate_mnemonic(12).unwrap();
1196 let addr = derive_address(&phrase, "evm", None).unwrap();
1197 assert!(addr.starts_with("0x"), "EVM address should start with 0x");
1198 assert_eq!(addr.len(), 42, "EVM address should be 42 chars");
1199 }
1200
1201 #[test]
1202 fn derive_address_deterministic() {
1203 let phrase = generate_mnemonic(12).unwrap();
1204 let a = derive_address(&phrase, "evm", None).unwrap();
1205 let b = derive_address(&phrase, "evm", None).unwrap();
1206 assert_eq!(a, b, "same mnemonic should produce same address");
1207 }
1208
1209 #[test]
1210 fn derive_address_different_index() {
1211 let phrase = generate_mnemonic(12).unwrap();
1212 let a = derive_address(&phrase, "evm", Some(0)).unwrap();
1213 let b = derive_address(&phrase, "evm", Some(1)).unwrap();
1214 assert_ne!(a, b, "different indices should produce different addresses");
1215 }
1216
1217 #[test]
1218 fn derive_address_invalid_chain() {
1219 let phrase = generate_mnemonic(12).unwrap();
1220 assert!(derive_address(&phrase, "nonexistent", None).is_err());
1221 }
1222
1223 #[test]
1224 fn derive_address_invalid_mnemonic() {
1225 assert!(derive_address("not a valid mnemonic phrase at all", "evm", None).is_err());
1226 }
1227
1228 #[test]
1233 fn mnemonic_wallet_create_export_reimport() {
1234 let v1 = tempfile::tempdir().unwrap();
1235 let v2 = tempfile::tempdir().unwrap();
1236
1237 let w1 = create_wallet("w1", None, None, Some(v1.path())).unwrap();
1239 assert!(!w1.accounts.is_empty());
1240
1241 let phrase = export_wallet("w1", None, Some(v1.path())).unwrap();
1243 assert_eq!(phrase.split_whitespace().count(), 12);
1244
1245 let w2 = import_wallet_mnemonic("w2", &phrase, None, None, Some(v2.path())).unwrap();
1247
1248 assert_eq!(w1.accounts.len(), w2.accounts.len());
1250 for (a1, a2) in w1.accounts.iter().zip(w2.accounts.iter()) {
1251 assert_eq!(a1.chain_id, a2.chain_id);
1252 assert_eq!(
1253 a1.address, a2.address,
1254 "address mismatch for {}",
1255 a1.chain_id
1256 );
1257 }
1258 }
1259
1260 #[test]
1261 fn mnemonic_wallet_sign_message_all_chains() {
1262 let dir = tempfile::tempdir().unwrap();
1263 let vault = dir.path();
1264 create_wallet("multi-sign", None, None, Some(vault)).unwrap();
1265
1266 let chains = [
1270 "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", "near",
1271 ];
1272 for chain in &chains {
1273 let result = sign_message(
1274 "multi-sign",
1275 chain,
1276 "test msg",
1277 None,
1278 None,
1279 None,
1280 Some(vault),
1281 );
1282 assert!(
1283 result.is_ok(),
1284 "sign_message should work for {chain}: {:?}",
1285 result.err()
1286 );
1287 let sig = result.unwrap();
1288 assert!(
1289 !sig.signature.is_empty(),
1290 "signature should be non-empty for {chain}"
1291 );
1292 }
1293 }
1294
1295 #[test]
1296 fn mnemonic_wallet_sign_tx_all_chains() {
1297 let dir = tempfile::tempdir().unwrap();
1298 let vault = dir.path();
1299 create_wallet("tx-sign", None, None, Some(vault)).unwrap();
1300
1301 let generic_tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1302 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);
1308
1309 let near_tx_hex = "42".repeat(80);
1313
1314 let xrpl_tx_hex = "12000024000000016140000000000F424068400000000000000C8114AFF3C2E33458B30714CA16FFEE19952DD35C17C883145720939C1336A7356A70ED861D5934345C6B6360";
1317
1318 let chains = [
1319 "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", "xrpl", "near",
1320 ];
1321 for chain in &chains {
1322 let tx = if *chain == "solana" {
1323 &solana_tx_hex
1324 } else if *chain == "near" {
1325 &near_tx_hex
1326 } else if *chain == "xrpl" {
1327 xrpl_tx_hex
1328 } else {
1329 generic_tx_hex
1330 };
1331 let result = sign_transaction("tx-sign", chain, tx, None, None, Some(vault));
1332 assert!(
1333 result.is_ok(),
1334 "sign_transaction should work for {chain}: {:?}",
1335 result.err()
1336 );
1337 }
1338 }
1339
1340 #[test]
1341 fn mnemonic_wallet_signing_is_deterministic() {
1342 let dir = tempfile::tempdir().unwrap();
1343 let vault = dir.path();
1344 create_wallet("det-sign", None, None, Some(vault)).unwrap();
1345
1346 let s1 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1347 let s2 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1348 assert_eq!(
1349 s1.signature, s2.signature,
1350 "same message should produce same signature"
1351 );
1352 }
1353
1354 #[test]
1355 fn mnemonic_wallet_different_messages_produce_different_sigs() {
1356 let dir = tempfile::tempdir().unwrap();
1357 let vault = dir.path();
1358 create_wallet("diff-msg", None, None, Some(vault)).unwrap();
1359
1360 let s1 = sign_message("diff-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
1361 let s2 = sign_message("diff-msg", "evm", "world", None, None, None, Some(vault)).unwrap();
1362 assert_ne!(s1.signature, s2.signature);
1363 }
1364
1365 #[test]
1370 fn privkey_wallet_sign_message() {
1371 let dir = tempfile::tempdir().unwrap();
1372 save_privkey_wallet("pk-sign", TEST_PRIVKEY, "", dir.path());
1373
1374 let sig = sign_message(
1375 "pk-sign",
1376 "evm",
1377 "hello",
1378 None,
1379 None,
1380 None,
1381 Some(dir.path()),
1382 )
1383 .unwrap();
1384 assert!(!sig.signature.is_empty());
1385 assert!(sig.recovery_id.is_some());
1386 }
1387
1388 #[test]
1389 fn privkey_wallet_sign_transaction() {
1390 let dir = tempfile::tempdir().unwrap();
1391 save_privkey_wallet("pk-tx", TEST_PRIVKEY, "", dir.path());
1392
1393 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1394 let sig = sign_transaction("pk-tx", "evm", tx, None, None, Some(dir.path())).unwrap();
1395 assert!(!sig.signature.is_empty());
1396 }
1397
1398 #[test]
1399 fn privkey_wallet_export_returns_json() {
1400 let dir = tempfile::tempdir().unwrap();
1401 save_privkey_wallet("pk-export", TEST_PRIVKEY, "", dir.path());
1402
1403 let exported = export_wallet("pk-export", None, Some(dir.path())).unwrap();
1404 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1405 assert_eq!(
1406 obj["secp256k1"].as_str().unwrap(),
1407 TEST_PRIVKEY,
1408 "exported secp256k1 key should match original"
1409 );
1410 assert!(obj["ed25519"].as_str().is_some(), "should have ed25519 key");
1411 }
1412
1413 #[test]
1414 fn privkey_wallet_signing_is_deterministic() {
1415 let dir = tempfile::tempdir().unwrap();
1416 save_privkey_wallet("pk-det", TEST_PRIVKEY, "", dir.path());
1417
1418 let s1 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1419 let s2 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1420 assert_eq!(s1.signature, s2.signature);
1421 }
1422
1423 #[test]
1424 fn privkey_and_mnemonic_wallets_produce_different_sigs() {
1425 let dir = tempfile::tempdir().unwrap();
1426 let vault = dir.path();
1427
1428 create_wallet("mn-w", None, None, Some(vault)).unwrap();
1429 save_privkey_wallet("pk-w", TEST_PRIVKEY, "", vault);
1430
1431 let mn_sig = sign_message("mn-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1432 let pk_sig = sign_message("pk-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1433 assert_ne!(
1434 mn_sig.signature, pk_sig.signature,
1435 "different keys should produce different signatures"
1436 );
1437 }
1438
1439 #[test]
1440 fn privkey_wallet_import_via_api() {
1441 let dir = tempfile::tempdir().unwrap();
1442 let vault = dir.path();
1443
1444 let info = import_wallet_private_key(
1445 "pk-api",
1446 TEST_PRIVKEY,
1447 Some("evm"),
1448 None,
1449 Some(vault),
1450 None,
1451 None,
1452 )
1453 .unwrap();
1454 assert!(
1455 !info.accounts.is_empty(),
1456 "should derive at least one account"
1457 );
1458
1459 let sig = sign_message("pk-api", "evm", "hello", None, None, None, Some(vault)).unwrap();
1461 assert!(!sig.signature.is_empty());
1462
1463 let exported = export_wallet("pk-api", None, Some(vault)).unwrap();
1465 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1466 assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1467 }
1468
1469 #[test]
1470 fn privkey_wallet_import_both_curve_keys() {
1471 let dir = tempfile::tempdir().unwrap();
1472 let vault = dir.path();
1473
1474 let secp_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
1475 let ed_key = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
1476
1477 let info = import_wallet_private_key(
1478 "pk-both",
1479 "", None, None,
1482 Some(vault),
1483 Some(secp_key),
1484 Some(ed_key),
1485 )
1486 .unwrap();
1487
1488 assert_eq!(
1489 info.accounts.len(),
1490 ALL_CHAIN_TYPES.len(),
1491 "should have one account per chain type"
1492 );
1493
1494 let sig = sign_message("pk-both", "evm", "hello", None, None, None, Some(vault)).unwrap();
1496 assert!(!sig.signature.is_empty());
1497
1498 let sig =
1500 sign_message("pk-both", "solana", "hello", None, None, None, Some(vault)).unwrap();
1501 assert!(!sig.signature.is_empty());
1502
1503 let exported = export_wallet("pk-both", None, Some(vault)).unwrap();
1505 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1506 assert_eq!(obj["secp256k1"].as_str().unwrap(), secp_key);
1507 assert_eq!(obj["ed25519"].as_str().unwrap(), ed_key);
1508 }
1509
1510 #[test]
1515 fn passphrase_protected_mnemonic_wallet() {
1516 let dir = tempfile::tempdir().unwrap();
1517 let vault = dir.path();
1518
1519 create_wallet("pass-mn", None, Some("s3cret"), Some(vault)).unwrap();
1520
1521 let sig = sign_message(
1523 "pass-mn",
1524 "evm",
1525 "hello",
1526 Some("s3cret"),
1527 None,
1528 None,
1529 Some(vault),
1530 )
1531 .unwrap();
1532 assert!(!sig.signature.is_empty());
1533
1534 let phrase = export_wallet("pass-mn", Some("s3cret"), Some(vault)).unwrap();
1536 assert_eq!(phrase.split_whitespace().count(), 12);
1537
1538 assert!(sign_message(
1540 "pass-mn",
1541 "evm",
1542 "hello",
1543 Some("wrong"),
1544 None,
1545 None,
1546 Some(vault)
1547 )
1548 .is_err());
1549 assert!(export_wallet("pass-mn", Some("wrong"), Some(vault)).is_err());
1550
1551 assert!(sign_message("pass-mn", "evm", "hello", None, None, None, Some(vault)).is_err());
1553 }
1554
1555 #[test]
1556 fn passphrase_protected_privkey_wallet() {
1557 let dir = tempfile::tempdir().unwrap();
1558 save_privkey_wallet("pass-pk", TEST_PRIVKEY, "mypass", dir.path());
1559
1560 let sig = sign_message(
1562 "pass-pk",
1563 "evm",
1564 "hello",
1565 Some("mypass"),
1566 None,
1567 None,
1568 Some(dir.path()),
1569 )
1570 .unwrap();
1571 assert!(!sig.signature.is_empty());
1572
1573 let exported = export_wallet("pass-pk", Some("mypass"), Some(dir.path())).unwrap();
1574 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1575 assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1576
1577 assert!(sign_message(
1579 "pass-pk",
1580 "evm",
1581 "hello",
1582 Some("wrong"),
1583 None,
1584 None,
1585 Some(dir.path())
1586 )
1587 .is_err());
1588 assert!(export_wallet("pass-pk", Some("wrong"), Some(dir.path())).is_err());
1589 }
1590
1591 #[test]
1596 fn evm_signature_is_recoverable() {
1597 use sha3::Digest;
1598 let dir = tempfile::tempdir().unwrap();
1599 let vault = dir.path();
1600
1601 let info = create_wallet("verify-evm", None, None, Some(vault)).unwrap();
1602 let evm_addr = info
1603 .accounts
1604 .iter()
1605 .find(|a| a.chain_id.starts_with("eip155:"))
1606 .unwrap()
1607 .address
1608 .clone();
1609
1610 let sig = sign_message(
1611 "verify-evm",
1612 "evm",
1613 "hello world",
1614 None,
1615 None,
1616 None,
1617 Some(vault),
1618 )
1619 .unwrap();
1620
1621 let msg = b"hello world";
1623 let prefix = format!("\x19Ethereum Signed Message:\n{}", msg.len());
1624 let mut prefixed = prefix.into_bytes();
1625 prefixed.extend_from_slice(msg);
1626
1627 let hash = sha3::Keccak256::digest(&prefixed);
1628 let sig_bytes = hex::decode(&sig.signature).unwrap();
1629 assert_eq!(
1630 sig_bytes.len(),
1631 65,
1632 "EVM signature should be 65 bytes (r + s + v)"
1633 );
1634
1635 let v = sig_bytes[64];
1637 assert!(
1638 v == 27 || v == 28,
1639 "EIP-191 v byte should be 27 or 28, got {v}"
1640 );
1641 let recid = k256::ecdsa::RecoveryId::try_from(v - 27).unwrap();
1642 let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
1643 let recovered_key =
1644 k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
1645
1646 let pubkey_bytes = recovered_key.to_encoded_point(false);
1648 let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
1649 let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
1650
1651 assert_eq!(
1653 recovered_addr.to_lowercase(),
1654 evm_addr.to_lowercase(),
1655 "recovered address should match wallet's EVM address"
1656 );
1657 }
1658
1659 #[test]
1664 fn error_nonexistent_wallet() {
1665 let dir = tempfile::tempdir().unwrap();
1666 assert!(get_wallet("nope", Some(dir.path())).is_err());
1667 assert!(export_wallet("nope", None, Some(dir.path())).is_err());
1668 assert!(sign_message("nope", "evm", "x", None, None, None, Some(dir.path())).is_err());
1669 assert!(delete_wallet("nope", Some(dir.path())).is_err());
1670 }
1671
1672 #[test]
1673 fn error_duplicate_wallet_name() {
1674 let dir = tempfile::tempdir().unwrap();
1675 let vault = dir.path();
1676 create_wallet("dup", None, None, Some(vault)).unwrap();
1677 assert!(create_wallet("dup", None, None, Some(vault)).is_err());
1678 }
1679
1680 #[test]
1681 fn error_invalid_private_key_hex() {
1682 let dir = tempfile::tempdir().unwrap();
1683 assert!(import_wallet_private_key(
1684 "bad",
1685 "not-hex",
1686 Some("evm"),
1687 None,
1688 Some(dir.path()),
1689 None,
1690 None,
1691 )
1692 .is_err());
1693 }
1694
1695 #[test]
1696 fn error_invalid_chain_for_signing() {
1697 let dir = tempfile::tempdir().unwrap();
1698 let vault = dir.path();
1699 create_wallet("chain-err", None, None, Some(vault)).unwrap();
1700 assert!(
1701 sign_message("chain-err", "fakecoin", "hi", None, None, None, Some(vault)).is_err()
1702 );
1703 }
1704
1705 #[test]
1706 fn error_invalid_tx_hex() {
1707 let dir = tempfile::tempdir().unwrap();
1708 let vault = dir.path();
1709 create_wallet("hex-err", None, None, Some(vault)).unwrap();
1710 assert!(
1711 sign_transaction("hex-err", "evm", "not-valid-hex!", None, None, Some(vault)).is_err()
1712 );
1713 }
1714
1715 #[test]
1720 fn list_wallets_empty_vault() {
1721 let dir = tempfile::tempdir().unwrap();
1722 let wallets = list_wallets(Some(dir.path())).unwrap();
1723 assert!(wallets.is_empty());
1724 }
1725
1726 #[test]
1727 fn get_wallet_by_name_and_id() {
1728 let dir = tempfile::tempdir().unwrap();
1729 let vault = dir.path();
1730 let info = create_wallet("lookup", None, None, Some(vault)).unwrap();
1731
1732 let by_name = get_wallet("lookup", Some(vault)).unwrap();
1733 assert_eq!(by_name.id, info.id);
1734
1735 let by_id = get_wallet(&info.id, Some(vault)).unwrap();
1736 assert_eq!(by_id.name, "lookup");
1737 }
1738
1739 #[test]
1740 fn rename_wallet_works() {
1741 let dir = tempfile::tempdir().unwrap();
1742 let vault = dir.path();
1743 let info = create_wallet("before", None, None, Some(vault)).unwrap();
1744
1745 rename_wallet("before", "after", Some(vault)).unwrap();
1746
1747 assert!(get_wallet("before", Some(vault)).is_err());
1748 let after = get_wallet("after", Some(vault)).unwrap();
1749 assert_eq!(after.id, info.id);
1750 }
1751
1752 #[test]
1753 fn rename_to_existing_name_fails() {
1754 let dir = tempfile::tempdir().unwrap();
1755 let vault = dir.path();
1756 create_wallet("a", None, None, Some(vault)).unwrap();
1757 create_wallet("b", None, None, Some(vault)).unwrap();
1758 assert!(rename_wallet("a", "b", Some(vault)).is_err());
1759 }
1760
1761 #[test]
1762 fn delete_wallet_removes_from_list() {
1763 let dir = tempfile::tempdir().unwrap();
1764 let vault = dir.path();
1765 create_wallet("del-me", None, None, Some(vault)).unwrap();
1766 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 1);
1767
1768 delete_wallet("del-me", Some(vault)).unwrap();
1769 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 0);
1770 }
1771
1772 #[test]
1777 fn sign_message_hex_encoding() {
1778 let dir = tempfile::tempdir().unwrap();
1779 let vault = dir.path();
1780 create_wallet("hex-enc", None, None, Some(vault)).unwrap();
1781
1782 let sig = sign_message(
1784 "hex-enc",
1785 "evm",
1786 "68656c6c6f",
1787 None,
1788 Some("hex"),
1789 None,
1790 Some(vault),
1791 )
1792 .unwrap();
1793 assert!(!sig.signature.is_empty());
1794
1795 let sig2 = sign_message(
1797 "hex-enc",
1798 "evm",
1799 "hello",
1800 None,
1801 Some("utf8"),
1802 None,
1803 Some(vault),
1804 )
1805 .unwrap();
1806 assert_eq!(
1807 sig.signature, sig2.signature,
1808 "hex and utf8 encoding of same bytes should produce same signature"
1809 );
1810 }
1811
1812 #[test]
1813 fn sign_message_invalid_encoding() {
1814 let dir = tempfile::tempdir().unwrap();
1815 let vault = dir.path();
1816 create_wallet("bad-enc", None, None, Some(vault)).unwrap();
1817 assert!(sign_message(
1818 "bad-enc",
1819 "evm",
1820 "hello",
1821 None,
1822 Some("base64"),
1823 None,
1824 Some(vault)
1825 )
1826 .is_err());
1827 }
1828
1829 #[test]
1834 fn multiple_wallets_coexist() {
1835 let dir = tempfile::tempdir().unwrap();
1836 let vault = dir.path();
1837
1838 create_wallet("w1", None, None, Some(vault)).unwrap();
1839 create_wallet("w2", None, None, Some(vault)).unwrap();
1840 save_privkey_wallet("w3", TEST_PRIVKEY, "", vault);
1841
1842 let wallets = list_wallets(Some(vault)).unwrap();
1843 assert_eq!(wallets.len(), 3);
1844
1845 let s1 = sign_message("w1", "evm", "test", None, None, None, Some(vault)).unwrap();
1847 let s2 = sign_message("w2", "evm", "test", None, None, None, Some(vault)).unwrap();
1848 let s3 = sign_message("w3", "evm", "test", None, None, None, Some(vault)).unwrap();
1849
1850 assert_ne!(s1.signature, s2.signature);
1852 assert_ne!(s1.signature, s3.signature);
1853 assert_ne!(s2.signature, s3.signature);
1854
1855 delete_wallet("w2", Some(vault)).unwrap();
1857 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 2);
1858 assert!(sign_message("w1", "evm", "test", None, None, None, Some(vault)).is_ok());
1859 assert!(sign_message("w3", "evm", "test", None, None, None, Some(vault)).is_ok());
1860 }
1861
1862 #[test]
1867 fn signed_tx_must_differ_from_raw_signature() {
1868 let dir = tempfile::tempdir().unwrap();
1878 let vault = dir.path();
1879 save_privkey_wallet("send-bug", TEST_PRIVKEY, "", vault);
1880
1881 let items: Vec<u8> = [
1883 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(&[]), ]
1893 .concat();
1894
1895 let mut unsigned_tx = vec![0x02u8];
1896 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
1897 let tx_hex = hex::encode(&unsigned_tx);
1898
1899 let sign_result =
1901 sign_transaction("send-bug", "evm", &tx_hex, None, None, Some(vault)).unwrap();
1902 let raw_signature = hex::decode(&sign_result.signature).unwrap();
1903
1904 let key = decrypt_signing_key("send-bug", ChainType::Evm, "", None, Some(vault)).unwrap();
1906 let signer = signer_for_chain(ChainType::Evm);
1907 let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
1908 let full_signed_tx = signer
1909 .encode_signed_transaction(&unsigned_tx, &output)
1910 .unwrap();
1911
1912 assert_eq!(
1915 raw_signature.len(),
1916 65,
1917 "raw EVM signature should be 65 bytes (r || s || v)"
1918 );
1919 assert!(
1920 full_signed_tx.len() > raw_signature.len(),
1921 "full signed tx ({} bytes) must be larger than raw signature ({} bytes)",
1922 full_signed_tx.len(),
1923 raw_signature.len()
1924 );
1925 assert_ne!(
1926 raw_signature, full_signed_tx,
1927 "raw signature and full signed transaction must differ — \
1928 broadcasting the raw signature (as CLI send_transaction.rs:43 does) is wrong"
1929 );
1930
1931 assert_eq!(
1933 full_signed_tx[0], 0x02,
1934 "full signed EIP-1559 tx must start with type byte 0x02"
1935 );
1936 }
1937
1938 #[test]
1943 fn char_create_wallet_sign_transaction_with_passphrase() {
1944 let dir = tempfile::tempdir().unwrap();
1945 let vault = dir.path();
1946 create_wallet("char-pass-tx", None, Some("secret"), Some(vault)).unwrap();
1947
1948 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1949 let sig =
1950 sign_transaction("char-pass-tx", "evm", tx, Some("secret"), None, Some(vault)).unwrap();
1951 assert!(!sig.signature.is_empty());
1952 assert!(sig.recovery_id.is_some());
1953 }
1954
1955 #[test]
1956 fn char_create_wallet_sign_transaction_empty_passphrase() {
1957 let dir = tempfile::tempdir().unwrap();
1958 let vault = dir.path();
1959 create_wallet("char-empty-tx", None, None, Some(vault)).unwrap();
1960
1961 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1962 let sig =
1963 sign_transaction("char-empty-tx", "evm", tx, Some(""), None, Some(vault)).unwrap();
1964 assert!(!sig.signature.is_empty());
1965 }
1966
1967 #[test]
1968 fn char_no_passphrase_none_none_sign_transaction() {
1969 let dir = tempfile::tempdir().unwrap();
1972 let vault = dir.path();
1973 create_wallet("char-none-none", None, None, Some(vault)).unwrap();
1974
1975 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1976 let sig = sign_transaction("char-none-none", "evm", tx, None, None, Some(vault)).unwrap();
1977 assert!(!sig.signature.is_empty());
1978 assert!(sig.recovery_id.is_some());
1979 }
1980
1981 #[test]
1982 fn char_no_passphrase_none_none_sign_message() {
1983 let dir = tempfile::tempdir().unwrap();
1984 let vault = dir.path();
1985 create_wallet("char-none-msg", None, None, Some(vault)).unwrap();
1986
1987 let sig = sign_message(
1988 "char-none-msg",
1989 "evm",
1990 "hello",
1991 None,
1992 None,
1993 None,
1994 Some(vault),
1995 )
1996 .unwrap();
1997 assert!(!sig.signature.is_empty());
1998 }
1999
2000 #[test]
2001 fn char_no_passphrase_none_none_export() {
2002 let dir = tempfile::tempdir().unwrap();
2003 let vault = dir.path();
2004 create_wallet("char-none-exp", None, None, Some(vault)).unwrap();
2005
2006 let phrase = export_wallet("char-none-exp", None, Some(vault)).unwrap();
2007 assert_eq!(phrase.split_whitespace().count(), 12);
2008 }
2009
2010 #[test]
2011 fn char_empty_passphrase_none_and_some_empty_are_equivalent() {
2012 let dir = tempfile::tempdir().unwrap();
2015 let vault = dir.path();
2016
2017 create_wallet("char-equiv", None, None, Some(vault)).unwrap();
2019
2020 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2021
2022 let sig_none = sign_transaction("char-equiv", "evm", tx, None, None, Some(vault)).unwrap();
2024 let sig_empty =
2025 sign_transaction("char-equiv", "evm", tx, Some(""), None, Some(vault)).unwrap();
2026
2027 assert_eq!(
2028 sig_none.signature, sig_empty.signature,
2029 "passphrase=None and passphrase=Some(\"\") must produce identical signatures"
2030 );
2031
2032 let msg_none =
2034 sign_message("char-equiv", "evm", "test", None, None, None, Some(vault)).unwrap();
2035 let msg_empty = sign_message(
2036 "char-equiv",
2037 "evm",
2038 "test",
2039 Some(""),
2040 None,
2041 None,
2042 Some(vault),
2043 )
2044 .unwrap();
2045
2046 assert_eq!(
2047 msg_none.signature, msg_empty.signature,
2048 "sign_message: None and Some(\"\") must be equivalent"
2049 );
2050
2051 let export_none = export_wallet("char-equiv", None, Some(vault)).unwrap();
2053 let export_empty = export_wallet("char-equiv", Some(""), Some(vault)).unwrap();
2054 assert_eq!(
2055 export_none, export_empty,
2056 "export_wallet: None and Some(\"\") must return the same mnemonic"
2057 );
2058 }
2059
2060 #[test]
2061 fn char_create_with_some_empty_sign_with_none() {
2062 let dir = tempfile::tempdir().unwrap();
2064 let vault = dir.path();
2065 create_wallet("char-some-none", None, Some(""), Some(vault)).unwrap();
2066
2067 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2068 let sig = sign_transaction("char-some-none", "evm", tx, None, None, Some(vault)).unwrap();
2069 assert!(!sig.signature.is_empty());
2070 }
2071
2072 #[test]
2073 fn char_no_passphrase_wallet_rejects_nonempty_passphrase() {
2074 let dir = tempfile::tempdir().unwrap();
2078 let vault = dir.path();
2079 create_wallet("char-no-pass-reject", None, None, Some(vault)).unwrap();
2080
2081 let result = sign_message(
2082 "char-no-pass-reject",
2083 "evm",
2084 "test",
2085 Some("some-random-passphrase"),
2086 None,
2087 None,
2088 Some(vault),
2089 );
2090 assert!(
2091 result.is_err(),
2092 "non-empty passphrase on empty-passphrase wallet should fail"
2093 );
2094 match result.unwrap_err() {
2095 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error, got: {other}"),
2097 }
2098 }
2099
2100 #[test]
2101 fn char_sign_transaction_wrong_passphrase_returns_crypto_error() {
2102 let dir = tempfile::tempdir().unwrap();
2103 let vault = dir.path();
2104 create_wallet("char-wrong-pass", None, Some("correct"), Some(vault)).unwrap();
2105
2106 let tx = "deadbeef";
2107 let result = sign_transaction(
2108 "char-wrong-pass",
2109 "evm",
2110 tx,
2111 Some("wrong"),
2112 None,
2113 Some(vault),
2114 );
2115 assert!(result.is_err());
2116 match result.unwrap_err() {
2117 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error, got: {other}"),
2119 }
2120 }
2121
2122 #[test]
2123 fn char_sign_transaction_nonexistent_wallet_returns_wallet_not_found() {
2124 let dir = tempfile::tempdir().unwrap();
2125 let result = sign_transaction("ghost", "evm", "deadbeef", None, None, Some(dir.path()));
2126 assert!(result.is_err());
2127 match result.unwrap_err() {
2128 OwsLibError::WalletNotFound(name) => assert_eq!(name, "ghost"),
2129 other => panic!("expected WalletNotFound, got: {other}"),
2130 }
2131 }
2132
2133 #[test]
2134 fn char_sign_and_send_invalid_rpc_returns_broadcast_failed() {
2135 let dir = tempfile::tempdir().unwrap();
2136 let vault = dir.path();
2137 create_wallet("char-rpc-fail", None, None, Some(vault)).unwrap();
2138
2139 let items: Vec<u8> = [
2141 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(&[]), ]
2151 .concat();
2152 let mut unsigned_tx = vec![0x02u8];
2153 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
2154 let tx_hex = hex::encode(&unsigned_tx);
2155
2156 let result = sign_and_send(
2157 "char-rpc-fail",
2158 "evm",
2159 &tx_hex,
2160 None,
2161 None,
2162 Some("http://127.0.0.1:1"), Some(vault),
2164 );
2165 assert!(result.is_err());
2166 match result.unwrap_err() {
2167 OwsLibError::BroadcastFailed(_) => {} other => panic!("expected BroadcastFailed, got: {other}"),
2169 }
2170 }
2171
2172 #[test]
2173 fn char_create_sign_rename_sign_with_new_name() {
2174 let dir = tempfile::tempdir().unwrap();
2175 let vault = dir.path();
2176 create_wallet("orig-name", None, None, Some(vault)).unwrap();
2177
2178 let sig1 = sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).unwrap();
2180 assert!(!sig1.signature.is_empty());
2181
2182 rename_wallet("orig-name", "new-name", Some(vault)).unwrap();
2184
2185 assert!(sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).is_err());
2187
2188 let sig2 = sign_message("new-name", "evm", "test", None, None, None, Some(vault)).unwrap();
2190 assert_eq!(
2191 sig1.signature, sig2.signature,
2192 "renamed wallet should produce identical signatures"
2193 );
2194 }
2195
2196 #[test]
2197 fn char_create_sign_delete_sign_returns_wallet_not_found() {
2198 let dir = tempfile::tempdir().unwrap();
2199 let vault = dir.path();
2200 create_wallet("del-me-char", None, None, Some(vault)).unwrap();
2201
2202 let sig =
2204 sign_message("del-me-char", "evm", "test", None, None, None, Some(vault)).unwrap();
2205 assert!(!sig.signature.is_empty());
2206
2207 delete_wallet("del-me-char", Some(vault)).unwrap();
2209
2210 let result = sign_message("del-me-char", "evm", "test", None, None, None, Some(vault));
2212 assert!(result.is_err());
2213 match result.unwrap_err() {
2214 OwsLibError::WalletNotFound(name) => assert_eq!(name, "del-me-char"),
2215 other => panic!("expected WalletNotFound, got: {other}"),
2216 }
2217 }
2218
2219 #[test]
2220 fn char_import_sign_export_reimport_sign_deterministic() {
2221 let v1 = tempfile::tempdir().unwrap();
2222 let v2 = tempfile::tempdir().unwrap();
2223
2224 let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
2226 import_wallet_mnemonic("char-det", phrase, None, None, Some(v1.path())).unwrap();
2227
2228 let sig1 = sign_message(
2230 "char-det",
2231 "evm",
2232 "determinism test",
2233 None,
2234 None,
2235 None,
2236 Some(v1.path()),
2237 )
2238 .unwrap();
2239
2240 let exported = export_wallet("char-det", None, Some(v1.path())).unwrap();
2242 assert_eq!(exported.trim(), phrase);
2243
2244 import_wallet_mnemonic("char-det-2", &exported, None, None, Some(v2.path())).unwrap();
2246
2247 let sig2 = sign_message(
2249 "char-det-2",
2250 "evm",
2251 "determinism test",
2252 None,
2253 None,
2254 None,
2255 Some(v2.path()),
2256 )
2257 .unwrap();
2258
2259 assert_eq!(
2260 sig1.signature, sig2.signature,
2261 "import→sign→export→reimport→sign must produce identical signatures"
2262 );
2263 }
2264
2265 #[test]
2266 fn char_import_private_key_sign_valid() {
2267 let dir = tempfile::tempdir().unwrap();
2268 let vault = dir.path();
2269
2270 import_wallet_private_key(
2271 "char-pk",
2272 TEST_PRIVKEY,
2273 Some("evm"),
2274 None,
2275 Some(vault),
2276 None,
2277 None,
2278 )
2279 .unwrap();
2280
2281 let sig = sign_transaction("char-pk", "evm", "deadbeef", None, None, Some(vault)).unwrap();
2282 assert!(!sig.signature.is_empty());
2283 assert!(sig.recovery_id.is_some());
2284 }
2285
2286 #[test]
2287 fn char_sign_message_all_chain_families() {
2288 let dir = tempfile::tempdir().unwrap();
2290 let vault = dir.path();
2291 create_wallet("char-all-chains", None, None, Some(vault)).unwrap();
2292
2293 let chains = [
2294 ("evm", true),
2295 ("solana", false),
2296 ("bitcoin", true),
2297 ("cosmos", true),
2298 ("tron", true),
2299 ("ton", false),
2300 ("sui", false),
2301 ];
2302 for (chain, has_recovery_id) in &chains {
2303 let result = sign_message(
2304 "char-all-chains",
2305 chain,
2306 "hello",
2307 None,
2308 None,
2309 None,
2310 Some(vault),
2311 );
2312 assert!(
2313 result.is_ok(),
2314 "sign_message failed for {chain}: {:?}",
2315 result.err()
2316 );
2317 let sig = result.unwrap();
2318 assert!(!sig.signature.is_empty(), "signature empty for {chain}");
2319 if *has_recovery_id {
2320 assert!(
2321 sig.recovery_id.is_some(),
2322 "expected recovery_id for {chain}"
2323 );
2324 }
2325 }
2326 }
2327
2328 #[test]
2329 fn char_sign_typed_data_evm_valid_signature() {
2330 let dir = tempfile::tempdir().unwrap();
2331 let vault = dir.path();
2332 create_wallet("char-typed", None, None, Some(vault)).unwrap();
2333
2334 let typed_data = r#"{
2335 "types": {
2336 "EIP712Domain": [
2337 {"name": "name", "type": "string"},
2338 {"name": "version", "type": "string"},
2339 {"name": "chainId", "type": "uint256"}
2340 ],
2341 "Test": [{"name": "value", "type": "uint256"}]
2342 },
2343 "primaryType": "Test",
2344 "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2345 "message": {"value": "42"}
2346 }"#;
2347
2348 let result = sign_typed_data("char-typed", "evm", typed_data, None, None, Some(vault));
2349 assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2350
2351 let sig = result.unwrap();
2352 let sig_bytes = hex::decode(&sig.signature).unwrap();
2353 assert_eq!(sig_bytes.len(), 65, "EIP-712 signature should be 65 bytes");
2354
2355 let v = sig_bytes[64];
2357 assert!(v == 27 || v == 28, "EIP-712 v should be 27 or 28, got {v}");
2358 }
2359
2360 #[test]
2365 fn char_sign_with_nonzero_account_index() {
2366 let dir = tempfile::tempdir().unwrap();
2369 let vault = dir.path();
2370 create_wallet("char-idx", None, None, Some(vault)).unwrap();
2371
2372 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2373
2374 let sig0 = sign_transaction("char-idx", "evm", tx, None, Some(0), Some(vault)).unwrap();
2375 let sig1 = sign_transaction("char-idx", "evm", tx, None, Some(1), Some(vault)).unwrap();
2376
2377 assert_ne!(
2378 sig0.signature, sig1.signature,
2379 "index 0 and index 1 must produce different signatures (different derived keys)"
2380 );
2381
2382 let sig_default = sign_transaction("char-idx", "evm", tx, None, None, Some(vault)).unwrap();
2384 assert_eq!(
2385 sig0.signature, sig_default.signature,
2386 "index=0 should match index=None (default)"
2387 );
2388 }
2389
2390 #[test]
2391 fn char_sign_with_nonzero_index_sign_message() {
2392 let dir = tempfile::tempdir().unwrap();
2393 let vault = dir.path();
2394 create_wallet("char-idx-msg", None, None, Some(vault)).unwrap();
2395
2396 let sig0 = sign_message(
2397 "char-idx-msg",
2398 "evm",
2399 "hello",
2400 None,
2401 None,
2402 Some(0),
2403 Some(vault),
2404 )
2405 .unwrap();
2406 let sig1 = sign_message(
2407 "char-idx-msg",
2408 "evm",
2409 "hello",
2410 None,
2411 None,
2412 Some(1),
2413 Some(vault),
2414 )
2415 .unwrap();
2416
2417 assert_ne!(
2418 sig0.signature, sig1.signature,
2419 "different account indices should yield different signatures"
2420 );
2421 }
2422
2423 #[test]
2424 fn char_sign_transaction_0x_prefix_stripped() {
2425 let dir = tempfile::tempdir().unwrap();
2428 let vault = dir.path();
2429 create_wallet("char-0x", None, None, Some(vault)).unwrap();
2430
2431 let tx_no_prefix = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2432 let tx_with_prefix = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2433
2434 let sig1 =
2435 sign_transaction("char-0x", "evm", tx_no_prefix, None, None, Some(vault)).unwrap();
2436 let sig2 =
2437 sign_transaction("char-0x", "evm", tx_with_prefix, None, None, Some(vault)).unwrap();
2438
2439 assert_eq!(
2440 sig1.signature, sig2.signature,
2441 "0x-prefixed and bare hex should produce identical signatures"
2442 );
2443 }
2444
2445 #[test]
2446 fn char_24_word_mnemonic_wallet_lifecycle() {
2447 let dir = tempfile::tempdir().unwrap();
2449 let vault = dir.path();
2450
2451 let info = create_wallet("char-24w", Some(24), None, Some(vault)).unwrap();
2452 assert!(!info.accounts.is_empty());
2453
2454 let phrase = export_wallet("char-24w", None, Some(vault)).unwrap();
2456 assert_eq!(
2457 phrase.split_whitespace().count(),
2458 24,
2459 "should be a 24-word mnemonic"
2460 );
2461
2462 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2464 let sig = sign_transaction("char-24w", "evm", tx, None, None, Some(vault)).unwrap();
2465 assert!(!sig.signature.is_empty());
2466
2467 for chain in &["evm", "solana", "bitcoin", "cosmos"] {
2469 let result = sign_message("char-24w", chain, "test", None, None, None, Some(vault));
2470 assert!(
2471 result.is_ok(),
2472 "24-word wallet sign_message failed for {chain}: {:?}",
2473 result.err()
2474 );
2475 }
2476
2477 let v2 = tempfile::tempdir().unwrap();
2479 import_wallet_mnemonic("char-24w-2", &phrase, None, None, Some(v2.path())).unwrap();
2480 let sig2 = sign_transaction("char-24w-2", "evm", tx, None, None, Some(v2.path())).unwrap();
2481 assert_eq!(
2482 sig.signature, sig2.signature,
2483 "reimported 24-word wallet must produce identical signature"
2484 );
2485 }
2486
2487 #[test]
2488 fn char_concurrent_signing() {
2489 use std::sync::Arc;
2492 use std::thread;
2493
2494 let dir = tempfile::tempdir().unwrap();
2495 let vault_path = Arc::new(dir.path().to_path_buf());
2496 create_wallet("char-conc", None, None, Some(&vault_path)).unwrap();
2497
2498 let handles: Vec<_> = (0..8)
2499 .map(|i| {
2500 let vp = Arc::clone(&vault_path);
2501 thread::spawn(move || {
2502 let msg = format!("thread-{i}");
2503 let result = sign_message(
2504 "char-conc",
2505 "evm",
2506 &msg,
2507 None,
2508 None,
2509 None,
2510 Some(vp.as_path()),
2511 );
2512 assert!(
2513 result.is_ok(),
2514 "concurrent sign_message failed in thread {i}: {:?}",
2515 result.err()
2516 );
2517 result.unwrap()
2518 })
2519 })
2520 .collect();
2521
2522 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2523
2524 for (i, sig) in results.iter().enumerate() {
2526 assert!(
2527 !sig.signature.is_empty(),
2528 "thread {i} produced empty signature"
2529 );
2530 }
2531
2532 for i in 0..results.len() {
2534 for j in (i + 1)..results.len() {
2535 assert_ne!(
2536 results[i].signature, results[j].signature,
2537 "threads {i} and {j} should produce different signatures (different messages)"
2538 );
2539 }
2540 }
2541 }
2542
2543 #[test]
2544 fn char_evm_sign_transaction_recoverable() {
2545 use sha3::Digest;
2548
2549 let dir = tempfile::tempdir().unwrap();
2550 let vault = dir.path();
2551 let info = create_wallet("char-tx-recover", None, None, Some(vault)).unwrap();
2552 let evm_addr = info
2553 .accounts
2554 .iter()
2555 .find(|a| a.chain_id.starts_with("eip155:"))
2556 .unwrap()
2557 .address
2558 .clone();
2559
2560 let tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2561 let sig =
2562 sign_transaction("char-tx-recover", "evm", tx_hex, None, None, Some(vault)).unwrap();
2563
2564 let sig_bytes = hex::decode(&sig.signature).unwrap();
2565 assert_eq!(sig_bytes.len(), 65);
2566
2567 let tx_bytes = hex::decode(tx_hex).unwrap();
2569 let hash = sha3::Keccak256::digest(&tx_bytes);
2570
2571 let v = sig_bytes[64];
2572 let recid = k256::ecdsa::RecoveryId::try_from(v).unwrap();
2573 let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
2574 let recovered_key =
2575 k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
2576
2577 let pubkey_bytes = recovered_key.to_encoded_point(false);
2579 let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
2580 let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
2581
2582 assert_eq!(
2583 recovered_addr.to_lowercase(),
2584 evm_addr.to_lowercase(),
2585 "recovered address from tx signature should match wallet's EVM address"
2586 );
2587 }
2588
2589 #[test]
2590 fn char_solana_extract_signable_through_sign_path() {
2591 let dir = tempfile::tempdir().unwrap();
2596 let vault = dir.path();
2597 create_wallet("char-sol-sig", None, None, Some(vault)).unwrap();
2598
2599 let message_payload = b"test solana message payload 1234";
2601 let mut tx_bytes = vec![0x01u8]; tx_bytes.extend_from_slice(&[0u8; 64]); tx_bytes.extend_from_slice(message_payload);
2604 let tx_hex = hex::encode(&tx_bytes);
2605
2606 let sig =
2611 sign_transaction("char-sol-sig", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2612 assert_eq!(
2613 hex::decode(&sig.signature).unwrap().len(),
2614 64,
2615 "Solana signature should be 64 bytes (Ed25519)"
2616 );
2617 assert!(sig.recovery_id.is_none(), "Ed25519 has no recovery ID");
2618
2619 let key =
2622 decrypt_signing_key("char-sol-sig", ChainType::Solana, "", None, Some(vault)).unwrap();
2623 let signer = signer_for_chain(ChainType::Solana);
2624
2625 let signable = signer.extract_signable_bytes(&tx_bytes).unwrap();
2626 assert_eq!(
2627 signable, message_payload,
2628 "extract_signable_bytes should return only the message portion"
2629 );
2630
2631 let output = signer.sign_transaction(key.expose(), signable).unwrap();
2632 let signed_tx = signer
2633 .encode_signed_transaction(&tx_bytes, &output)
2634 .unwrap();
2635
2636 assert_eq!(&signed_tx[1..65], &output.signature[..]);
2638 assert_eq!(&signed_tx[65..], message_payload);
2640 assert_eq!(signed_tx.len(), tx_bytes.len());
2642
2643 let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
2645 let verifying_key = signing_key.verifying_key();
2646 let ed_sig = ed25519_dalek::Signature::from_bytes(&output.signature.try_into().unwrap());
2647 verifying_key
2648 .verify_strict(message_payload, &ed_sig)
2649 .expect("Solana signature should verify against extracted message");
2650 }
2651
2652 #[test]
2653 fn char_library_encodes_before_broadcast() {
2654 let dir = tempfile::tempdir().unwrap();
2661 let vault = dir.path();
2662 create_wallet("char-encode", None, None, Some(vault)).unwrap();
2663
2664 let items: Vec<u8> = [
2666 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(&[]), ]
2676 .concat();
2677 let mut unsigned_tx = vec![0x02u8];
2678 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
2679 let tx_hex = hex::encode(&unsigned_tx);
2680
2681 let raw_sig =
2683 sign_transaction("char-encode", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2684 let raw_sig_bytes = hex::decode(&raw_sig.signature).unwrap();
2685
2686 let key =
2688 decrypt_signing_key("char-encode", ChainType::Evm, "", None, Some(vault)).unwrap();
2689 let signer = signer_for_chain(ChainType::Evm);
2690 let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
2691 let full_signed_tx = signer
2692 .encode_signed_transaction(&unsigned_tx, &output)
2693 .unwrap();
2694
2695 assert_eq!(raw_sig_bytes.len(), 65);
2697
2698 assert!(full_signed_tx.len() > 65);
2700 assert_eq!(
2701 full_signed_tx[0], 0x02,
2702 "should preserve EIP-1559 type byte"
2703 );
2704
2705 assert_ne!(raw_sig_bytes, full_signed_tx);
2707
2708 let r_bytes = &raw_sig_bytes[..32];
2711 let _s_bytes = &raw_sig_bytes[32..64];
2712
2713 let full_hex = hex::encode(&full_signed_tx);
2715 let r_hex = hex::encode(r_bytes);
2716 assert!(
2717 full_hex.contains(&r_hex),
2718 "full signed tx should contain the r component"
2719 );
2720 }
2721
2722 #[test]
2727 fn sign_typed_data_rejects_non_evm_chain() {
2728 let tmp = tempfile::tempdir().unwrap();
2729 let vault = tmp.path();
2730
2731 let w = save_privkey_wallet("typed-data-test", TEST_PRIVKEY, "pass", vault);
2732
2733 let typed_data = r#"{
2734 "types": {
2735 "EIP712Domain": [{"name": "name", "type": "string"}],
2736 "Test": [{"name": "value", "type": "uint256"}]
2737 },
2738 "primaryType": "Test",
2739 "domain": {"name": "Test"},
2740 "message": {"value": "1"}
2741 }"#;
2742
2743 let result = sign_typed_data(&w.id, "solana", typed_data, Some("pass"), None, Some(vault));
2744 assert!(result.is_err());
2745 let err_msg = result.unwrap_err().to_string();
2746 assert!(
2747 err_msg.contains("only supported for EVM"),
2748 "expected EVM-only error, got: {err_msg}"
2749 );
2750 }
2751
2752 #[test]
2753 fn sign_typed_data_evm_succeeds() {
2754 let tmp = tempfile::tempdir().unwrap();
2755 let vault = tmp.path();
2756
2757 let w = save_privkey_wallet("typed-data-evm", TEST_PRIVKEY, "pass", vault);
2758
2759 let typed_data = r#"{
2760 "types": {
2761 "EIP712Domain": [
2762 {"name": "name", "type": "string"},
2763 {"name": "version", "type": "string"},
2764 {"name": "chainId", "type": "uint256"}
2765 ],
2766 "Test": [{"name": "value", "type": "uint256"}]
2767 },
2768 "primaryType": "Test",
2769 "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2770 "message": {"value": "42"}
2771 }"#;
2772
2773 let result = sign_typed_data(&w.id, "evm", typed_data, Some("pass"), None, Some(vault));
2774 assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2775
2776 let sign_result = result.unwrap();
2777 assert!(
2778 !sign_result.signature.is_empty(),
2779 "signature should not be empty"
2780 );
2781 assert!(
2782 sign_result.recovery_id.is_some(),
2783 "recovery_id should be present for EVM"
2784 );
2785 }
2786
2787 #[test]
2792 fn sign_hash_owner_path_matches_direct_signer() {
2793 let tmp = tempfile::tempdir().unwrap();
2794 let vault = tmp.path();
2795 let wallet = save_privkey_wallet("hash-owner", TEST_PRIVKEY, "pass", vault);
2796 let hash_hex = "11".repeat(32);
2797
2798 let api_result = sign_hash(
2799 &wallet.id,
2800 "base",
2801 &hash_hex,
2802 Some("pass"),
2803 None,
2804 Some(vault),
2805 )
2806 .unwrap();
2807
2808 let key =
2809 decrypt_signing_key(&wallet.id, ChainType::Evm, "pass", None, Some(vault)).unwrap();
2810 let signer = signer_for_chain(ChainType::Evm);
2811 let direct = signer
2812 .sign(key.expose(), &hex::decode(&hash_hex).unwrap())
2813 .unwrap();
2814
2815 assert_eq!(api_result.signature, hex::encode(&direct.signature));
2816 assert_eq!(api_result.recovery_id, direct.recovery_id);
2817 }
2818
2819 #[test]
2820 fn sign_authorization_owner_path_matches_sign_hash() {
2821 let tmp = tempfile::tempdir().unwrap();
2822 let vault = tmp.path();
2823 let wallet = save_privkey_wallet("auth-owner", TEST_PRIVKEY, "pass", vault);
2824
2825 let auth_result = sign_authorization(
2826 &wallet.id,
2827 "base",
2828 "0x1111111111111111111111111111111111111111",
2829 "7",
2830 Some("pass"),
2831 None,
2832 Some(vault),
2833 )
2834 .unwrap();
2835
2836 let hash = ows_signer::chains::EvmSigner
2837 .authorization_hash("8453", "0x1111111111111111111111111111111111111111", "7")
2838 .unwrap();
2839
2840 let hash_result = sign_hash(
2841 &wallet.id,
2842 "base",
2843 &hex::encode(hash),
2844 Some("pass"),
2845 None,
2846 Some(vault),
2847 )
2848 .unwrap();
2849
2850 assert_eq!(auth_result.signature, hash_result.signature);
2851 assert_eq!(auth_result.recovery_id, hash_result.recovery_id);
2852 }
2853
2854 #[test]
2855 fn sign_hash_rejects_non_secp256k1_chains() {
2856 let tmp = tempfile::tempdir().unwrap();
2857 let vault = tmp.path();
2858 let wallet = create_wallet("hash-solana", None, None, Some(vault)).unwrap();
2859
2860 let err = sign_hash(
2861 &wallet.id,
2862 "solana",
2863 &"11".repeat(32),
2864 Some(""),
2865 None,
2866 Some(vault),
2867 )
2868 .unwrap_err();
2869
2870 match err {
2871 OwsLibError::InvalidInput(msg) => {
2872 assert!(msg.contains("secp256k1-backed chains"));
2873 }
2874 other => panic!("expected InvalidInput, got: {other}"),
2875 }
2876 }
2877
2878 #[test]
2879 fn sign_authorization_rejects_non_evm_chains() {
2880 let tmp = tempfile::tempdir().unwrap();
2881 let vault = tmp.path();
2882 let wallet = create_wallet("auth-tron", None, None, Some(vault)).unwrap();
2883
2884 let err = sign_authorization(
2885 &wallet.id,
2886 "tron",
2887 "0x1111111111111111111111111111111111111111",
2888 "7",
2889 Some(""),
2890 None,
2891 Some(vault),
2892 )
2893 .unwrap_err();
2894
2895 match err {
2896 OwsLibError::InvalidInput(msg) => {
2897 assert!(msg.contains("only supported for EVM chains"));
2898 }
2899 other => panic!("expected InvalidInput, got: {other}"),
2900 }
2901 }
2902
2903 #[test]
2904 fn sign_hash_api_key_path_obeys_policy() {
2905 let tmp = tempfile::tempdir().unwrap();
2906 let vault = tmp.path();
2907 let wallet = create_wallet("hash-agent", None, None, Some(vault)).unwrap();
2908 save_allowed_chains_policy(vault, "base-only-hash", vec!["eip155:8453".to_string()]);
2909
2910 let (token, _) = crate::key_ops::create_api_key(
2911 "hash-agent-key",
2912 std::slice::from_ref(&wallet.id),
2913 &["base-only-hash".to_string()],
2914 "",
2915 None,
2916 Some(vault),
2917 )
2918 .unwrap();
2919
2920 let allowed = sign_hash(
2921 &wallet.id,
2922 "base",
2923 &"22".repeat(32),
2924 Some(&token),
2925 None,
2926 Some(vault),
2927 );
2928 assert!(
2929 allowed.is_ok(),
2930 "allowed sign_hash failed: {:?}",
2931 allowed.err()
2932 );
2933
2934 let denied = sign_hash(
2935 &wallet.id,
2936 "ethereum",
2937 &"22".repeat(32),
2938 Some(&token),
2939 None,
2940 Some(vault),
2941 );
2942 match denied.unwrap_err() {
2943 OwsLibError::Core(OwsError::PolicyDenied { reason, .. }) => {
2944 assert!(reason.contains("not in allowlist"));
2945 }
2946 other => panic!("expected PolicyDenied, got: {other}"),
2947 }
2948 }
2949
2950 #[test]
2951 fn sign_authorization_api_key_path_matches_allowed_sign_hash() {
2952 let tmp = tempfile::tempdir().unwrap();
2953 let vault = tmp.path();
2954 let wallet = create_wallet("auth-agent", None, None, Some(vault)).unwrap();
2955 save_allowed_chains_policy(vault, "base-only-auth", vec!["eip155:8453".to_string()]);
2956
2957 let (token, _) = crate::key_ops::create_api_key(
2958 "auth-agent-key",
2959 std::slice::from_ref(&wallet.id),
2960 &["base-only-auth".to_string()],
2961 "",
2962 None,
2963 Some(vault),
2964 )
2965 .unwrap();
2966
2967 let auth_result = sign_authorization(
2968 &wallet.id,
2969 "base",
2970 "0x1111111111111111111111111111111111111111",
2971 "7",
2972 Some(&token),
2973 None,
2974 Some(vault),
2975 )
2976 .unwrap();
2977
2978 let hash = ows_signer::chains::EvmSigner
2979 .authorization_hash("8453", "0x1111111111111111111111111111111111111111", "7")
2980 .unwrap();
2981
2982 let hash_result = sign_hash(
2983 &wallet.id,
2984 "base",
2985 &hex::encode(hash),
2986 Some(&token),
2987 None,
2988 Some(vault),
2989 )
2990 .unwrap();
2991
2992 assert_eq!(auth_result.signature, hash_result.signature);
2993 assert_eq!(auth_result.recovery_id, hash_result.recovery_id);
2994 }
2995
2996 #[cfg(unix)]
2997 #[test]
2998 fn sign_authorization_api_key_policy_receives_authorization_payload() {
2999 use std::os::unix::fs::PermissionsExt;
3000
3001 let tmp = tempfile::tempdir().unwrap();
3002 let vault = tmp.path();
3003 let wallet = create_wallet("auth-raw-hex", None, None, Some(vault)).unwrap();
3004 let address = "0x1111111111111111111111111111111111111111";
3005 let nonce = "7";
3006 let payload = hex::encode(
3007 ows_signer::chains::EvmSigner
3008 .authorization_payload("8453", address, nonce)
3009 .unwrap(),
3010 );
3011
3012 let script = vault.join("check-auth-payload.sh");
3013 std::fs::write(
3014 &script,
3015 format!(
3016 "#!/bin/sh\nif grep -q '\"raw_hex\":\"{payload}\"'; then\n echo '{{\"allow\": true}}'\nelse\n echo '{{\"allow\": false, \"reason\": \"unexpected raw_hex\"}}'\nfi\n"
3017 ),
3018 )
3019 .unwrap();
3020 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
3021
3022 let policy = ows_core::Policy {
3023 id: "auth-payload-only".to_string(),
3024 name: "auth payload only".to_string(),
3025 version: 1,
3026 created_at: "2026-03-22T00:00:00Z".to_string(),
3027 rules: vec![],
3028 executable: Some(script.display().to_string()),
3029 config: None,
3030 action: ows_core::PolicyAction::Deny,
3031 };
3032 crate::policy_store::save_policy(&policy, Some(vault)).unwrap();
3033
3034 let (token, _) = crate::key_ops::create_api_key(
3035 "auth-payload-agent",
3036 std::slice::from_ref(&wallet.id),
3037 &["auth-payload-only".to_string()],
3038 "",
3039 None,
3040 Some(vault),
3041 )
3042 .unwrap();
3043
3044 let auth_result = sign_authorization(
3045 &wallet.id,
3046 "base",
3047 address,
3048 nonce,
3049 Some(&token),
3050 None,
3051 Some(vault),
3052 )
3053 .unwrap();
3054 assert!(!auth_result.signature.is_empty());
3055
3056 let hash = ows_signer::chains::EvmSigner
3057 .authorization_hash("8453", address, nonce)
3058 .unwrap();
3059 let err = sign_hash(
3060 &wallet.id,
3061 "base",
3062 &hex::encode(hash),
3063 Some(&token),
3064 None,
3065 Some(vault),
3066 )
3067 .unwrap_err();
3068
3069 match err {
3070 OwsLibError::Core(OwsError::PolicyDenied { reason, .. }) => {
3071 assert!(reason.contains("unexpected raw_hex"));
3072 }
3073 other => panic!("expected PolicyDenied, got: {other}"),
3074 }
3075 }
3076
3077 #[test]
3083 fn regression_owner_path_identical_to_direct_signer() {
3084 let dir = tempfile::tempdir().unwrap();
3089 let vault = dir.path();
3090 create_wallet("reg-owner", None, None, Some(vault)).unwrap();
3091
3092 let tx_hex = "deadbeefcafebabe";
3093
3094 let api_result =
3096 sign_transaction("reg-owner", "evm", tx_hex, None, None, Some(vault)).unwrap();
3097
3098 let key = decrypt_signing_key("reg-owner", ChainType::Evm, "", None, Some(vault)).unwrap();
3100 let signer = signer_for_chain(ChainType::Evm);
3101 let tx_bytes = hex::decode(tx_hex).unwrap();
3102 let direct_output = signer.sign_transaction(key.expose(), &tx_bytes).unwrap();
3103
3104 assert_eq!(
3105 api_result.signature,
3106 hex::encode(&direct_output.signature),
3107 "library API and direct signer must produce identical signatures"
3108 );
3109 assert_eq!(
3110 api_result.recovery_id, direct_output.recovery_id,
3111 "recovery_id must match"
3112 );
3113 }
3114
3115 #[test]
3116 fn regression_owner_passphrase_not_confused_with_token() {
3117 let dir = tempfile::tempdir().unwrap();
3120 let vault = dir.path();
3121 create_wallet("reg-pass", Some(12), Some("hunter2"), Some(vault)).unwrap();
3122
3123 let tx_hex = "deadbeef";
3124
3125 let result = sign_transaction(
3127 "reg-pass",
3128 "evm",
3129 tx_hex,
3130 Some("hunter2"),
3131 None,
3132 Some(vault),
3133 );
3134 assert!(
3135 result.is_ok(),
3136 "owner-mode signing failed: {:?}",
3137 result.err()
3138 );
3139
3140 let bad = sign_transaction("reg-pass", "evm", tx_hex, Some(""), None, Some(vault));
3143 assert!(bad.is_err());
3144 match bad.unwrap_err() {
3145 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error for wrong passphrase, got: {other}"),
3147 }
3148
3149 let none_result = sign_transaction("reg-pass", "evm", tx_hex, None, None, Some(vault));
3151 assert!(none_result.is_err());
3152 match none_result.unwrap_err() {
3153 OwsLibError::Crypto(_) => {}
3154 other => panic!("expected Crypto error for None passphrase, got: {other}"),
3155 }
3156 }
3157
3158 #[test]
3159 fn regression_sign_message_owner_path_unchanged() {
3160 let dir = tempfile::tempdir().unwrap();
3161 let vault = dir.path();
3162 create_wallet("reg-msg", None, None, Some(vault)).unwrap();
3163
3164 let api_result =
3166 sign_message("reg-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
3167
3168 let key = decrypt_signing_key("reg-msg", ChainType::Evm, "", None, Some(vault)).unwrap();
3170 let signer = signer_for_chain(ChainType::Evm);
3171 let direct = signer.sign_message(key.expose(), b"hello").unwrap();
3172
3173 assert_eq!(
3174 api_result.signature,
3175 hex::encode(&direct.signature),
3176 "sign_message owner path must match direct signer"
3177 );
3178 }
3179
3180 #[test]
3185 fn solana_broadcast_body_includes_encoding_param() {
3186 let dummy_tx = vec![0x01; 100];
3187 let body = build_solana_rpc_body(&dummy_tx);
3188
3189 assert_eq!(body["method"], "sendTransaction");
3190 assert_eq!(
3191 body["params"][1]["encoding"], "base64",
3192 "sendTransaction must specify encoding=base64 so Solana RPC \
3193 does not default to base58"
3194 );
3195 }
3196
3197 #[test]
3198 fn solana_broadcast_body_uses_base64_encoding() {
3199 use base64::Engine;
3200 let dummy_tx = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03];
3201 let body = build_solana_rpc_body(&dummy_tx);
3202
3203 let encoded = body["params"][0].as_str().unwrap();
3204 let decoded = base64::engine::general_purpose::STANDARD
3206 .decode(encoded)
3207 .expect("params[0] should be valid base64");
3208 assert_eq!(
3209 decoded, dummy_tx,
3210 "base64 should round-trip to original bytes"
3211 );
3212 }
3213
3214 #[test]
3215 fn solana_broadcast_body_is_not_hex_or_base58() {
3216 let dummy_tx = vec![0xFF; 50];
3218 let body = build_solana_rpc_body(&dummy_tx);
3219
3220 let encoded = body["params"][0].as_str().unwrap();
3221 let hex_encoded = hex::encode(&dummy_tx);
3222 assert_ne!(encoded, hex_encoded, "broadcast should use base64, not hex");
3223 assert!(
3226 encoded.contains('/') || encoded.contains('+') || encoded.ends_with('='),
3227 "base64 of 0xFF bytes should contain characters absent from base58"
3228 );
3229 }
3230
3231 #[test]
3232 fn solana_broadcast_body_jsonrpc_structure() {
3233 let body = build_solana_rpc_body(&[0u8; 10]);
3234 assert_eq!(body["jsonrpc"], "2.0");
3235 assert_eq!(body["id"], 1);
3236 assert_eq!(body["method"], "sendTransaction");
3237 assert!(body["params"].is_array());
3238 assert_eq!(
3239 body["params"].as_array().unwrap().len(),
3240 2,
3241 "params should have [tx_data, options_object]"
3242 );
3243 }
3244
3245 #[test]
3250 fn solana_sign_transaction_extracts_signable_bytes() {
3251 let dir = tempfile::tempdir().unwrap();
3254 let vault = dir.path();
3255 create_wallet("sol-extract", None, None, Some(vault)).unwrap();
3256
3257 let message_payload = b"test solana message for extraction";
3258 let mut full_tx = vec![0x01u8]; full_tx.extend_from_slice(&[0u8; 64]); full_tx.extend_from_slice(message_payload);
3261 let tx_hex = hex::encode(&full_tx);
3262
3263 let sig_result =
3265 sign_transaction("sol-extract", "solana", &tx_hex, None, None, Some(vault)).unwrap();
3266 let sig_bytes = hex::decode(&sig_result.signature).unwrap();
3267
3268 let key =
3270 decrypt_signing_key("sol-extract", ChainType::Solana, "", None, Some(vault)).unwrap();
3271 let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
3272 let verifying_key = signing_key.verifying_key();
3273 let ed_sig = ed25519_dalek::Signature::from_bytes(&sig_bytes.try_into().unwrap());
3274
3275 verifying_key
3276 .verify_strict(message_payload, &ed_sig)
3277 .expect("sign_transaction should sign the message portion, not the full envelope");
3278 }
3279
3280 #[test]
3281 fn solana_sign_transaction_full_tx_matches_extracted_sign() {
3282 let dir = tempfile::tempdir().unwrap();
3285 let vault = dir.path();
3286 create_wallet("sol-match", None, None, Some(vault)).unwrap();
3287
3288 let message_payload = b"matching signatures test";
3289 let mut full_tx = vec![0x01u8];
3290 full_tx.extend_from_slice(&[0u8; 64]);
3291 full_tx.extend_from_slice(message_payload);
3292 let tx_hex = hex::encode(&full_tx);
3293
3294 let api_sig =
3296 sign_transaction("sol-match", "solana", &tx_hex, None, None, Some(vault)).unwrap();
3297
3298 let key =
3300 decrypt_signing_key("sol-match", ChainType::Solana, "", None, Some(vault)).unwrap();
3301 let signer = signer_for_chain(ChainType::Solana);
3302 let signable = signer.extract_signable_bytes(&full_tx).unwrap();
3303 let direct = signer.sign_transaction(key.expose(), signable).unwrap();
3304
3305 assert_eq!(
3306 api_sig.signature,
3307 hex::encode(&direct.signature),
3308 "sign_transaction API and manual extract+sign must produce the same signature"
3309 );
3310 }
3311
3312 #[test]
3313 fn evm_sign_transaction_unaffected_by_extraction() {
3314 let dir = tempfile::tempdir().unwrap();
3317 let vault = dir.path();
3318 create_wallet("evm-regress", None, None, Some(vault)).unwrap();
3319
3320 let items: Vec<u8> = [
3321 ows_signer::rlp::encode_bytes(&[1]),
3322 ows_signer::rlp::encode_bytes(&[]),
3323 ows_signer::rlp::encode_bytes(&[1]),
3324 ows_signer::rlp::encode_bytes(&[100]),
3325 ows_signer::rlp::encode_bytes(&[0x52, 0x08]),
3326 ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]),
3327 ows_signer::rlp::encode_bytes(&[]),
3328 ows_signer::rlp::encode_bytes(&[]),
3329 ows_signer::rlp::encode_list(&[]),
3330 ]
3331 .concat();
3332 let mut unsigned_tx = vec![0x02u8];
3333 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
3334 let tx_hex = hex::encode(&unsigned_tx);
3335
3336 let sig1 =
3338 sign_transaction("evm-regress", "evm", &tx_hex, None, None, Some(vault)).unwrap();
3339 let sig2 =
3340 sign_transaction("evm-regress", "evm", &tx_hex, None, None, Some(vault)).unwrap();
3341 assert_eq!(sig1.signature, sig2.signature);
3342 assert_eq!(hex::decode(&sig1.signature).unwrap().len(), 65);
3343 }
3344
3345 #[test]
3350 #[ignore] fn solana_devnet_broadcast_encoding_accepted() {
3352 let bh_body = serde_json::json!({
3358 "jsonrpc": "2.0",
3359 "method": "getLatestBlockhash",
3360 "params": [],
3361 "id": 1
3362 });
3363 let bh_resp =
3364 curl_post_json("https://api.devnet.solana.com", &bh_body.to_string()).unwrap();
3365 let bh_parsed: serde_json::Value = serde_json::from_str(&bh_resp).unwrap();
3366 let blockhash_b58 = bh_parsed["result"]["value"]["blockhash"]
3367 .as_str()
3368 .expect("devnet should return a blockhash");
3369 let blockhash = bs58::decode(blockhash_b58).into_vec().unwrap();
3370 assert_eq!(blockhash.len(), 32);
3371
3372 let privkey =
3374 hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
3375 .unwrap();
3376 let signing_key =
3377 ed25519_dalek::SigningKey::from_bytes(&privkey.clone().try_into().unwrap());
3378 let sender_pubkey = signing_key.verifying_key().to_bytes();
3379
3380 let recipient_pubkey = [0x01; 32]; let system_program = [0u8; 32]; let mut message = vec![
3385 1, 0, 1, 3, ];
3390 message.extend_from_slice(&sender_pubkey);
3391 message.extend_from_slice(&recipient_pubkey);
3392 message.extend_from_slice(&system_program);
3393 message.extend_from_slice(&blockhash);
3395 message.push(1); message.push(2); message.push(2); message.push(0); message.push(1); message.push(12); message.extend_from_slice(&2u32.to_le_bytes()); message.extend_from_slice(&1u64.to_le_bytes()); let mut tx_bytes = vec![0x01u8]; tx_bytes.extend_from_slice(&[0u8; 64]); tx_bytes.extend_from_slice(&message);
3409
3410 let result = sign_encode_and_broadcast(
3412 &privkey,
3413 "solana",
3414 &tx_bytes,
3415 Some("https://api.devnet.solana.com"),
3416 );
3417
3418 match result {
3420 Ok(send_result) => {
3421 assert!(!send_result.tx_hash.is_empty());
3423 }
3424 Err(e) => {
3425 let err_str = format!("{e}");
3426 assert!(
3427 !err_str.contains("base58"),
3428 "should not get base58 encoding error: {err_str}"
3429 );
3430 assert!(
3431 !err_str.contains("InvalidCharacter"),
3432 "should not get InvalidCharacter error: {err_str}"
3433 );
3434 }
3436 }
3437 }
3438}