1use std::path::Path;
2use std::process::Command;
3
4use ows_core::{
5 default_chain_for_type, ChainType, Config, EncryptedWallet, KeyType, WalletAccount,
6 ALL_CHAIN_TYPES,
7};
8use ows_signer::{
9 decrypt, encrypt, signer_for_chain, CryptoEnvelope, HdDeriver, Mnemonic, MnemonicStrength,
10 SecretBytes,
11};
12
13use crate::error::OwsLibError;
14use crate::types::{AccountInfo, SendResult, SignResult, WalletInfo};
15use crate::vault;
16
17fn wallet_to_info(w: &EncryptedWallet) -> WalletInfo {
19 WalletInfo {
20 id: w.id.clone(),
21 name: w.name.clone(),
22 accounts: w
23 .accounts
24 .iter()
25 .map(|a| AccountInfo {
26 chain_id: a.chain_id.clone(),
27 address: a.address.clone(),
28 derivation_path: a.derivation_path.clone(),
29 })
30 .collect(),
31 created_at: w.created_at.clone(),
32 }
33}
34
35fn parse_chain(s: &str) -> Result<ows_core::Chain, OwsLibError> {
36 ows_core::parse_chain(s).map_err(OwsLibError::InvalidInput)
37}
38
39fn derive_all_accounts(mnemonic: &Mnemonic, index: u32) -> Result<Vec<WalletAccount>, OwsLibError> {
41 let mut accounts = Vec::with_capacity(ALL_CHAIN_TYPES.len());
42 for ct in &ALL_CHAIN_TYPES {
43 let chain = default_chain_for_type(*ct);
44 let signer = signer_for_chain(*ct);
45 let path = signer.default_derivation_path(index);
46 let curve = signer.curve();
47 let key = HdDeriver::derive_from_mnemonic(mnemonic, "", &path, curve)?;
48 let address = signer.derive_address(key.expose())?;
49 let account_id = format!("{}:{}", chain.chain_id, address);
50 accounts.push(WalletAccount {
51 account_id,
52 address,
53 chain_id: chain.chain_id.to_string(),
54 derivation_path: path,
55 });
56 }
57 Ok(accounts)
58}
59
60struct KeyPair {
63 secp256k1: Vec<u8>,
64 ed25519: Vec<u8>,
65}
66
67impl Drop for KeyPair {
68 fn drop(&mut self) {
69 use zeroize::Zeroize;
70 self.secp256k1.zeroize();
71 self.ed25519.zeroize();
72 }
73}
74
75impl KeyPair {
76 fn key_for_curve(&self, curve: ows_signer::Curve) -> &[u8] {
78 match curve {
79 ows_signer::Curve::Secp256k1 => &self.secp256k1,
80 ows_signer::Curve::Ed25519 => &self.ed25519,
81 }
82 }
83
84 fn to_json_bytes(&self) -> Vec<u8> {
86 let obj = serde_json::json!({
87 "secp256k1": hex::encode(&self.secp256k1),
88 "ed25519": hex::encode(&self.ed25519),
89 });
90 obj.to_string().into_bytes()
91 }
92
93 fn from_json_bytes(bytes: &[u8]) -> Result<Self, OwsLibError> {
95 let s = String::from_utf8(bytes.to_vec())
96 .map_err(|_| OwsLibError::InvalidInput("invalid key pair data".into()))?;
97 let obj: serde_json::Value = serde_json::from_str(&s)?;
98 let secp = obj["secp256k1"]
99 .as_str()
100 .ok_or_else(|| OwsLibError::InvalidInput("missing secp256k1 key".into()))?;
101 let ed = obj["ed25519"]
102 .as_str()
103 .ok_or_else(|| OwsLibError::InvalidInput("missing ed25519 key".into()))?;
104 Ok(KeyPair {
105 secp256k1: hex::decode(secp)
106 .map_err(|e| OwsLibError::InvalidInput(format!("invalid secp256k1 hex: {e}")))?,
107 ed25519: hex::decode(ed)
108 .map_err(|e| OwsLibError::InvalidInput(format!("invalid ed25519 hex: {e}")))?,
109 })
110 }
111}
112
113fn derive_all_accounts_from_keys(keys: &KeyPair) -> Result<Vec<WalletAccount>, OwsLibError> {
115 let mut accounts = Vec::with_capacity(ALL_CHAIN_TYPES.len());
116 for ct in &ALL_CHAIN_TYPES {
117 let signer = signer_for_chain(*ct);
118 let key = keys.key_for_curve(signer.curve());
119 let address = signer.derive_address(key)?;
120 let chain = default_chain_for_type(*ct);
121 accounts.push(WalletAccount {
122 account_id: format!("{}:{}", chain.chain_id, address),
123 address,
124 chain_id: chain.chain_id.to_string(),
125 derivation_path: String::new(),
126 });
127 }
128 Ok(accounts)
129}
130
131pub fn generate_mnemonic(words: u32) -> Result<String, OwsLibError> {
133 let strength = match words {
134 12 => MnemonicStrength::Words12,
135 24 => MnemonicStrength::Words24,
136 _ => return Err(OwsLibError::InvalidInput("words must be 12 or 24".into())),
137 };
138
139 let mnemonic = Mnemonic::generate(strength)?;
140 let phrase = mnemonic.phrase();
141 String::from_utf8(phrase.expose().to_vec())
142 .map_err(|e| OwsLibError::InvalidInput(format!("invalid UTF-8 in mnemonic: {e}")))
143}
144
145pub fn derive_address(
147 mnemonic_phrase: &str,
148 chain: &str,
149 index: Option<u32>,
150) -> Result<String, OwsLibError> {
151 let chain = parse_chain(chain)?;
152 let mnemonic = Mnemonic::from_phrase(mnemonic_phrase)?;
153 let signer = signer_for_chain(chain.chain_type);
154 let path = signer.default_derivation_path(index.unwrap_or(0));
155 let curve = signer.curve();
156
157 let key = HdDeriver::derive_from_mnemonic(&mnemonic, "", &path, curve)?;
158 let address = signer.derive_address(key.expose())?;
159 Ok(address)
160}
161
162pub fn create_wallet(
165 name: &str,
166 words: Option<u32>,
167 passphrase: Option<&str>,
168 vault_path: Option<&Path>,
169) -> Result<WalletInfo, OwsLibError> {
170 let passphrase = passphrase.unwrap_or("");
171 let words = words.unwrap_or(12);
172 let strength = match words {
173 12 => MnemonicStrength::Words12,
174 24 => MnemonicStrength::Words24,
175 _ => return Err(OwsLibError::InvalidInput("words must be 12 or 24".into())),
176 };
177
178 if vault::wallet_name_exists(name, vault_path)? {
179 return Err(OwsLibError::WalletNameExists(name.to_string()));
180 }
181
182 let mnemonic = Mnemonic::generate(strength)?;
183 let accounts = derive_all_accounts(&mnemonic, 0)?;
184
185 let phrase = mnemonic.phrase();
186 let crypto_envelope = encrypt(phrase.expose(), passphrase)?;
187 let crypto_json = serde_json::to_value(&crypto_envelope)?;
188
189 let wallet_id = uuid::Uuid::new_v4().to_string();
190
191 let wallet = EncryptedWallet::new(
192 wallet_id,
193 name.to_string(),
194 accounts,
195 crypto_json,
196 KeyType::Mnemonic,
197 );
198
199 vault::save_encrypted_wallet(&wallet, vault_path)?;
200 Ok(wallet_to_info(&wallet))
201}
202
203pub fn import_wallet_mnemonic(
205 name: &str,
206 mnemonic_phrase: &str,
207 passphrase: Option<&str>,
208 index: Option<u32>,
209 vault_path: Option<&Path>,
210) -> Result<WalletInfo, OwsLibError> {
211 let passphrase = passphrase.unwrap_or("");
212 let index = index.unwrap_or(0);
213
214 if vault::wallet_name_exists(name, vault_path)? {
215 return Err(OwsLibError::WalletNameExists(name.to_string()));
216 }
217
218 let mnemonic = Mnemonic::from_phrase(mnemonic_phrase)?;
219 let accounts = derive_all_accounts(&mnemonic, index)?;
220
221 let phrase = mnemonic.phrase();
222 let crypto_envelope = encrypt(phrase.expose(), passphrase)?;
223 let crypto_json = serde_json::to_value(&crypto_envelope)?;
224
225 let wallet_id = uuid::Uuid::new_v4().to_string();
226
227 let wallet = EncryptedWallet::new(
228 wallet_id,
229 name.to_string(),
230 accounts,
231 crypto_json,
232 KeyType::Mnemonic,
233 );
234
235 vault::save_encrypted_wallet(&wallet, vault_path)?;
236 Ok(wallet_to_info(&wallet))
237}
238
239fn decode_hex_key(hex_str: &str) -> Result<Vec<u8>, OwsLibError> {
241 let trimmed = hex_str.strip_prefix("0x").unwrap_or(hex_str);
242 hex::decode(trimmed)
243 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex private key: {e}")))
244}
245
246pub fn import_wallet_private_key(
255 name: &str,
256 private_key_hex: &str,
257 chain: Option<&str>,
258 passphrase: Option<&str>,
259 vault_path: Option<&Path>,
260 secp256k1_key_hex: Option<&str>,
261 ed25519_key_hex: Option<&str>,
262) -> Result<WalletInfo, OwsLibError> {
263 let passphrase = passphrase.unwrap_or("");
264
265 if vault::wallet_name_exists(name, vault_path)? {
266 return Err(OwsLibError::WalletNameExists(name.to_string()));
267 }
268
269 let keys = match (secp256k1_key_hex, ed25519_key_hex) {
270 (Some(secp_hex), Some(ed_hex)) => KeyPair {
272 secp256k1: decode_hex_key(secp_hex)?,
273 ed25519: decode_hex_key(ed_hex)?,
274 },
275 _ => {
277 let key_bytes = decode_hex_key(private_key_hex)?;
278
279 let source_curve = match chain {
281 Some(c) => {
282 let parsed = parse_chain(c)?;
283 signer_for_chain(parsed.chain_type).curve()
284 }
285 None => ows_signer::Curve::Secp256k1,
286 };
287
288 let mut other_key = vec![0u8; 32];
290 getrandom::getrandom(&mut other_key).map_err(|e| {
291 OwsLibError::InvalidInput(format!("failed to generate random key: {e}"))
292 })?;
293
294 match source_curve {
295 ows_signer::Curve::Secp256k1 => KeyPair {
296 secp256k1: key_bytes,
297 ed25519: ed25519_key_hex
298 .map(decode_hex_key)
299 .transpose()?
300 .unwrap_or(other_key),
301 },
302 ows_signer::Curve::Ed25519 => KeyPair {
303 secp256k1: secp256k1_key_hex
304 .map(decode_hex_key)
305 .transpose()?
306 .unwrap_or(other_key),
307 ed25519: key_bytes,
308 },
309 }
310 }
311 };
312
313 let accounts = derive_all_accounts_from_keys(&keys)?;
314
315 let payload = keys.to_json_bytes();
316 let crypto_envelope = encrypt(&payload, passphrase)?;
317 let crypto_json = serde_json::to_value(&crypto_envelope)?;
318
319 let wallet_id = uuid::Uuid::new_v4().to_string();
320
321 let wallet = EncryptedWallet::new(
322 wallet_id,
323 name.to_string(),
324 accounts,
325 crypto_json,
326 KeyType::PrivateKey,
327 );
328
329 vault::save_encrypted_wallet(&wallet, vault_path)?;
330 Ok(wallet_to_info(&wallet))
331}
332
333pub fn list_wallets(vault_path: Option<&Path>) -> Result<Vec<WalletInfo>, OwsLibError> {
335 let wallets = vault::list_encrypted_wallets(vault_path)?;
336 Ok(wallets.iter().map(wallet_to_info).collect())
337}
338
339pub fn get_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result<WalletInfo, OwsLibError> {
341 let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
342 Ok(wallet_to_info(&wallet))
343}
344
345pub fn delete_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibError> {
347 let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
348 vault::delete_wallet_file(&wallet.id, vault_path)?;
349 Ok(())
350}
351
352pub fn export_wallet(
355 name_or_id: &str,
356 passphrase: Option<&str>,
357 vault_path: Option<&Path>,
358) -> Result<String, OwsLibError> {
359 let passphrase = passphrase.unwrap_or("");
360 let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
361 let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
362 let secret = decrypt(&envelope, passphrase)?;
363
364 match wallet.key_type {
365 KeyType::Mnemonic => String::from_utf8(secret.expose().to_vec()).map_err(|_| {
366 OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
367 }),
368 KeyType::PrivateKey => {
369 String::from_utf8(secret.expose().to_vec())
371 .map_err(|_| OwsLibError::InvalidInput("wallet contains invalid key data".into()))
372 }
373 }
374}
375
376pub fn rename_wallet(
378 name_or_id: &str,
379 new_name: &str,
380 vault_path: Option<&Path>,
381) -> Result<(), OwsLibError> {
382 let mut wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
383
384 if wallet.name == new_name {
385 return Ok(());
386 }
387
388 if vault::wallet_name_exists(new_name, vault_path)? {
389 return Err(OwsLibError::WalletNameExists(new_name.to_string()));
390 }
391
392 wallet.name = new_name.to_string();
393 vault::save_encrypted_wallet(&wallet, vault_path)?;
394 Ok(())
395}
396
397pub fn sign_transaction(
403 wallet: &str,
404 chain: &str,
405 tx_hex: &str,
406 passphrase: Option<&str>,
407 index: Option<u32>,
408 vault_path: Option<&Path>,
409) -> Result<SignResult, OwsLibError> {
410 let credential = passphrase.unwrap_or("");
411
412 let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
413 let tx_bytes = hex::decode(tx_hex_clean)
414 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
415
416 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
418 let chain = parse_chain(chain)?;
419 return crate::key_ops::sign_with_api_key(
420 credential, wallet, &chain, &tx_bytes, index, vault_path,
421 );
422 }
423
424 let chain = parse_chain(chain)?;
426 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
427 let signer = signer_for_chain(chain.chain_type);
428 let signable = signer.extract_signable_bytes(&tx_bytes)?;
429 let output = signer.sign_transaction(key.expose(), signable)?;
430
431 Ok(SignResult {
432 signature: hex::encode(&output.signature),
433 recovery_id: output.recovery_id,
434 })
435}
436
437pub fn sign_message(
442 wallet: &str,
443 chain: &str,
444 message: &str,
445 passphrase: Option<&str>,
446 encoding: Option<&str>,
447 index: Option<u32>,
448 vault_path: Option<&Path>,
449) -> Result<SignResult, OwsLibError> {
450 let credential = passphrase.unwrap_or("");
451
452 let encoding = encoding.unwrap_or("utf8");
453 let msg_bytes = match encoding {
454 "utf8" => message.as_bytes().to_vec(),
455 "hex" => hex::decode(message)
456 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex message: {e}")))?,
457 _ => {
458 return Err(OwsLibError::InvalidInput(format!(
459 "unsupported encoding: {encoding} (use 'utf8' or 'hex')"
460 )))
461 }
462 };
463
464 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
466 let chain = parse_chain(chain)?;
467 return crate::key_ops::sign_message_with_api_key(
468 credential, wallet, &chain, &msg_bytes, index, vault_path,
469 );
470 }
471
472 let chain = parse_chain(chain)?;
474 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
475 let signer = signer_for_chain(chain.chain_type);
476 let output = signer.sign_message(key.expose(), &msg_bytes)?;
477
478 Ok(SignResult {
479 signature: hex::encode(&output.signature),
480 recovery_id: output.recovery_id,
481 })
482}
483
484pub fn sign_typed_data(
490 wallet: &str,
491 chain: &str,
492 typed_data_json: &str,
493 passphrase: Option<&str>,
494 index: Option<u32>,
495 vault_path: Option<&Path>,
496) -> Result<SignResult, OwsLibError> {
497 let credential = passphrase.unwrap_or("");
498 let chain = parse_chain(chain)?;
499
500 if chain.chain_type != ows_core::ChainType::Evm {
501 return Err(OwsLibError::InvalidInput(
502 "EIP-712 typed data signing is only supported for EVM chains".into(),
503 ));
504 }
505
506 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
507 return Err(OwsLibError::InvalidInput(
508 "EIP-712 typed data signing via API key is not yet supported; use sign_transaction"
509 .into(),
510 ));
511 }
512
513 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
514 let evm_signer = ows_signer::chains::EvmSigner;
515 let output = evm_signer.sign_typed_data(key.expose(), typed_data_json)?;
516
517 Ok(SignResult {
518 signature: hex::encode(&output.signature),
519 recovery_id: output.recovery_id,
520 })
521}
522
523pub fn sign_and_send(
529 wallet: &str,
530 chain: &str,
531 tx_hex: &str,
532 passphrase: Option<&str>,
533 index: Option<u32>,
534 rpc_url: Option<&str>,
535 vault_path: Option<&Path>,
536) -> Result<SendResult, OwsLibError> {
537 let credential = passphrase.unwrap_or("");
538
539 let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
540 let tx_bytes = hex::decode(tx_hex_clean)
541 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
542
543 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
545 let chain_info = parse_chain(chain)?;
546 let (key, _) = crate::key_ops::enforce_policy_and_decrypt_key(
547 credential,
548 wallet,
549 &chain_info,
550 &tx_bytes,
551 index,
552 vault_path,
553 )?;
554 return sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url);
555 }
556
557 let chain_info = parse_chain(chain)?;
559 let key = decrypt_signing_key(wallet, chain_info.chain_type, credential, index, vault_path)?;
560
561 sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url)
562}
563
564pub fn sign_encode_and_broadcast(
571 private_key: &[u8],
572 chain: &str,
573 tx_bytes: &[u8],
574 rpc_url: Option<&str>,
575) -> Result<SendResult, OwsLibError> {
576 let chain = parse_chain(chain)?;
577 let signer = signer_for_chain(chain.chain_type);
578
579 let signable = signer.extract_signable_bytes(tx_bytes)?;
581
582 let output = signer.sign_transaction(private_key, signable)?;
584
585 let signed_tx = signer.encode_signed_transaction(tx_bytes, &output)?;
587
588 let rpc = resolve_rpc_url(chain.chain_id, chain.chain_type, rpc_url)?;
590
591 let tx_hash = broadcast(chain.chain_type, &rpc, &signed_tx)?;
593
594 Ok(SendResult { tx_hash })
595}
596
597pub fn decrypt_signing_key(
604 wallet_name_or_id: &str,
605 chain_type: ChainType,
606 passphrase: &str,
607 index: Option<u32>,
608 vault_path: Option<&Path>,
609) -> Result<SecretBytes, OwsLibError> {
610 let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
611 let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
612 let secret = decrypt(&envelope, passphrase)?;
613
614 match wallet.key_type {
615 KeyType::Mnemonic => {
616 let phrase = std::str::from_utf8(secret.expose()).map_err(|_| {
618 OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
619 })?;
620 let mnemonic = Mnemonic::from_phrase(phrase)?;
621 let signer = signer_for_chain(chain_type);
622 let path = signer.default_derivation_path(index.unwrap_or(0));
623 let curve = signer.curve();
624 Ok(HdDeriver::derive_from_mnemonic_cached(
625 &mnemonic, "", &path, curve,
626 )?)
627 }
628 KeyType::PrivateKey => {
629 let keys = KeyPair::from_json_bytes(secret.expose())?;
631 let signer = signer_for_chain(chain_type);
632 Ok(SecretBytes::from_slice(keys.key_for_curve(signer.curve())))
633 }
634 }
635}
636
637fn resolve_rpc_url(
639 chain_id: &str,
640 chain_type: ChainType,
641 explicit: Option<&str>,
642) -> Result<String, OwsLibError> {
643 if let Some(url) = explicit {
644 return Ok(url.to_string());
645 }
646
647 let config = Config::load_or_default();
648 let defaults = Config::default_rpc();
649
650 if let Some(url) = config.rpc.get(chain_id) {
652 return Ok(url.clone());
653 }
654 if let Some(url) = defaults.get(chain_id) {
655 return Ok(url.clone());
656 }
657
658 let namespace = chain_type.namespace();
660 for (key, url) in &config.rpc {
661 if key.starts_with(namespace) {
662 return Ok(url.clone());
663 }
664 }
665 for (key, url) in &defaults {
666 if key.starts_with(namespace) {
667 return Ok(url.clone());
668 }
669 }
670
671 Err(OwsLibError::InvalidInput(format!(
672 "no RPC URL configured for chain '{chain_id}'"
673 )))
674}
675
676fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
678 match chain {
679 ChainType::Evm => broadcast_evm(rpc_url, signed_bytes),
680 ChainType::Solana => broadcast_solana(rpc_url, signed_bytes),
681 ChainType::Bitcoin => broadcast_bitcoin(rpc_url, signed_bytes),
682 ChainType::Cosmos => broadcast_cosmos(rpc_url, signed_bytes),
683 ChainType::Tron => broadcast_tron(rpc_url, signed_bytes),
684 ChainType::Ton => broadcast_ton(rpc_url, signed_bytes),
685 ChainType::Spark => Err(OwsLibError::InvalidInput(
686 "broadcast not yet supported for Spark".into(),
687 )),
688 ChainType::Filecoin => Err(OwsLibError::InvalidInput(
689 "broadcast not yet supported for Filecoin".into(),
690 )),
691 ChainType::Sui => broadcast_sui(rpc_url, signed_bytes),
692 }
693}
694
695fn broadcast_evm(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
696 let hex_tx = format!("0x{}", hex::encode(signed_bytes));
697 let body = serde_json::json!({
698 "jsonrpc": "2.0",
699 "method": "eth_sendRawTransaction",
700 "params": [hex_tx],
701 "id": 1
702 });
703 let resp = curl_post_json(rpc_url, &body.to_string())?;
704 extract_json_field(&resp, "result")
705}
706
707fn build_solana_rpc_body(signed_bytes: &[u8]) -> serde_json::Value {
708 use base64::Engine;
709 let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
710 serde_json::json!({
711 "jsonrpc": "2.0",
712 "method": "sendTransaction",
713 "params": [b64_tx, {"encoding": "base64"}],
714 "id": 1
715 })
716}
717
718fn broadcast_solana(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
719 let body = build_solana_rpc_body(signed_bytes);
720 let resp = curl_post_json(rpc_url, &body.to_string())?;
721 extract_json_field(&resp, "result")
722}
723
724fn broadcast_bitcoin(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
725 let hex_tx = hex::encode(signed_bytes);
726 let url = format!("{}/tx", rpc_url.trim_end_matches('/'));
727 let output = Command::new("curl")
728 .args([
729 "-fsSL",
730 "-X",
731 "POST",
732 "-H",
733 "Content-Type: text/plain",
734 "-d",
735 &hex_tx,
736 &url,
737 ])
738 .output()
739 .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
740
741 if !output.status.success() {
742 let stderr = String::from_utf8_lossy(&output.stderr);
743 return Err(OwsLibError::BroadcastFailed(format!(
744 "broadcast failed: {stderr}"
745 )));
746 }
747
748 let tx_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
749 if tx_hash.is_empty() {
750 return Err(OwsLibError::BroadcastFailed(
751 "empty response from broadcast".into(),
752 ));
753 }
754 Ok(tx_hash)
755}
756
757fn broadcast_cosmos(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
758 use base64::Engine;
759 let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
760 let url = format!("{}/cosmos/tx/v1beta1/txs", rpc_url.trim_end_matches('/'));
761 let body = serde_json::json!({
762 "tx_bytes": b64_tx,
763 "mode": "BROADCAST_MODE_SYNC"
764 });
765 let resp = curl_post_json(&url, &body.to_string())?;
766 let parsed: serde_json::Value = serde_json::from_str(&resp)?;
767 parsed["tx_response"]["txhash"]
768 .as_str()
769 .map(|s| s.to_string())
770 .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no txhash in response: {resp}")))
771}
772
773fn broadcast_tron(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
774 let hex_tx = hex::encode(signed_bytes);
775 let url = format!("{}/wallet/broadcasthex", rpc_url.trim_end_matches('/'));
776 let body = serde_json::json!({ "transaction": hex_tx });
777 let resp = curl_post_json(&url, &body.to_string())?;
778 extract_json_field(&resp, "txid")
779}
780
781fn broadcast_ton(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
782 use base64::Engine;
783 let b64_boc = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
784 let url = format!("{}/sendBoc", rpc_url.trim_end_matches('/'));
785 let body = serde_json::json!({ "boc": b64_boc });
786 let resp = curl_post_json(&url, &body.to_string())?;
787 let parsed: serde_json::Value = serde_json::from_str(&resp)?;
788 parsed["result"]["hash"]
789 .as_str()
790 .map(|s| s.to_string())
791 .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no hash in response: {resp}")))
792}
793
794fn broadcast_sui(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
795 use ows_signer::chains::sui::WIRE_SIG_LEN;
796
797 if signed_bytes.len() <= WIRE_SIG_LEN {
798 return Err(OwsLibError::InvalidInput(
799 "signed transaction too short to contain tx + signature".into(),
800 ));
801 }
802
803 let split = signed_bytes.len() - WIRE_SIG_LEN;
804 let tx_part = &signed_bytes[..split];
805 let sig_part = &signed_bytes[split..];
806
807 crate::sui_grpc::execute_transaction(rpc_url, tx_part, sig_part)
808}
809
810fn curl_post_json(url: &str, body: &str) -> Result<String, OwsLibError> {
811 let output = Command::new("curl")
812 .args([
813 "-fsSL",
814 "-X",
815 "POST",
816 "-H",
817 "Content-Type: application/json",
818 "-d",
819 body,
820 url,
821 ])
822 .output()
823 .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
824
825 if !output.status.success() {
826 let stderr = String::from_utf8_lossy(&output.stderr);
827 return Err(OwsLibError::BroadcastFailed(format!(
828 "broadcast failed: {stderr}"
829 )));
830 }
831
832 Ok(String::from_utf8_lossy(&output.stdout).to_string())
833}
834
835fn extract_json_field(json_str: &str, field: &str) -> Result<String, OwsLibError> {
836 let parsed: serde_json::Value = serde_json::from_str(json_str)?;
837
838 if let Some(error) = parsed.get("error") {
839 return Err(OwsLibError::BroadcastFailed(format!("RPC error: {error}")));
840 }
841
842 parsed[field]
843 .as_str()
844 .map(|s| s.to_string())
845 .ok_or_else(|| {
846 OwsLibError::BroadcastFailed(format!("no '{field}' in response: {json_str}"))
847 })
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853
854 fn save_privkey_wallet(
859 name: &str,
860 privkey_hex: &str,
861 passphrase: &str,
862 vault: &Path,
863 ) -> WalletInfo {
864 let key_bytes = hex::decode(privkey_hex).unwrap();
865
866 let mut ed_key = vec![0u8; 32];
868 getrandom::getrandom(&mut ed_key).unwrap();
869
870 let keys = KeyPair {
871 secp256k1: key_bytes,
872 ed25519: ed_key,
873 };
874 let accounts = derive_all_accounts_from_keys(&keys).unwrap();
875 let payload = keys.to_json_bytes();
876 let crypto_envelope = encrypt(&payload, passphrase).unwrap();
877 let crypto_json = serde_json::to_value(&crypto_envelope).unwrap();
878 let wallet = EncryptedWallet::new(
879 uuid::Uuid::new_v4().to_string(),
880 name.to_string(),
881 accounts,
882 crypto_json,
883 KeyType::PrivateKey,
884 );
885 vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
886 wallet_to_info(&wallet)
887 }
888
889 const TEST_PRIVKEY: &str = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
890
891 #[test]
896 fn mnemonic_12_words() {
897 let phrase = generate_mnemonic(12).unwrap();
898 assert_eq!(phrase.split_whitespace().count(), 12);
899 }
900
901 #[test]
902 fn mnemonic_24_words() {
903 let phrase = generate_mnemonic(24).unwrap();
904 assert_eq!(phrase.split_whitespace().count(), 24);
905 }
906
907 #[test]
908 fn mnemonic_invalid_word_count() {
909 assert!(generate_mnemonic(15).is_err());
910 assert!(generate_mnemonic(0).is_err());
911 assert!(generate_mnemonic(13).is_err());
912 }
913
914 #[test]
915 fn mnemonic_is_unique_each_call() {
916 let a = generate_mnemonic(12).unwrap();
917 let b = generate_mnemonic(12).unwrap();
918 assert_ne!(a, b, "two generated mnemonics should differ");
919 }
920
921 #[test]
926 fn derive_address_all_chains() {
927 let phrase = generate_mnemonic(12).unwrap();
928 let chains = ["evm", "solana", "bitcoin", "cosmos", "tron", "ton", "sui"];
929 for chain in &chains {
930 let addr = derive_address(&phrase, chain, None).unwrap();
931 assert!(!addr.is_empty(), "address should be non-empty for {chain}");
932 }
933 }
934
935 #[test]
936 fn derive_address_evm_format() {
937 let phrase = generate_mnemonic(12).unwrap();
938 let addr = derive_address(&phrase, "evm", None).unwrap();
939 assert!(addr.starts_with("0x"), "EVM address should start with 0x");
940 assert_eq!(addr.len(), 42, "EVM address should be 42 chars");
941 }
942
943 #[test]
944 fn derive_address_deterministic() {
945 let phrase = generate_mnemonic(12).unwrap();
946 let a = derive_address(&phrase, "evm", None).unwrap();
947 let b = derive_address(&phrase, "evm", None).unwrap();
948 assert_eq!(a, b, "same mnemonic should produce same address");
949 }
950
951 #[test]
952 fn derive_address_different_index() {
953 let phrase = generate_mnemonic(12).unwrap();
954 let a = derive_address(&phrase, "evm", Some(0)).unwrap();
955 let b = derive_address(&phrase, "evm", Some(1)).unwrap();
956 assert_ne!(a, b, "different indices should produce different addresses");
957 }
958
959 #[test]
960 fn derive_address_invalid_chain() {
961 let phrase = generate_mnemonic(12).unwrap();
962 assert!(derive_address(&phrase, "nonexistent", None).is_err());
963 }
964
965 #[test]
966 fn derive_address_invalid_mnemonic() {
967 assert!(derive_address("not a valid mnemonic phrase at all", "evm", None).is_err());
968 }
969
970 #[test]
975 fn mnemonic_wallet_create_export_reimport() {
976 let v1 = tempfile::tempdir().unwrap();
977 let v2 = tempfile::tempdir().unwrap();
978
979 let w1 = create_wallet("w1", None, None, Some(v1.path())).unwrap();
981 assert!(!w1.accounts.is_empty());
982
983 let phrase = export_wallet("w1", None, Some(v1.path())).unwrap();
985 assert_eq!(phrase.split_whitespace().count(), 12);
986
987 let w2 = import_wallet_mnemonic("w2", &phrase, None, None, Some(v2.path())).unwrap();
989
990 assert_eq!(w1.accounts.len(), w2.accounts.len());
992 for (a1, a2) in w1.accounts.iter().zip(w2.accounts.iter()) {
993 assert_eq!(a1.chain_id, a2.chain_id);
994 assert_eq!(
995 a1.address, a2.address,
996 "address mismatch for {}",
997 a1.chain_id
998 );
999 }
1000 }
1001
1002 #[test]
1003 fn mnemonic_wallet_sign_message_all_chains() {
1004 let dir = tempfile::tempdir().unwrap();
1005 let vault = dir.path();
1006 create_wallet("multi-sign", None, None, Some(vault)).unwrap();
1007
1008 let chains = [
1009 "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui",
1010 ];
1011 for chain in &chains {
1012 let result = sign_message(
1013 "multi-sign",
1014 chain,
1015 "test msg",
1016 None,
1017 None,
1018 None,
1019 Some(vault),
1020 );
1021 assert!(
1022 result.is_ok(),
1023 "sign_message should work for {chain}: {:?}",
1024 result.err()
1025 );
1026 let sig = result.unwrap();
1027 assert!(
1028 !sig.signature.is_empty(),
1029 "signature should be non-empty for {chain}"
1030 );
1031 }
1032 }
1033
1034 #[test]
1035 fn mnemonic_wallet_sign_tx_all_chains() {
1036 let dir = tempfile::tempdir().unwrap();
1037 let vault = dir.path();
1038 create_wallet("tx-sign", None, None, Some(vault)).unwrap();
1039
1040 let generic_tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1041 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);
1047
1048 let chains = [
1049 "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui",
1050 ];
1051 for chain in &chains {
1052 let tx = if *chain == "solana" {
1053 &solana_tx_hex
1054 } else {
1055 generic_tx_hex
1056 };
1057 let result = sign_transaction("tx-sign", chain, tx, None, None, Some(vault));
1058 assert!(
1059 result.is_ok(),
1060 "sign_transaction should work for {chain}: {:?}",
1061 result.err()
1062 );
1063 }
1064 }
1065
1066 #[test]
1067 fn mnemonic_wallet_signing_is_deterministic() {
1068 let dir = tempfile::tempdir().unwrap();
1069 let vault = dir.path();
1070 create_wallet("det-sign", None, None, Some(vault)).unwrap();
1071
1072 let s1 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1073 let s2 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1074 assert_eq!(
1075 s1.signature, s2.signature,
1076 "same message should produce same signature"
1077 );
1078 }
1079
1080 #[test]
1081 fn mnemonic_wallet_different_messages_produce_different_sigs() {
1082 let dir = tempfile::tempdir().unwrap();
1083 let vault = dir.path();
1084 create_wallet("diff-msg", None, None, Some(vault)).unwrap();
1085
1086 let s1 = sign_message("diff-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
1087 let s2 = sign_message("diff-msg", "evm", "world", None, None, None, Some(vault)).unwrap();
1088 assert_ne!(s1.signature, s2.signature);
1089 }
1090
1091 #[test]
1096 fn privkey_wallet_sign_message() {
1097 let dir = tempfile::tempdir().unwrap();
1098 save_privkey_wallet("pk-sign", TEST_PRIVKEY, "", dir.path());
1099
1100 let sig = sign_message(
1101 "pk-sign",
1102 "evm",
1103 "hello",
1104 None,
1105 None,
1106 None,
1107 Some(dir.path()),
1108 )
1109 .unwrap();
1110 assert!(!sig.signature.is_empty());
1111 assert!(sig.recovery_id.is_some());
1112 }
1113
1114 #[test]
1115 fn privkey_wallet_sign_transaction() {
1116 let dir = tempfile::tempdir().unwrap();
1117 save_privkey_wallet("pk-tx", TEST_PRIVKEY, "", dir.path());
1118
1119 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1120 let sig = sign_transaction("pk-tx", "evm", tx, None, None, Some(dir.path())).unwrap();
1121 assert!(!sig.signature.is_empty());
1122 }
1123
1124 #[test]
1125 fn privkey_wallet_export_returns_json() {
1126 let dir = tempfile::tempdir().unwrap();
1127 save_privkey_wallet("pk-export", TEST_PRIVKEY, "", dir.path());
1128
1129 let exported = export_wallet("pk-export", None, Some(dir.path())).unwrap();
1130 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1131 assert_eq!(
1132 obj["secp256k1"].as_str().unwrap(),
1133 TEST_PRIVKEY,
1134 "exported secp256k1 key should match original"
1135 );
1136 assert!(obj["ed25519"].as_str().is_some(), "should have ed25519 key");
1137 }
1138
1139 #[test]
1140 fn privkey_wallet_signing_is_deterministic() {
1141 let dir = tempfile::tempdir().unwrap();
1142 save_privkey_wallet("pk-det", TEST_PRIVKEY, "", dir.path());
1143
1144 let s1 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1145 let s2 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1146 assert_eq!(s1.signature, s2.signature);
1147 }
1148
1149 #[test]
1150 fn privkey_and_mnemonic_wallets_produce_different_sigs() {
1151 let dir = tempfile::tempdir().unwrap();
1152 let vault = dir.path();
1153
1154 create_wallet("mn-w", None, None, Some(vault)).unwrap();
1155 save_privkey_wallet("pk-w", TEST_PRIVKEY, "", vault);
1156
1157 let mn_sig = sign_message("mn-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1158 let pk_sig = sign_message("pk-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1159 assert_ne!(
1160 mn_sig.signature, pk_sig.signature,
1161 "different keys should produce different signatures"
1162 );
1163 }
1164
1165 #[test]
1166 fn privkey_wallet_import_via_api() {
1167 let dir = tempfile::tempdir().unwrap();
1168 let vault = dir.path();
1169
1170 let info = import_wallet_private_key(
1171 "pk-api",
1172 TEST_PRIVKEY,
1173 Some("evm"),
1174 None,
1175 Some(vault),
1176 None,
1177 None,
1178 )
1179 .unwrap();
1180 assert!(
1181 !info.accounts.is_empty(),
1182 "should derive at least one account"
1183 );
1184
1185 let sig = sign_message("pk-api", "evm", "hello", None, None, None, Some(vault)).unwrap();
1187 assert!(!sig.signature.is_empty());
1188
1189 let exported = export_wallet("pk-api", None, Some(vault)).unwrap();
1191 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1192 assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1193 }
1194
1195 #[test]
1196 fn privkey_wallet_import_both_curve_keys() {
1197 let dir = tempfile::tempdir().unwrap();
1198 let vault = dir.path();
1199
1200 let secp_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
1201 let ed_key = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
1202
1203 let info = import_wallet_private_key(
1204 "pk-both",
1205 "", None, None,
1208 Some(vault),
1209 Some(secp_key),
1210 Some(ed_key),
1211 )
1212 .unwrap();
1213
1214 assert_eq!(
1215 info.accounts.len(),
1216 ALL_CHAIN_TYPES.len(),
1217 "should have one account per chain type"
1218 );
1219
1220 let sig = sign_message("pk-both", "evm", "hello", None, None, None, Some(vault)).unwrap();
1222 assert!(!sig.signature.is_empty());
1223
1224 let sig =
1226 sign_message("pk-both", "solana", "hello", None, None, None, Some(vault)).unwrap();
1227 assert!(!sig.signature.is_empty());
1228
1229 let exported = export_wallet("pk-both", None, Some(vault)).unwrap();
1231 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1232 assert_eq!(obj["secp256k1"].as_str().unwrap(), secp_key);
1233 assert_eq!(obj["ed25519"].as_str().unwrap(), ed_key);
1234 }
1235
1236 #[test]
1241 fn passphrase_protected_mnemonic_wallet() {
1242 let dir = tempfile::tempdir().unwrap();
1243 let vault = dir.path();
1244
1245 create_wallet("pass-mn", None, Some("s3cret"), Some(vault)).unwrap();
1246
1247 let sig = sign_message(
1249 "pass-mn",
1250 "evm",
1251 "hello",
1252 Some("s3cret"),
1253 None,
1254 None,
1255 Some(vault),
1256 )
1257 .unwrap();
1258 assert!(!sig.signature.is_empty());
1259
1260 let phrase = export_wallet("pass-mn", Some("s3cret"), Some(vault)).unwrap();
1262 assert_eq!(phrase.split_whitespace().count(), 12);
1263
1264 assert!(sign_message(
1266 "pass-mn",
1267 "evm",
1268 "hello",
1269 Some("wrong"),
1270 None,
1271 None,
1272 Some(vault)
1273 )
1274 .is_err());
1275 assert!(export_wallet("pass-mn", Some("wrong"), Some(vault)).is_err());
1276
1277 assert!(sign_message("pass-mn", "evm", "hello", None, None, None, Some(vault)).is_err());
1279 }
1280
1281 #[test]
1282 fn passphrase_protected_privkey_wallet() {
1283 let dir = tempfile::tempdir().unwrap();
1284 save_privkey_wallet("pass-pk", TEST_PRIVKEY, "mypass", dir.path());
1285
1286 let sig = sign_message(
1288 "pass-pk",
1289 "evm",
1290 "hello",
1291 Some("mypass"),
1292 None,
1293 None,
1294 Some(dir.path()),
1295 )
1296 .unwrap();
1297 assert!(!sig.signature.is_empty());
1298
1299 let exported = export_wallet("pass-pk", Some("mypass"), Some(dir.path())).unwrap();
1300 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1301 assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1302
1303 assert!(sign_message(
1305 "pass-pk",
1306 "evm",
1307 "hello",
1308 Some("wrong"),
1309 None,
1310 None,
1311 Some(dir.path())
1312 )
1313 .is_err());
1314 assert!(export_wallet("pass-pk", Some("wrong"), Some(dir.path())).is_err());
1315 }
1316
1317 #[test]
1322 fn evm_signature_is_recoverable() {
1323 use sha3::Digest;
1324 let dir = tempfile::tempdir().unwrap();
1325 let vault = dir.path();
1326
1327 let info = create_wallet("verify-evm", None, None, Some(vault)).unwrap();
1328 let evm_addr = info
1329 .accounts
1330 .iter()
1331 .find(|a| a.chain_id.starts_with("eip155:"))
1332 .unwrap()
1333 .address
1334 .clone();
1335
1336 let sig = sign_message(
1337 "verify-evm",
1338 "evm",
1339 "hello world",
1340 None,
1341 None,
1342 None,
1343 Some(vault),
1344 )
1345 .unwrap();
1346
1347 let msg = b"hello world";
1349 let prefix = format!("\x19Ethereum Signed Message:\n{}", msg.len());
1350 let mut prefixed = prefix.into_bytes();
1351 prefixed.extend_from_slice(msg);
1352
1353 let hash = sha3::Keccak256::digest(&prefixed);
1354 let sig_bytes = hex::decode(&sig.signature).unwrap();
1355 assert_eq!(
1356 sig_bytes.len(),
1357 65,
1358 "EVM signature should be 65 bytes (r + s + v)"
1359 );
1360
1361 let v = sig_bytes[64];
1363 assert!(
1364 v == 27 || v == 28,
1365 "EIP-191 v byte should be 27 or 28, got {v}"
1366 );
1367 let recid = k256::ecdsa::RecoveryId::try_from(v - 27).unwrap();
1368 let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
1369 let recovered_key =
1370 k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
1371
1372 let pubkey_bytes = recovered_key.to_encoded_point(false);
1374 let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
1375 let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
1376
1377 assert_eq!(
1379 recovered_addr.to_lowercase(),
1380 evm_addr.to_lowercase(),
1381 "recovered address should match wallet's EVM address"
1382 );
1383 }
1384
1385 #[test]
1390 fn error_nonexistent_wallet() {
1391 let dir = tempfile::tempdir().unwrap();
1392 assert!(get_wallet("nope", Some(dir.path())).is_err());
1393 assert!(export_wallet("nope", None, Some(dir.path())).is_err());
1394 assert!(sign_message("nope", "evm", "x", None, None, None, Some(dir.path())).is_err());
1395 assert!(delete_wallet("nope", Some(dir.path())).is_err());
1396 }
1397
1398 #[test]
1399 fn error_duplicate_wallet_name() {
1400 let dir = tempfile::tempdir().unwrap();
1401 let vault = dir.path();
1402 create_wallet("dup", None, None, Some(vault)).unwrap();
1403 assert!(create_wallet("dup", None, None, Some(vault)).is_err());
1404 }
1405
1406 #[test]
1407 fn error_invalid_private_key_hex() {
1408 let dir = tempfile::tempdir().unwrap();
1409 assert!(import_wallet_private_key(
1410 "bad",
1411 "not-hex",
1412 Some("evm"),
1413 None,
1414 Some(dir.path()),
1415 None,
1416 None,
1417 )
1418 .is_err());
1419 }
1420
1421 #[test]
1422 fn error_invalid_chain_for_signing() {
1423 let dir = tempfile::tempdir().unwrap();
1424 let vault = dir.path();
1425 create_wallet("chain-err", None, None, Some(vault)).unwrap();
1426 assert!(
1427 sign_message("chain-err", "fakecoin", "hi", None, None, None, Some(vault)).is_err()
1428 );
1429 }
1430
1431 #[test]
1432 fn error_invalid_tx_hex() {
1433 let dir = tempfile::tempdir().unwrap();
1434 let vault = dir.path();
1435 create_wallet("hex-err", None, None, Some(vault)).unwrap();
1436 assert!(
1437 sign_transaction("hex-err", "evm", "not-valid-hex!", None, None, Some(vault)).is_err()
1438 );
1439 }
1440
1441 #[test]
1446 fn list_wallets_empty_vault() {
1447 let dir = tempfile::tempdir().unwrap();
1448 let wallets = list_wallets(Some(dir.path())).unwrap();
1449 assert!(wallets.is_empty());
1450 }
1451
1452 #[test]
1453 fn get_wallet_by_name_and_id() {
1454 let dir = tempfile::tempdir().unwrap();
1455 let vault = dir.path();
1456 let info = create_wallet("lookup", None, None, Some(vault)).unwrap();
1457
1458 let by_name = get_wallet("lookup", Some(vault)).unwrap();
1459 assert_eq!(by_name.id, info.id);
1460
1461 let by_id = get_wallet(&info.id, Some(vault)).unwrap();
1462 assert_eq!(by_id.name, "lookup");
1463 }
1464
1465 #[test]
1466 fn rename_wallet_works() {
1467 let dir = tempfile::tempdir().unwrap();
1468 let vault = dir.path();
1469 let info = create_wallet("before", None, None, Some(vault)).unwrap();
1470
1471 rename_wallet("before", "after", Some(vault)).unwrap();
1472
1473 assert!(get_wallet("before", Some(vault)).is_err());
1474 let after = get_wallet("after", Some(vault)).unwrap();
1475 assert_eq!(after.id, info.id);
1476 }
1477
1478 #[test]
1479 fn rename_to_existing_name_fails() {
1480 let dir = tempfile::tempdir().unwrap();
1481 let vault = dir.path();
1482 create_wallet("a", None, None, Some(vault)).unwrap();
1483 create_wallet("b", None, None, Some(vault)).unwrap();
1484 assert!(rename_wallet("a", "b", Some(vault)).is_err());
1485 }
1486
1487 #[test]
1488 fn delete_wallet_removes_from_list() {
1489 let dir = tempfile::tempdir().unwrap();
1490 let vault = dir.path();
1491 create_wallet("del-me", None, None, Some(vault)).unwrap();
1492 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 1);
1493
1494 delete_wallet("del-me", Some(vault)).unwrap();
1495 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 0);
1496 }
1497
1498 #[test]
1503 fn sign_message_hex_encoding() {
1504 let dir = tempfile::tempdir().unwrap();
1505 let vault = dir.path();
1506 create_wallet("hex-enc", None, None, Some(vault)).unwrap();
1507
1508 let sig = sign_message(
1510 "hex-enc",
1511 "evm",
1512 "68656c6c6f",
1513 None,
1514 Some("hex"),
1515 None,
1516 Some(vault),
1517 )
1518 .unwrap();
1519 assert!(!sig.signature.is_empty());
1520
1521 let sig2 = sign_message(
1523 "hex-enc",
1524 "evm",
1525 "hello",
1526 None,
1527 Some("utf8"),
1528 None,
1529 Some(vault),
1530 )
1531 .unwrap();
1532 assert_eq!(
1533 sig.signature, sig2.signature,
1534 "hex and utf8 encoding of same bytes should produce same signature"
1535 );
1536 }
1537
1538 #[test]
1539 fn sign_message_invalid_encoding() {
1540 let dir = tempfile::tempdir().unwrap();
1541 let vault = dir.path();
1542 create_wallet("bad-enc", None, None, Some(vault)).unwrap();
1543 assert!(sign_message(
1544 "bad-enc",
1545 "evm",
1546 "hello",
1547 None,
1548 Some("base64"),
1549 None,
1550 Some(vault)
1551 )
1552 .is_err());
1553 }
1554
1555 #[test]
1560 fn multiple_wallets_coexist() {
1561 let dir = tempfile::tempdir().unwrap();
1562 let vault = dir.path();
1563
1564 create_wallet("w1", None, None, Some(vault)).unwrap();
1565 create_wallet("w2", None, None, Some(vault)).unwrap();
1566 save_privkey_wallet("w3", TEST_PRIVKEY, "", vault);
1567
1568 let wallets = list_wallets(Some(vault)).unwrap();
1569 assert_eq!(wallets.len(), 3);
1570
1571 let s1 = sign_message("w1", "evm", "test", None, None, None, Some(vault)).unwrap();
1573 let s2 = sign_message("w2", "evm", "test", None, None, None, Some(vault)).unwrap();
1574 let s3 = sign_message("w3", "evm", "test", None, None, None, Some(vault)).unwrap();
1575
1576 assert_ne!(s1.signature, s2.signature);
1578 assert_ne!(s1.signature, s3.signature);
1579 assert_ne!(s2.signature, s3.signature);
1580
1581 delete_wallet("w2", Some(vault)).unwrap();
1583 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 2);
1584 assert!(sign_message("w1", "evm", "test", None, None, None, Some(vault)).is_ok());
1585 assert!(sign_message("w3", "evm", "test", None, None, None, Some(vault)).is_ok());
1586 }
1587
1588 #[test]
1593 fn signed_tx_must_differ_from_raw_signature() {
1594 let dir = tempfile::tempdir().unwrap();
1604 let vault = dir.path();
1605 save_privkey_wallet("send-bug", TEST_PRIVKEY, "", vault);
1606
1607 let items: Vec<u8> = [
1609 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(&[]), ]
1619 .concat();
1620
1621 let mut unsigned_tx = vec![0x02u8];
1622 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
1623 let tx_hex = hex::encode(&unsigned_tx);
1624
1625 let sign_result =
1627 sign_transaction("send-bug", "evm", &tx_hex, None, None, Some(vault)).unwrap();
1628 let raw_signature = hex::decode(&sign_result.signature).unwrap();
1629
1630 let key = decrypt_signing_key("send-bug", ChainType::Evm, "", None, Some(vault)).unwrap();
1632 let signer = signer_for_chain(ChainType::Evm);
1633 let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
1634 let full_signed_tx = signer
1635 .encode_signed_transaction(&unsigned_tx, &output)
1636 .unwrap();
1637
1638 assert_eq!(
1641 raw_signature.len(),
1642 65,
1643 "raw EVM signature should be 65 bytes (r || s || v)"
1644 );
1645 assert!(
1646 full_signed_tx.len() > raw_signature.len(),
1647 "full signed tx ({} bytes) must be larger than raw signature ({} bytes)",
1648 full_signed_tx.len(),
1649 raw_signature.len()
1650 );
1651 assert_ne!(
1652 raw_signature, full_signed_tx,
1653 "raw signature and full signed transaction must differ — \
1654 broadcasting the raw signature (as CLI send_transaction.rs:43 does) is wrong"
1655 );
1656
1657 assert_eq!(
1659 full_signed_tx[0], 0x02,
1660 "full signed EIP-1559 tx must start with type byte 0x02"
1661 );
1662 }
1663
1664 #[test]
1669 fn char_create_wallet_sign_transaction_with_passphrase() {
1670 let dir = tempfile::tempdir().unwrap();
1671 let vault = dir.path();
1672 create_wallet("char-pass-tx", None, Some("secret"), Some(vault)).unwrap();
1673
1674 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1675 let sig =
1676 sign_transaction("char-pass-tx", "evm", tx, Some("secret"), None, Some(vault)).unwrap();
1677 assert!(!sig.signature.is_empty());
1678 assert!(sig.recovery_id.is_some());
1679 }
1680
1681 #[test]
1682 fn char_create_wallet_sign_transaction_empty_passphrase() {
1683 let dir = tempfile::tempdir().unwrap();
1684 let vault = dir.path();
1685 create_wallet("char-empty-tx", None, None, Some(vault)).unwrap();
1686
1687 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1688 let sig =
1689 sign_transaction("char-empty-tx", "evm", tx, Some(""), None, Some(vault)).unwrap();
1690 assert!(!sig.signature.is_empty());
1691 }
1692
1693 #[test]
1694 fn char_no_passphrase_none_none_sign_transaction() {
1695 let dir = tempfile::tempdir().unwrap();
1698 let vault = dir.path();
1699 create_wallet("char-none-none", None, None, Some(vault)).unwrap();
1700
1701 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1702 let sig = sign_transaction("char-none-none", "evm", tx, None, None, Some(vault)).unwrap();
1703 assert!(!sig.signature.is_empty());
1704 assert!(sig.recovery_id.is_some());
1705 }
1706
1707 #[test]
1708 fn char_no_passphrase_none_none_sign_message() {
1709 let dir = tempfile::tempdir().unwrap();
1710 let vault = dir.path();
1711 create_wallet("char-none-msg", None, None, Some(vault)).unwrap();
1712
1713 let sig = sign_message(
1714 "char-none-msg",
1715 "evm",
1716 "hello",
1717 None,
1718 None,
1719 None,
1720 Some(vault),
1721 )
1722 .unwrap();
1723 assert!(!sig.signature.is_empty());
1724 }
1725
1726 #[test]
1727 fn char_no_passphrase_none_none_export() {
1728 let dir = tempfile::tempdir().unwrap();
1729 let vault = dir.path();
1730 create_wallet("char-none-exp", None, None, Some(vault)).unwrap();
1731
1732 let phrase = export_wallet("char-none-exp", None, Some(vault)).unwrap();
1733 assert_eq!(phrase.split_whitespace().count(), 12);
1734 }
1735
1736 #[test]
1737 fn char_empty_passphrase_none_and_some_empty_are_equivalent() {
1738 let dir = tempfile::tempdir().unwrap();
1741 let vault = dir.path();
1742
1743 create_wallet("char-equiv", None, None, Some(vault)).unwrap();
1745
1746 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1747
1748 let sig_none = sign_transaction("char-equiv", "evm", tx, None, None, Some(vault)).unwrap();
1750 let sig_empty =
1751 sign_transaction("char-equiv", "evm", tx, Some(""), None, Some(vault)).unwrap();
1752
1753 assert_eq!(
1754 sig_none.signature, sig_empty.signature,
1755 "passphrase=None and passphrase=Some(\"\") must produce identical signatures"
1756 );
1757
1758 let msg_none =
1760 sign_message("char-equiv", "evm", "test", None, None, None, Some(vault)).unwrap();
1761 let msg_empty = sign_message(
1762 "char-equiv",
1763 "evm",
1764 "test",
1765 Some(""),
1766 None,
1767 None,
1768 Some(vault),
1769 )
1770 .unwrap();
1771
1772 assert_eq!(
1773 msg_none.signature, msg_empty.signature,
1774 "sign_message: None and Some(\"\") must be equivalent"
1775 );
1776
1777 let export_none = export_wallet("char-equiv", None, Some(vault)).unwrap();
1779 let export_empty = export_wallet("char-equiv", Some(""), Some(vault)).unwrap();
1780 assert_eq!(
1781 export_none, export_empty,
1782 "export_wallet: None and Some(\"\") must return the same mnemonic"
1783 );
1784 }
1785
1786 #[test]
1787 fn char_create_with_some_empty_sign_with_none() {
1788 let dir = tempfile::tempdir().unwrap();
1790 let vault = dir.path();
1791 create_wallet("char-some-none", None, Some(""), Some(vault)).unwrap();
1792
1793 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1794 let sig = sign_transaction("char-some-none", "evm", tx, None, None, Some(vault)).unwrap();
1795 assert!(!sig.signature.is_empty());
1796 }
1797
1798 #[test]
1799 fn char_no_passphrase_wallet_rejects_nonempty_passphrase() {
1800 let dir = tempfile::tempdir().unwrap();
1804 let vault = dir.path();
1805 create_wallet("char-no-pass-reject", None, None, Some(vault)).unwrap();
1806
1807 let result = sign_message(
1808 "char-no-pass-reject",
1809 "evm",
1810 "test",
1811 Some("some-random-passphrase"),
1812 None,
1813 None,
1814 Some(vault),
1815 );
1816 assert!(
1817 result.is_err(),
1818 "non-empty passphrase on empty-passphrase wallet should fail"
1819 );
1820 match result.unwrap_err() {
1821 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error, got: {other}"),
1823 }
1824 }
1825
1826 #[test]
1827 fn char_sign_transaction_wrong_passphrase_returns_crypto_error() {
1828 let dir = tempfile::tempdir().unwrap();
1829 let vault = dir.path();
1830 create_wallet("char-wrong-pass", None, Some("correct"), Some(vault)).unwrap();
1831
1832 let tx = "deadbeef";
1833 let result = sign_transaction(
1834 "char-wrong-pass",
1835 "evm",
1836 tx,
1837 Some("wrong"),
1838 None,
1839 Some(vault),
1840 );
1841 assert!(result.is_err());
1842 match result.unwrap_err() {
1843 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error, got: {other}"),
1845 }
1846 }
1847
1848 #[test]
1849 fn char_sign_transaction_nonexistent_wallet_returns_wallet_not_found() {
1850 let dir = tempfile::tempdir().unwrap();
1851 let result = sign_transaction("ghost", "evm", "deadbeef", None, None, Some(dir.path()));
1852 assert!(result.is_err());
1853 match result.unwrap_err() {
1854 OwsLibError::WalletNotFound(name) => assert_eq!(name, "ghost"),
1855 other => panic!("expected WalletNotFound, got: {other}"),
1856 }
1857 }
1858
1859 #[test]
1860 fn char_sign_and_send_invalid_rpc_returns_broadcast_failed() {
1861 let dir = tempfile::tempdir().unwrap();
1862 let vault = dir.path();
1863 create_wallet("char-rpc-fail", None, None, Some(vault)).unwrap();
1864
1865 let items: Vec<u8> = [
1867 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(&[]), ]
1877 .concat();
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 result = sign_and_send(
1883 "char-rpc-fail",
1884 "evm",
1885 &tx_hex,
1886 None,
1887 None,
1888 Some("http://127.0.0.1:1"), Some(vault),
1890 );
1891 assert!(result.is_err());
1892 match result.unwrap_err() {
1893 OwsLibError::BroadcastFailed(_) => {} other => panic!("expected BroadcastFailed, got: {other}"),
1895 }
1896 }
1897
1898 #[test]
1899 fn char_create_sign_rename_sign_with_new_name() {
1900 let dir = tempfile::tempdir().unwrap();
1901 let vault = dir.path();
1902 create_wallet("orig-name", None, None, Some(vault)).unwrap();
1903
1904 let sig1 = sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).unwrap();
1906 assert!(!sig1.signature.is_empty());
1907
1908 rename_wallet("orig-name", "new-name", Some(vault)).unwrap();
1910
1911 assert!(sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).is_err());
1913
1914 let sig2 = sign_message("new-name", "evm", "test", None, None, None, Some(vault)).unwrap();
1916 assert_eq!(
1917 sig1.signature, sig2.signature,
1918 "renamed wallet should produce identical signatures"
1919 );
1920 }
1921
1922 #[test]
1923 fn char_create_sign_delete_sign_returns_wallet_not_found() {
1924 let dir = tempfile::tempdir().unwrap();
1925 let vault = dir.path();
1926 create_wallet("del-me-char", None, None, Some(vault)).unwrap();
1927
1928 let sig =
1930 sign_message("del-me-char", "evm", "test", None, None, None, Some(vault)).unwrap();
1931 assert!(!sig.signature.is_empty());
1932
1933 delete_wallet("del-me-char", Some(vault)).unwrap();
1935
1936 let result = sign_message("del-me-char", "evm", "test", None, None, None, Some(vault));
1938 assert!(result.is_err());
1939 match result.unwrap_err() {
1940 OwsLibError::WalletNotFound(name) => assert_eq!(name, "del-me-char"),
1941 other => panic!("expected WalletNotFound, got: {other}"),
1942 }
1943 }
1944
1945 #[test]
1946 fn char_import_sign_export_reimport_sign_deterministic() {
1947 let v1 = tempfile::tempdir().unwrap();
1948 let v2 = tempfile::tempdir().unwrap();
1949
1950 let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1952 import_wallet_mnemonic("char-det", phrase, None, None, Some(v1.path())).unwrap();
1953
1954 let sig1 = sign_message(
1956 "char-det",
1957 "evm",
1958 "determinism test",
1959 None,
1960 None,
1961 None,
1962 Some(v1.path()),
1963 )
1964 .unwrap();
1965
1966 let exported = export_wallet("char-det", None, Some(v1.path())).unwrap();
1968 assert_eq!(exported.trim(), phrase);
1969
1970 import_wallet_mnemonic("char-det-2", &exported, None, None, Some(v2.path())).unwrap();
1972
1973 let sig2 = sign_message(
1975 "char-det-2",
1976 "evm",
1977 "determinism test",
1978 None,
1979 None,
1980 None,
1981 Some(v2.path()),
1982 )
1983 .unwrap();
1984
1985 assert_eq!(
1986 sig1.signature, sig2.signature,
1987 "import→sign→export→reimport→sign must produce identical signatures"
1988 );
1989 }
1990
1991 #[test]
1992 fn char_import_private_key_sign_valid() {
1993 let dir = tempfile::tempdir().unwrap();
1994 let vault = dir.path();
1995
1996 import_wallet_private_key(
1997 "char-pk",
1998 TEST_PRIVKEY,
1999 Some("evm"),
2000 None,
2001 Some(vault),
2002 None,
2003 None,
2004 )
2005 .unwrap();
2006
2007 let sig = sign_transaction("char-pk", "evm", "deadbeef", None, None, Some(vault)).unwrap();
2008 assert!(!sig.signature.is_empty());
2009 assert!(sig.recovery_id.is_some());
2010 }
2011
2012 #[test]
2013 fn char_sign_message_all_chain_families() {
2014 let dir = tempfile::tempdir().unwrap();
2016 let vault = dir.path();
2017 create_wallet("char-all-chains", None, None, Some(vault)).unwrap();
2018
2019 let chains = [
2020 ("evm", true),
2021 ("solana", false),
2022 ("bitcoin", true),
2023 ("cosmos", true),
2024 ("tron", true),
2025 ("ton", false),
2026 ("sui", false),
2027 ];
2028 for (chain, has_recovery_id) in &chains {
2029 let result = sign_message(
2030 "char-all-chains",
2031 chain,
2032 "hello",
2033 None,
2034 None,
2035 None,
2036 Some(vault),
2037 );
2038 assert!(
2039 result.is_ok(),
2040 "sign_message failed for {chain}: {:?}",
2041 result.err()
2042 );
2043 let sig = result.unwrap();
2044 assert!(!sig.signature.is_empty(), "signature empty for {chain}");
2045 if *has_recovery_id {
2046 assert!(
2047 sig.recovery_id.is_some(),
2048 "expected recovery_id for {chain}"
2049 );
2050 }
2051 }
2052 }
2053
2054 #[test]
2055 fn char_sign_typed_data_evm_valid_signature() {
2056 let dir = tempfile::tempdir().unwrap();
2057 let vault = dir.path();
2058 create_wallet("char-typed", None, None, Some(vault)).unwrap();
2059
2060 let typed_data = r#"{
2061 "types": {
2062 "EIP712Domain": [
2063 {"name": "name", "type": "string"},
2064 {"name": "version", "type": "string"},
2065 {"name": "chainId", "type": "uint256"}
2066 ],
2067 "Test": [{"name": "value", "type": "uint256"}]
2068 },
2069 "primaryType": "Test",
2070 "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2071 "message": {"value": "42"}
2072 }"#;
2073
2074 let result = sign_typed_data("char-typed", "evm", typed_data, None, None, Some(vault));
2075 assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2076
2077 let sig = result.unwrap();
2078 let sig_bytes = hex::decode(&sig.signature).unwrap();
2079 assert_eq!(sig_bytes.len(), 65, "EIP-712 signature should be 65 bytes");
2080
2081 let v = sig_bytes[64];
2083 assert!(v == 27 || v == 28, "EIP-712 v should be 27 or 28, got {v}");
2084 }
2085
2086 #[test]
2091 fn char_sign_with_nonzero_account_index() {
2092 let dir = tempfile::tempdir().unwrap();
2095 let vault = dir.path();
2096 create_wallet("char-idx", None, None, Some(vault)).unwrap();
2097
2098 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2099
2100 let sig0 = sign_transaction("char-idx", "evm", tx, None, Some(0), Some(vault)).unwrap();
2101 let sig1 = sign_transaction("char-idx", "evm", tx, None, Some(1), Some(vault)).unwrap();
2102
2103 assert_ne!(
2104 sig0.signature, sig1.signature,
2105 "index 0 and index 1 must produce different signatures (different derived keys)"
2106 );
2107
2108 let sig_default = sign_transaction("char-idx", "evm", tx, None, None, Some(vault)).unwrap();
2110 assert_eq!(
2111 sig0.signature, sig_default.signature,
2112 "index=0 should match index=None (default)"
2113 );
2114 }
2115
2116 #[test]
2117 fn char_sign_with_nonzero_index_sign_message() {
2118 let dir = tempfile::tempdir().unwrap();
2119 let vault = dir.path();
2120 create_wallet("char-idx-msg", None, None, Some(vault)).unwrap();
2121
2122 let sig0 = sign_message(
2123 "char-idx-msg",
2124 "evm",
2125 "hello",
2126 None,
2127 None,
2128 Some(0),
2129 Some(vault),
2130 )
2131 .unwrap();
2132 let sig1 = sign_message(
2133 "char-idx-msg",
2134 "evm",
2135 "hello",
2136 None,
2137 None,
2138 Some(1),
2139 Some(vault),
2140 )
2141 .unwrap();
2142
2143 assert_ne!(
2144 sig0.signature, sig1.signature,
2145 "different account indices should yield different signatures"
2146 );
2147 }
2148
2149 #[test]
2150 fn char_sign_transaction_0x_prefix_stripped() {
2151 let dir = tempfile::tempdir().unwrap();
2154 let vault = dir.path();
2155 create_wallet("char-0x", None, None, Some(vault)).unwrap();
2156
2157 let tx_no_prefix = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2158 let tx_with_prefix = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2159
2160 let sig1 =
2161 sign_transaction("char-0x", "evm", tx_no_prefix, None, None, Some(vault)).unwrap();
2162 let sig2 =
2163 sign_transaction("char-0x", "evm", tx_with_prefix, None, None, Some(vault)).unwrap();
2164
2165 assert_eq!(
2166 sig1.signature, sig2.signature,
2167 "0x-prefixed and bare hex should produce identical signatures"
2168 );
2169 }
2170
2171 #[test]
2172 fn char_24_word_mnemonic_wallet_lifecycle() {
2173 let dir = tempfile::tempdir().unwrap();
2175 let vault = dir.path();
2176
2177 let info = create_wallet("char-24w", Some(24), None, Some(vault)).unwrap();
2178 assert!(!info.accounts.is_empty());
2179
2180 let phrase = export_wallet("char-24w", None, Some(vault)).unwrap();
2182 assert_eq!(
2183 phrase.split_whitespace().count(),
2184 24,
2185 "should be a 24-word mnemonic"
2186 );
2187
2188 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2190 let sig = sign_transaction("char-24w", "evm", tx, None, None, Some(vault)).unwrap();
2191 assert!(!sig.signature.is_empty());
2192
2193 for chain in &["evm", "solana", "bitcoin", "cosmos"] {
2195 let result = sign_message("char-24w", chain, "test", None, None, None, Some(vault));
2196 assert!(
2197 result.is_ok(),
2198 "24-word wallet sign_message failed for {chain}: {:?}",
2199 result.err()
2200 );
2201 }
2202
2203 let v2 = tempfile::tempdir().unwrap();
2205 import_wallet_mnemonic("char-24w-2", &phrase, None, None, Some(v2.path())).unwrap();
2206 let sig2 = sign_transaction("char-24w-2", "evm", tx, None, None, Some(v2.path())).unwrap();
2207 assert_eq!(
2208 sig.signature, sig2.signature,
2209 "reimported 24-word wallet must produce identical signature"
2210 );
2211 }
2212
2213 #[test]
2214 fn char_concurrent_signing() {
2215 use std::sync::Arc;
2218 use std::thread;
2219
2220 let dir = tempfile::tempdir().unwrap();
2221 let vault_path = Arc::new(dir.path().to_path_buf());
2222 create_wallet("char-conc", None, None, Some(&vault_path)).unwrap();
2223
2224 let handles: Vec<_> = (0..8)
2225 .map(|i| {
2226 let vp = Arc::clone(&vault_path);
2227 thread::spawn(move || {
2228 let msg = format!("thread-{i}");
2229 let result = sign_message(
2230 "char-conc",
2231 "evm",
2232 &msg,
2233 None,
2234 None,
2235 None,
2236 Some(vp.as_path()),
2237 );
2238 assert!(
2239 result.is_ok(),
2240 "concurrent sign_message failed in thread {i}: {:?}",
2241 result.err()
2242 );
2243 result.unwrap()
2244 })
2245 })
2246 .collect();
2247
2248 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2249
2250 for (i, sig) in results.iter().enumerate() {
2252 assert!(
2253 !sig.signature.is_empty(),
2254 "thread {i} produced empty signature"
2255 );
2256 }
2257
2258 for i in 0..results.len() {
2260 for j in (i + 1)..results.len() {
2261 assert_ne!(
2262 results[i].signature, results[j].signature,
2263 "threads {i} and {j} should produce different signatures (different messages)"
2264 );
2265 }
2266 }
2267 }
2268
2269 #[test]
2270 fn char_evm_sign_transaction_recoverable() {
2271 use sha3::Digest;
2274
2275 let dir = tempfile::tempdir().unwrap();
2276 let vault = dir.path();
2277 let info = create_wallet("char-tx-recover", None, None, Some(vault)).unwrap();
2278 let evm_addr = info
2279 .accounts
2280 .iter()
2281 .find(|a| a.chain_id.starts_with("eip155:"))
2282 .unwrap()
2283 .address
2284 .clone();
2285
2286 let tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2287 let sig =
2288 sign_transaction("char-tx-recover", "evm", tx_hex, None, None, Some(vault)).unwrap();
2289
2290 let sig_bytes = hex::decode(&sig.signature).unwrap();
2291 assert_eq!(sig_bytes.len(), 65);
2292
2293 let tx_bytes = hex::decode(tx_hex).unwrap();
2295 let hash = sha3::Keccak256::digest(&tx_bytes);
2296
2297 let v = sig_bytes[64];
2298 let recid = k256::ecdsa::RecoveryId::try_from(v).unwrap();
2299 let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
2300 let recovered_key =
2301 k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
2302
2303 let pubkey_bytes = recovered_key.to_encoded_point(false);
2305 let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
2306 let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
2307
2308 assert_eq!(
2309 recovered_addr.to_lowercase(),
2310 evm_addr.to_lowercase(),
2311 "recovered address from tx signature should match wallet's EVM address"
2312 );
2313 }
2314
2315 #[test]
2316 fn char_solana_extract_signable_through_sign_path() {
2317 let dir = tempfile::tempdir().unwrap();
2322 let vault = dir.path();
2323 create_wallet("char-sol-sig", None, None, Some(vault)).unwrap();
2324
2325 let message_payload = b"test solana message payload 1234";
2327 let mut tx_bytes = vec![0x01u8]; tx_bytes.extend_from_slice(&[0u8; 64]); tx_bytes.extend_from_slice(message_payload);
2330 let tx_hex = hex::encode(&tx_bytes);
2331
2332 let sig =
2337 sign_transaction("char-sol-sig", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2338 assert_eq!(
2339 hex::decode(&sig.signature).unwrap().len(),
2340 64,
2341 "Solana signature should be 64 bytes (Ed25519)"
2342 );
2343 assert!(sig.recovery_id.is_none(), "Ed25519 has no recovery ID");
2344
2345 let key =
2348 decrypt_signing_key("char-sol-sig", ChainType::Solana, "", None, Some(vault)).unwrap();
2349 let signer = signer_for_chain(ChainType::Solana);
2350
2351 let signable = signer.extract_signable_bytes(&tx_bytes).unwrap();
2352 assert_eq!(
2353 signable, message_payload,
2354 "extract_signable_bytes should return only the message portion"
2355 );
2356
2357 let output = signer.sign_transaction(key.expose(), signable).unwrap();
2358 let signed_tx = signer
2359 .encode_signed_transaction(&tx_bytes, &output)
2360 .unwrap();
2361
2362 assert_eq!(&signed_tx[1..65], &output.signature[..]);
2364 assert_eq!(&signed_tx[65..], message_payload);
2366 assert_eq!(signed_tx.len(), tx_bytes.len());
2368
2369 let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
2371 let verifying_key = signing_key.verifying_key();
2372 let ed_sig = ed25519_dalek::Signature::from_bytes(&output.signature.try_into().unwrap());
2373 verifying_key
2374 .verify_strict(message_payload, &ed_sig)
2375 .expect("Solana signature should verify against extracted message");
2376 }
2377
2378 #[test]
2379 fn char_library_encodes_before_broadcast() {
2380 let dir = tempfile::tempdir().unwrap();
2387 let vault = dir.path();
2388 create_wallet("char-encode", None, None, Some(vault)).unwrap();
2389
2390 let items: Vec<u8> = [
2392 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(&[]), ]
2402 .concat();
2403 let mut unsigned_tx = vec![0x02u8];
2404 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
2405 let tx_hex = hex::encode(&unsigned_tx);
2406
2407 let raw_sig =
2409 sign_transaction("char-encode", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2410 let raw_sig_bytes = hex::decode(&raw_sig.signature).unwrap();
2411
2412 let key =
2414 decrypt_signing_key("char-encode", ChainType::Evm, "", None, Some(vault)).unwrap();
2415 let signer = signer_for_chain(ChainType::Evm);
2416 let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
2417 let full_signed_tx = signer
2418 .encode_signed_transaction(&unsigned_tx, &output)
2419 .unwrap();
2420
2421 assert_eq!(raw_sig_bytes.len(), 65);
2423
2424 assert!(full_signed_tx.len() > 65);
2426 assert_eq!(
2427 full_signed_tx[0], 0x02,
2428 "should preserve EIP-1559 type byte"
2429 );
2430
2431 assert_ne!(raw_sig_bytes, full_signed_tx);
2433
2434 let r_bytes = &raw_sig_bytes[..32];
2437 let _s_bytes = &raw_sig_bytes[32..64];
2438
2439 let full_hex = hex::encode(&full_signed_tx);
2441 let r_hex = hex::encode(r_bytes);
2442 assert!(
2443 full_hex.contains(&r_hex),
2444 "full signed tx should contain the r component"
2445 );
2446 }
2447
2448 #[test]
2453 fn sign_typed_data_rejects_non_evm_chain() {
2454 let tmp = tempfile::tempdir().unwrap();
2455 let vault = tmp.path();
2456
2457 let w = save_privkey_wallet("typed-data-test", TEST_PRIVKEY, "pass", vault);
2458
2459 let typed_data = r#"{
2460 "types": {
2461 "EIP712Domain": [{"name": "name", "type": "string"}],
2462 "Test": [{"name": "value", "type": "uint256"}]
2463 },
2464 "primaryType": "Test",
2465 "domain": {"name": "Test"},
2466 "message": {"value": "1"}
2467 }"#;
2468
2469 let result = sign_typed_data(&w.id, "solana", typed_data, Some("pass"), None, Some(vault));
2470 assert!(result.is_err());
2471 let err_msg = result.unwrap_err().to_string();
2472 assert!(
2473 err_msg.contains("only supported for EVM"),
2474 "expected EVM-only error, got: {err_msg}"
2475 );
2476 }
2477
2478 #[test]
2479 fn sign_typed_data_evm_succeeds() {
2480 let tmp = tempfile::tempdir().unwrap();
2481 let vault = tmp.path();
2482
2483 let w = save_privkey_wallet("typed-data-evm", TEST_PRIVKEY, "pass", vault);
2484
2485 let typed_data = r#"{
2486 "types": {
2487 "EIP712Domain": [
2488 {"name": "name", "type": "string"},
2489 {"name": "version", "type": "string"},
2490 {"name": "chainId", "type": "uint256"}
2491 ],
2492 "Test": [{"name": "value", "type": "uint256"}]
2493 },
2494 "primaryType": "Test",
2495 "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2496 "message": {"value": "42"}
2497 }"#;
2498
2499 let result = sign_typed_data(&w.id, "evm", typed_data, Some("pass"), None, Some(vault));
2500 assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2501
2502 let sign_result = result.unwrap();
2503 assert!(
2504 !sign_result.signature.is_empty(),
2505 "signature should not be empty"
2506 );
2507 assert!(
2508 sign_result.recovery_id.is_some(),
2509 "recovery_id should be present for EVM"
2510 );
2511 }
2512
2513 #[test]
2519 fn regression_owner_path_identical_to_direct_signer() {
2520 let dir = tempfile::tempdir().unwrap();
2525 let vault = dir.path();
2526 create_wallet("reg-owner", None, None, Some(vault)).unwrap();
2527
2528 let tx_hex = "deadbeefcafebabe";
2529
2530 let api_result =
2532 sign_transaction("reg-owner", "evm", tx_hex, None, None, Some(vault)).unwrap();
2533
2534 let key = decrypt_signing_key("reg-owner", ChainType::Evm, "", None, Some(vault)).unwrap();
2536 let signer = signer_for_chain(ChainType::Evm);
2537 let tx_bytes = hex::decode(tx_hex).unwrap();
2538 let direct_output = signer.sign_transaction(key.expose(), &tx_bytes).unwrap();
2539
2540 assert_eq!(
2541 api_result.signature,
2542 hex::encode(&direct_output.signature),
2543 "library API and direct signer must produce identical signatures"
2544 );
2545 assert_eq!(
2546 api_result.recovery_id, direct_output.recovery_id,
2547 "recovery_id must match"
2548 );
2549 }
2550
2551 #[test]
2552 fn regression_owner_passphrase_not_confused_with_token() {
2553 let dir = tempfile::tempdir().unwrap();
2556 let vault = dir.path();
2557 create_wallet("reg-pass", Some(12), Some("hunter2"), Some(vault)).unwrap();
2558
2559 let tx_hex = "deadbeef";
2560
2561 let result = sign_transaction(
2563 "reg-pass",
2564 "evm",
2565 tx_hex,
2566 Some("hunter2"),
2567 None,
2568 Some(vault),
2569 );
2570 assert!(
2571 result.is_ok(),
2572 "owner-mode signing failed: {:?}",
2573 result.err()
2574 );
2575
2576 let bad = sign_transaction("reg-pass", "evm", tx_hex, Some(""), None, Some(vault));
2579 assert!(bad.is_err());
2580 match bad.unwrap_err() {
2581 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error for wrong passphrase, got: {other}"),
2583 }
2584
2585 let none_result = sign_transaction("reg-pass", "evm", tx_hex, None, None, Some(vault));
2587 assert!(none_result.is_err());
2588 match none_result.unwrap_err() {
2589 OwsLibError::Crypto(_) => {}
2590 other => panic!("expected Crypto error for None passphrase, got: {other}"),
2591 }
2592 }
2593
2594 #[test]
2595 fn regression_sign_message_owner_path_unchanged() {
2596 let dir = tempfile::tempdir().unwrap();
2597 let vault = dir.path();
2598 create_wallet("reg-msg", None, None, Some(vault)).unwrap();
2599
2600 let api_result =
2602 sign_message("reg-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
2603
2604 let key = decrypt_signing_key("reg-msg", ChainType::Evm, "", None, Some(vault)).unwrap();
2606 let signer = signer_for_chain(ChainType::Evm);
2607 let direct = signer.sign_message(key.expose(), b"hello").unwrap();
2608
2609 assert_eq!(
2610 api_result.signature,
2611 hex::encode(&direct.signature),
2612 "sign_message owner path must match direct signer"
2613 );
2614 }
2615
2616 #[test]
2621 fn solana_broadcast_body_includes_encoding_param() {
2622 let dummy_tx = vec![0x01; 100];
2623 let body = build_solana_rpc_body(&dummy_tx);
2624
2625 assert_eq!(body["method"], "sendTransaction");
2626 assert_eq!(
2627 body["params"][1]["encoding"], "base64",
2628 "sendTransaction must specify encoding=base64 so Solana RPC \
2629 does not default to base58"
2630 );
2631 }
2632
2633 #[test]
2634 fn solana_broadcast_body_uses_base64_encoding() {
2635 use base64::Engine;
2636 let dummy_tx = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03];
2637 let body = build_solana_rpc_body(&dummy_tx);
2638
2639 let encoded = body["params"][0].as_str().unwrap();
2640 let decoded = base64::engine::general_purpose::STANDARD
2642 .decode(encoded)
2643 .expect("params[0] should be valid base64");
2644 assert_eq!(
2645 decoded, dummy_tx,
2646 "base64 should round-trip to original bytes"
2647 );
2648 }
2649
2650 #[test]
2651 fn solana_broadcast_body_is_not_hex_or_base58() {
2652 let dummy_tx = vec![0xFF; 50];
2654 let body = build_solana_rpc_body(&dummy_tx);
2655
2656 let encoded = body["params"][0].as_str().unwrap();
2657 let hex_encoded = hex::encode(&dummy_tx);
2658 assert_ne!(encoded, hex_encoded, "broadcast should use base64, not hex");
2659 assert!(
2662 encoded.contains('/') || encoded.contains('+') || encoded.ends_with('='),
2663 "base64 of 0xFF bytes should contain characters absent from base58"
2664 );
2665 }
2666
2667 #[test]
2668 fn solana_broadcast_body_jsonrpc_structure() {
2669 let body = build_solana_rpc_body(&[0u8; 10]);
2670 assert_eq!(body["jsonrpc"], "2.0");
2671 assert_eq!(body["id"], 1);
2672 assert_eq!(body["method"], "sendTransaction");
2673 assert!(body["params"].is_array());
2674 assert_eq!(
2675 body["params"].as_array().unwrap().len(),
2676 2,
2677 "params should have [tx_data, options_object]"
2678 );
2679 }
2680
2681 #[test]
2686 fn solana_sign_transaction_extracts_signable_bytes() {
2687 let dir = tempfile::tempdir().unwrap();
2690 let vault = dir.path();
2691 create_wallet("sol-extract", None, None, Some(vault)).unwrap();
2692
2693 let message_payload = b"test solana message for extraction";
2694 let mut full_tx = vec![0x01u8]; full_tx.extend_from_slice(&[0u8; 64]); full_tx.extend_from_slice(message_payload);
2697 let tx_hex = hex::encode(&full_tx);
2698
2699 let sig_result =
2701 sign_transaction("sol-extract", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2702 let sig_bytes = hex::decode(&sig_result.signature).unwrap();
2703
2704 let key =
2706 decrypt_signing_key("sol-extract", ChainType::Solana, "", None, Some(vault)).unwrap();
2707 let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
2708 let verifying_key = signing_key.verifying_key();
2709 let ed_sig = ed25519_dalek::Signature::from_bytes(&sig_bytes.try_into().unwrap());
2710
2711 verifying_key
2712 .verify_strict(message_payload, &ed_sig)
2713 .expect("sign_transaction should sign the message portion, not the full envelope");
2714 }
2715
2716 #[test]
2717 fn solana_sign_transaction_full_tx_matches_extracted_sign() {
2718 let dir = tempfile::tempdir().unwrap();
2721 let vault = dir.path();
2722 create_wallet("sol-match", None, None, Some(vault)).unwrap();
2723
2724 let message_payload = b"matching signatures test";
2725 let mut full_tx = vec![0x01u8];
2726 full_tx.extend_from_slice(&[0u8; 64]);
2727 full_tx.extend_from_slice(message_payload);
2728 let tx_hex = hex::encode(&full_tx);
2729
2730 let api_sig =
2732 sign_transaction("sol-match", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2733
2734 let key =
2736 decrypt_signing_key("sol-match", ChainType::Solana, "", None, Some(vault)).unwrap();
2737 let signer = signer_for_chain(ChainType::Solana);
2738 let signable = signer.extract_signable_bytes(&full_tx).unwrap();
2739 let direct = signer.sign_transaction(key.expose(), signable).unwrap();
2740
2741 assert_eq!(
2742 api_sig.signature,
2743 hex::encode(&direct.signature),
2744 "sign_transaction API and manual extract+sign must produce the same signature"
2745 );
2746 }
2747
2748 #[test]
2749 fn evm_sign_transaction_unaffected_by_extraction() {
2750 let dir = tempfile::tempdir().unwrap();
2753 let vault = dir.path();
2754 create_wallet("evm-regress", None, None, Some(vault)).unwrap();
2755
2756 let items: Vec<u8> = [
2757 ows_signer::rlp::encode_bytes(&[1]),
2758 ows_signer::rlp::encode_bytes(&[]),
2759 ows_signer::rlp::encode_bytes(&[1]),
2760 ows_signer::rlp::encode_bytes(&[100]),
2761 ows_signer::rlp::encode_bytes(&[0x52, 0x08]),
2762 ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]),
2763 ows_signer::rlp::encode_bytes(&[]),
2764 ows_signer::rlp::encode_bytes(&[]),
2765 ows_signer::rlp::encode_list(&[]),
2766 ]
2767 .concat();
2768 let mut unsigned_tx = vec![0x02u8];
2769 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
2770 let tx_hex = hex::encode(&unsigned_tx);
2771
2772 let sig1 =
2774 sign_transaction("evm-regress", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2775 let sig2 =
2776 sign_transaction("evm-regress", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2777 assert_eq!(sig1.signature, sig2.signature);
2778 assert_eq!(hex::decode(&sig1.signature).unwrap().len(), 65);
2779 }
2780
2781 #[test]
2786 #[ignore] fn solana_devnet_broadcast_encoding_accepted() {
2788 let bh_body = serde_json::json!({
2794 "jsonrpc": "2.0",
2795 "method": "getLatestBlockhash",
2796 "params": [],
2797 "id": 1
2798 });
2799 let bh_resp =
2800 curl_post_json("https://api.devnet.solana.com", &bh_body.to_string()).unwrap();
2801 let bh_parsed: serde_json::Value = serde_json::from_str(&bh_resp).unwrap();
2802 let blockhash_b58 = bh_parsed["result"]["value"]["blockhash"]
2803 .as_str()
2804 .expect("devnet should return a blockhash");
2805 let blockhash = bs58::decode(blockhash_b58).into_vec().unwrap();
2806 assert_eq!(blockhash.len(), 32);
2807
2808 let privkey =
2810 hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
2811 .unwrap();
2812 let signing_key =
2813 ed25519_dalek::SigningKey::from_bytes(&privkey.clone().try_into().unwrap());
2814 let sender_pubkey = signing_key.verifying_key().to_bytes();
2815
2816 let recipient_pubkey = [0x01; 32]; let system_program = [0u8; 32]; let mut message = vec![
2821 1, 0, 1, 3, ];
2826 message.extend_from_slice(&sender_pubkey);
2827 message.extend_from_slice(&recipient_pubkey);
2828 message.extend_from_slice(&system_program);
2829 message.extend_from_slice(&blockhash);
2831 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);
2845
2846 let result = sign_encode_and_broadcast(
2848 &privkey,
2849 "solana",
2850 &tx_bytes,
2851 Some("https://api.devnet.solana.com"),
2852 );
2853
2854 match result {
2856 Ok(send_result) => {
2857 assert!(!send_result.tx_hash.is_empty());
2859 }
2860 Err(e) => {
2861 let err_str = format!("{e}");
2862 assert!(
2863 !err_str.contains("base58"),
2864 "should not get base58 encoding error: {err_str}"
2865 );
2866 assert!(
2867 !err_str.contains("InvalidCharacter"),
2868 "should not get InvalidCharacter error: {err_str}"
2869 );
2870 }
2872 }
2873 }
2874}