use crate::error::{AptosError, AptosResult};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationResult {
success: bool,
vm_status: String,
gas_used: u64,
max_gas_amount: u64,
gas_unit_price: u64,
changes: Vec<StateChange>,
events: Vec<SimulatedEvent>,
hash: String,
vm_error: Option<VmError>,
raw: serde_json::Value,
}
impl SimulationResult {
pub fn from_response(response: Vec<serde_json::Value>) -> AptosResult<Self> {
let data = response.into_iter().next().ok_or_else(|| AptosError::Api {
status_code: 200,
message: "Empty simulation response".into(),
error_code: None,
vm_error_code: None,
})?;
Self::from_json(data)
}
pub fn from_json(data: serde_json::Value) -> AptosResult<Self> {
let success = data
.get("success")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let vm_status = data
.get("vm_status")
.and_then(serde_json::Value::as_str)
.unwrap_or("Unknown")
.to_string();
let gas_used = data
.get("gas_used")
.and_then(serde_json::Value::as_str)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let max_gas_amount = data
.get("max_gas_amount")
.and_then(serde_json::Value::as_str)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let gas_unit_price = data
.get("gas_unit_price")
.and_then(serde_json::Value::as_str)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let hash = data
.get("hash")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
let changes = data
.get("changes")
.and_then(serde_json::Value::as_array)
.map(|arr| arr.iter().map(StateChange::from_json).collect())
.unwrap_or_default();
let events = data
.get("events")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(SimulatedEvent::from_json).collect())
.unwrap_or_default();
let vm_error = if success {
None
} else {
Some(VmError::from_status(&vm_status))
};
Ok(Self {
success,
vm_status,
gas_used,
max_gas_amount,
gas_unit_price,
changes,
events,
hash,
vm_error,
raw: data,
})
}
pub fn success(&self) -> bool {
self.success
}
pub fn failed(&self) -> bool {
!self.success
}
pub fn vm_status(&self) -> &str {
&self.vm_status
}
pub fn gas_used(&self) -> u64 {
self.gas_used
}
pub fn max_gas_amount(&self) -> u64 {
self.max_gas_amount
}
pub fn gas_unit_price(&self) -> u64 {
self.gas_unit_price
}
pub fn gas_cost(&self) -> u64 {
self.gas_used.saturating_mul(self.gas_unit_price)
}
pub fn safe_gas_estimate(&self) -> u64 {
self.gas_used.saturating_mul(120) / 100
}
pub fn changes(&self) -> &[StateChange] {
&self.changes
}
pub fn events(&self) -> &[SimulatedEvent] {
&self.events
}
pub fn hash(&self) -> &str {
&self.hash
}
pub fn vm_error(&self) -> Option<&VmError> {
self.vm_error.as_ref()
}
pub fn raw(&self) -> &serde_json::Value {
&self.raw
}
pub fn is_insufficient_balance(&self) -> bool {
self.vm_error
.as_ref()
.is_some_and(VmError::is_insufficient_balance)
}
pub fn is_sequence_number_error(&self) -> bool {
self.vm_error
.as_ref()
.is_some_and(VmError::is_sequence_number_error)
}
pub fn is_out_of_gas(&self) -> bool {
self.vm_error.as_ref().is_some_and(VmError::is_out_of_gas)
}
pub fn error_message(&self) -> Option<String> {
if self.success {
return None;
}
self.vm_error
.as_ref()
.map(VmError::user_message)
.or_else(|| Some(self.vm_status.clone()))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateChange {
pub change_type: String,
pub address: String,
pub resource_type: Option<String>,
pub module: Option<String>,
pub data: Option<serde_json::Value>,
}
impl StateChange {
fn from_json(json: &serde_json::Value) -> Self {
Self {
change_type: json
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string(),
address: json
.get("address")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string(),
resource_type: json
.get("data")
.and_then(|d| d.get("type"))
.and_then(serde_json::Value::as_str)
.map(ToString::to_string),
module: json
.get("module")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string),
data: json.get("data").cloned(),
}
}
pub fn is_write(&self) -> bool {
self.change_type == "write_resource"
}
pub fn is_delete(&self) -> bool {
self.change_type == "delete_resource"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulatedEvent {
pub event_type: String,
pub sequence_number: u64,
pub data: serde_json::Value,
}
impl SimulatedEvent {
fn from_json(json: &serde_json::Value) -> Self {
Self {
event_type: json
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
sequence_number: json
.get("sequence_number")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.unwrap_or(0),
data: json.get("data").cloned().unwrap_or(serde_json::Value::Null),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VmError {
pub category: VmErrorCategory,
pub status: String,
pub abort_code: Option<u64>,
pub location: Option<String>,
}
impl VmError {
fn from_status(status: &str) -> Self {
let category = VmErrorCategory::from_status(status);
let abort_code = if status.contains("ABORTED") {
status
.split('(')
.nth(1)
.and_then(|s| s.trim_end_matches(')').parse().ok())
} else {
None
};
let location = if status.contains("::") {
status
.split_whitespace()
.find(|s| s.contains("::"))
.map(|s| s.trim_end_matches(':').to_string())
} else {
None
};
Self {
category,
status: status.to_string(),
abort_code,
location,
}
}
pub fn is_insufficient_balance(&self) -> bool {
matches!(self.category, VmErrorCategory::InsufficientBalance)
|| self.status.contains("INSUFFICIENT")
|| self.status.contains("NOT_ENOUGH")
}
pub fn is_sequence_number_error(&self) -> bool {
matches!(self.category, VmErrorCategory::SequenceNumber)
}
pub fn is_out_of_gas(&self) -> bool {
matches!(self.category, VmErrorCategory::OutOfGas)
}
pub fn user_message(&self) -> String {
match self.category {
VmErrorCategory::InsufficientBalance => {
"Insufficient balance to complete this transaction".to_string()
}
VmErrorCategory::SequenceNumber => {
"Transaction sequence number mismatch - the account's sequence number may have changed".to_string()
}
VmErrorCategory::OutOfGas => {
"Transaction ran out of gas - try increasing max_gas_amount".to_string()
}
VmErrorCategory::MoveAbort => {
if let Some(code) = self.abort_code {
format!("Transaction aborted with code {code}")
} else {
"Transaction was aborted by the Move VM".to_string()
}
}
VmErrorCategory::ResourceNotFound => {
"Required resource not found on chain".to_string()
}
VmErrorCategory::ModuleNotFound => {
"Required module not found on chain".to_string()
}
VmErrorCategory::FunctionNotFound => {
"Function not found in the specified module".to_string()
}
VmErrorCategory::TypeMismatch => {
"Type argument mismatch in function call".to_string()
}
VmErrorCategory::Unknown => self.status.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VmErrorCategory {
InsufficientBalance,
SequenceNumber,
OutOfGas,
MoveAbort,
ResourceNotFound,
ModuleNotFound,
FunctionNotFound,
TypeMismatch,
Unknown,
}
impl VmErrorCategory {
fn from_status(status: &str) -> Self {
let status_upper = status.to_uppercase();
if status_upper.contains("INSUFFICIENT") || status_upper.contains("NOT_ENOUGH") {
Self::InsufficientBalance
} else if status_upper.contains("SEQUENCE_NUMBER")
|| status_upper.contains("SEQUENCE NUMBER")
{
Self::SequenceNumber
} else if status_upper.contains("OUT_OF_GAS") || status_upper.contains("OUT OF GAS") {
Self::OutOfGas
} else if status_upper.contains("ABORT") {
Self::MoveAbort
} else if status_upper.contains("RESOURCE") && status_upper.contains("NOT") {
Self::ResourceNotFound
} else if status_upper.contains("MODULE") && status_upper.contains("NOT") {
Self::ModuleNotFound
} else if status_upper.contains("FUNCTION") && status_upper.contains("NOT") {
Self::FunctionNotFound
} else if status_upper.contains("TYPE")
&& (status_upper.contains("MISMATCH") || status_upper.contains("ERROR"))
{
Self::TypeMismatch
} else {
Self::Unknown
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SimulationOptions {
pub estimate_gas_only: bool,
pub sequence_number_override: Option<u64>,
pub gas_unit_price_override: Option<u64>,
pub max_gas_amount_override: Option<u64>,
}
impl SimulationOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn estimate_gas_only(mut self) -> Self {
self.estimate_gas_only = true;
self
}
#[must_use]
pub fn with_sequence_number(mut self, seq: u64) -> Self {
self.sequence_number_override = Some(seq);
self
}
#[must_use]
pub fn with_gas_unit_price(mut self, price: u64) -> Self {
self.gas_unit_price_override = Some(price);
self
}
#[must_use]
pub fn with_max_gas_amount(mut self, amount: u64) -> Self {
self.max_gas_amount_override = Some(amount);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_success_result() {
let json = serde_json::json!({
"success": true,
"vm_status": "Executed successfully",
"gas_used": "100",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
assert!(result.success());
assert_eq!(result.gas_used(), 100);
assert_eq!(result.gas_cost(), 10000);
}
#[test]
fn test_parse_failed_result() {
let json = serde_json::json!({
"success": false,
"vm_status": "Move abort in 0x1::coin: EINSUFFICIENT_BALANCE(0x10001)",
"gas_used": "50",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x456",
"changes": [],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
assert!(result.failed());
assert!(result.is_insufficient_balance());
assert!(result.vm_error().is_some());
}
#[test]
fn test_error_categories() {
assert_eq!(
VmErrorCategory::from_status("INSUFFICIENT_BALANCE"),
VmErrorCategory::InsufficientBalance
);
assert_eq!(
VmErrorCategory::from_status("SEQUENCE_NUMBER_TOO_OLD"),
VmErrorCategory::SequenceNumber
);
assert_eq!(
VmErrorCategory::from_status("OUT_OF_GAS"),
VmErrorCategory::OutOfGas
);
assert_eq!(
VmErrorCategory::from_status("Move abort"),
VmErrorCategory::MoveAbort
);
}
#[test]
fn test_safe_gas_estimate() {
let json = serde_json::json!({
"success": true,
"vm_status": "Executed successfully",
"gas_used": "1000",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
assert_eq!(result.gas_used(), 1000);
assert_eq!(result.safe_gas_estimate(), 1200); }
#[test]
fn test_parse_events() {
let json = serde_json::json!({
"success": true,
"vm_status": "Executed successfully",
"gas_used": "100",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": [
{
"type": "0x1::coin::DepositEvent",
"sequence_number": "5",
"data": {"amount": "1000"}
}
]
});
let result = SimulationResult::from_json(json).unwrap();
assert_eq!(result.events().len(), 1);
assert_eq!(result.events()[0].event_type, "0x1::coin::DepositEvent");
assert_eq!(result.events()[0].sequence_number, 5);
}
#[test]
fn test_parse_changes() {
let json = serde_json::json!({
"success": true,
"vm_status": "Executed successfully",
"gas_used": "100",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [
{
"type": "write_resource",
"address": "0x1",
"data": {"type": "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>"}
}
],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
assert_eq!(result.changes().len(), 1);
assert!(result.changes()[0].is_write());
}
#[test]
fn test_simulation_options_default() {
let opts = SimulationOptions::default();
assert!(!opts.estimate_gas_only);
assert!(opts.sequence_number_override.is_none());
assert!(opts.gas_unit_price_override.is_none());
assert!(opts.max_gas_amount_override.is_none());
}
#[test]
fn test_simulation_options_builder() {
let opts = SimulationOptions::new()
.estimate_gas_only()
.with_sequence_number(5)
.with_gas_unit_price(200)
.with_max_gas_amount(500_000);
assert!(opts.estimate_gas_only);
assert_eq!(opts.sequence_number_override, Some(5));
assert_eq!(opts.gas_unit_price_override, Some(200));
assert_eq!(opts.max_gas_amount_override, Some(500_000));
}
#[test]
fn test_vm_error_category_resource_not_found() {
assert_eq!(
VmErrorCategory::from_status("RESOURCE_NOT_FOUND"),
VmErrorCategory::ResourceNotFound
);
}
#[test]
fn test_vm_error_category_module_not_found() {
assert_eq!(
VmErrorCategory::from_status("MODULE_NOT_PUBLISHED"),
VmErrorCategory::ModuleNotFound
);
}
#[test]
fn test_vm_error_category_function_not_found() {
assert_eq!(
VmErrorCategory::from_status("FUNCTION_NOT_FOUND"),
VmErrorCategory::FunctionNotFound
);
}
#[test]
fn test_vm_error_category_type_mismatch() {
assert_eq!(
VmErrorCategory::from_status("TYPE_MISMATCH"),
VmErrorCategory::TypeMismatch
);
assert_eq!(
VmErrorCategory::from_status("TYPE_ERROR"),
VmErrorCategory::TypeMismatch
);
}
#[test]
fn test_vm_error_category_unknown() {
assert_eq!(
VmErrorCategory::from_status("SOME_RANDOM_ERROR"),
VmErrorCategory::Unknown
);
}
#[test]
fn test_simulation_result_accessors() {
let json = serde_json::json!({
"success": true,
"vm_status": "Executed successfully",
"gas_used": "1500",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0xabc123",
"changes": [],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
assert!(result.success());
assert!(!result.failed());
assert_eq!(result.vm_status(), "Executed successfully");
assert_eq!(result.gas_used(), 1500);
assert_eq!(result.max_gas_amount(), 200_000);
assert_eq!(result.gas_unit_price(), 100);
assert_eq!(result.gas_cost(), 150_000); assert_eq!(result.hash(), "0xabc123");
assert!(result.events().is_empty());
assert!(result.changes().is_empty());
}
#[test]
fn test_simulation_result_from_response() {
let response = vec![serde_json::json!({
"success": true,
"vm_status": "Executed successfully",
"gas_used": "100",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": []
})];
let result = SimulationResult::from_response(response).unwrap();
assert!(result.success());
}
#[test]
fn test_simulation_result_from_empty_response() {
let response: Vec<serde_json::Value> = vec![];
let result = SimulationResult::from_response(response);
assert!(result.is_err());
}
#[test]
fn test_state_change_delete() {
let json = serde_json::json!({
"success": true,
"vm_status": "Executed successfully",
"gas_used": "100",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [
{
"type": "delete_resource",
"address": "0x1",
"data": {}
}
],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
assert_eq!(result.changes().len(), 1);
assert!(result.changes()[0].is_delete());
assert!(!result.changes()[0].is_write());
}
#[test]
fn test_simulation_result_with_vm_error() {
let json = serde_json::json!({
"success": false,
"vm_status": "INSUFFICIENT_BALANCE",
"gas_used": "0",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
assert!(result.failed());
assert!(result.is_insufficient_balance());
assert!(!result.is_out_of_gas());
assert!(!result.is_sequence_number_error());
}
#[test]
fn test_simulation_result_out_of_gas() {
let json = serde_json::json!({
"success": false,
"vm_status": "OUT_OF_GAS",
"gas_used": "200000",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
assert!(result.is_out_of_gas());
}
#[test]
fn test_simulation_result_sequence_error() {
let json = serde_json::json!({
"success": false,
"vm_status": "SEQUENCE_NUMBER_TOO_OLD",
"gas_used": "0",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
assert!(result.is_sequence_number_error());
}
#[test]
fn test_simulated_event_parsing() {
let json = serde_json::json!({
"success": true,
"vm_status": "Executed successfully",
"gas_used": "100",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": [
{
"type": "0x1::coin::WithdrawEvent",
"sequence_number": "10",
"data": {"amount": "500"}
},
{
"type": "0x1::coin::DepositEvent",
"sequence_number": "20",
"data": {"amount": "500"}
}
]
});
let result = SimulationResult::from_json(json).unwrap();
assert_eq!(result.events().len(), 2);
assert_eq!(result.events()[0].event_type, "0x1::coin::WithdrawEvent");
assert_eq!(result.events()[0].sequence_number, 10);
assert_eq!(result.events()[1].event_type, "0x1::coin::DepositEvent");
assert_eq!(result.events()[1].sequence_number, 20);
}
#[test]
fn test_vm_error_user_messages() {
let insufficient = VmError::from_status("INSUFFICIENT_BALANCE");
assert!(insufficient.user_message().contains("Insufficient"));
let seq_error = VmError::from_status("SEQUENCE_NUMBER_TOO_OLD");
assert!(seq_error.user_message().contains("sequence number"));
let out_of_gas = VmError::from_status("OUT_OF_GAS");
assert!(out_of_gas.user_message().contains("gas"));
let resource_not_found = VmError::from_status("RESOURCE_NOT_FOUND");
assert!(resource_not_found.user_message().contains("resource"));
let module_not_found = VmError::from_status("MODULE_NOT_PUBLISHED");
assert!(module_not_found.user_message().contains("module"));
let function_not_found = VmError::from_status("FUNCTION_NOT_FOUND");
assert!(function_not_found.user_message().contains("Function"));
let type_mismatch = VmError::from_status("TYPE_MISMATCH");
assert!(type_mismatch.user_message().contains("Type"));
let unknown = VmError::from_status("UNKNOWN_ERROR_XYZ");
assert_eq!(unknown.user_message(), "UNKNOWN_ERROR_XYZ");
}
#[test]
fn test_vm_error_move_abort_with_code() {
let abort = VmError::from_status("ABORTED in 0x1::coin: SOME_ERROR(65537)");
assert_eq!(abort.category, VmErrorCategory::MoveAbort);
assert_eq!(abort.abort_code, Some(65537));
assert!(abort.location.is_some());
assert!(abort.user_message().contains("65537"));
}
#[test]
fn test_vm_error_move_abort_without_code() {
let abort = VmError::from_status("Move abort");
assert_eq!(abort.category, VmErrorCategory::MoveAbort);
assert!(abort.abort_code.is_none());
assert!(abort.user_message().contains("aborted"));
}
#[test]
fn test_simulation_result_error_message_success() {
let json = serde_json::json!({
"success": true,
"vm_status": "Executed successfully",
"gas_used": "100",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
assert!(result.error_message().is_none());
}
#[test]
fn test_simulation_result_error_message_failure() {
let json = serde_json::json!({
"success": false,
"vm_status": "INSUFFICIENT_BALANCE",
"gas_used": "0",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
let error_msg = result.error_message().unwrap();
assert!(error_msg.contains("Insufficient"));
}
#[test]
fn test_simulation_result_raw_accessor() {
let json = serde_json::json!({
"success": true,
"vm_status": "Executed successfully",
"gas_used": "100",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": [],
"extra_field": "extra_value"
});
let result = SimulationResult::from_json(json).unwrap();
let raw = result.raw();
assert_eq!(
raw.get("extra_field").unwrap().as_str(),
Some("extra_value")
);
}
#[test]
fn test_state_change_with_module() {
let json = serde_json::json!({
"type": "write_module",
"address": "0x1",
"module": "my_module"
});
let change = StateChange::from_json(&json);
assert_eq!(change.change_type, "write_module");
assert_eq!(change.module, Some("my_module".to_string()));
}
#[test]
fn test_simulated_event_with_null_data() {
let json = serde_json::json!({
"type": "0x1::event::SomeEvent",
"sequence_number": "5"
});
let event = SimulatedEvent::from_json(&json);
assert_eq!(event.event_type, "0x1::event::SomeEvent");
assert_eq!(event.sequence_number, 5);
assert!(event.data.is_null());
}
#[test]
fn test_vm_error_not_enough_variant() {
let error = VmError::from_status("NOT_ENOUGH_GAS");
assert!(error.is_insufficient_balance() || error.status.contains("NOT_ENOUGH"));
}
#[test]
fn test_vm_error_category_sequence_number_variant() {
assert_eq!(
VmErrorCategory::from_status("SEQUENCE NUMBER INVALID"),
VmErrorCategory::SequenceNumber
);
}
#[test]
fn test_vm_error_category_out_of_gas_with_space() {
assert_eq!(
VmErrorCategory::from_status("OUT OF GAS"),
VmErrorCategory::OutOfGas
);
}
#[test]
fn test_simulation_result_missing_fields() {
let json = serde_json::json!({});
let result = SimulationResult::from_json(json).unwrap();
assert!(!result.success());
assert_eq!(result.gas_used(), 0);
assert_eq!(result.vm_status(), "Unknown");
}
#[test]
fn test_simulation_result_vm_error_accessor() {
let json = serde_json::json!({
"success": false,
"vm_status": "ABORT",
"gas_used": "0",
"max_gas_amount": "200000",
"gas_unit_price": "100",
"hash": "0x123",
"changes": [],
"events": []
});
let result = SimulationResult::from_json(json).unwrap();
assert!(result.vm_error().is_some());
let vm_error = result.vm_error().unwrap();
assert_eq!(vm_error.category, VmErrorCategory::MoveAbort);
}
#[test]
fn test_simulation_options_new() {
let opts = SimulationOptions::new();
assert!(!opts.estimate_gas_only);
}
}