#![forbid(unsafe_code)]
use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD, Engine};
use solana_sdk::{
hash::Hash,
instruction::Instruction,
message::{Message, VersionedMessage},
pubkey::Pubkey,
signature::Signature,
transaction::VersionedTransaction,
};
use std::str::FromStr;
pub mod transaction_signing {
use super::{Hash, Instruction, Pubkey, Result, VersionedTransaction};
pub fn prepare_transaction_for_signing(
instructions: &[Instruction],
payer: &Pubkey,
recent_blockhash: Hash,
) -> Result<String> {
super::prepare_transaction_for_signing(instructions, payer, recent_blockhash)
}
pub fn verify_signed_transaction(
signed_transaction_base64: &str,
expected_signer: &Pubkey,
) -> Result<VersionedTransaction> {
super::verify_signed_transaction(signed_transaction_base64, expected_signer)
}
pub fn extract_transaction_signature(signed_transaction_base64: &str) -> Result<String> {
super::extract_transaction_signature(signed_transaction_base64)
}
}
pub fn verify_wallet_signature(wallet_address: &str, signature: &str, message: &str) -> Result<()> {
tracing::debug!(
service = "tally-sdk",
component = "signature",
event = "signature_debug",
wallet_address = %wallet_address,
signature_len = signature.len(),
signature_first_chars = %signature.chars().take(20).collect::<String>(),
message_len = message.len(),
"Signature verification debug info"
);
let pubkey = Pubkey::from_str(wallet_address).context("Invalid wallet address format")?;
let sig = match Signature::from_str(signature) {
Ok(sig) => sig,
Err(_) => {
if signature.len() == 128 {
match hex::decode(signature) {
Ok(bytes) if bytes.len() == 64 => match <[u8; 64]>::try_from(bytes) {
Ok(array) => Signature::from(array),
Err(_) => anyhow::bail!("Failed to convert hex bytes to signature array"),
},
_ => anyhow::bail!(
"Invalid hex signature format - must be 128 hex characters (64 bytes)"
),
}
} else {
anyhow::bail!("Invalid signature format - must be base58 encoded (88 chars) or hex encoded (128 chars), got {} chars", signature.len());
}
}
};
let message_bytes = message.as_bytes();
if !sig.verify(pubkey.as_ref(), message_bytes) {
anyhow::bail!("Signature verification failed");
}
Ok(())
}
pub fn normalize_signature_format(signature_input: &str) -> Result<String> {
let signature = signature_input.trim();
if signature.is_empty() {
anyhow::bail!("Empty signature");
}
if Signature::from_str(signature).is_ok() {
return Ok(signature.to_string());
}
if signature.len() == 128 {
match hex::decode(signature) {
Ok(bytes) if bytes.len() == 64 => {
match <[u8; 64]>::try_from(bytes) {
Ok(array) => {
let sig = Signature::from(array);
return Ok(sig.to_string());
}
Err(_) => anyhow::bail!("Failed to convert hex bytes to signature array"),
}
}
_ => {
anyhow::bail!(
"Invalid hex signature format - must be 128 hex characters (64 bytes)"
)
}
}
}
anyhow::bail!(
"Invalid signature format - must be base58 encoded (88 chars) or hex encoded (128 chars), got {} chars",
signature.len()
)
}
pub fn prepare_transaction_for_signing(
instructions: &[Instruction],
payer: &Pubkey,
recent_blockhash: Hash,
) -> Result<String> {
let message = Message::new_with_blockhash(instructions, Some(payer), &recent_blockhash);
let num_signatures = message.header.num_required_signatures;
let versioned_message = VersionedMessage::Legacy(message);
let transaction = VersionedTransaction {
signatures: vec![Signature::default(); num_signatures as usize],
message: versioned_message,
};
let serialized =
bincode::serialize(&transaction).context("Failed to serialize transaction for signing")?;
Ok(STANDARD.encode(serialized))
}
pub fn verify_signed_transaction(
signed_transaction_base64: &str,
expected_signer: &Pubkey,
) -> Result<VersionedTransaction> {
let transaction_bytes = STANDARD
.decode(signed_transaction_base64)
.context("Failed to decode base64 transaction")?;
let transaction: VersionedTransaction = bincode::deserialize(&transaction_bytes)
.context("Failed to deserialize signed transaction")?;
if transaction.signatures.is_empty() {
anyhow::bail!("Transaction has no signatures");
}
let message_data = transaction.message.serialize();
let signature = &transaction.signatures[0];
if signature == &Signature::default() {
anyhow::bail!("Transaction signature is empty/default");
}
if !signature.verify(expected_signer.as_ref(), &message_data) {
anyhow::bail!("Transaction signature verification failed for expected signer");
}
Ok(transaction)
}
pub fn extract_transaction_signature(signed_transaction_base64: &str) -> Result<String> {
let transaction_bytes = STANDARD
.decode(signed_transaction_base64)
.context("Failed to decode base64 transaction")?;
let transaction: VersionedTransaction = bincode::deserialize(&transaction_bytes)
.context("Failed to deserialize signed transaction")?;
if transaction.signatures.is_empty() {
anyhow::bail!("Transaction has no signatures");
}
let signature = &transaction.signatures[0];
if signature == &Signature::default() {
anyhow::bail!("Transaction signature is empty/default");
}
Ok(signature.to_string())
}
#[must_use]
pub fn is_valid_wallet_address(address: &str) -> bool {
const BASE58_ALPHABET: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
if address.len() < 32 || address.len() > 44 {
return false;
}
address.chars().all(|c| BASE58_ALPHABET.contains(c))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_signature_verification_with_invalid_format() {
let wallet_address = "6YbW3k3oiU8kMbJLYCc8XA27c7DfqG5WSjTkED4Z2pj9";
let invalid_signature = "invalid_signature_format"; let message = "Sign this message to authenticate with Tally:\n\nNonce: tally_auth_1726800502_abc12345\nTimestamp: 2024-09-20T02:21:42.000Z";
let result = verify_wallet_signature(wallet_address, invalid_signature, message);
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(error_message.contains("Invalid signature format"));
}
#[test]
fn test_signature_verification_with_empty_signature() {
let wallet_address = "6YbW3k3oiU8kMbJLYCc8XA27c7DfqG5WSjTkED4Z2pj9";
let empty_signature = "";
let message = "test message";
let result = verify_wallet_signature(wallet_address, empty_signature, message);
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(error_message.contains("Invalid signature format"));
}
#[test]
fn test_signature_verification_with_invalid_base58() {
let wallet_address = "6YbW3k3oiU8kMbJLYCc8XA27c7DfqG5WSjTkED4Z2pj9";
let invalid_base58_signature = "000000OOOOOIIIIIllllllinvalid";
let message = "test message";
let result = verify_wallet_signature(wallet_address, invalid_base58_signature, message);
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(error_message.contains("Invalid signature format"));
}
#[test]
fn test_signature_verification_with_wrong_length_signature() {
let wallet_address = "6YbW3k3oiU8kMbJLYCc8XA27c7DfqG5WSjTkED4Z2pj9";
let short_signature = "ABC123";
let message = "test message";
let result = verify_wallet_signature(wallet_address, short_signature, message);
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(error_message.contains("Invalid signature format"));
}
#[test]
fn test_signature_verification_with_invalid_wallet_address() {
let invalid_wallet_address = "invalid_wallet";
let signature =
"5K6aZkd8hjw4oMXkNYkrjFzSjLaXvqWHk4GFRv3WvJ8Z3Q4J1L2M3N4P5Q6R7S8T9U1V2W3X4Y5Z6"; let message = "test message";
let result = verify_wallet_signature(invalid_wallet_address, signature, message);
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(error_message.contains("Invalid wallet address format"));
}
#[test]
fn test_signature_format_parsing() {
let wallet_address = "6YbW3k3oiU8kMbJLYCc8XA27c7DfqG5WSjTkED4Z2pj9";
let message = "test message";
let valid_base58 = "5K6aZkd8hjw4oMXkNYkrjFzSjLaXvqWHk4GFRv3WvJ8Z3Q4J1L2M3N4P5Q6R7S8T9U1V2W3X4Y5Z6abcdefghijk123456789ABCDEF";
let result = verify_wallet_signature(wallet_address, valid_base58, message);
assert!(result.is_err());
let valid_hex = "1234567890abcdef".repeat(8); let result = verify_wallet_signature(wallet_address, &valid_hex, message);
assert!(result.is_err());
let invalid_short = "too_short";
let result = verify_wallet_signature(wallet_address, invalid_short, message);
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(error_message.contains("Invalid signature format"));
let invalid_hex_wrong_length = "1234567890abcdef".repeat(4); let result = verify_wallet_signature(wallet_address, &invalid_hex_wrong_length, message);
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(error_message.contains("Invalid signature format"));
}
#[test]
fn test_normalize_signature_format() {
let result = normalize_signature_format("");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Empty signature"));
let result = normalize_signature_format(" ");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Empty signature"));
let valid_hex = "1234567890abcdef".repeat(8);
let result = normalize_signature_format(&valid_hex);
assert!(result.is_ok());
let invalid_hex = "1234567890abcdef".repeat(4); let result = normalize_signature_format(&invalid_hex);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid signature format"));
let invalid_format = "invalid_signature_format_123";
let result = normalize_signature_format(invalid_format);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid signature format"));
}
#[test]
fn test_prepare_transaction_for_signing() {
use solana_sdk::hash::Hash;
let payer = Pubkey::new_unique();
let _recipient = Pubkey::new_unique();
let recent_blockhash = Hash::default();
let instruction = Instruction {
program_id: Pubkey::new_unique(), accounts: vec![],
data: vec![],
};
let instructions = vec![instruction];
let result = prepare_transaction_for_signing(&instructions, &payer, recent_blockhash);
assert!(result.is_ok());
let transaction_base64 = result.unwrap();
assert!(!transaction_base64.is_empty());
let decoded = STANDARD.decode(&transaction_base64);
assert!(decoded.is_ok());
let transaction_bytes = decoded.unwrap();
let transaction: Result<VersionedTransaction, _> = bincode::deserialize(&transaction_bytes);
assert!(transaction.is_ok());
let tx = transaction.unwrap();
assert!(!tx.signatures.is_empty());
assert_eq!(tx.signatures[0], Signature::default());
}
#[test]
fn test_verify_signed_transaction_empty_signatures() {
use solana_sdk::hash::Hash;
let payer = Pubkey::new_unique();
let recent_blockhash = Hash::default();
let instructions = vec![];
let transaction_base64 =
prepare_transaction_for_signing(&instructions, &payer, recent_blockhash)
.expect("Should prepare transaction successfully");
let result = verify_signed_transaction(&transaction_base64, &payer);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Transaction signature is empty/default"));
}
#[test]
fn test_verify_signed_transaction_invalid_base64() {
let payer = Pubkey::new_unique();
let invalid_base64 = "not_valid_base64_data!!!";
let result = verify_signed_transaction(invalid_base64, &payer);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Failed to decode base64 transaction"));
}
#[test]
fn test_verify_signed_transaction_invalid_transaction_data() {
let payer = Pubkey::new_unique();
let invalid_transaction = STANDARD.encode(b"not a valid transaction");
let result = verify_signed_transaction(&invalid_transaction, &payer);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Failed to deserialize signed transaction"));
}
#[test]
fn test_extract_transaction_signature() {
use solana_sdk::hash::Hash;
let payer = Pubkey::new_unique();
let recent_blockhash = Hash::default();
let instruction = Instruction {
program_id: Pubkey::new_unique(),
accounts: vec![],
data: vec![],
};
let instructions = vec![instruction];
let transaction_base64 =
prepare_transaction_for_signing(&instructions, &payer, recent_blockhash)
.expect("Should prepare transaction successfully");
let result = extract_transaction_signature(&transaction_base64);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Transaction signature is empty/default"));
let invalid_base64 = "not_valid_base64_data!!!";
let result = extract_transaction_signature(invalid_base64);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Failed to decode base64 transaction"));
let invalid_transaction = STANDARD.encode(b"not a valid transaction");
let result = extract_transaction_signature(&invalid_transaction);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Failed to deserialize signed transaction"));
let transaction_bytes = STANDARD.decode(&transaction_base64).unwrap();
let mut transaction: VersionedTransaction =
bincode::deserialize(&transaction_bytes).unwrap();
let test_signature = Signature::new_unique();
transaction.signatures[0] = test_signature;
let signed_transaction_bytes = bincode::serialize(&transaction).unwrap();
let signed_transaction_base64 = STANDARD.encode(signed_transaction_bytes);
let result = extract_transaction_signature(&signed_transaction_base64);
assert!(result.is_ok());
let extracted_signature = result.unwrap();
assert_eq!(extracted_signature, test_signature.to_string());
}
#[test]
fn test_transaction_signing_module() {
use super::transaction_signing;
use solana_sdk::hash::Hash;
let payer = Pubkey::new_unique();
let recent_blockhash = Hash::default();
let instruction = Instruction {
program_id: Pubkey::new_unique(),
accounts: vec![],
data: vec![],
};
let instructions = vec![instruction];
let result = transaction_signing::prepare_transaction_for_signing(
&instructions,
&payer,
recent_blockhash,
);
assert!(result.is_ok());
let transaction_base64 = result.unwrap();
let result = transaction_signing::extract_transaction_signature(&transaction_base64);
assert!(result.is_err()); assert!(result
.unwrap_err()
.to_string()
.contains("Transaction signature is empty/default"));
let result = transaction_signing::verify_signed_transaction(&transaction_base64, &payer);
assert!(result.is_err()); }
#[test]
fn test_is_valid_wallet_address() {
assert!(is_valid_wallet_address(
"6YbW3k3oiU8kMbJLYCc8XA27c7DfqG5WSjTkED4Z2pj9"
));
assert!(!is_valid_wallet_address("too_short"));
assert!(!is_valid_wallet_address("contains_invalid_characters_0OIl"));
assert!(!is_valid_wallet_address(""));
assert!(!is_valid_wallet_address(
"way_too_long_to_be_a_valid_solana_wallet_address_definitely_longer_than_44_chars"
));
let min_length = "123456789ABCDEFGHJKLMNPQRSTUVWXYZa"; assert!(is_valid_wallet_address(min_length));
let max_length = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijk"; assert!(is_valid_wallet_address(max_length));
let too_short = "123456789ABCDEFGHJKLMNPQRSTUVWXYz"; assert!(is_valid_wallet_address(too_short));
let too_short_actual = "123456789ABCDEFGHJKLMNPQRSTUVWx"; assert!(!is_valid_wallet_address(too_short_actual));
let too_long = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkL"; assert!(!is_valid_wallet_address(too_long));
}
}