use std::str::FromStr;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde_json::{json, Value};
use solana_compute_budget_interface::ComputeBudgetInstruction;
use solana_sdk::{
hash::Hash,
instruction::{AccountMeta, Instruction},
message::Message,
pubkey,
pubkey::Pubkey,
signature::{Keypair, Signer},
transaction::Transaction,
};
use solana_system_interface::instruction as system_instruction;
use crate::error::{ButterflyBotError, Result};
use crate::security::solana_rpc_policy::SolanaRpcExecutionPolicy;
fn normalize_rpc_result(value: Value, method: &str) -> Result<Value> {
if let Some(error) = value.get("error") {
return Err(ButterflyBotError::Runtime(format!(
"solana rpc {method} error: {error}"
)));
}
value
.get("result")
.cloned()
.ok_or_else(|| ButterflyBotError::Runtime(format!("solana rpc {method} missing result")))
}
pub async fn rpc_call(endpoint: &str, method: &str, params: Value) -> Result<Value> {
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
});
let response = reqwest::Client::new()
.post(endpoint)
.json(&request)
.send()
.await
.map_err(|e| ButterflyBotError::Runtime(format!("solana rpc transport failure: {e}")))?;
let status = response.status();
let body: Value = response
.json()
.await
.map_err(|e| ButterflyBotError::Runtime(format!("solana rpc decode failure: {e}")))?;
if !status.is_success() {
return Err(ButterflyBotError::Runtime(format!(
"solana rpc http {}: {body}",
status
)));
}
normalize_rpc_result(body, method)
}
pub async fn get_balance(endpoint: &str, address: &str, commitment: &str) -> Result<u64> {
let result = rpc_call(
endpoint,
"getBalance",
json!([address, {"commitment": commitment}]),
)
.await?;
result
.get("value")
.and_then(|value| value.as_u64())
.ok_or_else(|| {
ButterflyBotError::Runtime("solana rpc getBalance missing value".to_string())
})
}
pub async fn get_latest_blockhash(endpoint: &str, commitment: &str) -> Result<String> {
let result = rpc_call(
endpoint,
"getLatestBlockhash",
json!([{ "commitment": commitment }]),
)
.await?;
result
.get("value")
.and_then(|value| value.get("blockhash"))
.and_then(|value| value.as_str())
.map(|value| value.to_string())
.ok_or_else(|| {
ButterflyBotError::Runtime(
"solana rpc getLatestBlockhash missing blockhash".to_string(),
)
})
}
pub fn build_transfer_transaction_base64(
from_seed: &[u8; 32],
to_address: &str,
lamports: u64,
latest_blockhash: &str,
policy: &SolanaRpcExecutionPolicy,
) -> Result<(String, String)> {
build_transfer_transaction_base64_with_unit_limit(
from_seed,
to_address,
lamports,
latest_blockhash,
policy,
policy.compute_budget.unit_limit,
)
}
pub fn build_transfer_transaction_base64_with_unit_limit(
from_seed: &[u8; 32],
to_address: &str,
lamports: u64,
latest_blockhash: &str,
policy: &SolanaRpcExecutionPolicy,
unit_limit: u32,
) -> Result<(String, String)> {
let signer = Keypair::new_from_array(*from_seed);
let destination = Pubkey::from_str(to_address)
.map_err(|e| ButterflyBotError::Runtime(format!("invalid destination pubkey: {e}")))?;
let recent_blockhash = Hash::from_str(latest_blockhash)
.map_err(|e| ButterflyBotError::Runtime(format!("invalid blockhash: {e}")))?;
let from_address = signer.pubkey().to_string();
let instructions = vec![
ComputeBudgetInstruction::set_compute_unit_limit(unit_limit),
ComputeBudgetInstruction::set_compute_unit_price(
policy.compute_budget.unit_price_microlamports,
),
system_instruction::transfer(&signer.pubkey(), &destination, lamports),
];
let message = Message::new(&instructions, Some(&signer.pubkey()));
let tx = Transaction::new(&[&signer], message, recent_blockhash);
let bytes = wincode::serialize(&tx)
.map_err(|e| ButterflyBotError::Runtime(format!("failed to serialize tx: {e}")))?;
Ok((STANDARD.encode(bytes), from_address))
}
pub struct SplTransferTransactionBuildArgs<'a> {
pub from_seed: &'a [u8; 32],
pub source_token_account: &'a str,
pub mint: &'a str,
pub destination_token_account: &'a str,
pub destination_owner: Option<&'a str>,
pub create_destination_ata: bool,
pub amount_atomic: u64,
pub decimals: u8,
pub latest_blockhash: &'a str,
pub policy: &'a SolanaRpcExecutionPolicy,
pub unit_limit: u32,
}
pub fn derive_associated_token_address(owner: &str, mint: &str) -> Result<String> {
let owner_pubkey = Pubkey::from_str(owner)
.map_err(|e| ButterflyBotError::Runtime(format!("invalid owner pubkey: {e}")))?;
let mint_pubkey = Pubkey::from_str(mint)
.map_err(|e| ButterflyBotError::Runtime(format!("invalid mint pubkey: {e}")))?;
let token_program = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
let ata_program = pubkey!("ATokenGPvbdGVxr1hDo4d5xQmPG2vVnSABtZ1fYx4hQ");
let (ata, _) = Pubkey::find_program_address(
&[
owner_pubkey.as_ref(),
token_program.as_ref(),
mint_pubkey.as_ref(),
],
&ata_program,
);
Ok(ata.to_string())
}
pub fn build_spl_transfer_transaction_base64_with_unit_limit(
args: SplTransferTransactionBuildArgs<'_>,
) -> Result<(String, String)> {
let signer = Keypair::new_from_array(*args.from_seed);
let source = Pubkey::from_str(args.source_token_account).map_err(|e| {
ButterflyBotError::Runtime(format!("invalid source token account pubkey: {e}"))
})?;
let mint = Pubkey::from_str(args.mint)
.map_err(|e| ButterflyBotError::Runtime(format!("invalid mint pubkey: {e}")))?;
let destination = Pubkey::from_str(args.destination_token_account).map_err(|e| {
ButterflyBotError::Runtime(format!("invalid destination token account pubkey: {e}"))
})?;
let recent_blockhash = Hash::from_str(args.latest_blockhash)
.map_err(|e| ButterflyBotError::Runtime(format!("invalid blockhash: {e}")))?;
let from_address = signer.pubkey().to_string();
let token_program = Pubkey::from_str("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
.map_err(|e| ButterflyBotError::Runtime(format!("invalid token program id: {e}")))?;
let mut data = Vec::with_capacity(10);
data.push(12u8);
data.extend_from_slice(&args.amount_atomic.to_le_bytes());
data.push(args.decimals);
let transfer_checked_ix = Instruction {
program_id: token_program,
accounts: vec![
AccountMeta::new(source, false),
AccountMeta::new_readonly(mint, false),
AccountMeta::new(destination, false),
AccountMeta::new_readonly(signer.pubkey(), true),
],
data,
};
let mut instructions = vec![
ComputeBudgetInstruction::set_compute_unit_limit(args.unit_limit),
ComputeBudgetInstruction::set_compute_unit_price(
args.policy.compute_budget.unit_price_microlamports,
),
];
if args.create_destination_ata {
let owner = args.destination_owner.ok_or_else(|| {
ButterflyBotError::Runtime(
"destination_owner is required when create_destination_ata is enabled".to_string(),
)
})?;
let owner_pubkey = Pubkey::from_str(owner).map_err(|e| {
ButterflyBotError::Runtime(format!("invalid destination owner pubkey: {e}"))
})?;
let ata_program = pubkey!("ATokenGPvbdGVxr1hDo4d5xQmPG2vVnSABtZ1fYx4hQ");
let system_program = pubkey!("11111111111111111111111111111111");
let create_idempotent_ata_ix = Instruction {
program_id: ata_program,
accounts: vec![
AccountMeta::new(signer.pubkey(), true),
AccountMeta::new(destination, false),
AccountMeta::new_readonly(owner_pubkey, false),
AccountMeta::new_readonly(mint, false),
AccountMeta::new_readonly(system_program, false),
AccountMeta::new_readonly(token_program, false),
],
data: vec![1u8],
};
instructions.push(create_idempotent_ata_ix);
}
instructions.push(transfer_checked_ix);
let message = Message::new(&instructions, Some(&signer.pubkey()));
let tx = Transaction::new(&[&signer], message, recent_blockhash);
let bytes = wincode::serialize(&tx)
.map_err(|e| ButterflyBotError::Runtime(format!("failed to serialize tx: {e}")))?;
Ok((STANDARD.encode(bytes), from_address))
}
pub async fn find_token_account_by_owner_and_mint(
endpoint: &str,
owner: &str,
mint: &str,
commitment: &str,
) -> Result<Option<String>> {
let result = rpc_call(
endpoint,
"getTokenAccountsByOwner",
json!([
owner,
{"mint": mint},
{"encoding": "jsonParsed", "commitment": commitment}
]),
)
.await?;
let first = result
.get("value")
.and_then(|value| value.as_array())
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("pubkey"))
.and_then(|pubkey| pubkey.as_str())
.map(|value| value.to_string());
Ok(first)
}
pub async fn get_token_account_balance(
endpoint: &str,
token_account: &str,
commitment: &str,
) -> Result<Value> {
rpc_call(
endpoint,
"getTokenAccountBalance",
json!([token_account, {"commitment": commitment}]),
)
.await
}
pub async fn get_token_decimals(endpoint: &str, mint: &str, commitment: &str) -> Result<u8> {
let result = rpc_call(
endpoint,
"getTokenSupply",
json!([mint, {"commitment": commitment}]),
)
.await?;
result
.get("value")
.and_then(|value| value.get("decimals"))
.and_then(|decimals| decimals.as_u64())
.map(|value| value as u8)
.ok_or_else(|| {
ButterflyBotError::Runtime("solana rpc getTokenSupply missing decimals".to_string())
})
}
pub fn probe_compute_unit_limit(policy: &SolanaRpcExecutionPolicy) -> u32 {
policy.compute_budget.unit_limit.clamp(1_000_000, 1_400_000)
}
pub fn recommended_compute_unit_limit(simulation_result: &Value, fallback: u32) -> u32 {
let consumed = simulation_result
.get("value")
.and_then(|value| value.get("unitsConsumed"))
.and_then(|value| value.as_u64());
let Some(consumed) = consumed else {
return fallback;
};
let padded = consumed
.saturating_mul(12)
.saturating_div(10)
.saturating_add(25_000);
padded.clamp(200_000, 1_400_000) as u32
}
pub async fn simulate_transaction(
endpoint: &str,
tx_base64: &str,
policy: &SolanaRpcExecutionPolicy,
) -> Result<Value> {
let mut options = json!({
"encoding": "base64",
"commitment": policy.simulation.commitment,
"replaceRecentBlockhash": policy.simulation.replace_recent_blockhash,
"sigVerify": policy.simulation.sig_verify,
});
if let Some(min_context_slot) = policy.simulation.min_context_slot {
options["minContextSlot"] = json!(min_context_slot);
}
rpc_call(endpoint, "simulateTransaction", json!([tx_base64, options])).await
}
pub async fn send_transaction(
endpoint: &str,
tx_base64: &str,
policy: &SolanaRpcExecutionPolicy,
) -> Result<String> {
let send_once = |skip_preflight: bool| async move {
rpc_call(
endpoint,
"sendTransaction",
json!([
tx_base64,
{
"encoding": "base64",
"skipPreflight": skip_preflight,
"preflightCommitment": policy.send.preflight_commitment,
"maxRetries": policy.send.max_retries,
}
]),
)
.await
};
let result = match send_once(policy.send.skip_preflight).await {
Ok(value) => value,
Err(err)
if !policy.send.skip_preflight
&& err
.to_string()
.to_ascii_lowercase()
.contains("preflight check is not supported") =>
{
tracing::warn!(
"Solana RPC does not support preflight checks; retrying sendTransaction with skipPreflight=true"
);
send_once(true).await?
}
Err(err) => return Err(err),
};
result
.as_str()
.map(|value| value.to_string())
.ok_or_else(|| {
ButterflyBotError::Runtime("solana rpc sendTransaction missing signature".to_string())
})
}
pub async fn get_signature_status(endpoint: &str, signature: &str) -> Result<Value> {
rpc_call(
endpoint,
"getSignatureStatuses",
json!([[signature], {"searchTransactionHistory": true}]),
)
.await
}
pub async fn get_signatures_for_address(
endpoint: &str,
address: &str,
limit: usize,
) -> Result<Value> {
rpc_call(
endpoint,
"getSignaturesForAddress",
json!([address, {"limit": limit.clamp(1, 100)}]),
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::solana_rpc_policy::SolanaRpcProvider;
fn decode_tx(encoded: &str) -> Transaction {
let bytes = STANDARD.decode(encoded).unwrap();
wincode::deserialize::<Transaction>(&bytes).unwrap()
}
#[test]
fn rebuild_uses_simulation_recommended_compute_limit() {
let policy = SolanaRpcExecutionPolicy::default_for_provider(SolanaRpcProvider::QuickNode);
let from_seed = [7u8; 32];
let to_address = "11111111111111111111111111111111";
let latest_blockhash = "11111111111111111111111111111111";
let lamports = 25_000;
let probe_limit = probe_compute_unit_limit(&policy);
let (probe_encoded, _) = build_transfer_transaction_base64_with_unit_limit(
&from_seed,
to_address,
lamports,
latest_blockhash,
&policy,
probe_limit,
)
.unwrap();
let simulation = json!({"value": {"unitsConsumed": 500_000}});
let adjusted_limit =
recommended_compute_unit_limit(&simulation, policy.compute_budget.unit_limit);
assert_ne!(adjusted_limit, probe_limit);
let (final_encoded, _) = build_transfer_transaction_base64_with_unit_limit(
&from_seed,
to_address,
lamports,
latest_blockhash,
&policy,
adjusted_limit,
)
.unwrap();
let probe_tx = decode_tx(&probe_encoded);
let final_tx = decode_tx(&final_encoded);
let expected_probe_ix = ComputeBudgetInstruction::set_compute_unit_limit(probe_limit);
let expected_final_ix = ComputeBudgetInstruction::set_compute_unit_limit(adjusted_limit);
assert_eq!(
probe_tx.message.instructions[0].data,
expected_probe_ix.data
);
assert_eq!(
final_tx.message.instructions[0].data,
expected_final_ix.data
);
}
}