use std::path::Path;
use std::process::Command;
use ows_core::{
default_chain_for_type, ChainType, Config, EncryptedWallet, KeyType, WalletAccount,
ALL_CHAIN_TYPES,
};
use ows_signer::{
decrypt, encrypt, signer_for_chain, CryptoEnvelope, HdDeriver, Mnemonic, MnemonicStrength,
SecretBytes,
};
use crate::error::OwsLibError;
use crate::types::{AccountInfo, SendResult, SignResult, WalletInfo};
use crate::vault;
fn wallet_to_info(w: &EncryptedWallet) -> WalletInfo {
WalletInfo {
id: w.id.clone(),
name: w.name.clone(),
accounts: w
.accounts
.iter()
.map(|a| AccountInfo {
chain_id: a.chain_id.clone(),
address: a.address.clone(),
derivation_path: a.derivation_path.clone(),
})
.collect(),
created_at: w.created_at.clone(),
}
}
fn parse_chain(s: &str) -> Result<ows_core::Chain, OwsLibError> {
ows_core::parse_chain(s).map_err(OwsLibError::InvalidInput)
}
fn derive_all_accounts(mnemonic: &Mnemonic, index: u32) -> Result<Vec<WalletAccount>, OwsLibError> {
let mut accounts = Vec::with_capacity(ALL_CHAIN_TYPES.len());
for ct in &ALL_CHAIN_TYPES {
let chain = default_chain_for_type(*ct);
let signer = signer_for_chain(*ct);
let path = signer.default_derivation_path(index);
let curve = signer.curve();
let key = HdDeriver::derive_from_mnemonic(mnemonic, "", &path, curve)?;
let address = signer.derive_address(key.expose())?;
let account_id = format!("{}:{}", chain.chain_id, address);
accounts.push(WalletAccount {
account_id,
address,
chain_id: chain.chain_id.to_string(),
derivation_path: path,
});
}
Ok(accounts)
}
struct KeyPair {
secp256k1: Vec<u8>,
ed25519: Vec<u8>,
}
impl Drop for KeyPair {
fn drop(&mut self) {
use zeroize::Zeroize;
self.secp256k1.zeroize();
self.ed25519.zeroize();
}
}
impl KeyPair {
fn key_for_curve(&self, curve: ows_signer::Curve) -> &[u8] {
match curve {
ows_signer::Curve::Secp256k1 => &self.secp256k1,
ows_signer::Curve::Ed25519 => &self.ed25519,
}
}
fn to_json_bytes(&self) -> Vec<u8> {
let obj = serde_json::json!({
"secp256k1": hex::encode(&self.secp256k1),
"ed25519": hex::encode(&self.ed25519),
});
obj.to_string().into_bytes()
}
fn from_json_bytes(bytes: &[u8]) -> Result<Self, OwsLibError> {
let s = String::from_utf8(bytes.to_vec())
.map_err(|_| OwsLibError::InvalidInput("invalid key pair data".into()))?;
let obj: serde_json::Value = serde_json::from_str(&s)?;
let secp = obj["secp256k1"]
.as_str()
.ok_or_else(|| OwsLibError::InvalidInput("missing secp256k1 key".into()))?;
let ed = obj["ed25519"]
.as_str()
.ok_or_else(|| OwsLibError::InvalidInput("missing ed25519 key".into()))?;
Ok(KeyPair {
secp256k1: hex::decode(secp)
.map_err(|e| OwsLibError::InvalidInput(format!("invalid secp256k1 hex: {e}")))?,
ed25519: hex::decode(ed)
.map_err(|e| OwsLibError::InvalidInput(format!("invalid ed25519 hex: {e}")))?,
})
}
}
fn derive_all_accounts_from_keys(keys: &KeyPair) -> Result<Vec<WalletAccount>, OwsLibError> {
let mut accounts = Vec::with_capacity(ALL_CHAIN_TYPES.len());
for ct in &ALL_CHAIN_TYPES {
let signer = signer_for_chain(*ct);
let key = keys.key_for_curve(signer.curve());
let address = signer.derive_address(key)?;
let chain = default_chain_for_type(*ct);
accounts.push(WalletAccount {
account_id: format!("{}:{}", chain.chain_id, address),
address,
chain_id: chain.chain_id.to_string(),
derivation_path: String::new(),
});
}
Ok(accounts)
}
pub(crate) fn secret_to_signing_key(
secret: &SecretBytes,
key_type: &KeyType,
chain_type: ChainType,
index: Option<u32>,
) -> Result<SecretBytes, OwsLibError> {
match key_type {
KeyType::Mnemonic => {
let phrase = std::str::from_utf8(secret.expose()).map_err(|_| {
OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
})?;
let mnemonic = Mnemonic::from_phrase(phrase)?;
let signer = signer_for_chain(chain_type);
let path = signer.default_derivation_path(index.unwrap_or(0));
let curve = signer.curve();
Ok(HdDeriver::derive_from_mnemonic_cached(
&mnemonic, "", &path, curve,
)?)
}
KeyType::PrivateKey => {
let keys = KeyPair::from_json_bytes(secret.expose())?;
let signer = signer_for_chain(chain_type);
Ok(SecretBytes::from_slice(keys.key_for_curve(signer.curve())))
}
}
}
pub fn generate_mnemonic(words: u32) -> Result<String, OwsLibError> {
let strength = match words {
12 => MnemonicStrength::Words12,
24 => MnemonicStrength::Words24,
_ => return Err(OwsLibError::InvalidInput("words must be 12 or 24".into())),
};
let mnemonic = Mnemonic::generate(strength)?;
let phrase = mnemonic.phrase();
String::from_utf8(phrase.expose().to_vec())
.map_err(|e| OwsLibError::InvalidInput(format!("invalid UTF-8 in mnemonic: {e}")))
}
pub fn derive_address(
mnemonic_phrase: &str,
chain: &str,
index: Option<u32>,
) -> Result<String, OwsLibError> {
let chain = parse_chain(chain)?;
let mnemonic = Mnemonic::from_phrase(mnemonic_phrase)?;
let signer = signer_for_chain(chain.chain_type);
let path = signer.default_derivation_path(index.unwrap_or(0));
let curve = signer.curve();
let key = HdDeriver::derive_from_mnemonic(&mnemonic, "", &path, curve)?;
let address = signer.derive_address(key.expose())?;
Ok(address)
}
pub fn create_wallet(
name: &str,
words: Option<u32>,
passphrase: Option<&str>,
vault_path: Option<&Path>,
) -> Result<WalletInfo, OwsLibError> {
let passphrase = passphrase.unwrap_or("");
let words = words.unwrap_or(12);
let strength = match words {
12 => MnemonicStrength::Words12,
24 => MnemonicStrength::Words24,
_ => return Err(OwsLibError::InvalidInput("words must be 12 or 24".into())),
};
if vault::wallet_name_exists(name, vault_path)? {
return Err(OwsLibError::WalletNameExists(name.to_string()));
}
let mnemonic = Mnemonic::generate(strength)?;
let accounts = derive_all_accounts(&mnemonic, 0)?;
let phrase = mnemonic.phrase();
let crypto_envelope = encrypt(phrase.expose(), passphrase)?;
let crypto_json = serde_json::to_value(&crypto_envelope)?;
let wallet_id = uuid::Uuid::new_v4().to_string();
let wallet = EncryptedWallet::new(
wallet_id,
name.to_string(),
accounts,
crypto_json,
KeyType::Mnemonic,
);
vault::save_encrypted_wallet(&wallet, vault_path)?;
Ok(wallet_to_info(&wallet))
}
pub fn import_wallet_mnemonic(
name: &str,
mnemonic_phrase: &str,
passphrase: Option<&str>,
index: Option<u32>,
vault_path: Option<&Path>,
) -> Result<WalletInfo, OwsLibError> {
let passphrase = passphrase.unwrap_or("");
let index = index.unwrap_or(0);
if vault::wallet_name_exists(name, vault_path)? {
return Err(OwsLibError::WalletNameExists(name.to_string()));
}
let mnemonic = Mnemonic::from_phrase(mnemonic_phrase)?;
let accounts = derive_all_accounts(&mnemonic, index)?;
let phrase = mnemonic.phrase();
let crypto_envelope = encrypt(phrase.expose(), passphrase)?;
let crypto_json = serde_json::to_value(&crypto_envelope)?;
let wallet_id = uuid::Uuid::new_v4().to_string();
let wallet = EncryptedWallet::new(
wallet_id,
name.to_string(),
accounts,
crypto_json,
KeyType::Mnemonic,
);
vault::save_encrypted_wallet(&wallet, vault_path)?;
Ok(wallet_to_info(&wallet))
}
fn decode_hex_key(hex_str: &str) -> Result<Vec<u8>, OwsLibError> {
let trimmed = hex_str.strip_prefix("0x").unwrap_or(hex_str);
hex::decode(trimmed)
.map_err(|e| OwsLibError::InvalidInput(format!("invalid hex private key: {e}")))
}
pub fn import_wallet_private_key(
name: &str,
private_key_hex: &str,
chain: Option<&str>,
passphrase: Option<&str>,
vault_path: Option<&Path>,
secp256k1_key_hex: Option<&str>,
ed25519_key_hex: Option<&str>,
) -> Result<WalletInfo, OwsLibError> {
let passphrase = passphrase.unwrap_or("");
if vault::wallet_name_exists(name, vault_path)? {
return Err(OwsLibError::WalletNameExists(name.to_string()));
}
let keys = match (secp256k1_key_hex, ed25519_key_hex) {
(Some(secp_hex), Some(ed_hex)) => KeyPair {
secp256k1: decode_hex_key(secp_hex)?,
ed25519: decode_hex_key(ed_hex)?,
},
_ => {
let key_bytes = decode_hex_key(private_key_hex)?;
let source_curve = match chain {
Some(c) => {
let parsed = parse_chain(c)?;
signer_for_chain(parsed.chain_type).curve()
}
None => ows_signer::Curve::Secp256k1,
};
let mut other_key = vec![0u8; 32];
getrandom::getrandom(&mut other_key).map_err(|e| {
OwsLibError::InvalidInput(format!("failed to generate random key: {e}"))
})?;
match source_curve {
ows_signer::Curve::Secp256k1 => KeyPair {
secp256k1: key_bytes,
ed25519: ed25519_key_hex
.map(decode_hex_key)
.transpose()?
.unwrap_or(other_key),
},
ows_signer::Curve::Ed25519 => KeyPair {
secp256k1: secp256k1_key_hex
.map(decode_hex_key)
.transpose()?
.unwrap_or(other_key),
ed25519: key_bytes,
},
}
}
};
let accounts = derive_all_accounts_from_keys(&keys)?;
let payload = keys.to_json_bytes();
let crypto_envelope = encrypt(&payload, passphrase)?;
let crypto_json = serde_json::to_value(&crypto_envelope)?;
let wallet_id = uuid::Uuid::new_v4().to_string();
let wallet = EncryptedWallet::new(
wallet_id,
name.to_string(),
accounts,
crypto_json,
KeyType::PrivateKey,
);
vault::save_encrypted_wallet(&wallet, vault_path)?;
Ok(wallet_to_info(&wallet))
}
pub fn list_wallets(vault_path: Option<&Path>) -> Result<Vec<WalletInfo>, OwsLibError> {
let wallets = vault::list_encrypted_wallets(vault_path)?;
Ok(wallets.iter().map(wallet_to_info).collect())
}
pub fn get_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result<WalletInfo, OwsLibError> {
let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
Ok(wallet_to_info(&wallet))
}
pub fn delete_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibError> {
let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
vault::delete_wallet_file(&wallet.id, vault_path)?;
Ok(())
}
pub fn export_wallet(
name_or_id: &str,
passphrase: Option<&str>,
vault_path: Option<&Path>,
) -> Result<String, OwsLibError> {
let passphrase = passphrase.unwrap_or("");
let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
let secret = decrypt(&envelope, passphrase)?;
match wallet.key_type {
KeyType::Mnemonic => String::from_utf8(secret.expose().to_vec()).map_err(|_| {
OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into())
}),
KeyType::PrivateKey => {
String::from_utf8(secret.expose().to_vec())
.map_err(|_| OwsLibError::InvalidInput("wallet contains invalid key data".into()))
}
}
}
pub fn rename_wallet(
name_or_id: &str,
new_name: &str,
vault_path: Option<&Path>,
) -> Result<(), OwsLibError> {
let mut wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?;
if wallet.name == new_name {
return Ok(());
}
if vault::wallet_name_exists(new_name, vault_path)? {
return Err(OwsLibError::WalletNameExists(new_name.to_string()));
}
wallet.name = new_name.to_string();
vault::save_encrypted_wallet(&wallet, vault_path)?;
Ok(())
}
pub fn sign_transaction(
wallet: &str,
chain: &str,
tx_hex: &str,
passphrase: Option<&str>,
index: Option<u32>,
vault_path: Option<&Path>,
) -> Result<SignResult, OwsLibError> {
let credential = passphrase.unwrap_or("");
let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
let tx_bytes = hex::decode(tx_hex_clean)
.map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
let chain = parse_chain(chain)?;
return crate::key_ops::sign_with_api_key(
credential, wallet, &chain, &tx_bytes, index, vault_path,
);
}
let chain = parse_chain(chain)?;
let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
let signer = signer_for_chain(chain.chain_type);
let signable = signer.extract_signable_bytes(&tx_bytes)?;
let output = signer.sign_transaction(key.expose(), signable)?;
Ok(SignResult {
signature: hex::encode(&output.signature),
recovery_id: output.recovery_id,
})
}
pub fn sign_message(
wallet: &str,
chain: &str,
message: &str,
passphrase: Option<&str>,
encoding: Option<&str>,
index: Option<u32>,
vault_path: Option<&Path>,
) -> Result<SignResult, OwsLibError> {
let credential = passphrase.unwrap_or("");
let encoding = encoding.unwrap_or("utf8");
let msg_bytes = match encoding {
"utf8" => message.as_bytes().to_vec(),
"hex" => hex::decode(message)
.map_err(|e| OwsLibError::InvalidInput(format!("invalid hex message: {e}")))?,
_ => {
return Err(OwsLibError::InvalidInput(format!(
"unsupported encoding: {encoding} (use 'utf8' or 'hex')"
)))
}
};
if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
let chain = parse_chain(chain)?;
return crate::key_ops::sign_message_with_api_key(
credential, wallet, &chain, &msg_bytes, index, vault_path,
);
}
let chain = parse_chain(chain)?;
let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
let signer = signer_for_chain(chain.chain_type);
let output = signer.sign_message(key.expose(), &msg_bytes)?;
Ok(SignResult {
signature: hex::encode(&output.signature),
recovery_id: output.recovery_id,
})
}
pub fn sign_typed_data(
wallet: &str,
chain: &str,
typed_data_json: &str,
passphrase: Option<&str>,
index: Option<u32>,
vault_path: Option<&Path>,
) -> Result<SignResult, OwsLibError> {
let credential = passphrase.unwrap_or("");
let chain = parse_chain(chain)?;
if chain.chain_type != ows_core::ChainType::Evm {
return Err(OwsLibError::InvalidInput(
"EIP-712 typed data signing is only supported for EVM chains".into(),
));
}
if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
return Err(OwsLibError::InvalidInput(
"EIP-712 typed data signing via API key is not yet supported; use sign_transaction"
.into(),
));
}
let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?;
let evm_signer = ows_signer::chains::EvmSigner;
let output = evm_signer.sign_typed_data(key.expose(), typed_data_json)?;
Ok(SignResult {
signature: hex::encode(&output.signature),
recovery_id: output.recovery_id,
})
}
pub fn sign_and_send(
wallet: &str,
chain: &str,
tx_hex: &str,
passphrase: Option<&str>,
index: Option<u32>,
rpc_url: Option<&str>,
vault_path: Option<&Path>,
) -> Result<SendResult, OwsLibError> {
let credential = passphrase.unwrap_or("");
let tx_hex_clean = tx_hex.strip_prefix("0x").unwrap_or(tx_hex);
let tx_bytes = hex::decode(tx_hex_clean)
.map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?;
if credential.starts_with(crate::key_store::TOKEN_PREFIX) {
let chain_info = parse_chain(chain)?;
let (key, _) = crate::key_ops::enforce_policy_and_decrypt_key(
credential,
wallet,
&chain_info,
&tx_bytes,
index,
vault_path,
)?;
return sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url);
}
let chain_info = parse_chain(chain)?;
let key = decrypt_signing_key(wallet, chain_info.chain_type, credential, index, vault_path)?;
sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url)
}
pub fn sign_encode_and_broadcast(
private_key: &[u8],
chain: &str,
tx_bytes: &[u8],
rpc_url: Option<&str>,
) -> Result<SendResult, OwsLibError> {
let chain = parse_chain(chain)?;
let signer = signer_for_chain(chain.chain_type);
let signable = signer.extract_signable_bytes(tx_bytes)?;
let output = signer.sign_transaction(private_key, signable)?;
let signed_tx = signer.encode_signed_transaction(tx_bytes, &output)?;
let rpc = resolve_rpc_url(chain.chain_id, chain.chain_type, rpc_url)?;
let tx_hash = broadcast(chain.chain_type, &rpc, &signed_tx)?;
Ok(SendResult { tx_hash })
}
pub fn decrypt_signing_key(
wallet_name_or_id: &str,
chain_type: ChainType,
passphrase: &str,
index: Option<u32>,
vault_path: Option<&Path>,
) -> Result<SecretBytes, OwsLibError> {
let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
let secret = decrypt(&envelope, passphrase)?;
secret_to_signing_key(&secret, &wallet.key_type, chain_type, index)
}
fn resolve_rpc_url(
chain_id: &str,
chain_type: ChainType,
explicit: Option<&str>,
) -> Result<String, OwsLibError> {
if let Some(url) = explicit {
return Ok(url.to_string());
}
let config = Config::load_or_default();
let defaults = Config::default_rpc();
if let Some(url) = config.rpc.get(chain_id) {
return Ok(url.clone());
}
if let Some(url) = defaults.get(chain_id) {
return Ok(url.clone());
}
let namespace = chain_type.namespace();
for (key, url) in &config.rpc {
if key.starts_with(namespace) {
return Ok(url.clone());
}
}
for (key, url) in &defaults {
if key.starts_with(namespace) {
return Ok(url.clone());
}
}
Err(OwsLibError::InvalidInput(format!(
"no RPC URL configured for chain '{chain_id}'"
)))
}
fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
match chain {
ChainType::Evm => broadcast_evm(rpc_url, signed_bytes),
ChainType::Solana => broadcast_solana(rpc_url, signed_bytes),
ChainType::Bitcoin => broadcast_bitcoin(rpc_url, signed_bytes),
ChainType::Cosmos => broadcast_cosmos(rpc_url, signed_bytes),
ChainType::Tron => broadcast_tron(rpc_url, signed_bytes),
ChainType::Ton => broadcast_ton(rpc_url, signed_bytes),
ChainType::Spark => Err(OwsLibError::InvalidInput(
"broadcast not yet supported for Spark".into(),
)),
ChainType::Filecoin => Err(OwsLibError::InvalidInput(
"broadcast not yet supported for Filecoin".into(),
)),
ChainType::Sui => broadcast_sui(rpc_url, signed_bytes),
ChainType::Xrpl => broadcast_xrpl(rpc_url, signed_bytes),
}
}
fn broadcast_xrpl(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
let tx_blob = hex::encode_upper(signed_bytes);
let body = serde_json::json!({
"method": "submit",
"params": [{ "tx_blob": tx_blob }]
});
let resp_str = curl_post_json(rpc_url, &body.to_string())?;
let resp: serde_json::Value = serde_json::from_str(&resp_str)?;
let engine_result = resp["result"]["engine_result"].as_str().unwrap_or("");
if !engine_result.starts_with("tes") {
let msg = resp["result"]["engine_result_message"]
.as_str()
.unwrap_or(engine_result);
return Err(OwsLibError::BroadcastFailed(format!(
"XRPL submit failed ({engine_result}): {msg}"
)));
}
resp["result"]["tx_json"]["hash"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| {
OwsLibError::BroadcastFailed(format!("no hash in XRPL response: {resp_str}"))
})
}
fn broadcast_evm(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
let hex_tx = format!("0x{}", hex::encode(signed_bytes));
let body = serde_json::json!({
"jsonrpc": "2.0",
"method": "eth_sendRawTransaction",
"params": [hex_tx],
"id": 1
});
let resp = curl_post_json(rpc_url, &body.to_string())?;
extract_json_field(&resp, "result")
}
fn build_solana_rpc_body(signed_bytes: &[u8]) -> serde_json::Value {
use base64::Engine;
let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
serde_json::json!({
"jsonrpc": "2.0",
"method": "sendTransaction",
"params": [b64_tx, {"encoding": "base64"}],
"id": 1
})
}
fn broadcast_solana(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
let body = build_solana_rpc_body(signed_bytes);
let resp = curl_post_json(rpc_url, &body.to_string())?;
extract_json_field(&resp, "result")
}
fn broadcast_bitcoin(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
let hex_tx = hex::encode(signed_bytes);
let url = format!("{}/tx", rpc_url.trim_end_matches('/'));
let output = Command::new("curl")
.args([
"-fsSL",
"-X",
"POST",
"-H",
"Content-Type: text/plain",
"-d",
&hex_tx,
&url,
])
.output()
.map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(OwsLibError::BroadcastFailed(format!(
"broadcast failed: {stderr}"
)));
}
let tx_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
if tx_hash.is_empty() {
return Err(OwsLibError::BroadcastFailed(
"empty response from broadcast".into(),
));
}
Ok(tx_hash)
}
fn broadcast_cosmos(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
use base64::Engine;
let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
let url = format!("{}/cosmos/tx/v1beta1/txs", rpc_url.trim_end_matches('/'));
let body = serde_json::json!({
"tx_bytes": b64_tx,
"mode": "BROADCAST_MODE_SYNC"
});
let resp = curl_post_json(&url, &body.to_string())?;
let parsed: serde_json::Value = serde_json::from_str(&resp)?;
parsed["tx_response"]["txhash"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| OwsLibError::BroadcastFailed(format!("no txhash in response: {resp}")))
}
fn broadcast_tron(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
let hex_tx = hex::encode(signed_bytes);
let url = format!("{}/wallet/broadcasthex", rpc_url.trim_end_matches('/'));
let body = serde_json::json!({ "transaction": hex_tx });
let resp = curl_post_json(&url, &body.to_string())?;
extract_json_field(&resp, "txid")
}
fn broadcast_ton(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
use base64::Engine;
let b64_boc = base64::engine::general_purpose::STANDARD.encode(signed_bytes);
let url = format!("{}/sendBoc", rpc_url.trim_end_matches('/'));
let body = serde_json::json!({ "boc": b64_boc });
let resp = curl_post_json(&url, &body.to_string())?;
let parsed: serde_json::Value = serde_json::from_str(&resp)?;
parsed["result"]["hash"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| OwsLibError::BroadcastFailed(format!("no hash in response: {resp}")))
}
fn broadcast_sui(rpc_url: &str, signed_bytes: &[u8]) -> Result<String, OwsLibError> {
use ows_signer::chains::sui::WIRE_SIG_LEN;
if signed_bytes.len() <= WIRE_SIG_LEN {
return Err(OwsLibError::InvalidInput(
"signed transaction too short to contain tx + signature".into(),
));
}
let split = signed_bytes.len() - WIRE_SIG_LEN;
let tx_part = &signed_bytes[..split];
let sig_part = &signed_bytes[split..];
crate::sui_grpc::execute_transaction(rpc_url, tx_part, sig_part)
}
fn curl_post_json(url: &str, body: &str) -> Result<String, OwsLibError> {
let output = Command::new("curl")
.args([
"-fsSL",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
body,
url,
])
.output()
.map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(OwsLibError::BroadcastFailed(format!(
"broadcast failed: {stderr}"
)));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn extract_json_field(json_str: &str, field: &str) -> Result<String, OwsLibError> {
let parsed: serde_json::Value = serde_json::from_str(json_str)?;
if let Some(error) = parsed.get("error") {
return Err(OwsLibError::BroadcastFailed(format!("RPC error: {error}")));
}
parsed[field]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| {
OwsLibError::BroadcastFailed(format!("no '{field}' in response: {json_str}"))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn save_privkey_wallet(
name: &str,
privkey_hex: &str,
passphrase: &str,
vault: &Path,
) -> WalletInfo {
let key_bytes = hex::decode(privkey_hex).unwrap();
let mut ed_key = vec![0u8; 32];
getrandom::getrandom(&mut ed_key).unwrap();
let keys = KeyPair {
secp256k1: key_bytes,
ed25519: ed_key,
};
let accounts = derive_all_accounts_from_keys(&keys).unwrap();
let payload = keys.to_json_bytes();
let crypto_envelope = encrypt(&payload, passphrase).unwrap();
let crypto_json = serde_json::to_value(&crypto_envelope).unwrap();
let wallet = EncryptedWallet::new(
uuid::Uuid::new_v4().to_string(),
name.to_string(),
accounts,
crypto_json,
KeyType::PrivateKey,
);
vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
wallet_to_info(&wallet)
}
const TEST_PRIVKEY: &str = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
#[test]
fn mnemonic_12_words() {
let phrase = generate_mnemonic(12).unwrap();
assert_eq!(phrase.split_whitespace().count(), 12);
}
#[test]
fn mnemonic_24_words() {
let phrase = generate_mnemonic(24).unwrap();
assert_eq!(phrase.split_whitespace().count(), 24);
}
#[test]
fn mnemonic_invalid_word_count() {
assert!(generate_mnemonic(15).is_err());
assert!(generate_mnemonic(0).is_err());
assert!(generate_mnemonic(13).is_err());
}
#[test]
fn mnemonic_is_unique_each_call() {
let a = generate_mnemonic(12).unwrap();
let b = generate_mnemonic(12).unwrap();
assert_ne!(a, b, "two generated mnemonics should differ");
}
#[test]
fn derive_address_all_chains() {
let phrase = generate_mnemonic(12).unwrap();
let chains = [
"evm", "solana", "bitcoin", "cosmos", "tron", "ton", "sui", "xrpl",
];
for chain in &chains {
let addr = derive_address(&phrase, chain, None).unwrap();
assert!(!addr.is_empty(), "address should be non-empty for {chain}");
}
}
#[test]
fn derive_address_evm_format() {
let phrase = generate_mnemonic(12).unwrap();
let addr = derive_address(&phrase, "evm", None).unwrap();
assert!(addr.starts_with("0x"), "EVM address should start with 0x");
assert_eq!(addr.len(), 42, "EVM address should be 42 chars");
}
#[test]
fn derive_address_deterministic() {
let phrase = generate_mnemonic(12).unwrap();
let a = derive_address(&phrase, "evm", None).unwrap();
let b = derive_address(&phrase, "evm", None).unwrap();
assert_eq!(a, b, "same mnemonic should produce same address");
}
#[test]
fn derive_address_different_index() {
let phrase = generate_mnemonic(12).unwrap();
let a = derive_address(&phrase, "evm", Some(0)).unwrap();
let b = derive_address(&phrase, "evm", Some(1)).unwrap();
assert_ne!(a, b, "different indices should produce different addresses");
}
#[test]
fn derive_address_invalid_chain() {
let phrase = generate_mnemonic(12).unwrap();
assert!(derive_address(&phrase, "nonexistent", None).is_err());
}
#[test]
fn derive_address_invalid_mnemonic() {
assert!(derive_address("not a valid mnemonic phrase at all", "evm", None).is_err());
}
#[test]
fn mnemonic_wallet_create_export_reimport() {
let v1 = tempfile::tempdir().unwrap();
let v2 = tempfile::tempdir().unwrap();
let w1 = create_wallet("w1", None, None, Some(v1.path())).unwrap();
assert!(!w1.accounts.is_empty());
let phrase = export_wallet("w1", None, Some(v1.path())).unwrap();
assert_eq!(phrase.split_whitespace().count(), 12);
let w2 = import_wallet_mnemonic("w2", &phrase, None, None, Some(v2.path())).unwrap();
assert_eq!(w1.accounts.len(), w2.accounts.len());
for (a1, a2) in w1.accounts.iter().zip(w2.accounts.iter()) {
assert_eq!(a1.chain_id, a2.chain_id);
assert_eq!(
a1.address, a2.address,
"address mismatch for {}",
a1.chain_id
);
}
}
#[test]
fn mnemonic_wallet_sign_message_all_chains() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("multi-sign", None, None, Some(vault)).unwrap();
let chains = [
"evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui",
];
for chain in &chains {
let result = sign_message(
"multi-sign",
chain,
"test msg",
None,
None,
None,
Some(vault),
);
assert!(
result.is_ok(),
"sign_message should work for {chain}: {:?}",
result.err()
);
let sig = result.unwrap();
assert!(
!sig.signature.is_empty(),
"signature should be non-empty for {chain}"
);
}
}
#[test]
fn mnemonic_wallet_sign_tx_all_chains() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("tx-sign", None, None, Some(vault)).unwrap();
let generic_tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
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);
let chains = [
"evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", "xrpl",
];
for chain in &chains {
let tx = if *chain == "solana" {
&solana_tx_hex
} else {
generic_tx_hex
};
let result = sign_transaction("tx-sign", chain, tx, None, None, Some(vault));
assert!(
result.is_ok(),
"sign_transaction should work for {chain}: {:?}",
result.err()
);
}
}
#[test]
fn mnemonic_wallet_signing_is_deterministic() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("det-sign", None, None, Some(vault)).unwrap();
let s1 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
let s2 = sign_message("det-sign", "evm", "hello", None, None, None, Some(vault)).unwrap();
assert_eq!(
s1.signature, s2.signature,
"same message should produce same signature"
);
}
#[test]
fn mnemonic_wallet_different_messages_produce_different_sigs() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("diff-msg", None, None, Some(vault)).unwrap();
let s1 = sign_message("diff-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
let s2 = sign_message("diff-msg", "evm", "world", None, None, None, Some(vault)).unwrap();
assert_ne!(s1.signature, s2.signature);
}
#[test]
fn privkey_wallet_sign_message() {
let dir = tempfile::tempdir().unwrap();
save_privkey_wallet("pk-sign", TEST_PRIVKEY, "", dir.path());
let sig = sign_message(
"pk-sign",
"evm",
"hello",
None,
None,
None,
Some(dir.path()),
)
.unwrap();
assert!(!sig.signature.is_empty());
assert!(sig.recovery_id.is_some());
}
#[test]
fn privkey_wallet_sign_transaction() {
let dir = tempfile::tempdir().unwrap();
save_privkey_wallet("pk-tx", TEST_PRIVKEY, "", dir.path());
let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let sig = sign_transaction("pk-tx", "evm", tx, None, None, Some(dir.path())).unwrap();
assert!(!sig.signature.is_empty());
}
#[test]
fn privkey_wallet_export_returns_json() {
let dir = tempfile::tempdir().unwrap();
save_privkey_wallet("pk-export", TEST_PRIVKEY, "", dir.path());
let exported = export_wallet("pk-export", None, Some(dir.path())).unwrap();
let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
assert_eq!(
obj["secp256k1"].as_str().unwrap(),
TEST_PRIVKEY,
"exported secp256k1 key should match original"
);
assert!(obj["ed25519"].as_str().is_some(), "should have ed25519 key");
}
#[test]
fn privkey_wallet_signing_is_deterministic() {
let dir = tempfile::tempdir().unwrap();
save_privkey_wallet("pk-det", TEST_PRIVKEY, "", dir.path());
let s1 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
let s2 = sign_message("pk-det", "evm", "test", None, None, None, Some(dir.path())).unwrap();
assert_eq!(s1.signature, s2.signature);
}
#[test]
fn privkey_and_mnemonic_wallets_produce_different_sigs() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("mn-w", None, None, Some(vault)).unwrap();
save_privkey_wallet("pk-w", TEST_PRIVKEY, "", vault);
let mn_sig = sign_message("mn-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
let pk_sig = sign_message("pk-w", "evm", "hello", None, None, None, Some(vault)).unwrap();
assert_ne!(
mn_sig.signature, pk_sig.signature,
"different keys should produce different signatures"
);
}
#[test]
fn privkey_wallet_import_via_api() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
let info = import_wallet_private_key(
"pk-api",
TEST_PRIVKEY,
Some("evm"),
None,
Some(vault),
None,
None,
)
.unwrap();
assert!(
!info.accounts.is_empty(),
"should derive at least one account"
);
let sig = sign_message("pk-api", "evm", "hello", None, None, None, Some(vault)).unwrap();
assert!(!sig.signature.is_empty());
let exported = export_wallet("pk-api", None, Some(vault)).unwrap();
let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
}
#[test]
fn privkey_wallet_import_both_curve_keys() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
let secp_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
let ed_key = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
let info = import_wallet_private_key(
"pk-both",
"", None, None,
Some(vault),
Some(secp_key),
Some(ed_key),
)
.unwrap();
assert_eq!(
info.accounts.len(),
ALL_CHAIN_TYPES.len(),
"should have one account per chain type"
);
let sig = sign_message("pk-both", "evm", "hello", None, None, None, Some(vault)).unwrap();
assert!(!sig.signature.is_empty());
let sig =
sign_message("pk-both", "solana", "hello", None, None, None, Some(vault)).unwrap();
assert!(!sig.signature.is_empty());
let exported = export_wallet("pk-both", None, Some(vault)).unwrap();
let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
assert_eq!(obj["secp256k1"].as_str().unwrap(), secp_key);
assert_eq!(obj["ed25519"].as_str().unwrap(), ed_key);
}
#[test]
fn passphrase_protected_mnemonic_wallet() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("pass-mn", None, Some("s3cret"), Some(vault)).unwrap();
let sig = sign_message(
"pass-mn",
"evm",
"hello",
Some("s3cret"),
None,
None,
Some(vault),
)
.unwrap();
assert!(!sig.signature.is_empty());
let phrase = export_wallet("pass-mn", Some("s3cret"), Some(vault)).unwrap();
assert_eq!(phrase.split_whitespace().count(), 12);
assert!(sign_message(
"pass-mn",
"evm",
"hello",
Some("wrong"),
None,
None,
Some(vault)
)
.is_err());
assert!(export_wallet("pass-mn", Some("wrong"), Some(vault)).is_err());
assert!(sign_message("pass-mn", "evm", "hello", None, None, None, Some(vault)).is_err());
}
#[test]
fn passphrase_protected_privkey_wallet() {
let dir = tempfile::tempdir().unwrap();
save_privkey_wallet("pass-pk", TEST_PRIVKEY, "mypass", dir.path());
let sig = sign_message(
"pass-pk",
"evm",
"hello",
Some("mypass"),
None,
None,
Some(dir.path()),
)
.unwrap();
assert!(!sig.signature.is_empty());
let exported = export_wallet("pass-pk", Some("mypass"), Some(dir.path())).unwrap();
let obj: serde_json::Value = serde_json::from_str(&exported).unwrap();
assert_eq!(obj["secp256k1"].as_str().unwrap(), TEST_PRIVKEY);
assert!(sign_message(
"pass-pk",
"evm",
"hello",
Some("wrong"),
None,
None,
Some(dir.path())
)
.is_err());
assert!(export_wallet("pass-pk", Some("wrong"), Some(dir.path())).is_err());
}
#[test]
fn evm_signature_is_recoverable() {
use sha3::Digest;
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
let info = create_wallet("verify-evm", None, None, Some(vault)).unwrap();
let evm_addr = info
.accounts
.iter()
.find(|a| a.chain_id.starts_with("eip155:"))
.unwrap()
.address
.clone();
let sig = sign_message(
"verify-evm",
"evm",
"hello world",
None,
None,
None,
Some(vault),
)
.unwrap();
let msg = b"hello world";
let prefix = format!("\x19Ethereum Signed Message:\n{}", msg.len());
let mut prefixed = prefix.into_bytes();
prefixed.extend_from_slice(msg);
let hash = sha3::Keccak256::digest(&prefixed);
let sig_bytes = hex::decode(&sig.signature).unwrap();
assert_eq!(
sig_bytes.len(),
65,
"EVM signature should be 65 bytes (r + s + v)"
);
let v = sig_bytes[64];
assert!(
v == 27 || v == 28,
"EIP-191 v byte should be 27 or 28, got {v}"
);
let recid = k256::ecdsa::RecoveryId::try_from(v - 27).unwrap();
let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
let recovered_key =
k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
let pubkey_bytes = recovered_key.to_encoded_point(false);
let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
assert_eq!(
recovered_addr.to_lowercase(),
evm_addr.to_lowercase(),
"recovered address should match wallet's EVM address"
);
}
#[test]
fn error_nonexistent_wallet() {
let dir = tempfile::tempdir().unwrap();
assert!(get_wallet("nope", Some(dir.path())).is_err());
assert!(export_wallet("nope", None, Some(dir.path())).is_err());
assert!(sign_message("nope", "evm", "x", None, None, None, Some(dir.path())).is_err());
assert!(delete_wallet("nope", Some(dir.path())).is_err());
}
#[test]
fn error_duplicate_wallet_name() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("dup", None, None, Some(vault)).unwrap();
assert!(create_wallet("dup", None, None, Some(vault)).is_err());
}
#[test]
fn error_invalid_private_key_hex() {
let dir = tempfile::tempdir().unwrap();
assert!(import_wallet_private_key(
"bad",
"not-hex",
Some("evm"),
None,
Some(dir.path()),
None,
None,
)
.is_err());
}
#[test]
fn error_invalid_chain_for_signing() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("chain-err", None, None, Some(vault)).unwrap();
assert!(
sign_message("chain-err", "fakecoin", "hi", None, None, None, Some(vault)).is_err()
);
}
#[test]
fn error_invalid_tx_hex() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("hex-err", None, None, Some(vault)).unwrap();
assert!(
sign_transaction("hex-err", "evm", "not-valid-hex!", None, None, Some(vault)).is_err()
);
}
#[test]
fn list_wallets_empty_vault() {
let dir = tempfile::tempdir().unwrap();
let wallets = list_wallets(Some(dir.path())).unwrap();
assert!(wallets.is_empty());
}
#[test]
fn get_wallet_by_name_and_id() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
let info = create_wallet("lookup", None, None, Some(vault)).unwrap();
let by_name = get_wallet("lookup", Some(vault)).unwrap();
assert_eq!(by_name.id, info.id);
let by_id = get_wallet(&info.id, Some(vault)).unwrap();
assert_eq!(by_id.name, "lookup");
}
#[test]
fn rename_wallet_works() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
let info = create_wallet("before", None, None, Some(vault)).unwrap();
rename_wallet("before", "after", Some(vault)).unwrap();
assert!(get_wallet("before", Some(vault)).is_err());
let after = get_wallet("after", Some(vault)).unwrap();
assert_eq!(after.id, info.id);
}
#[test]
fn rename_to_existing_name_fails() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("a", None, None, Some(vault)).unwrap();
create_wallet("b", None, None, Some(vault)).unwrap();
assert!(rename_wallet("a", "b", Some(vault)).is_err());
}
#[test]
fn delete_wallet_removes_from_list() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("del-me", None, None, Some(vault)).unwrap();
assert_eq!(list_wallets(Some(vault)).unwrap().len(), 1);
delete_wallet("del-me", Some(vault)).unwrap();
assert_eq!(list_wallets(Some(vault)).unwrap().len(), 0);
}
#[test]
fn sign_message_hex_encoding() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("hex-enc", None, None, Some(vault)).unwrap();
let sig = sign_message(
"hex-enc",
"evm",
"68656c6c6f",
None,
Some("hex"),
None,
Some(vault),
)
.unwrap();
assert!(!sig.signature.is_empty());
let sig2 = sign_message(
"hex-enc",
"evm",
"hello",
None,
Some("utf8"),
None,
Some(vault),
)
.unwrap();
assert_eq!(
sig.signature, sig2.signature,
"hex and utf8 encoding of same bytes should produce same signature"
);
}
#[test]
fn sign_message_invalid_encoding() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("bad-enc", None, None, Some(vault)).unwrap();
assert!(sign_message(
"bad-enc",
"evm",
"hello",
None,
Some("base64"),
None,
Some(vault)
)
.is_err());
}
#[test]
fn multiple_wallets_coexist() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("w1", None, None, Some(vault)).unwrap();
create_wallet("w2", None, None, Some(vault)).unwrap();
save_privkey_wallet("w3", TEST_PRIVKEY, "", vault);
let wallets = list_wallets(Some(vault)).unwrap();
assert_eq!(wallets.len(), 3);
let s1 = sign_message("w1", "evm", "test", None, None, None, Some(vault)).unwrap();
let s2 = sign_message("w2", "evm", "test", None, None, None, Some(vault)).unwrap();
let s3 = sign_message("w3", "evm", "test", None, None, None, Some(vault)).unwrap();
assert_ne!(s1.signature, s2.signature);
assert_ne!(s1.signature, s3.signature);
assert_ne!(s2.signature, s3.signature);
delete_wallet("w2", Some(vault)).unwrap();
assert_eq!(list_wallets(Some(vault)).unwrap().len(), 2);
assert!(sign_message("w1", "evm", "test", None, None, None, Some(vault)).is_ok());
assert!(sign_message("w3", "evm", "test", None, None, None, Some(vault)).is_ok());
}
#[test]
fn signed_tx_must_differ_from_raw_signature() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
save_privkey_wallet("send-bug", TEST_PRIVKEY, "", vault);
let items: Vec<u8> = [
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(&[]), ]
.concat();
let mut unsigned_tx = vec![0x02u8];
unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
let tx_hex = hex::encode(&unsigned_tx);
let sign_result =
sign_transaction("send-bug", "evm", &tx_hex, None, None, Some(vault)).unwrap();
let raw_signature = hex::decode(&sign_result.signature).unwrap();
let key = decrypt_signing_key("send-bug", ChainType::Evm, "", None, Some(vault)).unwrap();
let signer = signer_for_chain(ChainType::Evm);
let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
let full_signed_tx = signer
.encode_signed_transaction(&unsigned_tx, &output)
.unwrap();
assert_eq!(
raw_signature.len(),
65,
"raw EVM signature should be 65 bytes (r || s || v)"
);
assert!(
full_signed_tx.len() > raw_signature.len(),
"full signed tx ({} bytes) must be larger than raw signature ({} bytes)",
full_signed_tx.len(),
raw_signature.len()
);
assert_ne!(
raw_signature, full_signed_tx,
"raw signature and full signed transaction must differ — \
broadcasting the raw signature (as CLI send_transaction.rs:43 does) is wrong"
);
assert_eq!(
full_signed_tx[0], 0x02,
"full signed EIP-1559 tx must start with type byte 0x02"
);
}
#[test]
fn char_create_wallet_sign_transaction_with_passphrase() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-pass-tx", None, Some("secret"), Some(vault)).unwrap();
let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let sig =
sign_transaction("char-pass-tx", "evm", tx, Some("secret"), None, Some(vault)).unwrap();
assert!(!sig.signature.is_empty());
assert!(sig.recovery_id.is_some());
}
#[test]
fn char_create_wallet_sign_transaction_empty_passphrase() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-empty-tx", None, None, Some(vault)).unwrap();
let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let sig =
sign_transaction("char-empty-tx", "evm", tx, Some(""), None, Some(vault)).unwrap();
assert!(!sig.signature.is_empty());
}
#[test]
fn char_no_passphrase_none_none_sign_transaction() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-none-none", None, None, Some(vault)).unwrap();
let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let sig = sign_transaction("char-none-none", "evm", tx, None, None, Some(vault)).unwrap();
assert!(!sig.signature.is_empty());
assert!(sig.recovery_id.is_some());
}
#[test]
fn char_no_passphrase_none_none_sign_message() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-none-msg", None, None, Some(vault)).unwrap();
let sig = sign_message(
"char-none-msg",
"evm",
"hello",
None,
None,
None,
Some(vault),
)
.unwrap();
assert!(!sig.signature.is_empty());
}
#[test]
fn char_no_passphrase_none_none_export() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-none-exp", None, None, Some(vault)).unwrap();
let phrase = export_wallet("char-none-exp", None, Some(vault)).unwrap();
assert_eq!(phrase.split_whitespace().count(), 12);
}
#[test]
fn char_empty_passphrase_none_and_some_empty_are_equivalent() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-equiv", None, None, Some(vault)).unwrap();
let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let sig_none = sign_transaction("char-equiv", "evm", tx, None, None, Some(vault)).unwrap();
let sig_empty =
sign_transaction("char-equiv", "evm", tx, Some(""), None, Some(vault)).unwrap();
assert_eq!(
sig_none.signature, sig_empty.signature,
"passphrase=None and passphrase=Some(\"\") must produce identical signatures"
);
let msg_none =
sign_message("char-equiv", "evm", "test", None, None, None, Some(vault)).unwrap();
let msg_empty = sign_message(
"char-equiv",
"evm",
"test",
Some(""),
None,
None,
Some(vault),
)
.unwrap();
assert_eq!(
msg_none.signature, msg_empty.signature,
"sign_message: None and Some(\"\") must be equivalent"
);
let export_none = export_wallet("char-equiv", None, Some(vault)).unwrap();
let export_empty = export_wallet("char-equiv", Some(""), Some(vault)).unwrap();
assert_eq!(
export_none, export_empty,
"export_wallet: None and Some(\"\") must return the same mnemonic"
);
}
#[test]
fn char_create_with_some_empty_sign_with_none() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-some-none", None, Some(""), Some(vault)).unwrap();
let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let sig = sign_transaction("char-some-none", "evm", tx, None, None, Some(vault)).unwrap();
assert!(!sig.signature.is_empty());
}
#[test]
fn char_no_passphrase_wallet_rejects_nonempty_passphrase() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-no-pass-reject", None, None, Some(vault)).unwrap();
let result = sign_message(
"char-no-pass-reject",
"evm",
"test",
Some("some-random-passphrase"),
None,
None,
Some(vault),
);
assert!(
result.is_err(),
"non-empty passphrase on empty-passphrase wallet should fail"
);
match result.unwrap_err() {
OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error, got: {other}"),
}
}
#[test]
fn char_sign_transaction_wrong_passphrase_returns_crypto_error() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-wrong-pass", None, Some("correct"), Some(vault)).unwrap();
let tx = "deadbeef";
let result = sign_transaction(
"char-wrong-pass",
"evm",
tx,
Some("wrong"),
None,
Some(vault),
);
assert!(result.is_err());
match result.unwrap_err() {
OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error, got: {other}"),
}
}
#[test]
fn char_sign_transaction_nonexistent_wallet_returns_wallet_not_found() {
let dir = tempfile::tempdir().unwrap();
let result = sign_transaction("ghost", "evm", "deadbeef", None, None, Some(dir.path()));
assert!(result.is_err());
match result.unwrap_err() {
OwsLibError::WalletNotFound(name) => assert_eq!(name, "ghost"),
other => panic!("expected WalletNotFound, got: {other}"),
}
}
#[test]
fn char_sign_and_send_invalid_rpc_returns_broadcast_failed() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-rpc-fail", None, None, Some(vault)).unwrap();
let items: Vec<u8> = [
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(&[]), ]
.concat();
let mut unsigned_tx = vec![0x02u8];
unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
let tx_hex = hex::encode(&unsigned_tx);
let result = sign_and_send(
"char-rpc-fail",
"evm",
&tx_hex,
None,
None,
Some("http://127.0.0.1:1"), Some(vault),
);
assert!(result.is_err());
match result.unwrap_err() {
OwsLibError::BroadcastFailed(_) => {} other => panic!("expected BroadcastFailed, got: {other}"),
}
}
#[test]
fn char_create_sign_rename_sign_with_new_name() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("orig-name", None, None, Some(vault)).unwrap();
let sig1 = sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).unwrap();
assert!(!sig1.signature.is_empty());
rename_wallet("orig-name", "new-name", Some(vault)).unwrap();
assert!(sign_message("orig-name", "evm", "test", None, None, None, Some(vault)).is_err());
let sig2 = sign_message("new-name", "evm", "test", None, None, None, Some(vault)).unwrap();
assert_eq!(
sig1.signature, sig2.signature,
"renamed wallet should produce identical signatures"
);
}
#[test]
fn char_create_sign_delete_sign_returns_wallet_not_found() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("del-me-char", None, None, Some(vault)).unwrap();
let sig =
sign_message("del-me-char", "evm", "test", None, None, None, Some(vault)).unwrap();
assert!(!sig.signature.is_empty());
delete_wallet("del-me-char", Some(vault)).unwrap();
let result = sign_message("del-me-char", "evm", "test", None, None, None, Some(vault));
assert!(result.is_err());
match result.unwrap_err() {
OwsLibError::WalletNotFound(name) => assert_eq!(name, "del-me-char"),
other => panic!("expected WalletNotFound, got: {other}"),
}
}
#[test]
fn char_import_sign_export_reimport_sign_deterministic() {
let v1 = tempfile::tempdir().unwrap();
let v2 = tempfile::tempdir().unwrap();
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
import_wallet_mnemonic("char-det", phrase, None, None, Some(v1.path())).unwrap();
let sig1 = sign_message(
"char-det",
"evm",
"determinism test",
None,
None,
None,
Some(v1.path()),
)
.unwrap();
let exported = export_wallet("char-det", None, Some(v1.path())).unwrap();
assert_eq!(exported.trim(), phrase);
import_wallet_mnemonic("char-det-2", &exported, None, None, Some(v2.path())).unwrap();
let sig2 = sign_message(
"char-det-2",
"evm",
"determinism test",
None,
None,
None,
Some(v2.path()),
)
.unwrap();
assert_eq!(
sig1.signature, sig2.signature,
"import→sign→export→reimport→sign must produce identical signatures"
);
}
#[test]
fn char_import_private_key_sign_valid() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
import_wallet_private_key(
"char-pk",
TEST_PRIVKEY,
Some("evm"),
None,
Some(vault),
None,
None,
)
.unwrap();
let sig = sign_transaction("char-pk", "evm", "deadbeef", None, None, Some(vault)).unwrap();
assert!(!sig.signature.is_empty());
assert!(sig.recovery_id.is_some());
}
#[test]
fn char_sign_message_all_chain_families() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-all-chains", None, None, Some(vault)).unwrap();
let chains = [
("evm", true),
("solana", false),
("bitcoin", true),
("cosmos", true),
("tron", true),
("ton", false),
("sui", false),
];
for (chain, has_recovery_id) in &chains {
let result = sign_message(
"char-all-chains",
chain,
"hello",
None,
None,
None,
Some(vault),
);
assert!(
result.is_ok(),
"sign_message failed for {chain}: {:?}",
result.err()
);
let sig = result.unwrap();
assert!(!sig.signature.is_empty(), "signature empty for {chain}");
if *has_recovery_id {
assert!(
sig.recovery_id.is_some(),
"expected recovery_id for {chain}"
);
}
}
}
#[test]
fn char_sign_typed_data_evm_valid_signature() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-typed", None, None, Some(vault)).unwrap();
let typed_data = r#"{
"types": {
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"}
],
"Test": [{"name": "value", "type": "uint256"}]
},
"primaryType": "Test",
"domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
"message": {"value": "42"}
}"#;
let result = sign_typed_data("char-typed", "evm", typed_data, None, None, Some(vault));
assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
let sig = result.unwrap();
let sig_bytes = hex::decode(&sig.signature).unwrap();
assert_eq!(sig_bytes.len(), 65, "EIP-712 signature should be 65 bytes");
let v = sig_bytes[64];
assert!(v == 27 || v == 28, "EIP-712 v should be 27 or 28, got {v}");
}
#[test]
fn char_sign_with_nonzero_account_index() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-idx", None, None, Some(vault)).unwrap();
let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let sig0 = sign_transaction("char-idx", "evm", tx, None, Some(0), Some(vault)).unwrap();
let sig1 = sign_transaction("char-idx", "evm", tx, None, Some(1), Some(vault)).unwrap();
assert_ne!(
sig0.signature, sig1.signature,
"index 0 and index 1 must produce different signatures (different derived keys)"
);
let sig_default = sign_transaction("char-idx", "evm", tx, None, None, Some(vault)).unwrap();
assert_eq!(
sig0.signature, sig_default.signature,
"index=0 should match index=None (default)"
);
}
#[test]
fn char_sign_with_nonzero_index_sign_message() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-idx-msg", None, None, Some(vault)).unwrap();
let sig0 = sign_message(
"char-idx-msg",
"evm",
"hello",
None,
None,
Some(0),
Some(vault),
)
.unwrap();
let sig1 = sign_message(
"char-idx-msg",
"evm",
"hello",
None,
None,
Some(1),
Some(vault),
)
.unwrap();
assert_ne!(
sig0.signature, sig1.signature,
"different account indices should yield different signatures"
);
}
#[test]
fn char_sign_transaction_0x_prefix_stripped() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-0x", None, None, Some(vault)).unwrap();
let tx_no_prefix = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let tx_with_prefix = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let sig1 =
sign_transaction("char-0x", "evm", tx_no_prefix, None, None, Some(vault)).unwrap();
let sig2 =
sign_transaction("char-0x", "evm", tx_with_prefix, None, None, Some(vault)).unwrap();
assert_eq!(
sig1.signature, sig2.signature,
"0x-prefixed and bare hex should produce identical signatures"
);
}
#[test]
fn char_24_word_mnemonic_wallet_lifecycle() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
let info = create_wallet("char-24w", Some(24), None, Some(vault)).unwrap();
assert!(!info.accounts.is_empty());
let phrase = export_wallet("char-24w", None, Some(vault)).unwrap();
assert_eq!(
phrase.split_whitespace().count(),
24,
"should be a 24-word mnemonic"
);
let tx = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let sig = sign_transaction("char-24w", "evm", tx, None, None, Some(vault)).unwrap();
assert!(!sig.signature.is_empty());
for chain in &["evm", "solana", "bitcoin", "cosmos"] {
let result = sign_message("char-24w", chain, "test", None, None, None, Some(vault));
assert!(
result.is_ok(),
"24-word wallet sign_message failed for {chain}: {:?}",
result.err()
);
}
let v2 = tempfile::tempdir().unwrap();
import_wallet_mnemonic("char-24w-2", &phrase, None, None, Some(v2.path())).unwrap();
let sig2 = sign_transaction("char-24w-2", "evm", tx, None, None, Some(v2.path())).unwrap();
assert_eq!(
sig.signature, sig2.signature,
"reimported 24-word wallet must produce identical signature"
);
}
#[test]
fn char_concurrent_signing() {
use std::sync::Arc;
use std::thread;
let dir = tempfile::tempdir().unwrap();
let vault_path = Arc::new(dir.path().to_path_buf());
create_wallet("char-conc", None, None, Some(&vault_path)).unwrap();
let handles: Vec<_> = (0..8)
.map(|i| {
let vp = Arc::clone(&vault_path);
thread::spawn(move || {
let msg = format!("thread-{i}");
let result = sign_message(
"char-conc",
"evm",
&msg,
None,
None,
None,
Some(vp.as_path()),
);
assert!(
result.is_ok(),
"concurrent sign_message failed in thread {i}: {:?}",
result.err()
);
result.unwrap()
})
})
.collect();
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
for (i, sig) in results.iter().enumerate() {
assert!(
!sig.signature.is_empty(),
"thread {i} produced empty signature"
);
}
for i in 0..results.len() {
for j in (i + 1)..results.len() {
assert_ne!(
results[i].signature, results[j].signature,
"threads {i} and {j} should produce different signatures (different messages)"
);
}
}
}
#[test]
fn char_evm_sign_transaction_recoverable() {
use sha3::Digest;
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
let info = create_wallet("char-tx-recover", None, None, Some(vault)).unwrap();
let evm_addr = info
.accounts
.iter()
.find(|a| a.chain_id.starts_with("eip155:"))
.unwrap()
.address
.clone();
let tx_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let sig =
sign_transaction("char-tx-recover", "evm", tx_hex, None, None, Some(vault)).unwrap();
let sig_bytes = hex::decode(&sig.signature).unwrap();
assert_eq!(sig_bytes.len(), 65);
let tx_bytes = hex::decode(tx_hex).unwrap();
let hash = sha3::Keccak256::digest(&tx_bytes);
let v = sig_bytes[64];
let recid = k256::ecdsa::RecoveryId::try_from(v).unwrap();
let ecdsa_sig = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap();
let recovered_key =
k256::ecdsa::VerifyingKey::recover_from_prehash(&hash, &ecdsa_sig, recid).unwrap();
let pubkey_bytes = recovered_key.to_encoded_point(false);
let pubkey_hash = sha3::Keccak256::digest(&pubkey_bytes.as_bytes()[1..]);
let recovered_addr = format!("0x{}", hex::encode(&pubkey_hash[12..]));
assert_eq!(
recovered_addr.to_lowercase(),
evm_addr.to_lowercase(),
"recovered address from tx signature should match wallet's EVM address"
);
}
#[test]
fn char_solana_extract_signable_through_sign_path() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-sol-sig", None, None, Some(vault)).unwrap();
let message_payload = b"test solana message payload 1234";
let mut tx_bytes = vec![0x01u8]; tx_bytes.extend_from_slice(&[0u8; 64]); tx_bytes.extend_from_slice(message_payload);
let tx_hex = hex::encode(&tx_bytes);
let sig =
sign_transaction("char-sol-sig", "solana", &tx_hex, None, None, Some(vault)).unwrap();
assert_eq!(
hex::decode(&sig.signature).unwrap().len(),
64,
"Solana signature should be 64 bytes (Ed25519)"
);
assert!(sig.recovery_id.is_none(), "Ed25519 has no recovery ID");
let key =
decrypt_signing_key("char-sol-sig", ChainType::Solana, "", None, Some(vault)).unwrap();
let signer = signer_for_chain(ChainType::Solana);
let signable = signer.extract_signable_bytes(&tx_bytes).unwrap();
assert_eq!(
signable, message_payload,
"extract_signable_bytes should return only the message portion"
);
let output = signer.sign_transaction(key.expose(), signable).unwrap();
let signed_tx = signer
.encode_signed_transaction(&tx_bytes, &output)
.unwrap();
assert_eq!(&signed_tx[1..65], &output.signature[..]);
assert_eq!(&signed_tx[65..], message_payload);
assert_eq!(signed_tx.len(), tx_bytes.len());
let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
let verifying_key = signing_key.verifying_key();
let ed_sig = ed25519_dalek::Signature::from_bytes(&output.signature.try_into().unwrap());
verifying_key
.verify_strict(message_payload, &ed_sig)
.expect("Solana signature should verify against extracted message");
}
#[test]
fn char_library_encodes_before_broadcast() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("char-encode", None, None, Some(vault)).unwrap();
let items: Vec<u8> = [
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(&[]), ]
.concat();
let mut unsigned_tx = vec![0x02u8];
unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
let tx_hex = hex::encode(&unsigned_tx);
let raw_sig =
sign_transaction("char-encode", "evm", &tx_hex, None, None, Some(vault)).unwrap();
let raw_sig_bytes = hex::decode(&raw_sig.signature).unwrap();
let key =
decrypt_signing_key("char-encode", ChainType::Evm, "", None, Some(vault)).unwrap();
let signer = signer_for_chain(ChainType::Evm);
let output = signer.sign_transaction(key.expose(), &unsigned_tx).unwrap();
let full_signed_tx = signer
.encode_signed_transaction(&unsigned_tx, &output)
.unwrap();
assert_eq!(raw_sig_bytes.len(), 65);
assert!(full_signed_tx.len() > 65);
assert_eq!(
full_signed_tx[0], 0x02,
"should preserve EIP-1559 type byte"
);
assert_ne!(raw_sig_bytes, full_signed_tx);
let r_bytes = &raw_sig_bytes[..32];
let _s_bytes = &raw_sig_bytes[32..64];
let full_hex = hex::encode(&full_signed_tx);
let r_hex = hex::encode(r_bytes);
assert!(
full_hex.contains(&r_hex),
"full signed tx should contain the r component"
);
}
#[test]
fn sign_typed_data_rejects_non_evm_chain() {
let tmp = tempfile::tempdir().unwrap();
let vault = tmp.path();
let w = save_privkey_wallet("typed-data-test", TEST_PRIVKEY, "pass", vault);
let typed_data = r#"{
"types": {
"EIP712Domain": [{"name": "name", "type": "string"}],
"Test": [{"name": "value", "type": "uint256"}]
},
"primaryType": "Test",
"domain": {"name": "Test"},
"message": {"value": "1"}
}"#;
let result = sign_typed_data(&w.id, "solana", typed_data, Some("pass"), None, Some(vault));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("only supported for EVM"),
"expected EVM-only error, got: {err_msg}"
);
}
#[test]
fn sign_typed_data_evm_succeeds() {
let tmp = tempfile::tempdir().unwrap();
let vault = tmp.path();
let w = save_privkey_wallet("typed-data-evm", TEST_PRIVKEY, "pass", vault);
let typed_data = r#"{
"types": {
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"}
],
"Test": [{"name": "value", "type": "uint256"}]
},
"primaryType": "Test",
"domain": {"name": "TestDapp", "version": "1", "chainId": "1"},
"message": {"value": "42"}
}"#;
let result = sign_typed_data(&w.id, "evm", typed_data, Some("pass"), None, Some(vault));
assert!(result.is_ok(), "sign_typed_data failed: {:?}", result.err());
let sign_result = result.unwrap();
assert!(
!sign_result.signature.is_empty(),
"signature should not be empty"
);
assert!(
sign_result.recovery_id.is_some(),
"recovery_id should be present for EVM"
);
}
#[test]
fn regression_owner_path_identical_to_direct_signer() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("reg-owner", None, None, Some(vault)).unwrap();
let tx_hex = "deadbeefcafebabe";
let api_result =
sign_transaction("reg-owner", "evm", tx_hex, None, None, Some(vault)).unwrap();
let key = decrypt_signing_key("reg-owner", ChainType::Evm, "", None, Some(vault)).unwrap();
let signer = signer_for_chain(ChainType::Evm);
let tx_bytes = hex::decode(tx_hex).unwrap();
let direct_output = signer.sign_transaction(key.expose(), &tx_bytes).unwrap();
assert_eq!(
api_result.signature,
hex::encode(&direct_output.signature),
"library API and direct signer must produce identical signatures"
);
assert_eq!(
api_result.recovery_id, direct_output.recovery_id,
"recovery_id must match"
);
}
#[test]
fn regression_owner_passphrase_not_confused_with_token() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("reg-pass", Some(12), Some("hunter2"), Some(vault)).unwrap();
let tx_hex = "deadbeef";
let result = sign_transaction(
"reg-pass",
"evm",
tx_hex,
Some("hunter2"),
None,
Some(vault),
);
assert!(
result.is_ok(),
"owner-mode signing failed: {:?}",
result.err()
);
let bad = sign_transaction("reg-pass", "evm", tx_hex, Some(""), None, Some(vault));
assert!(bad.is_err());
match bad.unwrap_err() {
OwsLibError::Crypto(_) => {} other => panic!("expected Crypto error for wrong passphrase, got: {other}"),
}
let none_result = sign_transaction("reg-pass", "evm", tx_hex, None, None, Some(vault));
assert!(none_result.is_err());
match none_result.unwrap_err() {
OwsLibError::Crypto(_) => {}
other => panic!("expected Crypto error for None passphrase, got: {other}"),
}
}
#[test]
fn regression_sign_message_owner_path_unchanged() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("reg-msg", None, None, Some(vault)).unwrap();
let api_result =
sign_message("reg-msg", "evm", "hello", None, None, None, Some(vault)).unwrap();
let key = decrypt_signing_key("reg-msg", ChainType::Evm, "", None, Some(vault)).unwrap();
let signer = signer_for_chain(ChainType::Evm);
let direct = signer.sign_message(key.expose(), b"hello").unwrap();
assert_eq!(
api_result.signature,
hex::encode(&direct.signature),
"sign_message owner path must match direct signer"
);
}
#[test]
fn solana_broadcast_body_includes_encoding_param() {
let dummy_tx = vec![0x01; 100];
let body = build_solana_rpc_body(&dummy_tx);
assert_eq!(body["method"], "sendTransaction");
assert_eq!(
body["params"][1]["encoding"], "base64",
"sendTransaction must specify encoding=base64 so Solana RPC \
does not default to base58"
);
}
#[test]
fn solana_broadcast_body_uses_base64_encoding() {
use base64::Engine;
let dummy_tx = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03];
let body = build_solana_rpc_body(&dummy_tx);
let encoded = body["params"][0].as_str().unwrap();
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("params[0] should be valid base64");
assert_eq!(
decoded, dummy_tx,
"base64 should round-trip to original bytes"
);
}
#[test]
fn solana_broadcast_body_is_not_hex_or_base58() {
let dummy_tx = vec![0xFF; 50];
let body = build_solana_rpc_body(&dummy_tx);
let encoded = body["params"][0].as_str().unwrap();
let hex_encoded = hex::encode(&dummy_tx);
assert_ne!(encoded, hex_encoded, "broadcast should use base64, not hex");
assert!(
encoded.contains('/') || encoded.contains('+') || encoded.ends_with('='),
"base64 of 0xFF bytes should contain characters absent from base58"
);
}
#[test]
fn solana_broadcast_body_jsonrpc_structure() {
let body = build_solana_rpc_body(&[0u8; 10]);
assert_eq!(body["jsonrpc"], "2.0");
assert_eq!(body["id"], 1);
assert_eq!(body["method"], "sendTransaction");
assert!(body["params"].is_array());
assert_eq!(
body["params"].as_array().unwrap().len(),
2,
"params should have [tx_data, options_object]"
);
}
#[test]
fn solana_sign_transaction_extracts_signable_bytes() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("sol-extract", None, None, Some(vault)).unwrap();
let message_payload = b"test solana message for extraction";
let mut full_tx = vec![0x01u8]; full_tx.extend_from_slice(&[0u8; 64]); full_tx.extend_from_slice(message_payload);
let tx_hex = hex::encode(&full_tx);
let sig_result =
sign_transaction("sol-extract", "solana", &tx_hex, None, None, Some(vault)).unwrap();
let sig_bytes = hex::decode(&sig_result.signature).unwrap();
let key =
decrypt_signing_key("sol-extract", ChainType::Solana, "", None, Some(vault)).unwrap();
let signing_key = ed25519_dalek::SigningKey::from_bytes(&key.expose().try_into().unwrap());
let verifying_key = signing_key.verifying_key();
let ed_sig = ed25519_dalek::Signature::from_bytes(&sig_bytes.try_into().unwrap());
verifying_key
.verify_strict(message_payload, &ed_sig)
.expect("sign_transaction should sign the message portion, not the full envelope");
}
#[test]
fn solana_sign_transaction_full_tx_matches_extracted_sign() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("sol-match", None, None, Some(vault)).unwrap();
let message_payload = b"matching signatures test";
let mut full_tx = vec![0x01u8];
full_tx.extend_from_slice(&[0u8; 64]);
full_tx.extend_from_slice(message_payload);
let tx_hex = hex::encode(&full_tx);
let api_sig =
sign_transaction("sol-match", "solana", &tx_hex, None, None, Some(vault)).unwrap();
let key =
decrypt_signing_key("sol-match", ChainType::Solana, "", None, Some(vault)).unwrap();
let signer = signer_for_chain(ChainType::Solana);
let signable = signer.extract_signable_bytes(&full_tx).unwrap();
let direct = signer.sign_transaction(key.expose(), signable).unwrap();
assert_eq!(
api_sig.signature,
hex::encode(&direct.signature),
"sign_transaction API and manual extract+sign must produce the same signature"
);
}
#[test]
fn evm_sign_transaction_unaffected_by_extraction() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path();
create_wallet("evm-regress", None, None, Some(vault)).unwrap();
let items: Vec<u8> = [
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(&[]),
]
.concat();
let mut unsigned_tx = vec![0x02u8];
unsigned_tx.extend_from_slice(&ows_signer::rlp::encode_list(&items));
let tx_hex = hex::encode(&unsigned_tx);
let sig1 =
sign_transaction("evm-regress", "evm", &tx_hex, None, None, Some(vault)).unwrap();
let sig2 =
sign_transaction("evm-regress", "evm", &tx_hex, None, None, Some(vault)).unwrap();
assert_eq!(sig1.signature, sig2.signature);
assert_eq!(hex::decode(&sig1.signature).unwrap().len(), 65);
}
#[test]
#[ignore] fn solana_devnet_broadcast_encoding_accepted() {
let bh_body = serde_json::json!({
"jsonrpc": "2.0",
"method": "getLatestBlockhash",
"params": [],
"id": 1
});
let bh_resp =
curl_post_json("https://api.devnet.solana.com", &bh_body.to_string()).unwrap();
let bh_parsed: serde_json::Value = serde_json::from_str(&bh_resp).unwrap();
let blockhash_b58 = bh_parsed["result"]["value"]["blockhash"]
.as_str()
.expect("devnet should return a blockhash");
let blockhash = bs58::decode(blockhash_b58).into_vec().unwrap();
assert_eq!(blockhash.len(), 32);
let privkey =
hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
.unwrap();
let signing_key =
ed25519_dalek::SigningKey::from_bytes(&privkey.clone().try_into().unwrap());
let sender_pubkey = signing_key.verifying_key().to_bytes();
let recipient_pubkey = [0x01; 32]; let system_program = [0u8; 32];
let mut message = vec![
1, 0, 1, 3, ];
message.extend_from_slice(&sender_pubkey);
message.extend_from_slice(&recipient_pubkey);
message.extend_from_slice(&system_program);
message.extend_from_slice(&blockhash);
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);
let result = sign_encode_and_broadcast(
&privkey,
"solana",
&tx_bytes,
Some("https://api.devnet.solana.com"),
);
match result {
Ok(send_result) => {
assert!(!send_result.tx_hash.is_empty());
}
Err(e) => {
let err_str = format!("{e}");
assert!(
!err_str.contains("base58"),
"should not get base58 encoding error: {err_str}"
);
assert!(
!err_str.contains("InvalidCharacter"),
"should not get InvalidCharacter error: {err_str}"
);
}
}
}
}