diff --git a/Cargo.lock b/Cargo.lock index 33ede1e9c..621a00f03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1435,6 +1435,17 @@ dependencies = [ "syn", ] +[[package]] +name = "soroban-simulation" +version = "20.1.0" +dependencies = [ + "anyhow", + "rand", + "soroban-env-host", + "static_assertions", + "thiserror", +] + [[package]] name = "soroban-synth-wasm" version = "20.1.0" diff --git a/Cargo.toml b/Cargo.toml index 26b47a8e0..17f32e9e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "soroban-test-wasms", "soroban-synth-wasm", "soroban-bench-utils", + "soroban-simulation", ] exclude = ["soroban-test-wasms/wasm-workspace"] diff --git a/cackle.toml b/cackle.toml index 3b69e7d40..5fc37d7c1 100644 --- a/cackle.toml +++ b/cackle.toml @@ -127,6 +127,8 @@ build.allow_apis = [ ] allow_apis = [ "time", + "thread", + "rand", ] [pkg.unicode-ident] @@ -401,3 +403,11 @@ allow_unsafe = true [pkg.itertools] allow_unsafe = true + +[pkg.anyhow] +allow_unsafe = true +from.build.allow_apis = [ + "env", + "fs", + "process", +] \ No newline at end of file diff --git a/soroban-simulation/Cargo.toml b/soroban-simulation/Cargo.toml new file mode 100644 index 000000000..9b506d797 --- /dev/null +++ b/soroban-simulation/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "soroban-simulation" +description = "Soroban host invocation simulations." +homepage = "https://github.com/stellar/rs-soroban-env" +repository = "https://github.com/stellar/rs-soroban-env" +authors = ["Stellar Development Foundation "] +license = "Apache-2.0" +version.workspace = true +readme = "../README.md" +edition = "2021" +rust-version.workspace = true + +[dependencies] +# To be removed +anyhow = { version = "1.0.75", features = [] } +thiserror = "1.0.46" + +soroban-env-host = { workspace = true, features = ["recording_auth", "testutils"]} +static_assertions = "1.1.0" +rand = "0.8.5" + +[package.metadata.docs.rs] +all-features = true diff --git a/soroban-simulation/src/fees.rs b/soroban-simulation/src/fees.rs new file mode 100644 index 000000000..b619be507 --- /dev/null +++ b/soroban-simulation/src/fees.rs @@ -0,0 +1,490 @@ +use super::ledger_storage::{LedgerGetter, LedgerStorage}; +use super::state_ttl::{get_restored_ledger_sequence, TTLLedgerEntry}; +use anyhow::{bail, ensure, Context, Error, Result}; +use soroban_env_host::budget::Budget; +use soroban_env_host::e2e_invoke::{ + extract_rent_changes, get_ledger_changes, LedgerEntryChange, TtlEntryMap, +}; +use soroban_env_host::fees::{ + compute_rent_fee, compute_transaction_resource_fee, compute_write_fee_per_1kb, + FeeConfiguration, LedgerEntryRentChange, RentFeeConfiguration, TransactionResources, + WriteFeeConfiguration, +}; +use soroban_env_host::storage::{AccessType, Footprint, Storage}; +use soroban_env_host::xdr; +use soroban_env_host::xdr::ContractDataDurability::Persistent; +use soroban_env_host::xdr::{ + ConfigSettingEntry, ConfigSettingId, ContractEventType, DecoratedSignature, DiagnosticEvent, + ExtendFootprintTtlOp, ExtensionPoint, InvokeHostFunctionOp, LedgerFootprint, LedgerKey, Limits, + Memo, MuxedAccount, MuxedAccountMed25519, Operation, OperationBody, Preconditions, + RestoreFootprintOp, ScVal, SequenceNumber, Signature, SignatureHint, SorobanResources, + SorobanTransactionData, Transaction, TransactionExt, TransactionV1Envelope, Uint256, WriteXdr, +}; +use std::cmp::max; +use std::convert::{TryFrom, TryInto}; + +#[allow(clippy::too_many_arguments)] +pub(crate) fn compute_host_function_transaction_data_and_min_fee( + op: &InvokeHostFunctionOp, + pre_storage: &LedgerStorage, + post_storage: &Storage, + budget: &Budget, + events: &[DiagnosticEvent], + invocation_result: &ScVal, + bucket_list_size: u64, + current_ledger_seq: u32, +) -> Result<(SorobanTransactionData, i64)> { + let ledger_changes = get_ledger_changes(budget, post_storage, pre_storage, TtlEntryMap::new())?; + let soroban_resources = + calculate_host_function_soroban_resources(&ledger_changes, &post_storage.footprint, budget) + .context("cannot compute host function resources")?; + + let contract_events_size = + calculate_contract_events_size_bytes(events).context("cannot calculate events size")?; + let invocation_return_size = u32::try_from(invocation_result.to_xdr(Limits::none())?.len())?; + // This is totally unintuitive, but it's what's expected by the library + let final_contract_events_size = contract_events_size + invocation_return_size; + + let transaction_resources = TransactionResources { + instructions: soroban_resources.instructions, + read_entries: u32::try_from(soroban_resources.footprint.read_only.as_vec().len())?, + write_entries: u32::try_from(soroban_resources.footprint.read_write.as_vec().len())?, + read_bytes: soroban_resources.read_bytes, + write_bytes: soroban_resources.write_bytes, + // Note: we could get a better transaction size if the full transaction was passed down + transaction_size_bytes: estimate_max_transaction_size_for_operation( + &OperationBody::InvokeHostFunction(op.clone()), + &soroban_resources.footprint, + ) + .context("cannot estimate maximum transaction size")?, + contract_events_size_bytes: final_contract_events_size, + }; + let rent_changes = extract_rent_changes(&ledger_changes); + + finalize_transaction_data_and_min_fee( + pre_storage, + &transaction_resources, + soroban_resources, + &rent_changes, + current_ledger_seq, + bucket_list_size, + ) +} + +fn estimate_max_transaction_size_for_operation( + op: &OperationBody, + fp: &LedgerFootprint, +) -> Result { + let source = MuxedAccount::MuxedEd25519(MuxedAccountMed25519 { + id: 0, + ed25519: Uint256([0; 32]), + }); + // generate the maximum memo size and signature size + // TODO: is this being too conservative? + let memo_text: Vec = [0; 28].into(); + let signatures: Vec = vec![ + DecoratedSignature { + hint: SignatureHint([0; 4]), + signature: Signature::default(), + }; + 20 + ]; + let envelope = TransactionV1Envelope { + tx: Transaction { + source_account: source.clone(), + fee: 0, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::Text(memo_text.try_into()?), + operations: vec![Operation { + source_account: Some(source), + body: op.clone(), + }] + .try_into()?, + ext: TransactionExt::V1(SorobanTransactionData { + resources: SorobanResources { + footprint: fp.clone(), + instructions: 0, + read_bytes: 0, + write_bytes: 0, + }, + resource_fee: 0, + ext: ExtensionPoint::V0, + }), + }, + signatures: signatures.try_into()?, + }; + + let envelope_xdr = envelope.to_xdr(Limits::none())?; + let envelope_size = envelope_xdr.len(); + + // Add a 15% leeway + let envelope_size = envelope_size * 115 / 100; + Ok(u32::try_from(envelope_size)?) +} + +#[allow(clippy::cast_possible_truncation)] +fn calculate_host_function_soroban_resources( + ledger_changes: &[LedgerEntryChange], + footprint: &Footprint, + budget: &Budget, +) -> Result { + let ledger_footprint = storage_footprint_to_ledger_footprint(footprint) + .context("cannot convert storage footprint to ledger footprint")?; + let read_bytes: u32 = ledger_changes.iter().map(|c| c.old_entry_size_bytes).sum(); + + let write_bytes: u32 = ledger_changes + .iter() + .map(|c| c.encoded_new_value.as_ref().map_or(0, Vec::len) as u32) + .sum(); + + // Add a 20% leeway with a minimum of 1 million instructions + let budget_instructions = budget + .get_cpu_insns_consumed() + .context("cannot get instructions consumed")?; + let instructions = max( + budget_instructions + 1000000, + budget_instructions * 120 / 100, + ); + Ok(SorobanResources { + footprint: ledger_footprint, + instructions: u32::try_from(instructions)?, + read_bytes, + write_bytes, + }) +} + +#[allow(clippy::cast_possible_wrap)] +fn get_fee_configurations( + ledger_storage: &LedgerStorage, + bucket_list_size: u64, +) -> Result<(FeeConfiguration, RentFeeConfiguration)> { + let ConfigSettingEntry::ContractComputeV0(compute) = + ledger_storage.get_configuration_setting(ConfigSettingId::ContractComputeV0)? + else { + bail!("unexpected config setting entry for ComputeV0 key"); + }; + + let ConfigSettingEntry::ContractLedgerCostV0(ledger_cost) = + ledger_storage.get_configuration_setting(ConfigSettingId::ContractLedgerCostV0)? + else { + bail!("unexpected config setting entry for LedgerCostV0 key"); + }; + + let ConfigSettingEntry::ContractHistoricalDataV0(historical_data) = + ledger_storage.get_configuration_setting(ConfigSettingId::ContractHistoricalDataV0)? + else { + bail!("unexpected config setting entry for HistoricalDataV0 key"); + }; + + let ConfigSettingEntry::ContractEventsV0(events) = + ledger_storage.get_configuration_setting(ConfigSettingId::ContractEventsV0)? + else { + bail!("unexpected config setting entry for EventsV0 key"); + }; + + let ConfigSettingEntry::ContractBandwidthV0(bandwidth) = + ledger_storage.get_configuration_setting(ConfigSettingId::ContractBandwidthV0)? + else { + bail!("unexpected config setting entry for BandwidthV0 key"); + }; + + let ConfigSettingEntry::StateArchival(state_archival) = + ledger_storage.get_configuration_setting(ConfigSettingId::StateArchival)? + else { + bail!("unexpected config setting entry for StateArchival key"); + }; + + let write_fee_configuration = WriteFeeConfiguration { + bucket_list_target_size_bytes: ledger_cost.bucket_list_target_size_bytes, + write_fee_1kb_bucket_list_low: ledger_cost.write_fee1_kb_bucket_list_low, + write_fee_1kb_bucket_list_high: ledger_cost.write_fee1_kb_bucket_list_high, + bucket_list_write_fee_growth_factor: ledger_cost.bucket_list_write_fee_growth_factor, + }; + + let write_fee_per_1kb = + compute_write_fee_per_1kb(bucket_list_size as i64, &write_fee_configuration); + + let fee_configuration = FeeConfiguration { + fee_per_instruction_increment: compute.fee_rate_per_instructions_increment, + fee_per_read_entry: ledger_cost.fee_read_ledger_entry, + fee_per_write_entry: ledger_cost.fee_write_ledger_entry, + fee_per_read_1kb: ledger_cost.fee_read1_kb, + fee_per_write_1kb: write_fee_per_1kb, + fee_per_historical_1kb: historical_data.fee_historical1_kb, + fee_per_contract_event_1kb: events.fee_contract_events1_kb, + fee_per_transaction_size_1kb: bandwidth.fee_tx_size1_kb, + }; + let rent_fee_configuration = RentFeeConfiguration { + fee_per_write_1kb: write_fee_per_1kb, + fee_per_write_entry: ledger_cost.fee_write_ledger_entry, + persistent_rent_rate_denominator: state_archival.persistent_rent_rate_denominator, + temporary_rent_rate_denominator: state_archival.temp_rent_rate_denominator, + }; + Ok((fee_configuration, rent_fee_configuration)) +} + +#[allow(clippy::cast_possible_truncation)] +fn calculate_unmodified_ledger_entry_bytes( + ledger_entries: &[LedgerKey], + pre_storage: &LedgerStorage, + include_not_live: bool, +) -> Result { + let mut res: usize = 0; + for lk in ledger_entries { + let entry_xdr = pre_storage + .get_xdr(lk, include_not_live) + .with_context(|| format!("cannot get xdr of ledger entry with key {lk:?}"))?; + let entry_size = entry_xdr.len(); + res += entry_size; + } + Ok(res as u32) +} + +fn calculate_contract_events_size_bytes(events: &[DiagnosticEvent]) -> Result { + let mut res: u32 = 0; + for e in events { + if e.event.type_ != ContractEventType::Contract + && e.event.type_ != ContractEventType::System + { + continue; + } + let event_xdr = e + .to_xdr(Limits::none()) + .with_context(|| format!("cannot marshal event {e:?}"))?; + res += u32::try_from(event_xdr.len())?; + } + Ok(res) +} + +fn storage_footprint_to_ledger_footprint(foot: &Footprint) -> Result { + let mut read_only: Vec = Vec::with_capacity(foot.0.len()); + let mut read_write: Vec = Vec::with_capacity(foot.0.len()); + for (k, v) in &foot.0 { + match v { + AccessType::ReadOnly => read_only.push((**k).clone()), + AccessType::ReadWrite => read_write.push((**k).clone()), + } + } + Ok(LedgerFootprint { + read_only: read_only.try_into()?, + read_write: read_write.try_into()?, + }) +} + +fn finalize_transaction_data_and_min_fee( + pre_storage: &LedgerStorage, + transaction_resources: &TransactionResources, + soroban_resources: SorobanResources, + rent_changes: &Vec, + current_ledger_seq: u32, + bucket_list_size: u64, +) -> Result<(SorobanTransactionData, i64)> { + let (fee_configuration, rent_fee_configuration) = + get_fee_configurations(pre_storage, bucket_list_size) + .context("failed to obtain configuration settings from the network")?; + let (non_refundable_fee, refundable_fee) = + compute_transaction_resource_fee(transaction_resources, &fee_configuration); + let rent_fee = compute_rent_fee(rent_changes, &rent_fee_configuration, current_ledger_seq); + let resource_fee = refundable_fee + non_refundable_fee + rent_fee; + let transaction_data = SorobanTransactionData { + resources: soroban_resources, + resource_fee, + ext: ExtensionPoint::V0, + }; + let res = (transaction_data, resource_fee); + Ok(res) +} + +pub(crate) fn compute_extend_footprint_ttl_transaction_data_and_min_fee( + footprint: LedgerFootprint, + extend_to: u32, + ledger_storage: &LedgerStorage, + bucket_list_size: u64, + current_ledger_seq: u32, +) -> Result<(SorobanTransactionData, i64)> { + let rent_changes = compute_extend_footprint_rent_changes( + &footprint, + ledger_storage, + extend_to, + current_ledger_seq, + ) + .context("cannot compute extend rent changes")?; + + let unmodified_entry_bytes = calculate_unmodified_ledger_entry_bytes( + footprint.read_only.as_slice(), + ledger_storage, + false, + ) + .context("cannot calculate read_bytes resource")?; + + let soroban_resources = SorobanResources { + footprint, + instructions: 0, + read_bytes: unmodified_entry_bytes, + write_bytes: 0, + }; + let transaction_size_bytes = estimate_max_transaction_size_for_operation( + &OperationBody::ExtendFootprintTtl(ExtendFootprintTtlOp { + ext: ExtensionPoint::V0, + extend_to, + }), + &soroban_resources.footprint, + ) + .context("cannot estimate maximum transaction size")?; + let transaction_resources = TransactionResources { + instructions: 0, + read_entries: u32::try_from(soroban_resources.footprint.read_only.as_vec().len())?, + write_entries: 0, + read_bytes: soroban_resources.read_bytes, + write_bytes: 0, + transaction_size_bytes, + contract_events_size_bytes: 0, + }; + finalize_transaction_data_and_min_fee( + ledger_storage, + &transaction_resources, + soroban_resources, + &rent_changes, + current_ledger_seq, + bucket_list_size, + ) +} + +#[allow(clippy::cast_possible_truncation)] +fn compute_extend_footprint_rent_changes( + footprint: &LedgerFootprint, + ledger_storage: &LedgerStorage, + extend_to: u32, + current_ledger_seq: u32, +) -> Result> { + let mut rent_changes: Vec = + Vec::with_capacity(footprint.read_only.len()); + for key in footprint.read_only.as_slice() { + let unmodified_entry_and_ttl = ledger_storage.get(key, false).with_context(|| { + format!("cannot find extend footprint ledger entry with key {key:?}") + })?; + let size = (key.to_xdr(Limits::none())?.len() + + unmodified_entry_and_ttl.0.to_xdr(Limits::none())?.len()) as u32; + let ttl_entry: Box = + (&unmodified_entry_and_ttl) + .try_into() + .map_err(|e: String| { + Error::msg(e.clone()).context("incorrect ledger entry type in footprint") + })?; + let new_live_until_ledger = current_ledger_seq + extend_to; + if new_live_until_ledger <= ttl_entry.live_until_ledger_seq() { + // The extend would be ineffective + continue; + } + let rent_change = LedgerEntryRentChange { + is_persistent: ttl_entry.durability() == Persistent, + old_size_bytes: size, + new_size_bytes: size, + old_live_until_ledger: ttl_entry.live_until_ledger_seq(), + new_live_until_ledger, + }; + rent_changes.push(rent_change); + } + Ok(rent_changes) +} + +pub(crate) fn compute_restore_footprint_transaction_data_and_min_fee( + footprint: LedgerFootprint, + ledger_storage: &LedgerStorage, + bucket_list_size: u64, + current_ledger_seq: u32, +) -> Result<(SorobanTransactionData, i64)> { + let ConfigSettingEntry::StateArchival(state_archival) = + ledger_storage.get_configuration_setting(ConfigSettingId::StateArchival)? + else { + bail!("unexpected config setting entry for StateArchival key"); + }; + let rent_changes = compute_restore_footprint_rent_changes( + &footprint, + ledger_storage, + state_archival.min_persistent_ttl, + current_ledger_seq, + ) + .context("cannot compute restore rent changes")?; + + let write_bytes = calculate_unmodified_ledger_entry_bytes( + footprint.read_write.as_vec(), + ledger_storage, + true, + ) + .context("cannot calculate write_bytes resource")?; + let soroban_resources = SorobanResources { + footprint, + instructions: 0, + read_bytes: write_bytes, + write_bytes, + }; + let transaction_size_bytes = estimate_max_transaction_size_for_operation( + &OperationBody::RestoreFootprint(RestoreFootprintOp { + ext: ExtensionPoint::V0, + }), + &soroban_resources.footprint, + ) + .context("cannot estimate maximum transaction size")?; + let transaction_resources = TransactionResources { + instructions: 0, + read_entries: 0, + write_entries: u32::try_from(soroban_resources.footprint.read_write.as_vec().len())?, + read_bytes: soroban_resources.read_bytes, + write_bytes: soroban_resources.write_bytes, + transaction_size_bytes, + contract_events_size_bytes: 0, + }; + finalize_transaction_data_and_min_fee( + ledger_storage, + &transaction_resources, + soroban_resources, + &rent_changes, + current_ledger_seq, + bucket_list_size, + ) +} + +#[allow(clippy::cast_possible_truncation)] +fn compute_restore_footprint_rent_changes( + footprint: &LedgerFootprint, + ledger_storage: &LedgerStorage, + min_persistent_ttl: u32, + current_ledger_seq: u32, +) -> Result> { + let mut rent_changes: Vec = + Vec::with_capacity(footprint.read_write.len()); + for key in footprint.read_write.as_vec() { + let unmodified_entry_and_ttl = ledger_storage.get(key, true).with_context(|| { + format!("cannot find restore footprint ledger entry with key {key:?}") + })?; + let size = (key.to_xdr(Limits::none())?.len() + + unmodified_entry_and_ttl.0.to_xdr(Limits::none())?.len()) as u32; + let ttl_entry: Box = + (&unmodified_entry_and_ttl) + .try_into() + .map_err(|e: String| { + Error::msg(e.clone()).context("incorrect ledger entry type in footprint") + })?; + ensure!( + ttl_entry.durability() == Persistent, + "non-persistent entry in footprint: key = {key:?}" + ); + if ttl_entry.is_live(current_ledger_seq) { + // noop (the entry is alive) + continue; + } + let new_live_until_ledger = + get_restored_ledger_sequence(current_ledger_seq, min_persistent_ttl); + let rent_change = LedgerEntryRentChange { + is_persistent: true, + old_size_bytes: 0, + new_size_bytes: size, + old_live_until_ledger: 0, + new_live_until_ledger, + }; + rent_changes.push(rent_change); + } + Ok(rent_changes) +} diff --git a/soroban-simulation/src/ledger_storage.rs b/soroban-simulation/src/ledger_storage.rs new file mode 100644 index 000000000..9efe53010 --- /dev/null +++ b/soroban-simulation/src/ledger_storage.rs @@ -0,0 +1,212 @@ +use super::state_ttl::{get_restored_ledger_sequence, TTLLedgerEntry}; +use soroban_env_host::storage::SnapshotSource; +use soroban_env_host::xdr::ContractDataDurability::{Persistent, Temporary}; +use soroban_env_host::xdr::{ + ConfigSettingEntry, ConfigSettingId, Error as XdrError, LedgerEntry, LedgerEntryData, + LedgerKey, LedgerKeyConfigSetting, Limits, ScError, ScErrorCode, WriteXdr, +}; +use soroban_env_host::HostError; +use std::cell::RefCell; +use std::collections::HashSet; +use std::convert::TryInto; +use std::ffi::NulError; +use std::rc::Rc; +use std::str::Utf8Error; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("not found")] + NotFound, + #[error("entry is not live")] + NotLive, + #[error("xdr processing error: {0}")] + Xdr(#[from] XdrError), + #[error("nul error: {0}")] + NulError(#[from] NulError), + #[error("utf8 error: {0}")] + Utf8Error(#[from] Utf8Error), + #[error("unexpected config ledger entry for setting_id {setting_id}")] + UnexpectedConfigLedgerEntry { setting_id: String }, + #[error("unexpected ledger entry type ({ledger_entry_type}) for ttl ledger key")] + UnexpectedLedgerEntryTypeForTtlKey { ledger_entry_type: String }, +} + +impl From for HostError { + fn from(value: Error) -> Self { + match value { + Error::NotFound | Error::NotLive => ScError::Storage(ScErrorCode::MissingValue).into(), + Error::Xdr(_) => ScError::Value(ScErrorCode::InvalidInput).into(), + _ => ScError::Context(ScErrorCode::InternalError).into(), + } + } +} + +struct EntryRestoreTracker { + min_persistent_ttl: u32, + // RefCell is needed to mutate the hashset inside SnapshotSource::get(), which is an immutable method + ledger_keys_requiring_restore: RefCell>, +} + +impl EntryRestoreTracker { + // Tracks ledger entries which need to be restored and returns its ttl as it was restored + pub(crate) fn track_and_restore( + &self, + current_ledger_sequence: u32, + key: &LedgerKey, + entry_and_ttl: &(LedgerEntry, Option), + ) -> Option { + let ttl_entry: Box = match entry_and_ttl.try_into() { + Ok(e) => e, + Err(_) => { + // Nothing to track, the entry does not have a ttl + return None; + } + }; + if ttl_entry.durability() != Persistent || ttl_entry.is_live(current_ledger_sequence) { + // Nothing to track, the entry isn't persistent (and thus not restorable) or + // it is alive + return Some(ttl_entry.live_until_ledger_seq()); + } + self.ledger_keys_requiring_restore + .borrow_mut() + .insert(key.clone()); + Some(get_restored_ledger_sequence( + current_ledger_sequence, + self.min_persistent_ttl, + )) + } +} + +pub trait LedgerGetter { + fn get( + &self, + key: &LedgerKey, + include_not_live: bool, + ) -> Result<(LedgerEntry, Option), Error>; +} + +pub struct LedgerStorage { + ledger_getter: Box, + current_ledger_sequence: u32, + restore_tracker: Option, +} + +impl LedgerStorage { + pub fn new(ledger_getter: Box, current_ledger_sequence: u32) -> Self { + LedgerStorage { + ledger_getter, + current_ledger_sequence, + restore_tracker: None, + } + } + + pub fn with_restore_tracking( + ledger_getter: Box, + current_ledger_sequence: u32, + ) -> Result { + // First, we initialize it without the tracker, to get the minimum restore ledger from the network + let mut ledger_storage = LedgerStorage { + ledger_getter, + current_ledger_sequence, + restore_tracker: None, + }; + let setting_id = ConfigSettingId::StateArchival; + let ConfigSettingEntry::StateArchival(state_archival) = + ledger_storage.get_configuration_setting(setting_id)? + else { + return Err(Error::UnexpectedConfigLedgerEntry { + setting_id: setting_id.name().to_string(), + }); + }; + // Now that we have the state archival config, we can build the tracker + ledger_storage.restore_tracker = Some(EntryRestoreTracker { + ledger_keys_requiring_restore: RefCell::new(HashSet::new()), + min_persistent_ttl: state_archival.min_persistent_ttl, + }); + Ok(ledger_storage) + } + + pub(crate) fn get_xdr( + &self, + key: &LedgerKey, + include_not_live: bool, + ) -> Result, Error> { + // TODO: this can be optimized since for entry types other than ContractCode/ContractData, + // they don't need to be deserialized and serialized again + let (entry, _) = self.ledger_getter.get(key, include_not_live)?; + Ok(entry.to_xdr(Limits::none())?) + } + + pub(crate) fn get_configuration_setting( + &self, + setting_id: ConfigSettingId, + ) -> Result { + let key = LedgerKey::ConfigSetting(LedgerKeyConfigSetting { + config_setting_id: setting_id, + }); + match self.ledger_getter.get(&key, false)? { + ( + LedgerEntry { + data: LedgerEntryData::ConfigSetting(cs), + .. + }, + _, + ) => Ok(cs), + _ => Err(Error::UnexpectedConfigLedgerEntry { + setting_id: setting_id.name().to_string(), + }), + } + } + + pub(crate) fn get_ledger_keys_requiring_restore(&self) -> HashSet { + match self.restore_tracker { + Some(ref t) => t.ledger_keys_requiring_restore.borrow().clone(), + None => HashSet::new(), + } + } +} + +impl LedgerGetter for LedgerStorage { + fn get( + &self, + key: &LedgerKey, + include_not_live: bool, + ) -> anyhow::Result<(LedgerEntry, Option), Error> { + self.ledger_getter.get(key, include_not_live) + } +} + +impl SnapshotSource for LedgerStorage { + fn get(&self, key: &Rc) -> Result<(Rc, Option), HostError> { + if let Some(ref tracker) = self.restore_tracker { + let mut entry_and_ttl = self.ledger_getter.get(key, true)?; + // Explicitly discard temporary ttl'ed entries + if let Ok(ttl_entry) = TryInto::>::try_into(&entry_and_ttl) { + if ttl_entry.durability() == Temporary + && !ttl_entry.is_live(self.current_ledger_sequence) + { + return Err(HostError::from(Error::NotLive)); + } + } + // If the entry is not live, we modify the ttl to make it seem like it was restored + entry_and_ttl.1 = + tracker.track_and_restore(self.current_ledger_sequence, key, &entry_and_ttl); + return Ok((entry_and_ttl.0.into(), entry_and_ttl.1)); + } + let entry_and_ttl = self + .ledger_getter + .get(key, false) + .map_err(HostError::from)?; + Ok((entry_and_ttl.0.into(), entry_and_ttl.1)) + } + + fn has(&self, key: &Rc) -> Result { + let result = ::get(self, key); + if let Err(ref host_error) = result { + if host_error.error.is_code(ScErrorCode::MissingValue) { + return Ok(false); + } + } + result.map(|_| true) + } +} diff --git a/soroban-simulation/src/lib.rs b/soroban-simulation/src/lib.rs new file mode 100644 index 000000000..376e0c5e1 --- /dev/null +++ b/soroban-simulation/src/lib.rs @@ -0,0 +1,315 @@ +mod fees; +mod ledger_storage; +mod state_ttl; + +use anyhow::{anyhow, bail, Context, Result}; +use ledger_storage::LedgerStorage; +use soroban_env_host::auth::RecordedAuthPayload; +use soroban_env_host::budget::Budget; +use soroban_env_host::events::Events; +use soroban_env_host::storage::Storage; +use soroban_env_host::xdr::{ + AccountId, ConfigSettingEntry, ConfigSettingId, DiagnosticEvent, InvokeHostFunctionOp, + LedgerFootprint, LedgerKey, OperationBody, ScVal, SorobanAddressCredentials, + SorobanAuthorizationEntry, SorobanCredentials, SorobanTransactionData, VecM, +}; +use soroban_env_host::{DiagnosticLevel, Host, LedgerInfo}; +use std::collections::HashSet; +use std::convert::{TryFrom, TryInto}; +use std::iter::FromIterator; +use std::rc::Rc; + +pub struct RestorePreamble { + pub transaction_data: SorobanTransactionData, + pub min_fee: i64, +} + +#[derive(Default)] +pub struct SimulationResult { + pub error: String, + pub auth: Vec, + pub result: Option, + pub transaction_data: Option, + pub min_fee: i64, + pub events: Vec, + pub cpu_instructions: u64, + pub memory_bytes: u64, + pub restore_preamble: Option, +} + +pub fn simulate_invoke_hf_op( + ledger_storage: LedgerStorage, + bucket_list_size: u64, + invoke_hf_op: InvokeHostFunctionOp, + source_account: AccountId, + ledger_info: LedgerInfo, + enable_debug: bool, +) -> std::result::Result> { + let ledger_storage_rc = Rc::new(ledger_storage); + let budget = get_budget_from_network_config_params(&ledger_storage_rc) + .context("cannot create budget")?; + let storage = Storage::with_recording_footprint(ledger_storage_rc.clone()); + let host = Host::with_storage_and_budget(storage, budget); + host.set_source_account(source_account.clone()) + .context("cannot set source account")?; + if enable_debug { + host.set_diagnostic_level(DiagnosticLevel::Debug) + .context("cannot set debug diagnostic level")?; + } + host.set_ledger_info(ledger_info.clone()) + .context("cannot set ledger info")?; + host.set_base_prng_seed(rand::Rng::gen(&mut rand::thread_rng())) + .context("cannot set base prng seed")?; + + // We make an assumption here: + // - if a transaction doesn't include any soroban authorization entries the client either + // doesn't know the authorization entries, or there are none. In either case it is best to + // record the authorization entries and return them to the client. + // - if a transaction *does* include soroban authorization entries, then the client *already* + // knows the needed entries, so we should try them in enforcing mode so that we can validate + // them, and return the correct fees and footprint. + let needs_auth_recording = invoke_hf_op.auth.is_empty(); + if needs_auth_recording { + host.switch_to_recording_auth(true) + .context("cannot switch auth to recording mode")?; + } else { + host.set_authorization_entries(invoke_hf_op.auth.to_vec()) + .context("cannot set authorization entries")?; + } + + // Run the simulation. + let maybe_result = host + .invoke_function(invoke_hf_op.host_function.clone()) + .context("host invocation failed"); + let auths: VecM = if needs_auth_recording { + let payloads = host.get_recorded_auth_payloads()?; + VecM::try_from( + payloads + .iter() + .map(recorded_auth_payload_to_xdr) + .collect::>>()?, + )? + } else { + invoke_hf_op.auth + }; + + let budget = host.budget_cloned(); + // Recover, convert and return the storage footprint and other values to C. + let (storage, events) = host.try_finish().context("cannot finish host invocation")?; + + let diagnostic_events = host_events_to_diagnostic_events(&events); + let result = match maybe_result { + Ok(r) => r, + // If the invocation failed, try to at least add the diagnostic events + Err(e) => { + return Ok(SimulationResult { + // See https://docs.rs/anyhow/latest/anyhow/struct.Error.html#display-representations + error: format!("{e:?}"), + events: diagnostic_events, + ..Default::default() + }); + } + }; + + let invoke_host_function_with_auth = InvokeHostFunctionOp { + host_function: invoke_hf_op.host_function, + auth: auths.clone(), + }; + let (transaction_data, min_fee) = fees::compute_host_function_transaction_data_and_min_fee( + &invoke_host_function_with_auth, + &ledger_storage_rc, + &storage, + &budget, + &diagnostic_events, + &result, + bucket_list_size, + ledger_info.sequence_number, + ) + .context("cannot compute resources and fees")?; + + let restore_preamble = compute_restore_preamble( + ledger_storage_rc.get_ledger_keys_requiring_restore(), + &ledger_storage_rc, + bucket_list_size, + ledger_info.sequence_number, + ) + .context("cannot compute restore preamble")?; + + Ok(SimulationResult { + auth: auths.to_vec(), + result: Some(result), + transaction_data: Some(transaction_data), + min_fee, + events: diagnostic_events, + cpu_instructions: budget + .get_cpu_insns_consumed() + .context("cannot get cpu instructions")?, + memory_bytes: budget + .get_mem_bytes_consumed() + .context("cannot get consumed memory")?, + restore_preamble, + ..Default::default() + }) +} + +fn recorded_auth_payload_to_xdr( + payload: &RecordedAuthPayload, +) -> Result { + let result = match (payload.address.clone(), payload.nonce) { + (Some(address), Some(nonce)) => SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address, + nonce, + // signature is left empty. This is where the client will put their signatures when + // submitting the transaction. + signature_expiration_ledger: 0, + signature: ScVal::Void, + }), + root_invocation: payload.invocation.clone(), + }, + (None, None) => SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: payload.invocation.clone(), + }, + // the address and the nonce can't be present independently + (a,n) => + bail!("recorded_auth_payload_to_xdr: address and nonce present independently (address: {:?}, nonce: {:?})", a, n), + }; + Ok(result) +} + +fn compute_restore_preamble( + entries: HashSet, + ledger_storage: &LedgerStorage, + bucket_list_size: u64, + current_ledger_seq: u32, +) -> Result> { + if entries.is_empty() { + return Ok(None); + } + let read_write_vec: Vec = Vec::from_iter(entries); + let restore_footprint = LedgerFootprint { + read_only: VecM::default(), + read_write: read_write_vec.try_into()?, + }; + let (transaction_data, min_fee) = fees::compute_restore_footprint_transaction_data_and_min_fee( + restore_footprint, + ledger_storage, + bucket_list_size, + current_ledger_seq, + )?; + Ok(Some(RestorePreamble { + transaction_data, + min_fee, + })) +} + +fn host_events_to_diagnostic_events(events: &Events) -> Vec { + let mut res: Vec = Vec::with_capacity(events.0.len()); + for e in &events.0 { + let diagnostic_event = DiagnosticEvent { + in_successful_contract_call: !e.failed_call, + event: e.event.clone(), + }; + res.push(diagnostic_event); + } + res +} +#[allow(clippy::cast_sign_loss)] +fn get_budget_from_network_config_params(ledger_storage: &LedgerStorage) -> Result { + let ConfigSettingEntry::ContractComputeV0(compute) = + ledger_storage.get_configuration_setting(ConfigSettingId::ContractComputeV0)? + else { + bail!("unexpected config setting entry for ComputeV0 key"); + }; + + let ConfigSettingEntry::ContractCostParamsCpuInstructions(cost_params_cpu) = ledger_storage + .get_configuration_setting(ConfigSettingId::ContractCostParamsCpuInstructions)? + else { + bail!("unexpected config setting entry for CostParamsCpuInstructions key"); + }; + + let ConfigSettingEntry::ContractCostParamsMemoryBytes(cost_params_memory) = + ledger_storage.get_configuration_setting(ConfigSettingId::ContractCostParamsMemoryBytes)? + else { + bail!("unexpected config setting entry for CostParamsMemoryBytes key"); + }; + let budget = Budget::try_from_configs( + compute.tx_max_instructions as u64, + u64::from(compute.tx_memory_limit), + cost_params_cpu, + cost_params_memory, + ) + .context("cannot create budget from network configuration")?; + Ok(budget) +} + +pub fn simulate_footprint_ttl_op( + ledger_storage: &LedgerStorage, + bucket_list_size: u64, + op_body: OperationBody, + footprint: LedgerFootprint, + current_ledger_seq: u32, +) -> std::result::Result> { + let a = match op_body { + OperationBody::ExtendFootprintTtl(op) => simulate_extend_footprint_ttl( + footprint, + op.extend_to, + ledger_storage, + bucket_list_size, + current_ledger_seq, + ), + OperationBody::RestoreFootprint(_) => simulate_restore_footprint( + footprint, + ledger_storage, + bucket_list_size, + current_ledger_seq, + ), + op => Err(anyhow!( + "simulate_footprint_ttl_op(): unsupported operation type {}", + op.name() + )), + }; + Ok(a?) +} + +fn simulate_extend_footprint_ttl( + footprint: LedgerFootprint, + extend_to: u32, + ledger_storage: &LedgerStorage, + bucket_list_size: u64, + current_ledger_seq: u32, +) -> Result { + let (transaction_data, min_fee) = + fees::compute_extend_footprint_ttl_transaction_data_and_min_fee( + footprint, + extend_to, + ledger_storage, + bucket_list_size, + current_ledger_seq, + )?; + Ok(SimulationResult { + transaction_data: Some(transaction_data), + min_fee, + ..Default::default() + }) +} + +fn simulate_restore_footprint( + footprint: LedgerFootprint, + ledger_storage: &LedgerStorage, + bucket_list_size: u64, + current_ledger_seq: u32, +) -> Result { + let (transaction_data, min_fee) = fees::compute_restore_footprint_transaction_data_and_min_fee( + footprint, + ledger_storage, + bucket_list_size, + current_ledger_seq, + )?; + Ok(SimulationResult { + transaction_data: Some(transaction_data), + min_fee, + ..Default::default() + }) +} diff --git a/soroban-simulation/src/state_ttl.rs b/soroban-simulation/src/state_ttl.rs new file mode 100644 index 000000000..a3ef4e688 --- /dev/null +++ b/soroban-simulation/src/state_ttl.rs @@ -0,0 +1,63 @@ +use soroban_env_host::xdr::ContractDataDurability::Persistent; +use soroban_env_host::xdr::{ + ContractCodeEntry, ContractDataDurability, ContractDataEntry, LedgerEntry, LedgerEntryData, +}; +use std::convert::TryInto; + +pub(crate) trait TTLLedgerEntry { + fn durability(&self) -> ContractDataDurability; + fn live_until_ledger_seq(&self) -> u32; + fn is_live(&self, current_ledger_seq: u32) -> bool { + self.live_until_ledger_seq() >= current_ledger_seq + } +} + +impl TTLLedgerEntry for (&ContractCodeEntry, u32) { + fn durability(&self) -> ContractDataDurability { + Persistent + } + + fn live_until_ledger_seq(&self) -> u32 { + self.1 + } +} + +impl TTLLedgerEntry for (&ContractDataEntry, u32) { + fn durability(&self) -> ContractDataDurability { + self.0.durability + } + + fn live_until_ledger_seq(&self) -> u32 { + self.1 + } +} + +// Convert a ledger entry and its Time to live (i.e. live_until_seq) into a TTLLedgerEntry +impl<'a> TryInto> for &'a (LedgerEntry, Option) { + type Error = String; + + fn try_into(self) -> Result, Self::Error> { + match (&self.0.data, self.1) { + (LedgerEntryData::ContractData(d), Some(live_until_seq)) => { + Ok(Box::new((d, live_until_seq))) + } + (LedgerEntryData::ContractCode(c), Some(live_until_seq)) => { + Ok(Box::new((c, live_until_seq))) + } + (LedgerEntryData::ContractData(_) | LedgerEntryData::ContractCode(_), _) => Err( + format!("missing ttl for ledger entry ({})", self.0.data.name()), + ), + _ => Err(format!( + "ledger entry type ({}) cannot have a TTL", + self.0.data.name() + )), + } + } +} + +pub(crate) fn get_restored_ledger_sequence( + current_ledger_seq: u32, + min_persistent_ttl: u32, +) -> u32 { + current_ledger_seq + min_persistent_ttl - 1 +}