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(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
426pub fn sign_transaction(
432 wallet: &str,
433 chain: &str,
434 tx_hex: &str,
435 passphrase: Option<&str>,
436 index: Option<u32>,
437 vault_path: Option<&Path>,
438) -> Result<SignResult, OwsLibError> {
439 let credential = passphrase.unwrap_or("");
440
441 let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
442 let tx_bytes = hex::decode(tx_hex_clean)
443 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
444
445 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
447 let chain = parse_chain(chain)?;
448 return crate::key_ops::sign_with_api_key(
449 credential, wallet, &chain, &tx_bytes, index, vault_path,
450 );
451 }
452
453 let chain = parse_chain(chain)?;
455 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
456 let signer = signer_for_chain(chain.chain_type);
457 let signable = signer.extract_signable_bytes(&tx_bytes)?;
458 let output = signer.sign_transaction(key.expose(), signable)?;
459
460 Ok(SignResult {
461 signature: hex::encode(&output.signature),
462 recovery_id: output.recovery_id,
463 })
464}
465
466pub fn sign_message(
471 wallet: &str,
472 chain: &str,
473 message: &str,
474 passphrase: Option<&str>,
475 encoding: Option<&str>,
476 index: Option<u32>,
477 vault_path: Option<&Path>,
478) -> Result<SignResult, OwsLibError> {
479 let credential = passphrase.unwrap_or("");
480
481 let encoding = encoding.unwrap_or("utf8");
482 let msg_bytes = match encoding {
483 "utf8" => message.as_bytes().to_vec(),
484 "hex" => hex::decode(message)
485 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex message: {e}")))?,
486 _ => {
487 return Err(OwsLibError::InvalidInput(format!(
488 "unsupported encoding: {encoding} (use 'utf8' or 'hex')"
489 )))
490 }
491 };
492
493 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
495 let chain = parse_chain(chain)?;
496 return crate::key_ops::sign_message_with_api_key(
497 credential, wallet, &chain, &msg_bytes, index, vault_path,
498 );
499 }
500
501 let chain = parse_chain(chain)?;
503 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
504 let signer = signer_for_chain(chain.chain_type);
505 let output = signer.sign_message(key.expose(), &msg_bytes)?;
506
507 Ok(SignResult {
508 signature: hex::encode(&output.signature),
509 recovery_id: output.recovery_id,
510 })
511}
512
513pub fn sign_typed_data(
519 wallet: &str,
520 chain: &str,
521 typed_data_json: &str,
522 passphrase: Option<&str>,
523 index: Option<u32>,
524 vault_path: Option<&Path>,
525) -> Result<SignResult, OwsLibError> {
526 let credential = passphrase.unwrap_or("");
527 let chain = parse_chain(chain)?;
528
529 if chain.chain_type != ows_core::ChainType::Evm {
530 return Err(OwsLibError::InvalidInput(
531 "EIP-712 typed data signing is only supported for EVM chains".into(),
532 ));
533 }
534
535 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
536 return Err(OwsLibError::InvalidInput(
537 "EIP-712 typed data signing via API key is not yet supported; use sign_transaction"
538 .into(),
539 ));
540 }
541
542 let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
543 let evm_signer = ows_signer::chains::EvmSigner;
544 let output = evm_signer.sign_typed_data(key.expose(), typed_data_json)?;
545
546 Ok(SignResult {
547 signature: hex::encode(&output.signature),
548 recovery_id: output.recovery_id,
549 })
550}
551
552pub fn sign_and_send(
558 wallet: &str,
559 chain: &str,
560 tx_hex: &str,
561 passphrase: Option<&str>,
562 index: Option<u32>,
563 rpc_url: Option<&str>,
564 vault_path: Option<&Path>,
565) -> Result<SendResult, OwsLibError> {
566 let credential = passphrase.unwrap_or("");
567
568 let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
569 let tx_bytes = hex::decode(tx_hex_clean)
570 .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
571
572 if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
574 let chain_info = parse_chain(chain)?;
575 let (key, _) = crate::key_ops::enforce_policy_and_decrypt_key(
576 credential,
577 wallet,
578 &chain_info,
579 &tx_bytes,
580 index,
581 vault_path,
582 )?;
583 return sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url);
584 }
585
586 let chain_info = parse_chain(chain)?;
588 let key = decrypt_signing_key(wallet, chain_info.chain_type, credential, index, vault_path)?;
589
590 sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url)
591}
592
593pub fn sign_encode_and_broadcast(
600 private_key: &[u8],
601 chain: &str,
602 tx_bytes: &[u8],
603 rpc_url: Option<&str>,
604) -> Result<SendResult, OwsLibError> {
605 let chain = parse_chain(chain)?;
606 let signer = signer_for_chain(chain.chain_type);
607
608 let signable = signer.extract_signable_bytes(tx_bytes)?;
610
611 let output = signer.sign_transaction(private_key, signable)?;
613
614 let signed_tx = signer.encode_signed_transaction(tx_bytes, &output)?;
616
617 let rpc = resolve_rpc_url(chain.chain_id, chain.chain_type, rpc_url)?;
619
620 let tx_hash = broadcast(chain.chain_type, &rpc, &signed_tx)?;
622
623 Ok(SendResult { tx_hash })
624}
625
626pub fn decrypt_signing_key(
633 wallet_name_or_id: &str,
634 chain_type: ChainType,
635 passphrase: &str,
636 index: Option<u32>,
637 vault_path: Option<&Path>,
638) -> Result<SecretBytes, OwsLibError> {
639 let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
640 let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
641 let secret = decrypt(&envelope, passphrase)?;
642 secret_to_signing_key(&secret, &wallet.key_type, chain_type, index)
643}
644
645fn resolve_rpc_url(
647 chain_id: &str,
648 chain_type: ChainType,
649 explicit: Option<&str>,
650) -> Result<String, OwsLibError> {
651 if let Some(url) = explicit {
652 return Ok(url.to_string());
653 }
654
655 let config = Config::load_or_default();
656 let defaults = Config::default_rpc();
657
658 if let Some(url) = config.rpc.get(chain_id) {
660 return Ok(url.clone());
661 }
662 if let Some(url) = defaults.get(chain_id) {
663 return Ok(url.clone());
664 }
665
666 let namespace = chain_type.namespace();
668 for (key, url) in &config.rpc {
669 if key.starts_with(namespace) {
670 return Ok(url.clone());
671 }
672 }
673 for (key, url) in &defaults {
674 if key.starts_with(namespace) {
675 return Ok(url.clone());
676 }
677 }
678
679 Err(OwsLibError::InvalidInput(format!(
680 "no RPC URL configured for chain '{chain_id}'"
681 )))
682}
683
684fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
686 match chain {
687 ChainType::Evm => broadcast_evm(rpc_url, signed_bytes),
688 ChainType::Solana => broadcast_solana(rpc_url, signed_bytes),
689 ChainType::Bitcoin => broadcast_bitcoin(rpc_url, signed_bytes),
690 ChainType::Cosmos => broadcast_cosmos(rpc_url, signed_bytes),
691 ChainType::Tron => broadcast_tron(rpc_url, signed_bytes),
692 ChainType::Ton => broadcast_ton(rpc_url, signed_bytes),
693 ChainType::Spark => Err(OwsLibError::InvalidInput(
694 "broadcast not yet supported for Spark".into(),
695 )),
696 ChainType::Filecoin => Err(OwsLibError::InvalidInput(
697 "broadcast not yet supported for Filecoin".into(),
698 )),
699 ChainType::Sui => broadcast_sui(rpc_url, signed_bytes),
700 ChainType::Xrpl => broadcast_xrpl(rpc_url, signed_bytes),
701 }
702}
703
704fn broadcast_xrpl(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
705 let tx_blob = hex::encode_upper(signed_bytes);
706 let body = serde_json::json!({
707 "method": "submit",
708 "params": [{ "tx_blob": tx_blob }]
709 });
710 let resp_str = curl_post_json(rpc_url, &body.to_string())?;
711 let resp: serde_json::Value = serde_json::from_str(&resp_str)?;
712
713 let engine_result = resp["result"]["engine_result"].as_str().unwrap_or("");
715 if !engine_result.starts_with("tes") {
716 let msg = resp["result"]["engine_result_message"]
717 .as_str()
718 .unwrap_or(engine_result);
719 return Err(OwsLibError::BroadcastFailed(format!(
720 "XRPL submit failed ({engine_result}): {msg}"
721 )));
722 }
723
724 resp["result"]["tx_json"]["hash"]
725 .as_str()
726 .map(|s| s.to_string())
727 .ok_or_else(|| {
728 OwsLibError::BroadcastFailed(format!("no hash in XRPL response: {resp_str}"))
729 })
730}
731
732fn broadcast_evm(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
733 let hex_tx = format!("0x{}", hex::encode(signed_bytes));
734 let body = serde_json::json!({
735 "jsonrpc": "2.0",
736 "method": "eth_sendRawTransaction",
737 "params": [hex_tx],
738 "id": 1
739 });
740 let resp = curl_post_json(rpc_url, &body.to_string())?;
741 extract_json_field(&resp, "result")
742}
743
744fn build_solana_rpc_body(signed_bytes: &[u8]) -> serde_json::Value {
745 use base64::Engine;
746 let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
747 serde_json::json!({
748 "jsonrpc": "2.0",
749 "method": "sendTransaction",
750 "params": [b64_tx, {"encoding": "base64"}],
751 "id": 1
752 })
753}
754
755fn broadcast_solana(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
756 let body = build_solana_rpc_body(signed_bytes);
757 let resp = curl_post_json(rpc_url, &body.to_string())?;
758 extract_json_field(&resp, "result")
759}
760
761fn broadcast_bitcoin(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
762 let hex_tx = hex::encode(signed_bytes);
763 let url = format!("{}/tx", rpc_url.trim_end_matches('/'));
764 let output = Command::new("curl")
765 .args([
766 "-fsSL",
767 "-X",
768 "POST",
769 "-H",
770 "Content-Type: text/plain",
771 "-d",
772 &hex_tx,
773 &url,
774 ])
775 .output()
776 .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
777
778 if !output.status.success() {
779 let stderr = String::from_utf8_lossy(&output.stderr);
780 return Err(OwsLibError::BroadcastFailed(format!(
781 "broadcast failed: {stderr}"
782 )));
783 }
784
785 let tx_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
786 if tx_hash.is_empty() {
787 return Err(OwsLibError::BroadcastFailed(
788 "empty response from broadcast".into(),
789 ));
790 }
791 Ok(tx_hash)
792}
793
794fn broadcast_cosmos(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
795 use base64::Engine;
796 let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
797 let url = format!("{}/cosmos/tx/v1beta1/txs", rpc_url.trim_end_matches('/'));
798 let body = serde_json::json!({
799 "tx_bytes": b64_tx,
800 "mode": "BROADCAST_MODE_SYNC"
801 });
802 let resp = curl_post_json(&url, &body.to_string())?;
803 let parsed: serde_json::Value = serde_json::from_str(&resp)?;
804 parsed["tx_response"]["txhash"]
805 .as_str()
806 .map(|s| s.to_string())
807 .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no txhash in response: {resp}")))
808}
809
810fn broadcast_tron(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
811 let hex_tx = hex::encode(signed_bytes);
812 let url = format!("{}/wallet/broadcasthex", rpc_url.trim_end_matches('/'));
813 let body = serde_json::json!({ "transaction": hex_tx });
814 let resp = curl_post_json(&url, &body.to_string())?;
815 extract_json_field(&resp, "txid")
816}
817
818fn broadcast_ton(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
819 use base64::Engine;
820 let b64_boc = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
821 let url = format!("{}/sendBoc", rpc_url.trim_end_matches('/'));
822 let body = serde_json::json!({ "boc": b64_boc });
823 let resp = curl_post_json(&url, &body.to_string())?;
824 let parsed: serde_json::Value = serde_json::from_str(&resp)?;
825 parsed["result"]["hash"]
826 .as_str()
827 .map(|s| s.to_string())
828 .ok_or_else(|| OwsLibError::BroadcastFailed(format!("no hash in response: {resp}")))
829}
830
831fn broadcast_sui(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
832 use ows_signer::chains::sui::WIRE_SIG_LEN;
833
834 if signed_bytes.len() <= WIRE_SIG_LEN {
835 return Err(OwsLibError::InvalidInput(
836 "signed transaction too short to contain tx + signature".into(),
837 ));
838 }
839
840 let split = signed_bytes.len() - WIRE_SIG_LEN;
841 let tx_part = &signed_bytes[..split];
842 let sig_part = &signed_bytes[split..];
843
844 crate::sui_grpc::execute_transaction(rpc_url, tx_part, sig_part)
845}
846
847fn curl_post_json(url: &str, body: &str) -> Result<String, OwsLibError> {
848 let output = Command::new("curl")
849 .args([
850 "-fsSL",
851 "-X",
852 "POST",
853 "-H",
854 "Content-Type: application/json",
855 "-d",
856 body,
857 url,
858 ])
859 .output()
860 .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
861
862 if !output.status.success() {
863 let stderr = String::from_utf8_lossy(&output.stderr);
864 return Err(OwsLibError::BroadcastFailed(format!(
865 "broadcast failed: {stderr}"
866 )));
867 }
868
869 Ok(String::from_utf8_lossy(&output.stdout).to_string())
870}
871
872fn extract_json_field(json_str: &str, field: &str) -> Result<String, OwsLibError> {
873 let parsed: serde_json::Value = serde_json::from_str(json_str)?;
874
875 if let Some(error) = parsed.get("error") {
876 return Err(OwsLibError::BroadcastFailed(format!("RPC error: {error}")));
877 }
878
879 parsed[field]
880 .as_str()
881 .map(|s| s.to_string())
882 .ok_or_else(|| {
883 OwsLibError::BroadcastFailed(format!("no '{field}' in response: {json_str}"))
884 })
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890
891 fn save_privkey_wallet(
896 name: &str,
897 privkey_hex: &str,
898 passphrase: &str,
899 vault: &Path,
900 ) -> WalletInfo {
901 let key_bytes = hex::decode(privkey_hex).unwrap();
902
903 let mut ed_key = vec![0u8; 32];
905 getrandom::getrandom(&mut ed_key).unwrap();
906
907 let keys = KeyPair {
908 secp256k1: key_bytes,
909 ed25519: ed_key,
910 };
911 let accounts = derive_all_accounts_from_keys(&keys).unwrap();
912 let payload = keys.to_json_bytes();
913 let crypto_envelope = encrypt(&payload, passphrase).unwrap();
914 let crypto_json = serde_json::to_value(&crypto_envelope).unwrap();
915 let wallet = EncryptedWallet::new(
916 uuid::Uuid::new_v4().to_string(),
917 name.to_string(),
918 accounts,
919 crypto_json,
920 KeyType::PrivateKey,
921 );
922 vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
923 wallet_to_info(&wallet)
924 }
925
926 const TEST_PRIVKEY: &str = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
927
928 #[test]
933 fn mnemonic_12_words() {
934 let phrase = generate_mnemonic(12).unwrap();
935 assert_eq!(phrase.split_whitespace().count(), 12);
936 }
937
938 #[test]
939 fn mnemonic_24_words() {
940 let phrase = generate_mnemonic(24).unwrap();
941 assert_eq!(phrase.split_whitespace().count(), 24);
942 }
943
944 #[test]
945 fn mnemonic_invalid_word_count() {
946 assert!(generate_mnemonic(15).is_err());
947 assert!(generate_mnemonic(0).is_err());
948 assert!(generate_mnemonic(13).is_err());
949 }
950
951 #[test]
952 fn mnemonic_is_unique_each_call() {
953 let a = generate_mnemonic(12).unwrap();
954 let b = generate_mnemonic(12).unwrap();
955 assert_ne!(a, b, "two generated mnemonics should differ");
956 }
957
958 #[test]
963 fn derive_address_all_chains() {
964 let phrase = generate_mnemonic(12).unwrap();
965 let chains = [
966 "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "sui", "xrpl",
967 ];
968 for chain in &chains {
969 let addr = derive_address(&phrase, chain, None).unwrap();
970 assert!(!addr.is_empty(), "address should be non-empty for {chain}");
971 }
972 }
973
974 #[test]
975 fn derive_address_evm_format() {
976 let phrase = generate_mnemonic(12).unwrap();
977 let addr = derive_address(&phrase, "evm", None).unwrap();
978 assert!(addr.starts_with("0x"), "EVM address should start with 0x");
979 assert_eq!(addr.len(), 42, "EVM address should be 42 chars");
980 }
981
982 #[test]
983 fn derive_address_deterministic() {
984 let phrase = generate_mnemonic(12).unwrap();
985 let a = derive_address(&phrase, "evm", None).unwrap();
986 let b = derive_address(&phrase, "evm", None).unwrap();
987 assert_eq!(a, b, "same mnemonic should produce same address");
988 }
989
990 #[test]
991 fn derive_address_different_index() {
992 let phrase = generate_mnemonic(12).unwrap();
993 let a = derive_address(&phrase, "evm", Some(0)).unwrap();
994 let b = derive_address(&phrase, "evm", Some(1)).unwrap();
995 assert_ne!(a, b, "different indices should produce different addresses");
996 }
997
998 #[test]
999 fn derive_address_invalid_chain() {
1000 let phrase = generate_mnemonic(12).unwrap();
1001 assert!(derive_address(&phrase, "nonexistent", None).is_err());
1002 }
1003
1004 #[test]
1005 fn derive_address_invalid_mnemonic() {
1006 assert!(derive_address("not a valid mnemonic phrase at all", "evm", None).is_err());
1007 }
1008
1009 #[test]
1014 fn mnemonic_wallet_create_export_reimport() {
1015 let v1 = tempfile::tempdir().unwrap();
1016 let v2 = tempfile::tempdir().unwrap();
1017
1018 let w1 = create_wallet("w1", None, None, Some(v1.path())).unwrap();
1020 assert!(!w1.accounts.is_empty());
1021
1022 let phrase = export_wallet("w1", None, Some(v1.path())).unwrap();
1024 assert_eq!(phrase.split_whitespace().count(), 12);
1025
1026 let w2 = import_wallet_mnemonic("w2", &phrase, None, None, Some(v2.path())).unwrap();
1028
1029 assert_eq!(w1.accounts.len(), w2.accounts.len());
1031 for (a1, a2) in w1.accounts.iter().zip(w2.accounts.iter()) {
1032 assert_eq!(a1.chain_id, a2.chain_id);
1033 assert_eq!(
1034 a1.address, a2.address,
1035 "address mismatch for {}",
1036 a1.chain_id
1037 );
1038 }
1039 }
1040
1041 #[test]
1042 fn mnemonic_wallet_sign_message_all_chains() {
1043 let dir = tempfile::tempdir().unwrap();
1044 let vault = dir.path();
1045 create_wallet("multi-sign", None, None, Some(vault)).unwrap();
1046
1047 let chains = [
1048 "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui",
1049 ];
1050 for chain in &chains {
1051 let result = sign_message(
1052 "multi-sign",
1053 chain,
1054 "test msg",
1055 None,
1056 None,
1057 None,
1058 Some(vault),
1059 );
1060 assert!(
1061 result.is_ok(),
1062 "sign_message should work for {chain}: {:?}",
1063 result.err()
1064 );
1065 let sig = result.unwrap();
1066 assert!(
1067 !sig.signature.is_empty(),
1068 "signature should be non-empty for {chain}"
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn mnemonic_wallet_sign_tx_all_chains() {
1075 let dir = tempfile::tempdir().unwrap();
1076 let vault = dir.path();
1077 create_wallet("tx-sign", None, None, Some(vault)).unwrap();
1078
1079 let generic_tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1080 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);
1086
1087 let chains = [
1088 "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", "xrpl",
1089 ];
1090 for chain in &chains {
1091 let tx = if *chain == "solana" {
1092 &solana_tx_hex
1093 } else {
1094 generic_tx_hex
1095 };
1096 let result = sign_transaction("tx-sign", chain, tx, None, None, Some(vault));
1097 assert!(
1098 result.is_ok(),
1099 "sign_transaction should work for {chain}: {:?}",
1100 result.err()
1101 );
1102 }
1103 }
1104
1105 #[test]
1106 fn mnemonic_wallet_signing_is_deterministic() {
1107 let dir = tempfile::tempdir().unwrap();
1108 let vault = dir.path();
1109 create_wallet("det-sign", None, None, Some(vault)).unwrap();
1110
1111 let s1 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1112 let s2 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
1113 assert_eq!(
1114 s1.signature, s2.signature,
1115 "same message should produce same signature"
1116 );
1117 }
1118
1119 #[test]
1120 fn mnemonic_wallet_different_messages_produce_different_sigs() {
1121 let dir = tempfile::tempdir().unwrap();
1122 let vault = dir.path();
1123 create_wallet("diff-msg", None, None, Some(vault)).unwrap();
1124
1125 let s1 = sign_message("diff-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
1126 let s2 = sign_message("diff-msg", "evm", "world", None, None, None, Some(vault)).unwrap();
1127 assert_ne!(s1.signature, s2.signature);
1128 }
1129
1130 #[test]
1135 fn privkey_wallet_sign_message() {
1136 let dir = tempfile::tempdir().unwrap();
1137 save_privkey_wallet("pk-sign", TEST_PRIVKEY, "", dir.path());
1138
1139 let sig = sign_message(
1140 "pk-sign",
1141 "evm",
1142 "hello",
1143 None,
1144 None,
1145 None,
1146 Some(dir.path()),
1147 )
1148 .unwrap();
1149 assert!(!sig.signature.is_empty());
1150 assert!(sig.recovery_id.is_some());
1151 }
1152
1153 #[test]
1154 fn privkey_wallet_sign_transaction() {
1155 let dir = tempfile::tempdir().unwrap();
1156 save_privkey_wallet("pk-tx", TEST_PRIVKEY, "", dir.path());
1157
1158 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1159 let sig = sign_transaction("pk-tx", "evm", tx, None, None, Some(dir.path())).unwrap();
1160 assert!(!sig.signature.is_empty());
1161 }
1162
1163 #[test]
1164 fn privkey_wallet_export_returns_json() {
1165 let dir = tempfile::tempdir().unwrap();
1166 save_privkey_wallet("pk-export", TEST_PRIVKEY, "", dir.path());
1167
1168 let exported = export_wallet("pk-export", None, Some(dir.path())).unwrap();
1169 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1170 assert_eq!(
1171 obj["secp256k1"].as_str().unwrap(),
1172 TEST_PRIVKEY,
1173 "exported secp256k1 key should match original"
1174 );
1175 assert!(obj["ed25519"].as_str().is_some(), "should have ed25519 key");
1176 }
1177
1178 #[test]
1179 fn privkey_wallet_signing_is_deterministic() {
1180 let dir = tempfile::tempdir().unwrap();
1181 save_privkey_wallet("pk-det", TEST_PRIVKEY, "", dir.path());
1182
1183 let s1 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1184 let s2 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
1185 assert_eq!(s1.signature, s2.signature);
1186 }
1187
1188 #[test]
1189 fn privkey_and_mnemonic_wallets_produce_different_sigs() {
1190 let dir = tempfile::tempdir().unwrap();
1191 let vault = dir.path();
1192
1193 create_wallet("mn-w", None, None, Some(vault)).unwrap();
1194 save_privkey_wallet("pk-w", TEST_PRIVKEY, "", vault);
1195
1196 let mn_sig = sign_message("mn-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1197 let pk_sig = sign_message("pk-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
1198 assert_ne!(
1199 mn_sig.signature, pk_sig.signature,
1200 "different keys should produce different signatures"
1201 );
1202 }
1203
1204 #[test]
1205 fn privkey_wallet_import_via_api() {
1206 let dir = tempfile::tempdir().unwrap();
1207 let vault = dir.path();
1208
1209 let info = import_wallet_private_key(
1210 "pk-api",
1211 TEST_PRIVKEY,
1212 Some("evm"),
1213 None,
1214 Some(vault),
1215 None,
1216 None,
1217 )
1218 .unwrap();
1219 assert!(
1220 !info.accounts.is_empty(),
1221 "should derive at least one account"
1222 );
1223
1224 let sig = sign_message("pk-api", "evm", "hello", None, None, None, Some(vault)).unwrap();
1226 assert!(!sig.signature.is_empty());
1227
1228 let exported = export_wallet("pk-api", None, Some(vault)).unwrap();
1230 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1231 assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1232 }
1233
1234 #[test]
1235 fn privkey_wallet_import_both_curve_keys() {
1236 let dir = tempfile::tempdir().unwrap();
1237 let vault = dir.path();
1238
1239 let secp_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
1240 let ed_key = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
1241
1242 let info = import_wallet_private_key(
1243 "pk-both",
1244 "", None, None,
1247 Some(vault),
1248 Some(secp_key),
1249 Some(ed_key),
1250 )
1251 .unwrap();
1252
1253 assert_eq!(
1254 info.accounts.len(),
1255 ALL_CHAIN_TYPES.len(),
1256 "should have one account per chain type"
1257 );
1258
1259 let sig = sign_message("pk-both", "evm", "hello", None, None, None, Some(vault)).unwrap();
1261 assert!(!sig.signature.is_empty());
1262
1263 let sig =
1265 sign_message("pk-both", "solana", "hello", None, None, None, Some(vault)).unwrap();
1266 assert!(!sig.signature.is_empty());
1267
1268 let exported = export_wallet("pk-both", None, Some(vault)).unwrap();
1270 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1271 assert_eq!(obj["secp256k1"].as_str().unwrap(), secp_key);
1272 assert_eq!(obj["ed25519"].as_str().unwrap(), ed_key);
1273 }
1274
1275 #[test]
1280 fn passphrase_protected_mnemonic_wallet() {
1281 let dir = tempfile::tempdir().unwrap();
1282 let vault = dir.path();
1283
1284 create_wallet("pass-mn", None, Some("s3cret"), Some(vault)).unwrap();
1285
1286 let sig = sign_message(
1288 "pass-mn",
1289 "evm",
1290 "hello",
1291 Some("s3cret"),
1292 None,
1293 None,
1294 Some(vault),
1295 )
1296 .unwrap();
1297 assert!(!sig.signature.is_empty());
1298
1299 let phrase = export_wallet("pass-mn", Some("s3cret"), Some(vault)).unwrap();
1301 assert_eq!(phrase.split_whitespace().count(), 12);
1302
1303 assert!(sign_message(
1305 "pass-mn",
1306 "evm",
1307 "hello",
1308 Some("wrong"),
1309 None,
1310 None,
1311 Some(vault)
1312 )
1313 .is_err());
1314 assert!(export_wallet("pass-mn", Some("wrong"), Some(vault)).is_err());
1315
1316 assert!(sign_message("pass-mn", "evm", "hello", None, None, None, Some(vault)).is_err());
1318 }
1319
1320 #[test]
1321 fn passphrase_protected_privkey_wallet() {
1322 let dir = tempfile::tempdir().unwrap();
1323 save_privkey_wallet("pass-pk", TEST_PRIVKEY, "mypass", dir.path());
1324
1325 let sig = sign_message(
1327 "pass-pk",
1328 "evm",
1329 "hello",
1330 Some("mypass"),
1331 None,
1332 None,
1333 Some(dir.path()),
1334 )
1335 .unwrap();
1336 assert!(!sig.signature.is_empty());
1337
1338 let exported = export_wallet("pass-pk", Some("mypass"), Some(dir.path())).unwrap();
1339 let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
1340 assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
1341
1342 assert!(sign_message(
1344 "pass-pk",
1345 "evm",
1346 "hello",
1347 Some("wrong"),
1348 None,
1349 None,
1350 Some(dir.path())
1351 )
1352 .is_err());
1353 assert!(export_wallet("pass-pk", Some("wrong"), Some(dir.path())).is_err());
1354 }
1355
1356 #[test]
1361 fn evm_signature_is_recoverable() {
1362 use sha3::Digest;
1363 let dir = tempfile::tempdir().unwrap();
1364 let vault = dir.path();
1365
1366 let info = create_wallet("verify-evm", None, None, Some(vault)).unwrap();
1367 let evm_addr = info
1368 .accounts
1369 .iter()
1370 .find(|a| a.chain_id.starts_with("eip155:"))
1371 .unwrap()
1372 .address
1373 .clone();
1374
1375 let sig = sign_message(
1376 "verify-evm",
1377 "evm",
1378 "hello world",
1379 None,
1380 None,
1381 None,
1382 Some(vault),
1383 )
1384 .unwrap();
1385
1386 let msg = b"hello world";
1388 let prefix = format!("\x19Ethereum Signed Message:\n{}", msg.len());
1389 let mut prefixed = prefix.into_bytes();
1390 prefixed.extend_from_slice(msg);
1391
1392 let hash = sha3::Keccak256::digest(&prefixed);
1393 let sig_bytes = hex::decode(&sig.signature).unwrap();
1394 assert_eq!(
1395 sig_bytes.len(),
1396 65,
1397 "EVM signature should be 65 bytes (r + s + v)"
1398 );
1399
1400 let v = sig_bytes[64];
1402 assert!(
1403 v == 27 || v == 28,
1404 "EIP-191 v byte should be 27 or 28, got {v}"
1405 );
1406 let recid = k256::ecdsa::RecoveryId::try_from(v - 27).unwrap();
1407 let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
1408 let recovered_key =
1409 k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
1410
1411 let pubkey_bytes = recovered_key.to_encoded_point(false);
1413 let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
1414 let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
1415
1416 assert_eq!(
1418 recovered_addr.to_lowercase(),
1419 evm_addr.to_lowercase(),
1420 "recovered address should match wallet's EVM address"
1421 );
1422 }
1423
1424 #[test]
1429 fn error_nonexistent_wallet() {
1430 let dir = tempfile::tempdir().unwrap();
1431 assert!(get_wallet("nope", Some(dir.path())).is_err());
1432 assert!(export_wallet("nope", None, Some(dir.path())).is_err());
1433 assert!(sign_message("nope", "evm", "x", None, None, None, Some(dir.path())).is_err());
1434 assert!(delete_wallet("nope", Some(dir.path())).is_err());
1435 }
1436
1437 #[test]
1438 fn error_duplicate_wallet_name() {
1439 let dir = tempfile::tempdir().unwrap();
1440 let vault = dir.path();
1441 create_wallet("dup", None, None, Some(vault)).unwrap();
1442 assert!(create_wallet("dup", None, None, Some(vault)).is_err());
1443 }
1444
1445 #[test]
1446 fn error_invalid_private_key_hex() {
1447 let dir = tempfile::tempdir().unwrap();
1448 assert!(import_wallet_private_key(
1449 "bad",
1450 "not-hex",
1451 Some("evm"),
1452 None,
1453 Some(dir.path()),
1454 None,
1455 None,
1456 )
1457 .is_err());
1458 }
1459
1460 #[test]
1461 fn error_invalid_chain_for_signing() {
1462 let dir = tempfile::tempdir().unwrap();
1463 let vault = dir.path();
1464 create_wallet("chain-err", None, None, Some(vault)).unwrap();
1465 assert!(
1466 sign_message("chain-err", "fakecoin", "hi", None, None, None, Some(vault)).is_err()
1467 );
1468 }
1469
1470 #[test]
1471 fn error_invalid_tx_hex() {
1472 let dir = tempfile::tempdir().unwrap();
1473 let vault = dir.path();
1474 create_wallet("hex-err", None, None, Some(vault)).unwrap();
1475 assert!(
1476 sign_transaction("hex-err", "evm", "not-valid-hex!", None, None, Some(vault)).is_err()
1477 );
1478 }
1479
1480 #[test]
1485 fn list_wallets_empty_vault() {
1486 let dir = tempfile::tempdir().unwrap();
1487 let wallets = list_wallets(Some(dir.path())).unwrap();
1488 assert!(wallets.is_empty());
1489 }
1490
1491 #[test]
1492 fn get_wallet_by_name_and_id() {
1493 let dir = tempfile::tempdir().unwrap();
1494 let vault = dir.path();
1495 let info = create_wallet("lookup", None, None, Some(vault)).unwrap();
1496
1497 let by_name = get_wallet("lookup", Some(vault)).unwrap();
1498 assert_eq!(by_name.id, info.id);
1499
1500 let by_id = get_wallet(&info.id, Some(vault)).unwrap();
1501 assert_eq!(by_id.name, "lookup");
1502 }
1503
1504 #[test]
1505 fn rename_wallet_works() {
1506 let dir = tempfile::tempdir().unwrap();
1507 let vault = dir.path();
1508 let info = create_wallet("before", None, None, Some(vault)).unwrap();
1509
1510 rename_wallet("before", "after", Some(vault)).unwrap();
1511
1512 assert!(get_wallet("before", Some(vault)).is_err());
1513 let after = get_wallet("after", Some(vault)).unwrap();
1514 assert_eq!(after.id, info.id);
1515 }
1516
1517 #[test]
1518 fn rename_to_existing_name_fails() {
1519 let dir = tempfile::tempdir().unwrap();
1520 let vault = dir.path();
1521 create_wallet("a", None, None, Some(vault)).unwrap();
1522 create_wallet("b", None, None, Some(vault)).unwrap();
1523 assert!(rename_wallet("a", "b", Some(vault)).is_err());
1524 }
1525
1526 #[test]
1527 fn delete_wallet_removes_from_list() {
1528 let dir = tempfile::tempdir().unwrap();
1529 let vault = dir.path();
1530 create_wallet("del-me", None, None, Some(vault)).unwrap();
1531 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 1);
1532
1533 delete_wallet("del-me", Some(vault)).unwrap();
1534 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 0);
1535 }
1536
1537 #[test]
1542 fn sign_message_hex_encoding() {
1543 let dir = tempfile::tempdir().unwrap();
1544 let vault = dir.path();
1545 create_wallet("hex-enc", None, None, Some(vault)).unwrap();
1546
1547 let sig = sign_message(
1549 "hex-enc",
1550 "evm",
1551 "68656c6c6f",
1552 None,
1553 Some("hex"),
1554 None,
1555 Some(vault),
1556 )
1557 .unwrap();
1558 assert!(!sig.signature.is_empty());
1559
1560 let sig2 = sign_message(
1562 "hex-enc",
1563 "evm",
1564 "hello",
1565 None,
1566 Some("utf8"),
1567 None,
1568 Some(vault),
1569 )
1570 .unwrap();
1571 assert_eq!(
1572 sig.signature, sig2.signature,
1573 "hex and utf8 encoding of same bytes should produce same signature"
1574 );
1575 }
1576
1577 #[test]
1578 fn sign_message_invalid_encoding() {
1579 let dir = tempfile::tempdir().unwrap();
1580 let vault = dir.path();
1581 create_wallet("bad-enc", None, None, Some(vault)).unwrap();
1582 assert!(sign_message(
1583 "bad-enc",
1584 "evm",
1585 "hello",
1586 None,
1587 Some("base64"),
1588 None,
1589 Some(vault)
1590 )
1591 .is_err());
1592 }
1593
1594 #[test]
1599 fn multiple_wallets_coexist() {
1600 let dir = tempfile::tempdir().unwrap();
1601 let vault = dir.path();
1602
1603 create_wallet("w1", None, None, Some(vault)).unwrap();
1604 create_wallet("w2", None, None, Some(vault)).unwrap();
1605 save_privkey_wallet("w3", TEST_PRIVKEY, "", vault);
1606
1607 let wallets = list_wallets(Some(vault)).unwrap();
1608 assert_eq!(wallets.len(), 3);
1609
1610 let s1 = sign_message("w1", "evm", "test", None, None, None, Some(vault)).unwrap();
1612 let s2 = sign_message("w2", "evm", "test", None, None, None, Some(vault)).unwrap();
1613 let s3 = sign_message("w3", "evm", "test", None, None, None, Some(vault)).unwrap();
1614
1615 assert_ne!(s1.signature, s2.signature);
1617 assert_ne!(s1.signature, s3.signature);
1618 assert_ne!(s2.signature, s3.signature);
1619
1620 delete_wallet("w2", Some(vault)).unwrap();
1622 assert_eq!(list_wallets(Some(vault)).unwrap().len(), 2);
1623 assert!(sign_message("w1", "evm", "test", None, None, None, Some(vault)).is_ok());
1624 assert!(sign_message("w3", "evm", "test", None, None, None, Some(vault)).is_ok());
1625 }
1626
1627 #[test]
1632 fn signed_tx_must_differ_from_raw_signature() {
1633 let dir = tempfile::tempdir().unwrap();
1643 let vault = dir.path();
1644 save_privkey_wallet("send-bug", TEST_PRIVKEY, "", vault);
1645
1646 let items: Vec<u8> = [
1648 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(&[]), ]
1658 .concat();
1659
1660 let mut unsigned_tx = vec![0x02u8];
1661 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
1662 let tx_hex = hex::encode(&unsigned_tx);
1663
1664 let sign_result =
1666 sign_transaction("send-bug", "evm", &tx_hex, None, None, Some(vault)).unwrap();
1667 let raw_signature = hex::decode(&sign_result.signature).unwrap();
1668
1669 let key = decrypt_signing_key("send-bug", ChainType::Evm, "", None, Some(vault)).unwrap();
1671 let signer = signer_for_chain(ChainType::Evm);
1672 let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
1673 let full_signed_tx = signer
1674 .encode_signed_transaction(&unsigned_tx, &output)
1675 .unwrap();
1676
1677 assert_eq!(
1680 raw_signature.len(),
1681 65,
1682 "raw EVM signature should be 65 bytes (r || s || v)"
1683 );
1684 assert!(
1685 full_signed_tx.len() > raw_signature.len(),
1686 "full signed tx ({} bytes) must be larger than raw signature ({} bytes)",
1687 full_signed_tx.len(),
1688 raw_signature.len()
1689 );
1690 assert_ne!(
1691 raw_signature, full_signed_tx,
1692 "raw signature and full signed transaction must differ — \
1693 broadcasting the raw signature (as CLI send_transaction.rs:43 does) is wrong"
1694 );
1695
1696 assert_eq!(
1698 full_signed_tx[0], 0x02,
1699 "full signed EIP-1559 tx must start with type byte 0x02"
1700 );
1701 }
1702
1703 #[test]
1708 fn char_create_wallet_sign_transaction_with_passphrase() {
1709 let dir = tempfile::tempdir().unwrap();
1710 let vault = dir.path();
1711 create_wallet("char-pass-tx", None, Some("secret"), Some(vault)).unwrap();
1712
1713 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1714 let sig =
1715 sign_transaction("char-pass-tx", "evm", tx, Some("secret"), None, Some(vault)).unwrap();
1716 assert!(!sig.signature.is_empty());
1717 assert!(sig.recovery_id.is_some());
1718 }
1719
1720 #[test]
1721 fn char_create_wallet_sign_transaction_empty_passphrase() {
1722 let dir = tempfile::tempdir().unwrap();
1723 let vault = dir.path();
1724 create_wallet("char-empty-tx", None, None, Some(vault)).unwrap();
1725
1726 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1727 let sig =
1728 sign_transaction("char-empty-tx", "evm", tx, Some(""), None, Some(vault)).unwrap();
1729 assert!(!sig.signature.is_empty());
1730 }
1731
1732 #[test]
1733 fn char_no_passphrase_none_none_sign_transaction() {
1734 let dir = tempfile::tempdir().unwrap();
1737 let vault = dir.path();
1738 create_wallet("char-none-none", None, None, Some(vault)).unwrap();
1739
1740 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1741 let sig = sign_transaction("char-none-none", "evm", tx, None, None, Some(vault)).unwrap();
1742 assert!(!sig.signature.is_empty());
1743 assert!(sig.recovery_id.is_some());
1744 }
1745
1746 #[test]
1747 fn char_no_passphrase_none_none_sign_message() {
1748 let dir = tempfile::tempdir().unwrap();
1749 let vault = dir.path();
1750 create_wallet("char-none-msg", None, None, Some(vault)).unwrap();
1751
1752 let sig = sign_message(
1753 "char-none-msg",
1754 "evm",
1755 "hello",
1756 None,
1757 None,
1758 None,
1759 Some(vault),
1760 )
1761 .unwrap();
1762 assert!(!sig.signature.is_empty());
1763 }
1764
1765 #[test]
1766 fn char_no_passphrase_none_none_export() {
1767 let dir = tempfile::tempdir().unwrap();
1768 let vault = dir.path();
1769 create_wallet("char-none-exp", None, None, Some(vault)).unwrap();
1770
1771 let phrase = export_wallet("char-none-exp", None, Some(vault)).unwrap();
1772 assert_eq!(phrase.split_whitespace().count(), 12);
1773 }
1774
1775 #[test]
1776 fn char_empty_passphrase_none_and_some_empty_are_equivalent() {
1777 let dir = tempfile::tempdir().unwrap();
1780 let vault = dir.path();
1781
1782 create_wallet("char-equiv", None, None, Some(vault)).unwrap();
1784
1785 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1786
1787 let sig_none = sign_transaction("char-equiv", "evm", tx, None, None, Some(vault)).unwrap();
1789 let sig_empty =
1790 sign_transaction("char-equiv", "evm", tx, Some(""), None, Some(vault)).unwrap();
1791
1792 assert_eq!(
1793 sig_none.signature, sig_empty.signature,
1794 "passphrase=None and passphrase=Some(\"\") must produce identical signatures"
1795 );
1796
1797 let msg_none =
1799 sign_message("char-equiv", "evm", "test", None, None, None, Some(vault)).unwrap();
1800 let msg_empty = sign_message(
1801 "char-equiv",
1802 "evm",
1803 "test",
1804 Some(""),
1805 None,
1806 None,
1807 Some(vault),
1808 )
1809 .unwrap();
1810
1811 assert_eq!(
1812 msg_none.signature, msg_empty.signature,
1813 "sign_message: None and Some(\"\") must be equivalent"
1814 );
1815
1816 let export_none = export_wallet("char-equiv", None, Some(vault)).unwrap();
1818 let export_empty = export_wallet("char-equiv", Some(""), Some(vault)).unwrap();
1819 assert_eq!(
1820 export_none, export_empty,
1821 "export_wallet: None and Some(\"\") must return the same mnemonic"
1822 );
1823 }
1824
1825 #[test]
1826 fn char_create_with_some_empty_sign_with_none() {
1827 let dir = tempfile::tempdir().unwrap();
1829 let vault = dir.path();
1830 create_wallet("char-some-none", None, Some(""), Some(vault)).unwrap();
1831
1832 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1833 let sig = sign_transaction("char-some-none", "evm", tx, None, None, Some(vault)).unwrap();
1834 assert!(!sig.signature.is_empty());
1835 }
1836
1837 #[test]
1838 fn char_no_passphrase_wallet_rejects_nonempty_passphrase() {
1839 let dir = tempfile::tempdir().unwrap();
1843 let vault = dir.path();
1844 create_wallet("char-no-pass-reject", None, None, Some(vault)).unwrap();
1845
1846 let result = sign_message(
1847 "char-no-pass-reject",
1848 "evm",
1849 "test",
1850 Some("some-random-passphrase"),
1851 None,
1852 None,
1853 Some(vault),
1854 );
1855 assert!(
1856 result.is_err(),
1857 "non-empty passphrase on empty-passphrase wallet should fail"
1858 );
1859 match result.unwrap_err() {
1860 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error, got: {other}"),
1862 }
1863 }
1864
1865 #[test]
1866 fn char_sign_transaction_wrong_passphrase_returns_crypto_error() {
1867 let dir = tempfile::tempdir().unwrap();
1868 let vault = dir.path();
1869 create_wallet("char-wrong-pass", None, Some("correct"), Some(vault)).unwrap();
1870
1871 let tx = "deadbeef";
1872 let result = sign_transaction(
1873 "char-wrong-pass",
1874 "evm",
1875 tx,
1876 Some("wrong"),
1877 None,
1878 Some(vault),
1879 );
1880 assert!(result.is_err());
1881 match result.unwrap_err() {
1882 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error, got: {other}"),
1884 }
1885 }
1886
1887 #[test]
1888 fn char_sign_transaction_nonexistent_wallet_returns_wallet_not_found() {
1889 let dir = tempfile::tempdir().unwrap();
1890 let result = sign_transaction("ghost", "evm", "deadbeef", None, None, Some(dir.path()));
1891 assert!(result.is_err());
1892 match result.unwrap_err() {
1893 OwsLibError::WalletNotFound(name) => assert_eq!(name, "ghost"),
1894 other => panic!("expected WalletNotFound, got: {other}"),
1895 }
1896 }
1897
1898 #[test]
1899 fn char_sign_and_send_invalid_rpc_returns_broadcast_failed() {
1900 let dir = tempfile::tempdir().unwrap();
1901 let vault = dir.path();
1902 create_wallet("char-rpc-fail", None, None, Some(vault)).unwrap();
1903
1904 let items: Vec<u8> = [
1906 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(&[]), ]
1916 .concat();
1917 let mut unsigned_tx = vec![0x02u8];
1918 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
1919 let tx_hex = hex::encode(&unsigned_tx);
1920
1921 let result = sign_and_send(
1922 "char-rpc-fail",
1923 "evm",
1924 &tx_hex,
1925 None,
1926 None,
1927 Some("http://127.0.0.1:1"), Some(vault),
1929 );
1930 assert!(result.is_err());
1931 match result.unwrap_err() {
1932 OwsLibError::BroadcastFailed(_) => {} other => panic!("expected BroadcastFailed, got: {other}"),
1934 }
1935 }
1936
1937 #[test]
1938 fn char_create_sign_rename_sign_with_new_name() {
1939 let dir = tempfile::tempdir().unwrap();
1940 let vault = dir.path();
1941 create_wallet("orig-name", None, None, Some(vault)).unwrap();
1942
1943 let sig1 = sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).unwrap();
1945 assert!(!sig1.signature.is_empty());
1946
1947 rename_wallet("orig-name", "new-name", Some(vault)).unwrap();
1949
1950 assert!(sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).is_err());
1952
1953 let sig2 = sign_message("new-name", "evm", "test", None, None, None, Some(vault)).unwrap();
1955 assert_eq!(
1956 sig1.signature, sig2.signature,
1957 "renamed wallet should produce identical signatures"
1958 );
1959 }
1960
1961 #[test]
1962 fn char_create_sign_delete_sign_returns_wallet_not_found() {
1963 let dir = tempfile::tempdir().unwrap();
1964 let vault = dir.path();
1965 create_wallet("del-me-char", None, None, Some(vault)).unwrap();
1966
1967 let sig =
1969 sign_message("del-me-char", "evm", "test", None, None, None, Some(vault)).unwrap();
1970 assert!(!sig.signature.is_empty());
1971
1972 delete_wallet("del-me-char", Some(vault)).unwrap();
1974
1975 let result = sign_message("del-me-char", "evm", "test", None, None, None, Some(vault));
1977 assert!(result.is_err());
1978 match result.unwrap_err() {
1979 OwsLibError::WalletNotFound(name) => assert_eq!(name, "del-me-char"),
1980 other => panic!("expected WalletNotFound, got: {other}"),
1981 }
1982 }
1983
1984 #[test]
1985 fn char_import_sign_export_reimport_sign_deterministic() {
1986 let v1 = tempfile::tempdir().unwrap();
1987 let v2 = tempfile::tempdir().unwrap();
1988
1989 let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1991 import_wallet_mnemonic("char-det", phrase, None, None, Some(v1.path())).unwrap();
1992
1993 let sig1 = sign_message(
1995 "char-det",
1996 "evm",
1997 "determinism test",
1998 None,
1999 None,
2000 None,
2001 Some(v1.path()),
2002 )
2003 .unwrap();
2004
2005 let exported = export_wallet("char-det", None, Some(v1.path())).unwrap();
2007 assert_eq!(exported.trim(), phrase);
2008
2009 import_wallet_mnemonic("char-det-2", &exported, None, None, Some(v2.path())).unwrap();
2011
2012 let sig2 = sign_message(
2014 "char-det-2",
2015 "evm",
2016 "determinism test",
2017 None,
2018 None,
2019 None,
2020 Some(v2.path()),
2021 )
2022 .unwrap();
2023
2024 assert_eq!(
2025 sig1.signature, sig2.signature,
2026 "import→sign→export→reimport→sign must produce identical signatures"
2027 );
2028 }
2029
2030 #[test]
2031 fn char_import_private_key_sign_valid() {
2032 let dir = tempfile::tempdir().unwrap();
2033 let vault = dir.path();
2034
2035 import_wallet_private_key(
2036 "char-pk",
2037 TEST_PRIVKEY,
2038 Some("evm"),
2039 None,
2040 Some(vault),
2041 None,
2042 None,
2043 )
2044 .unwrap();
2045
2046 let sig = sign_transaction("char-pk", "evm", "deadbeef", None, None, Some(vault)).unwrap();
2047 assert!(!sig.signature.is_empty());
2048 assert!(sig.recovery_id.is_some());
2049 }
2050
2051 #[test]
2052 fn char_sign_message_all_chain_families() {
2053 let dir = tempfile::tempdir().unwrap();
2055 let vault = dir.path();
2056 create_wallet("char-all-chains", None, None, Some(vault)).unwrap();
2057
2058 let chains = [
2059 ("evm", true),
2060 ("solana", false),
2061 ("bitcoin", true),
2062 ("cosmos", true),
2063 ("tron", true),
2064 ("ton", false),
2065 ("sui", false),
2066 ];
2067 for (chain, has_recovery_id) in &chains {
2068 let result = sign_message(
2069 "char-all-chains",
2070 chain,
2071 "hello",
2072 None,
2073 None,
2074 None,
2075 Some(vault),
2076 );
2077 assert!(
2078 result.is_ok(),
2079 "sign_message failed for {chain}: {:?}",
2080 result.err()
2081 );
2082 let sig = result.unwrap();
2083 assert!(!sig.signature.is_empty(), "signature empty for {chain}");
2084 if *has_recovery_id {
2085 assert!(
2086 sig.recovery_id.is_some(),
2087 "expected recovery_id for {chain}"
2088 );
2089 }
2090 }
2091 }
2092
2093 #[test]
2094 fn char_sign_typed_data_evm_valid_signature() {
2095 let dir = tempfile::tempdir().unwrap();
2096 let vault = dir.path();
2097 create_wallet("char-typed", None, None, Some(vault)).unwrap();
2098
2099 let typed_data = r#"{
2100 "types": {
2101 "EIP712Domain": [
2102 {"name": "name", "type": "string"},
2103 {"name": "version", "type": "string"},
2104 {"name": "chainId", "type": "uint256"}
2105 ],
2106 "Test": [{"name": "value", "type": "uint256"}]
2107 },
2108 "primaryType": "Test",
2109 "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2110 "message": {"value": "42"}
2111 }"#;
2112
2113 let result = sign_typed_data("char-typed", "evm", typed_data, None, None, Some(vault));
2114 assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2115
2116 let sig = result.unwrap();
2117 let sig_bytes = hex::decode(&sig.signature).unwrap();
2118 assert_eq!(sig_bytes.len(), 65, "EIP-712 signature should be 65 bytes");
2119
2120 let v = sig_bytes[64];
2122 assert!(v == 27 || v == 28, "EIP-712 v should be 27 or 28, got {v}");
2123 }
2124
2125 #[test]
2130 fn char_sign_with_nonzero_account_index() {
2131 let dir = tempfile::tempdir().unwrap();
2134 let vault = dir.path();
2135 create_wallet("char-idx", None, None, Some(vault)).unwrap();
2136
2137 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2138
2139 let sig0 = sign_transaction("char-idx", "evm", tx, None, Some(0), Some(vault)).unwrap();
2140 let sig1 = sign_transaction("char-idx", "evm", tx, None, Some(1), Some(vault)).unwrap();
2141
2142 assert_ne!(
2143 sig0.signature, sig1.signature,
2144 "index 0 and index 1 must produce different signatures (different derived keys)"
2145 );
2146
2147 let sig_default = sign_transaction("char-idx", "evm", tx, None, None, Some(vault)).unwrap();
2149 assert_eq!(
2150 sig0.signature, sig_default.signature,
2151 "index=0 should match index=None (default)"
2152 );
2153 }
2154
2155 #[test]
2156 fn char_sign_with_nonzero_index_sign_message() {
2157 let dir = tempfile::tempdir().unwrap();
2158 let vault = dir.path();
2159 create_wallet("char-idx-msg", None, None, Some(vault)).unwrap();
2160
2161 let sig0 = sign_message(
2162 "char-idx-msg",
2163 "evm",
2164 "hello",
2165 None,
2166 None,
2167 Some(0),
2168 Some(vault),
2169 )
2170 .unwrap();
2171 let sig1 = sign_message(
2172 "char-idx-msg",
2173 "evm",
2174 "hello",
2175 None,
2176 None,
2177 Some(1),
2178 Some(vault),
2179 )
2180 .unwrap();
2181
2182 assert_ne!(
2183 sig0.signature, sig1.signature,
2184 "different account indices should yield different signatures"
2185 );
2186 }
2187
2188 #[test]
2189 fn char_sign_transaction_0x_prefix_stripped() {
2190 let dir = tempfile::tempdir().unwrap();
2193 let vault = dir.path();
2194 create_wallet("char-0x", None, None, Some(vault)).unwrap();
2195
2196 let tx_no_prefix = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2197 let tx_with_prefix = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2198
2199 let sig1 =
2200 sign_transaction("char-0x", "evm", tx_no_prefix, None, None, Some(vault)).unwrap();
2201 let sig2 =
2202 sign_transaction("char-0x", "evm", tx_with_prefix, None, None, Some(vault)).unwrap();
2203
2204 assert_eq!(
2205 sig1.signature, sig2.signature,
2206 "0x-prefixed and bare hex should produce identical signatures"
2207 );
2208 }
2209
2210 #[test]
2211 fn char_24_word_mnemonic_wallet_lifecycle() {
2212 let dir = tempfile::tempdir().unwrap();
2214 let vault = dir.path();
2215
2216 let info = create_wallet("char-24w", Some(24), None, Some(vault)).unwrap();
2217 assert!(!info.accounts.is_empty());
2218
2219 let phrase = export_wallet("char-24w", None, Some(vault)).unwrap();
2221 assert_eq!(
2222 phrase.split_whitespace().count(),
2223 24,
2224 "should be a 24-word mnemonic"
2225 );
2226
2227 let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2229 let sig = sign_transaction("char-24w", "evm", tx, None, None, Some(vault)).unwrap();
2230 assert!(!sig.signature.is_empty());
2231
2232 for chain in &["evm", "solana", "bitcoin", "cosmos"] {
2234 let result = sign_message("char-24w", chain, "test", None, None, None, Some(vault));
2235 assert!(
2236 result.is_ok(),
2237 "24-word wallet sign_message failed for {chain}: {:?}",
2238 result.err()
2239 );
2240 }
2241
2242 let v2 = tempfile::tempdir().unwrap();
2244 import_wallet_mnemonic("char-24w-2", &phrase, None, None, Some(v2.path())).unwrap();
2245 let sig2 = sign_transaction("char-24w-2", "evm", tx, None, None, Some(v2.path())).unwrap();
2246 assert_eq!(
2247 sig.signature, sig2.signature,
2248 "reimported 24-word wallet must produce identical signature"
2249 );
2250 }
2251
2252 #[test]
2253 fn char_concurrent_signing() {
2254 use std::sync::Arc;
2257 use std::thread;
2258
2259 let dir = tempfile::tempdir().unwrap();
2260 let vault_path = Arc::new(dir.path().to_path_buf());
2261 create_wallet("char-conc", None, None, Some(&vault_path)).unwrap();
2262
2263 let handles: Vec<_> = (0..8)
2264 .map(|i| {
2265 let vp = Arc::clone(&vault_path);
2266 thread::spawn(move || {
2267 let msg = format!("thread-{i}");
2268 let result = sign_message(
2269 "char-conc",
2270 "evm",
2271 &msg,
2272 None,
2273 None,
2274 None,
2275 Some(vp.as_path()),
2276 );
2277 assert!(
2278 result.is_ok(),
2279 "concurrent sign_message failed in thread {i}: {:?}",
2280 result.err()
2281 );
2282 result.unwrap()
2283 })
2284 })
2285 .collect();
2286
2287 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2288
2289 for (i, sig) in results.iter().enumerate() {
2291 assert!(
2292 !sig.signature.is_empty(),
2293 "thread {i} produced empty signature"
2294 );
2295 }
2296
2297 for i in 0..results.len() {
2299 for j in (i + 1)..results.len() {
2300 assert_ne!(
2301 results[i].signature, results[j].signature,
2302 "threads {i} and {j} should produce different signatures (different messages)"
2303 );
2304 }
2305 }
2306 }
2307
2308 #[test]
2309 fn char_evm_sign_transaction_recoverable() {
2310 use sha3::Digest;
2313
2314 let dir = tempfile::tempdir().unwrap();
2315 let vault = dir.path();
2316 let info = create_wallet("char-tx-recover", None, None, Some(vault)).unwrap();
2317 let evm_addr = info
2318 .accounts
2319 .iter()
2320 .find(|a| a.chain_id.starts_with("eip155:"))
2321 .unwrap()
2322 .address
2323 .clone();
2324
2325 let tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2326 let sig =
2327 sign_transaction("char-tx-recover", "evm", tx_hex, None, None, Some(vault)).unwrap();
2328
2329 let sig_bytes = hex::decode(&sig.signature).unwrap();
2330 assert_eq!(sig_bytes.len(), 65);
2331
2332 let tx_bytes = hex::decode(tx_hex).unwrap();
2334 let hash = sha3::Keccak256::digest(&tx_bytes);
2335
2336 let v = sig_bytes[64];
2337 let recid = k256::ecdsa::RecoveryId::try_from(v).unwrap();
2338 let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
2339 let recovered_key =
2340 k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
2341
2342 let pubkey_bytes = recovered_key.to_encoded_point(false);
2344 let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
2345 let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
2346
2347 assert_eq!(
2348 recovered_addr.to_lowercase(),
2349 evm_addr.to_lowercase(),
2350 "recovered address from tx signature should match wallet's EVM address"
2351 );
2352 }
2353
2354 #[test]
2355 fn char_solana_extract_signable_through_sign_path() {
2356 let dir = tempfile::tempdir().unwrap();
2361 let vault = dir.path();
2362 create_wallet("char-sol-sig", None, None, Some(vault)).unwrap();
2363
2364 let message_payload = b"test solana message payload 1234";
2366 let mut tx_bytes = vec![0x01u8]; tx_bytes.extend_from_slice(&[0u8; 64]); tx_bytes.extend_from_slice(message_payload);
2369 let tx_hex = hex::encode(&tx_bytes);
2370
2371 let sig =
2376 sign_transaction("char-sol-sig", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2377 assert_eq!(
2378 hex::decode(&sig.signature).unwrap().len(),
2379 64,
2380 "Solana signature should be 64 bytes (Ed25519)"
2381 );
2382 assert!(sig.recovery_id.is_none(), "Ed25519 has no recovery ID");
2383
2384 let key =
2387 decrypt_signing_key("char-sol-sig", ChainType::Solana, "", None, Some(vault)).unwrap();
2388 let signer = signer_for_chain(ChainType::Solana);
2389
2390 let signable = signer.extract_signable_bytes(&tx_bytes).unwrap();
2391 assert_eq!(
2392 signable, message_payload,
2393 "extract_signable_bytes should return only the message portion"
2394 );
2395
2396 let output = signer.sign_transaction(key.expose(), signable).unwrap();
2397 let signed_tx = signer
2398 .encode_signed_transaction(&tx_bytes, &output)
2399 .unwrap();
2400
2401 assert_eq!(&signed_tx[1..65], &output.signature[..]);
2403 assert_eq!(&signed_tx[65..], message_payload);
2405 assert_eq!(signed_tx.len(), tx_bytes.len());
2407
2408 let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
2410 let verifying_key = signing_key.verifying_key();
2411 let ed_sig = ed25519_dalek::Signature::from_bytes(&output.signature.try_into().unwrap());
2412 verifying_key
2413 .verify_strict(message_payload, &ed_sig)
2414 .expect("Solana signature should verify against extracted message");
2415 }
2416
2417 #[test]
2418 fn char_library_encodes_before_broadcast() {
2419 let dir = tempfile::tempdir().unwrap();
2426 let vault = dir.path();
2427 create_wallet("char-encode", None, None, Some(vault)).unwrap();
2428
2429 let items: Vec<u8> = [
2431 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(&[]), ]
2441 .concat();
2442 let mut unsigned_tx = vec![0x02u8];
2443 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
2444 let tx_hex = hex::encode(&unsigned_tx);
2445
2446 let raw_sig =
2448 sign_transaction("char-encode", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2449 let raw_sig_bytes = hex::decode(&raw_sig.signature).unwrap();
2450
2451 let key =
2453 decrypt_signing_key("char-encode", ChainType::Evm, "", None, Some(vault)).unwrap();
2454 let signer = signer_for_chain(ChainType::Evm);
2455 let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
2456 let full_signed_tx = signer
2457 .encode_signed_transaction(&unsigned_tx, &output)
2458 .unwrap();
2459
2460 assert_eq!(raw_sig_bytes.len(), 65);
2462
2463 assert!(full_signed_tx.len() > 65);
2465 assert_eq!(
2466 full_signed_tx[0], 0x02,
2467 "should preserve EIP-1559 type byte"
2468 );
2469
2470 assert_ne!(raw_sig_bytes, full_signed_tx);
2472
2473 let r_bytes = &raw_sig_bytes[..32];
2476 let _s_bytes = &raw_sig_bytes[32..64];
2477
2478 let full_hex = hex::encode(&full_signed_tx);
2480 let r_hex = hex::encode(r_bytes);
2481 assert!(
2482 full_hex.contains(&r_hex),
2483 "full signed tx should contain the r component"
2484 );
2485 }
2486
2487 #[test]
2492 fn sign_typed_data_rejects_non_evm_chain() {
2493 let tmp = tempfile::tempdir().unwrap();
2494 let vault = tmp.path();
2495
2496 let w = save_privkey_wallet("typed-data-test", TEST_PRIVKEY, "pass", vault);
2497
2498 let typed_data = r#"{
2499 "types": {
2500 "EIP712Domain": [{"name": "name", "type": "string"}],
2501 "Test": [{"name": "value", "type": "uint256"}]
2502 },
2503 "primaryType": "Test",
2504 "domain": {"name": "Test"},
2505 "message": {"value": "1"}
2506 }"#;
2507
2508 let result = sign_typed_data(&w.id, "solana", typed_data, Some("pass"), None, Some(vault));
2509 assert!(result.is_err());
2510 let err_msg = result.unwrap_err().to_string();
2511 assert!(
2512 err_msg.contains("only supported for EVM"),
2513 "expected EVM-only error, got: {err_msg}"
2514 );
2515 }
2516
2517 #[test]
2518 fn sign_typed_data_evm_succeeds() {
2519 let tmp = tempfile::tempdir().unwrap();
2520 let vault = tmp.path();
2521
2522 let w = save_privkey_wallet("typed-data-evm", TEST_PRIVKEY, "pass", vault);
2523
2524 let typed_data = r#"{
2525 "types": {
2526 "EIP712Domain": [
2527 {"name": "name", "type": "string"},
2528 {"name": "version", "type": "string"},
2529 {"name": "chainId", "type": "uint256"}
2530 ],
2531 "Test": [{"name": "value", "type": "uint256"}]
2532 },
2533 "primaryType": "Test",
2534 "domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
2535 "message": {"value": "42"}
2536 }"#;
2537
2538 let result = sign_typed_data(&w.id, "evm", typed_data, Some("pass"), None, Some(vault));
2539 assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
2540
2541 let sign_result = result.unwrap();
2542 assert!(
2543 !sign_result.signature.is_empty(),
2544 "signature should not be empty"
2545 );
2546 assert!(
2547 sign_result.recovery_id.is_some(),
2548 "recovery_id should be present for EVM"
2549 );
2550 }
2551
2552 #[test]
2558 fn regression_owner_path_identical_to_direct_signer() {
2559 let dir = tempfile::tempdir().unwrap();
2564 let vault = dir.path();
2565 create_wallet("reg-owner", None, None, Some(vault)).unwrap();
2566
2567 let tx_hex = "deadbeefcafebabe";
2568
2569 let api_result =
2571 sign_transaction("reg-owner", "evm", tx_hex, None, None, Some(vault)).unwrap();
2572
2573 let key = decrypt_signing_key("reg-owner", ChainType::Evm, "", None, Some(vault)).unwrap();
2575 let signer = signer_for_chain(ChainType::Evm);
2576 let tx_bytes = hex::decode(tx_hex).unwrap();
2577 let direct_output = signer.sign_transaction(key.expose(), &tx_bytes).unwrap();
2578
2579 assert_eq!(
2580 api_result.signature,
2581 hex::encode(&direct_output.signature),
2582 "library API and direct signer must produce identical signatures"
2583 );
2584 assert_eq!(
2585 api_result.recovery_id, direct_output.recovery_id,
2586 "recovery_id must match"
2587 );
2588 }
2589
2590 #[test]
2591 fn regression_owner_passphrase_not_confused_with_token() {
2592 let dir = tempfile::tempdir().unwrap();
2595 let vault = dir.path();
2596 create_wallet("reg-pass", Some(12), Some("hunter2"), Some(vault)).unwrap();
2597
2598 let tx_hex = "deadbeef";
2599
2600 let result = sign_transaction(
2602 "reg-pass",
2603 "evm",
2604 tx_hex,
2605 Some("hunter2"),
2606 None,
2607 Some(vault),
2608 );
2609 assert!(
2610 result.is_ok(),
2611 "owner-mode signing failed: {:?}",
2612 result.err()
2613 );
2614
2615 let bad = sign_transaction("reg-pass", "evm", tx_hex, Some(""), None, Some(vault));
2618 assert!(bad.is_err());
2619 match bad.unwrap_err() {
2620 OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error for wrong passphrase, got: {other}"),
2622 }
2623
2624 let none_result = sign_transaction("reg-pass", "evm", tx_hex, None, None, Some(vault));
2626 assert!(none_result.is_err());
2627 match none_result.unwrap_err() {
2628 OwsLibError::Crypto(_) => {}
2629 other => panic!("expected Crypto error for None passphrase, got: {other}"),
2630 }
2631 }
2632
2633 #[test]
2634 fn regression_sign_message_owner_path_unchanged() {
2635 let dir = tempfile::tempdir().unwrap();
2636 let vault = dir.path();
2637 create_wallet("reg-msg", None, None, Some(vault)).unwrap();
2638
2639 let api_result =
2641 sign_message("reg-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
2642
2643 let key = decrypt_signing_key("reg-msg", ChainType::Evm, "", None, Some(vault)).unwrap();
2645 let signer = signer_for_chain(ChainType::Evm);
2646 let direct = signer.sign_message(key.expose(), b"hello").unwrap();
2647
2648 assert_eq!(
2649 api_result.signature,
2650 hex::encode(&direct.signature),
2651 "sign_message owner path must match direct signer"
2652 );
2653 }
2654
2655 #[test]
2660 fn solana_broadcast_body_includes_encoding_param() {
2661 let dummy_tx = vec![0x01; 100];
2662 let body = build_solana_rpc_body(&dummy_tx);
2663
2664 assert_eq!(body["method"], "sendTransaction");
2665 assert_eq!(
2666 body["params"][1]["encoding"], "base64",
2667 "sendTransaction must specify encoding=base64 so Solana RPC \
2668 does not default to base58"
2669 );
2670 }
2671
2672 #[test]
2673 fn solana_broadcast_body_uses_base64_encoding() {
2674 use base64::Engine;
2675 let dummy_tx = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03];
2676 let body = build_solana_rpc_body(&dummy_tx);
2677
2678 let encoded = body["params"][0].as_str().unwrap();
2679 let decoded = base64::engine::general_purpose::STANDARD
2681 .decode(encoded)
2682 .expect("params[0] should be valid base64");
2683 assert_eq!(
2684 decoded, dummy_tx,
2685 "base64 should round-trip to original bytes"
2686 );
2687 }
2688
2689 #[test]
2690 fn solana_broadcast_body_is_not_hex_or_base58() {
2691 let dummy_tx = vec![0xFF; 50];
2693 let body = build_solana_rpc_body(&dummy_tx);
2694
2695 let encoded = body["params"][0].as_str().unwrap();
2696 let hex_encoded = hex::encode(&dummy_tx);
2697 assert_ne!(encoded, hex_encoded, "broadcast should use base64, not hex");
2698 assert!(
2701 encoded.contains('/') || encoded.contains('+') || encoded.ends_with('='),
2702 "base64 of 0xFF bytes should contain characters absent from base58"
2703 );
2704 }
2705
2706 #[test]
2707 fn solana_broadcast_body_jsonrpc_structure() {
2708 let body = build_solana_rpc_body(&[0u8; 10]);
2709 assert_eq!(body["jsonrpc"], "2.0");
2710 assert_eq!(body["id"], 1);
2711 assert_eq!(body["method"], "sendTransaction");
2712 assert!(body["params"].is_array());
2713 assert_eq!(
2714 body["params"].as_array().unwrap().len(),
2715 2,
2716 "params should have [tx_data, options_object]"
2717 );
2718 }
2719
2720 #[test]
2725 fn solana_sign_transaction_extracts_signable_bytes() {
2726 let dir = tempfile::tempdir().unwrap();
2729 let vault = dir.path();
2730 create_wallet("sol-extract", None, None, Some(vault)).unwrap();
2731
2732 let message_payload = b"test solana message for extraction";
2733 let mut full_tx = vec![0x01u8]; full_tx.extend_from_slice(&[0u8; 64]); full_tx.extend_from_slice(message_payload);
2736 let tx_hex = hex::encode(&full_tx);
2737
2738 let sig_result =
2740 sign_transaction("sol-extract", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2741 let sig_bytes = hex::decode(&sig_result.signature).unwrap();
2742
2743 let key =
2745 decrypt_signing_key("sol-extract", ChainType::Solana, "", None, Some(vault)).unwrap();
2746 let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
2747 let verifying_key = signing_key.verifying_key();
2748 let ed_sig = ed25519_dalek::Signature::from_bytes(&sig_bytes.try_into().unwrap());
2749
2750 verifying_key
2751 .verify_strict(message_payload, &ed_sig)
2752 .expect("sign_transaction should sign the message portion, not the full envelope");
2753 }
2754
2755 #[test]
2756 fn solana_sign_transaction_full_tx_matches_extracted_sign() {
2757 let dir = tempfile::tempdir().unwrap();
2760 let vault = dir.path();
2761 create_wallet("sol-match", None, None, Some(vault)).unwrap();
2762
2763 let message_payload = b"matching signatures test";
2764 let mut full_tx = vec![0x01u8];
2765 full_tx.extend_from_slice(&[0u8; 64]);
2766 full_tx.extend_from_slice(message_payload);
2767 let tx_hex = hex::encode(&full_tx);
2768
2769 let api_sig =
2771 sign_transaction("sol-match", "solana", &tx_hex, None, None, Some(vault)).unwrap();
2772
2773 let key =
2775 decrypt_signing_key("sol-match", ChainType::Solana, "", None, Some(vault)).unwrap();
2776 let signer = signer_for_chain(ChainType::Solana);
2777 let signable = signer.extract_signable_bytes(&full_tx).unwrap();
2778 let direct = signer.sign_transaction(key.expose(), signable).unwrap();
2779
2780 assert_eq!(
2781 api_sig.signature,
2782 hex::encode(&direct.signature),
2783 "sign_transaction API and manual extract+sign must produce the same signature"
2784 );
2785 }
2786
2787 #[test]
2788 fn evm_sign_transaction_unaffected_by_extraction() {
2789 let dir = tempfile::tempdir().unwrap();
2792 let vault = dir.path();
2793 create_wallet("evm-regress", None, None, Some(vault)).unwrap();
2794
2795 let items: Vec<u8> = [
2796 ows_signer::rlp::encode_bytes(&[1]),
2797 ows_signer::rlp::encode_bytes(&[]),
2798 ows_signer::rlp::encode_bytes(&[1]),
2799 ows_signer::rlp::encode_bytes(&[100]),
2800 ows_signer::rlp::encode_bytes(&[0x52, 0x08]),
2801 ows_signer::rlp::encode_bytes(&[0xDE, 0xAD]),
2802 ows_signer::rlp::encode_bytes(&[]),
2803 ows_signer::rlp::encode_bytes(&[]),
2804 ows_signer::rlp::encode_list(&[]),
2805 ]
2806 .concat();
2807 let mut unsigned_tx = vec![0x02u8];
2808 unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
2809 let tx_hex = hex::encode(&unsigned_tx);
2810
2811 let sig1 =
2813 sign_transaction("evm-regress", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2814 let sig2 =
2815 sign_transaction("evm-regress", "evm", &tx_hex, None, None, Some(vault)).unwrap();
2816 assert_eq!(sig1.signature, sig2.signature);
2817 assert_eq!(hex::decode(&sig1.signature).unwrap().len(), 65);
2818 }
2819
2820 #[test]
2825 #[ignore] fn solana_devnet_broadcast_encoding_accepted() {
2827 let bh_body = serde_json::json!({
2833 "jsonrpc": "2.0",
2834 "method": "getLatestBlockhash",
2835 "params": [],
2836 "id": 1
2837 });
2838 let bh_resp =
2839 curl_post_json("https://api.devnet.solana.com", &bh_body.to_string()).unwrap();
2840 let bh_parsed: serde_json::Value = serde_json::from_str(&bh_resp).unwrap();
2841 let blockhash_b58 = bh_parsed["result"]["value"]["blockhash"]
2842 .as_str()
2843 .expect("devnet should return a blockhash");
2844 let blockhash = bs58::decode(blockhash_b58).into_vec().unwrap();
2845 assert_eq!(blockhash.len(), 32);
2846
2847 let privkey =
2849 hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
2850 .unwrap();
2851 let signing_key =
2852 ed25519_dalek::SigningKey::from_bytes(&privkey.clone().try_into().unwrap());
2853 let sender_pubkey = signing_key.verifying_key().to_bytes();
2854
2855 let recipient_pubkey = [0x01; 32]; let system_program = [0u8; 32]; let mut message = vec![
2860 1, 0, 1, 3, ];
2865 message.extend_from_slice(&sender_pubkey);
2866 message.extend_from_slice(&recipient_pubkey);
2867 message.extend_from_slice(&system_program);
2868 message.extend_from_slice(&blockhash);
2870 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);
2884
2885 let result = sign_encode_and_broadcast(
2887 &privkey,
2888 "solana",
2889 &tx_bytes,
2890 Some("https://api.devnet.solana.com"),
2891 );
2892
2893 match result {
2895 Ok(send_result) => {
2896 assert!(!send_result.tx_hash.is_empty());
2898 }
2899 Err(e) => {
2900 let err_str = format!("{e}");
2901 assert!(
2902 !err_str.contains("base58"),
2903 "should not get base58 encoding error: {err_str}"
2904 );
2905 assert!(
2906 !err_str.contains("InvalidCharacter"),
2907 "should not get InvalidCharacter error: {err_str}"
2908 );
2909 }
2911 }
2912 }
2913}