#![allow(missing_docs, missing_debug_implementations)]
use crate::neo_builder::{ScriptBuilder, Signer};
use crate::neo_clients::{APITrait, HttpProvider, RpcClient};
use crate::neo_error::unified::{ErrorRecovery, NeoError};
use crate::neo_types::{
ContractParameter, NeoVMStateType, ScriptHash, ScriptHashExtension, StackItem,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationResult {
pub success: bool,
pub vm_state: NeoVMStateType,
pub gas_consumed: u64,
pub system_fee: u64,
pub network_fee: u64,
pub total_fee: u64,
pub state_changes: StateChanges,
pub notifications: Vec<Notification>,
pub return_values: Vec<StackItem>,
pub warnings: Vec<SimulationWarning>,
pub suggestions: Vec<OptimizationSuggestion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateChanges {
pub storage: HashMap<ScriptHash, Vec<StorageChange>>,
pub balances: HashMap<String, BalanceChange>,
pub transfers: Vec<TokenTransfer>,
pub deployments: Vec<ContractDeployment>,
pub updates: Vec<ContractUpdate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageChange {
pub key: Vec<u8>,
pub old_value: Option<Vec<u8>>,
pub new_value: Option<Vec<u8>>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BalanceChange {
pub address: String,
pub neo_delta: i64,
pub gas_delta: i64,
pub token_changes: HashMap<String, i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenTransfer {
pub token: ScriptHash,
pub symbol: String,
pub from: String,
pub to: String,
pub amount: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractDeployment {
pub hash: ScriptHash,
pub name: String,
pub cost: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractUpdate {
pub hash: ScriptHash,
pub update_type: String,
pub cost: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Notification {
pub contract: ScriptHash,
pub event_name: String,
pub state: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationWarning {
pub level: WarningLevel,
pub message: String,
pub suggestion: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum WarningLevel {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptimizationSuggestion {
pub optimization_type: OptimizationType,
pub description: String,
pub gas_savings: Option<u64>,
pub implementation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum OptimizationType {
BatchOperations,
CacheResults,
OptimizeScript,
ReduceStorageOperations,
UseNativeContracts,
Other(String),
}
pub struct TransactionSimulator {
client: Arc<RpcClient<HttpProvider>>,
cache: HashMap<String, CachedSimulation>,
optimization_rules: Vec<OptimizationRule>,
}
impl TransactionSimulator {
pub fn new(client: Arc<RpcClient<HttpProvider>>) -> Self {
Self {
client,
cache: HashMap::new(),
optimization_rules: Self::default_optimization_rules(),
}
}
pub async fn simulate_transaction(
&mut self,
script: &[u8],
signers: Vec<Signer>,
) -> Result<SimulationResult, NeoError> {
let tx_hash = self.calculate_script_hash(script);
if let Some(cached) = self.cache.get(&tx_hash) {
if cached.is_valid() {
return Ok(cached.result.clone());
}
}
let result = self.perform_simulation(script, signers).await?;
self.cache.insert(
tx_hash,
CachedSimulation { result: result.clone(), timestamp: std::time::SystemTime::now() },
);
Ok(result)
}
pub async fn simulate_script(
&mut self,
script: &[u8],
signers: Vec<Signer>,
) -> Result<SimulationResult, NeoError> {
let result = self
.client
.invoke_script(hex::encode(script), signers.clone())
.await
.map_err(|e| NeoError::Network {
message: format!("Failed to simulate script: {}", e),
source: None,
recovery: ErrorRecovery::new()
.suggest("Check script validity")
.suggest("Verify signers have sufficient balance"),
})?;
self.parse_invocation_result(result, script, signers).await
}
pub async fn estimate_gas(
&mut self,
contract: &ScriptHash,
method: &str,
params: &[ContractParameter],
signers: Vec<Signer>,
) -> Result<GasEstimate, NeoError> {
let script = ScriptBuilder::new()
.contract_call(contract, method, params, None)
.map_err(|e| NeoError::Contract {
message: format!("Failed to build script: {}", e),
source: None,
recovery: ErrorRecovery::new(),
contract: None,
method: None,
})?
.to_bytes();
let simulation = self.simulate_script(&script, signers).await?;
Ok(GasEstimate {
system_fee: simulation.system_fee,
network_fee: simulation.network_fee,
total_fee: simulation.total_fee,
gas_consumed: simulation.gas_consumed,
safety_margin: (simulation.total_fee as f64 * 0.1) as u64, warnings: simulation.warnings,
})
}
pub async fn preview_state_changes(
&mut self,
script: &[u8],
signers: Vec<Signer>,
) -> Result<StateChanges, NeoError> {
let simulation = self.simulate_transaction(script, signers).await?;
Ok(simulation.state_changes)
}
async fn perform_simulation(
&self,
script: &[u8],
signers: Vec<Signer>,
) -> Result<SimulationResult, NeoError> {
let _block_height = self.client.get_block_count().await.map_err(|e| NeoError::Network {
message: format!("Failed to get block height: {}", e),
source: None,
recovery: ErrorRecovery::new(),
})?;
let invocation_result = self
.client
.invoke_script(hex::encode(script), signers.clone())
.await
.map_err(|e| NeoError::Network {
message: format!("Failed to invoke script: {}", e),
source: None,
recovery: ErrorRecovery::new()
.suggest("Check transaction script")
.suggest("Verify signers and witnesses"),
})?;
let mut result = self.parse_invocation_result(invocation_result, script, signers).await?;
result.suggestions = self.apply_optimization_rules(script, &result);
result.warnings = self.analyze_for_warnings(script, &result);
Ok(result)
}
async fn parse_invocation_result(
&self,
result: crate::neo_types::InvocationResult,
script: &[u8],
signers: Vec<Signer>,
) -> Result<SimulationResult, NeoError> {
let success = matches!(result.state, NeoVMStateType::Halt);
let notifications = self.parse_notifications(&result);
let state_changes = self.analyze_state_changes(&result, script).await?;
let gas_consumed = self.parse_gas_consumed(&result.gas_consumed)?;
let system_fee = self.calculate_system_fee(gas_consumed);
let network_fee = self.calculate_network_fee(script.len(), signers.len());
Ok(SimulationResult {
success,
vm_state: result.state,
gas_consumed,
system_fee,
network_fee,
total_fee: system_fee + network_fee,
state_changes,
notifications,
return_values: result.stack.clone(),
warnings: Vec::new(),
suggestions: Vec::new(),
})
}
fn parse_gas_consumed(&self, gas_consumed: &str) -> Result<u64, NeoError> {
if let Ok(value) = gas_consumed.parse::<u64>() {
return Ok(value);
}
if let Ok(value) = gas_consumed.parse::<f64>() {
if value.is_finite() && value >= 0.0 {
return Ok(value as u64);
}
}
Err(NeoError::Other {
message: format!(
"Invalid gas consumption value in simulation response: {gas_consumed}"
),
source: None,
recovery: ErrorRecovery::new()
.suggest("Inspect the raw invocation result from the RPC node")
.suggest("Verify the connected node returns a numeric gasconsumed field"),
})
}
fn parse_notifications(
&self,
result: &crate::neo_types::InvocationResult,
) -> Vec<Notification> {
result
.notifications
.as_ref()
.map(|notifications| {
notifications
.iter()
.map(|n| Notification {
contract: n.contract,
event_name: n.event_name.clone(),
state: serde_json::to_value(&n.state).unwrap_or(serde_json::Value::Null),
})
.collect()
})
.unwrap_or_default()
}
async fn analyze_state_changes(
&self,
result: &crate::neo_types::InvocationResult,
_script: &[u8],
) -> Result<StateChanges, NeoError> {
let storage = HashMap::new();
let balances = HashMap::new();
let mut transfers = Vec::new();
let deployments = Vec::new();
let updates = Vec::new();
if let Some(notifications) = &result.notifications {
for notification in notifications {
if notification.event_name == "Transfer" {
let parsed_notification = Notification {
contract: notification.contract,
event_name: notification.event_name.clone(),
state: serde_json::to_value(¬ification.state)
.unwrap_or(serde_json::Value::Null),
};
if let Some(transfer) =
self.parse_transfer_notification(&parsed_notification).await
{
transfers.push(transfer);
}
}
}
}
Ok(StateChanges { storage, balances, transfers, deployments, updates })
}
async fn parse_transfer_notification(
&self,
notification: &Notification,
) -> Option<TokenTransfer> {
let token_symbol = self
.get_token_symbol(¬ification.contract)
.await
.unwrap_or_else(|_| notification.contract.to_hex());
Some(TokenTransfer {
token: notification.contract,
symbol: token_symbol,
from: "sender".to_string(), to: "receiver".to_string(), amount: "0".to_string(), })
}
async fn get_token_symbol(&self, contract: &ScriptHash) -> Result<String, NeoError> {
let result = self
.client
.invoke_function(contract, "symbol".to_string(), vec![], None)
.await
.map_err(|e| NeoError::Contract {
message: format!("Failed to get token symbol: {}", e),
source: None,
recovery: ErrorRecovery::new(),
contract: None,
method: Some("symbol".to_string()),
})?;
Self::extract_token_symbol(&result)
}
fn extract_token_symbol(
result: &crate::neo_types::InvocationResult,
) -> Result<String, NeoError> {
let item = result.stack.first().ok_or_else(|| NeoError::Contract {
message: "Token symbol response stack is empty".to_string(),
source: None,
recovery: ErrorRecovery::new().suggest("Inspect the raw contract response"),
contract: None,
method: Some("symbol".to_string()),
})?;
item.as_string().ok_or_else(|| NeoError::Contract {
message: "Token symbol response is not a string-compatible stack item".to_string(),
source: None,
recovery: ErrorRecovery::new().suggest("Inspect the raw contract response"),
contract: None,
method: Some("symbol".to_string()),
})
}
fn calculate_system_fee(&self, gas_consumed: u64) -> u64 {
(gas_consumed as f64 * 1.1) as u64
}
fn calculate_network_fee(&self, script_size: usize, signer_count: usize) -> u64 {
let base_fee = 100_000; let size_fee = script_size as u64 * 1000; let verification_fee = signer_count as u64 * 1_000_000;
base_fee + size_fee + verification_fee
}
fn apply_optimization_rules(
&self,
script: &[u8],
result: &SimulationResult,
) -> Vec<OptimizationSuggestion> {
let mut suggestions = Vec::new();
for rule in &self.optimization_rules {
if let Some(suggestion) = rule.check(script, result) {
suggestions.push(suggestion);
}
}
suggestions
}
fn analyze_for_warnings(
&self,
_script: &[u8],
result: &SimulationResult,
) -> Vec<SimulationWarning> {
let mut warnings = Vec::new();
if !result.success {
warnings.push(SimulationWarning {
level: WarningLevel::Error,
message: "Transaction simulation failed".to_string(),
suggestion: Some(
"Review script logic and ensure all preconditions are met".to_string(),
),
});
}
if result.gas_consumed > 10_000_000 {
warnings.push(SimulationWarning {
level: WarningLevel::Warning,
message: format!(
"High gas consumption: {} GAS",
result.gas_consumed as f64 / 100_000_000.0
),
suggestion: Some(
"Consider optimizing the script or breaking into smaller transactions"
.to_string(),
),
});
}
if result.total_fee > 1_000_000_000 {
warnings.push(SimulationWarning {
level: WarningLevel::Warning,
message: "Transaction requires significant GAS balance".to_string(),
suggestion: Some("Ensure account has sufficient GAS balance".to_string()),
});
}
warnings
}
fn calculate_script_hash(&self, script: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(script);
format!("{:x}", hasher.finalize())
}
fn default_optimization_rules() -> Vec<OptimizationRule> {
vec![
OptimizationRule::BatchTransfers,
OptimizationRule::UseNativeContracts,
OptimizationRule::MinimizeStorageOps,
OptimizationRule::CacheRepeatedCalls,
]
}
}
#[derive(Debug, Clone, Copy)]
pub enum OptimizationRule {
BatchTransfers,
UseNativeContracts,
MinimizeStorageOps,
CacheRepeatedCalls,
}
impl OptimizationRule {
fn check(&self, _script: &[u8], result: &SimulationResult) -> Option<OptimizationSuggestion> {
match self {
Self::BatchTransfers => {
if result.notifications.iter().filter(|n| n.event_name == "Transfer").count() > 3 {
Some(OptimizationSuggestion {
optimization_type: OptimizationType::BatchOperations,
description: "Multiple transfers detected. Consider batching.".to_string(),
gas_savings: Some(result.gas_consumed / 10),
implementation: Some(
"Use a batch transfer method or combine operations".to_string(),
),
})
} else {
None
}
},
Self::UseNativeContracts => {
None },
Self::MinimizeStorageOps => {
if result.state_changes.storage.values().map(|v| v.len()).sum::<usize>() > 10 {
Some(OptimizationSuggestion {
optimization_type: OptimizationType::ReduceStorageOperations,
description: "Many storage operations detected".to_string(),
gas_savings: Some(result.gas_consumed / 20),
implementation: Some(
"Cache values in memory and batch storage updates".to_string(),
),
})
} else {
None
}
},
Self::CacheRepeatedCalls => {
None },
}
}
}
struct CachedSimulation {
result: SimulationResult,
timestamp: std::time::SystemTime,
}
impl CachedSimulation {
fn is_valid(&self) -> bool {
self.timestamp.elapsed().map(|d| d.as_secs() < 60).unwrap_or(false)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GasEstimate {
pub system_fee: u64,
pub network_fee: u64,
pub total_fee: u64,
pub gas_consumed: u64,
pub safety_margin: u64,
pub warnings: Vec<SimulationWarning>,
}
pub struct TransactionSimulatorBuilder {
client: Option<Arc<RpcClient<HttpProvider>>>,
cache_duration: std::time::Duration,
optimization_rules: Vec<OptimizationRule>,
}
impl Default for TransactionSimulatorBuilder {
fn default() -> Self {
Self::new()
}
}
impl TransactionSimulatorBuilder {
pub fn new() -> Self {
Self {
client: None,
cache_duration: std::time::Duration::from_secs(60),
optimization_rules: Vec::new(),
}
}
pub fn client(mut self, client: Arc<RpcClient<HttpProvider>>) -> Self {
self.client = Some(client);
self
}
pub fn cache_duration(mut self, duration: std::time::Duration) -> Self {
self.cache_duration = duration;
self
}
pub fn add_optimization_rule(mut self, rule: OptimizationRule) -> Self {
self.optimization_rules.push(rule);
self
}
pub fn build(self) -> Result<TransactionSimulator, NeoError> {
let client = self.client.ok_or_else(|| NeoError::Contract {
message: "RPC client is required".to_string(),
source: None,
recovery: ErrorRecovery::new().suggest("Provide an RPC client using .client() method"),
contract: None,
method: None,
})?;
let mut simulator = TransactionSimulator::new(client);
if !self.optimization_rules.is_empty() {
simulator.optimization_rules = self.optimization_rules;
}
Ok(simulator)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::neo_clients::HttpProvider;
use crate::neo_types::InvocationResult;
use crate::neo_types::StackItem;
use std::sync::Arc;
#[test]
fn test_gas_estimate_creation() {
let estimate = GasEstimate {
system_fee: 1_000_000,
network_fee: 500_000,
total_fee: 1_500_000,
gas_consumed: 900_000,
safety_margin: 150_000,
warnings: vec![],
};
assert_eq!(estimate.total_fee, estimate.system_fee + estimate.network_fee);
assert_eq!(estimate.safety_margin, 150_000);
}
#[test]
fn test_simulation_result() {
let result = SimulationResult {
success: true,
vm_state: NeoVMStateType::Halt,
gas_consumed: 1_000_000,
system_fee: 1_100_000,
network_fee: 500_000,
total_fee: 1_600_000,
state_changes: StateChanges {
storage: HashMap::new(),
balances: HashMap::new(),
transfers: vec![],
deployments: vec![],
updates: vec![],
},
notifications: vec![],
return_values: vec![],
warnings: vec![],
suggestions: vec![],
};
assert!(result.success);
assert_eq!(result.total_fee, result.system_fee + result.network_fee);
}
#[test]
fn test_warning_levels() {
let info = SimulationWarning {
level: WarningLevel::Info,
message: "Information".to_string(),
suggestion: None,
};
let warning = SimulationWarning {
level: WarningLevel::Warning,
message: "Warning".to_string(),
suggestion: Some("Fix this".to_string()),
};
assert_eq!(info.level, WarningLevel::Info);
assert_eq!(warning.level, WarningLevel::Warning);
}
#[tokio::test]
async fn test_parse_invocation_result_rejects_invalid_gas_consumed() {
let client = Arc::new(RpcClient::new(HttpProvider::new("http://localhost:10332").unwrap()));
let simulator = TransactionSimulator::new(client);
let result =
InvocationResult { gas_consumed: "not-a-number".to_string(), ..Default::default() };
let err = simulator.parse_invocation_result(result, &[], vec![]).await.unwrap_err();
match err {
NeoError::Other { message, .. } => {
assert!(message.contains("Invalid gas consumption value"));
},
other => panic!("expected generic parsing error, got {other:?}"),
}
}
#[test]
fn test_extract_token_symbol_reads_first_stack_item() {
let result = InvocationResult {
stack: vec![StackItem::ByteString { value: "R0FT".to_string() }],
..Default::default()
};
let symbol = TransactionSimulator::extract_token_symbol(&result).unwrap();
assert_eq!(symbol, "GAS");
}
#[test]
fn test_extract_token_symbol_rejects_non_string_items() {
let result = InvocationResult {
stack: vec![StackItem::Map { value: vec![] }],
..Default::default()
};
let err = TransactionSimulator::extract_token_symbol(&result).unwrap_err();
match err {
NeoError::Contract { message, .. } => {
assert!(message.contains("not a string-compatible"));
},
other => panic!("expected contract error, got {other:?}"),
}
}
}