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