diff --git a/Cargo.lock b/Cargo.lock index 59c6bf04..432776e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -462,6 +462,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "dissimilar" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f8e79d1fbf76bdfbde321e902714bf6c49df88a7dda6fc682fc2979226962d" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -558,6 +564,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b90ca2580b73ab6a1f724b76ca11ab632df820fd6040c336200d2c1df7b3c82c" +[[package]] +name = "expect-test" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e0be0a561335815e06dab7c62e50353134c796e7a6155402a64bcff66b6a5e0" +dependencies = [ + "dissimilar", + "once_cell", +] + [[package]] name = "fastrand" version = "2.1.1" @@ -1336,6 +1352,7 @@ dependencies = [ "curve25519-dalek", "derive_arbitrary", "ed25519-dalek", + "expect-test", "hex", "libfuzzer-sys", "proptest", diff --git a/soroban-sdk/Cargo.toml b/soroban-sdk/Cargo.toml index dd530a67..752fbf32 100644 --- a/soroban-sdk/Cargo.toml +++ b/soroban-sdk/Cargo.toml @@ -53,6 +53,7 @@ derive_arbitrary = { version = "~1.3.0" } proptest = "1.2.0" proptest-arbitrary-interop = "0.1.0" libfuzzer-sys = "0.4.7" +expect-test = "1.4.1" [features] alloc = [] diff --git a/soroban-sdk/src/cost_estimate.rs b/soroban-sdk/src/cost_estimate.rs new file mode 100644 index 00000000..3abd7d28 --- /dev/null +++ b/soroban-sdk/src/cost_estimate.rs @@ -0,0 +1,111 @@ +use soroban_env_host::{fees::FeeConfiguration, FeeEstimate, InvocationResources}; + +use crate::{testutils::budget::Budget, Env}; + +pub struct CostEstimate { + env: Env, +} + +impl CostEstimate { + pub(crate) fn new(env: Env) -> Self { + Self { env } + } + + /// Enables detailed per-invocation resource cost metering. + /// + /// The top-level contract invocations and lifecycle operations (such as + /// `register` or `env.deployer()` operations) will be metered and the + /// all the metering information will reset in-between them. Metering will + /// not be reset while inside the call (e.g. if a contract calls or creates + /// another contract, that won't reset metering). + /// + /// The metered resources for the last invocation can be retrieved with + /// `resources()`, the estimated fee corresponding to these resources can be + /// retrieved with `fee()`, and the detailed CPU and memory memory + /// breakdown can be retrieved with `detailed_metering()`. + /// + /// While the resource metering may be useful for contract optimization, + /// keep in mind that resource and fee estimation may be imprecise. Use + /// simulation with RPC in order to get the exact resources for submitting + /// the transactions to the network. + pub fn enable(&self) { + self.env.host().enable_invocation_metering(); + } + + /// Returns the resources metered during the last top level contract + /// invocation. + /// + /// In order to get non-`None` results, `enable()` has to + /// be called and at least one invocation has to happen after that. + /// + /// Take the return value with a grain of salt. The returned resources mostly + /// correspond only to the operations that have happened during the host + /// invocation, i.e. this won't try to simulate the work that happens in + /// production scenarios (e.g. certain XDR rountrips). This also doesn't try + /// to model resources related to the transaction size. + /// + /// The returned value is as useful as the preceding setup, e.g. if a test + /// contract is used instead of a Wasm contract, all the costs related to + /// VM instantiation and execution, as well as Wasm reads/rent bumps will be + /// missed. + pub fn resources(&self) -> Option { + self.env.host().get_last_invocation_resources() + } + + /// Estimates the fee for the last invocation's resources, i.e. the + /// resources returned by `resources()`. + /// + /// In order to get non-`None` results, `enable()` has to + /// be called and at least one invocation has to happen after that. + /// + /// The fees are computed using the snapshot of the Stellar Pubnet fees made + /// on 2024-12-11. + /// + /// Take the return value with a grain of salt as both the resource estimate + /// and the fee rates may be imprecise. + /// + /// The returned value is as useful as the preceding setup, e.g. if a test + /// contract is used instead of a Wasm contract, all the costs related to + /// VM instantiation and execution, as well as Wasm reads/rent bumps will be + /// missed. + pub fn fee(&self) -> Option { + // This is a snapshot of the fees as of 2024-12-11. + let pubnet_fee_config = FeeConfiguration { + fee_per_instruction_increment: 25, + fee_per_read_entry: 6250, + fee_per_write_entry: 10000, + fee_per_read_1kb: 1786, + // This is a bit higher than the current network fee, it's an + // overestimate for the sake of providing a bit more conservative + // results in case if the state grows. + fee_per_write_1kb: 12000, + fee_per_historical_1kb: 16235, + fee_per_contract_event_1kb: 10000, + fee_per_transaction_size_1kb: 1624, + }; + let pubnet_persistent_rent_rate_denominator = 2103; + let pubnet_temp_rent_rate_denominator = 4206; + if let Some(resources) = self.resources() { + Some(resources.estimate_fees( + &pubnet_fee_config, + pubnet_persistent_rent_rate_denominator, + pubnet_temp_rent_rate_denominator, + )) + } else { + None + } + } + + /// Returns the detailed CPU and memory metering information recorded thus + /// far. + /// + /// The metering resets before every top-level contract level invocation. + /// + /// Note, that unlike `resources()`/`fee()` this will always return some + /// value. If there was no contract call, then the resulting value will + /// correspond to metering any environment setup that has been made thus + /// far. + pub fn detailed_metering(&self) -> Budget { + Budget::new(self.env.host().budget_cloned()) + } +} diff --git a/soroban-sdk/src/env.rs b/soroban-sdk/src/env.rs index 8fd002ba..a3211442 100644 --- a/soroban-sdk/src/env.rs +++ b/soroban-sdk/src/env.rs @@ -113,6 +113,7 @@ where } use crate::auth::InvokerContractAuthEntry; +use crate::cost_estimate::CostEstimate; use crate::unwrap::UnwrapInfallible; use crate::unwrap::UnwrapOptimized; use crate::InvokeError; @@ -576,6 +577,26 @@ impl Env { env } + /// Returns the resources metered during the last top level contract + /// invocation. + /// + /// In order to get non-`None` results, `enable_invocation_metering` has to + /// be called and at least one invocation has to happen after that. + /// + /// Take the return value with a grain of salt. The returned resources mostly + /// correspond only to the operations that have happened during the host + /// invocation, i.e. this won't try to simulate the work that happens in + /// production scenarios (e.g. certain XDR rountrips). This also doesn't try + /// to model resources related to the transaction size. + /// + /// The returned value is as useful as the preceding setup, e.g. if a test + /// contract is used instead of a Wasm contract, all the costs related to + /// VM instantiation and execution, as well as Wasm reads/rent bumps will be + /// missed. + pub fn cost_estimate(&self) -> CostEstimate { + CostEstimate::new(self.clone()) + } + /// Register a contract with the [Env] for testing. /// /// Pass the contract type when the contract is defined in the current crate diff --git a/soroban-sdk/src/lib.rs b/soroban-sdk/src/lib.rs index 0461ac23..d999624a 100644 --- a/soroban-sdk/src/lib.rs +++ b/soroban-sdk/src/lib.rs @@ -802,6 +802,8 @@ pub mod data { } pub mod auth; mod bytes; +#[cfg(any(test, feature = "testutils"))] +pub mod cost_estimate; pub mod crypto; pub mod deploy; mod error; diff --git a/soroban-sdk/src/tests.rs b/soroban-sdk/src/tests.rs index 054c5867..120a61c3 100644 --- a/soroban-sdk/src/tests.rs +++ b/soroban-sdk/src/tests.rs @@ -25,6 +25,7 @@ mod contract_udt_struct; mod contract_udt_struct_tuple; mod contractimport; mod contractimport_with_error; +mod cost_estimate; mod crypto_bls12_381; mod crypto_ed25519; mod crypto_keccak256; diff --git a/soroban-sdk/src/tests/cost_estimate.rs b/soroban-sdk/src/tests/cost_estimate.rs new file mode 100644 index 00000000..e72bbc6f --- /dev/null +++ b/soroban-sdk/src/tests/cost_estimate.rs @@ -0,0 +1,146 @@ +use crate as soroban_sdk; +use expect_test::expect; +use soroban_sdk::Env; +use soroban_sdk_macros::symbol_short; + +mod contract_data { + use crate as soroban_sdk; + soroban_sdk::contractimport!(file = "test_wasms/test_contract_data.wasm"); +} + +// Update the test data in this test via running it with `UPDATE_EXPECT=1`. +#[test] +fn test_cost_estimate_with_storage() { + let e = Env::default(); + e.cost_estimate().enable(); + + let contract_id = e.register(contract_data::WASM, ()); + let client = contract_data::Client::new(&e, &contract_id); + + // Write a single new entry to the storage. + client.put(&symbol_short!("k1"), &symbol_short!("v1")); + expect![[r#" + InvocationResources { + instructions: 455853, + mem_bytes: 1162241, + read_entries: 2, + write_entries: 1, + read_bytes: 1028, + write_bytes: 80, + contract_events_size_bytes: 0, + persistent_rent_ledger_bytes: 327600, + persistent_entry_rent_bumps: 1, + temporary_rent_ledger_bytes: 0, + temporary_entry_rent_bumps: 0, + }"#]] + .assert_eq(format!("{:#?}", e.cost_estimate().resources().unwrap()).as_str()); + expect![[r#" + FeeEstimate { + total: 45010, + instructions: 1140, + read_entries: 18750, + write_entries: 10000, + read_bytes: 1793, + write_bytes: 938, + contract_events: 0, + persistent_entry_rent: 12389, + temporary_entry_rent: 0, + }"#]] + .assert_eq(format!("{:#?}", e.cost_estimate().fee().unwrap()).as_str()); + + // Read an entry from the storage. Now there are no write-related resources + // and fees consumed. + assert_eq!(client.get(&symbol_short!("k1")), Some(symbol_short!("v1"))); + expect![[r#" + InvocationResources { + instructions: 454080, + mem_bytes: 1161338, + read_entries: 3, + write_entries: 0, + read_bytes: 1108, + write_bytes: 0, + contract_events_size_bytes: 0, + persistent_rent_ledger_bytes: 0, + persistent_entry_rent_bumps: 0, + temporary_rent_ledger_bytes: 0, + temporary_entry_rent_bumps: 0, + }"#]] + .assert_eq(format!("{:#?}", e.cost_estimate().resources().unwrap()).as_str()); + expect![[r#" + FeeEstimate { + total: 21819, + instructions: 1136, + read_entries: 18750, + write_entries: 0, + read_bytes: 1933, + write_bytes: 0, + contract_events: 0, + persistent_entry_rent: 0, + temporary_entry_rent: 0, + }"#]] + .assert_eq(format!("{:#?}", e.cost_estimate().fee().unwrap()).as_str()); + + // Delete the entry. There is 1 write_entry, but 0 write_bytes and no rent + // as this is deletion. + client.del(&symbol_short!("k1")); + expect![[r#" + InvocationResources { + instructions: 452458, + mem_bytes: 1161558, + read_entries: 2, + write_entries: 1, + read_bytes: 1108, + write_bytes: 0, + contract_events_size_bytes: 0, + persistent_rent_ledger_bytes: 0, + persistent_entry_rent_bumps: 0, + temporary_rent_ledger_bytes: 0, + temporary_entry_rent_bumps: 0, + }"#]] + .assert_eq(format!("{:#?}", e.cost_estimate().resources().unwrap()).as_str()); + expect![[r#" + FeeEstimate { + total: 31815, + instructions: 1132, + read_entries: 18750, + write_entries: 10000, + read_bytes: 1933, + write_bytes: 0, + contract_events: 0, + persistent_entry_rent: 0, + temporary_entry_rent: 0, + }"#]] + .assert_eq(format!("{:#?}", e.cost_estimate().fee().unwrap()).as_str()); + + // Read an entry again, now it no longer exists, so there is less read_bytes + // than in the case when the entry is present. + assert_eq!(client.get(&symbol_short!("k1")), None); + expect![[r#" + InvocationResources { + instructions: 452445, + mem_bytes: 1161202, + read_entries: 3, + write_entries: 0, + read_bytes: 1028, + write_bytes: 0, + contract_events_size_bytes: 0, + persistent_rent_ledger_bytes: 0, + persistent_entry_rent_bumps: 0, + temporary_rent_ledger_bytes: 0, + temporary_entry_rent_bumps: 0, + }"#]] + .assert_eq(format!("{:#?}", e.cost_estimate().resources().unwrap()).as_str()); + expect![[r#" + FeeEstimate { + total: 21675, + instructions: 1132, + read_entries: 18750, + write_entries: 0, + read_bytes: 1793, + write_bytes: 0, + contract_events: 0, + persistent_entry_rent: 0, + temporary_entry_rent: 0, + }"#]] + .assert_eq(format!("{:#?}", e.cost_estimate().fee().unwrap()).as_str()); +} diff --git a/soroban-sdk/test_snapshots/tests/cost_estimate/test_cost_estimate_with_storage.1.json b/soroban-sdk/test_snapshots/tests/cost_estimate/test_cost_estimate_with_storage.1.json new file mode 100644 index 00000000..ee238468 --- /dev/null +++ b/soroban-sdk/test_snapshots/tests/cost_estimate/test_cost_estimate_with_storage.1.json @@ -0,0 +1,128 @@ +{ + "generators": { + "address": 1, + "nonce": 0 + }, + "auth": [ + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CBKMUZNFQIAL775XBB2W2GP5CNHBM5YGH6C3XB7AY6SUVO2IBU3VYK2V", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBKMUZNFQIAL775XBB2W2GP5CNHBM5YGH6C3XB7AY6SUVO2IBU3VYK2V", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "fd41d2f77920ca07b723e05f732a82db4c2f6459eb2be6b40c4f225434569550" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "fd41d2f77920ca07b723e05f732a82db4c2f6459eb2be6b40c4f225434569550" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": { + "v1": { + "ext": "v0", + "cost_inputs": { + "ext": "v0", + "n_instructions": 137, + "n_functions": 5, + "n_globals": 3, + "n_table_entries": 0, + "n_types": 5, + "n_data_segments": 0, + "n_elem_segments": 0, + "n_imports": 4, + "n_exports": 7, + "n_data_segment_bytes": 0 + } + } + }, + "hash": "fd41d2f77920ca07b723e05f732a82db4c2f6459eb2be6b40c4f225434569550", + "code": "0061736d01000000011b0560037e7e7e017e60027e7e017e60027f7e0060017e017e600000021904016c015f0000016c01300001016c01310001016c01320001030605010203030405030100100619037f01418080c0000b7f00418080c0000b7f00418080c0000b073b07066d656d6f727902000370757400040367657400060364656c0007015f00080a5f5f646174615f656e6403010b5f5f686561705f6261736503020ad502056601017f23808080800041206b2202248080808000200241106a200010858080800002402002290310a70d0020022903182100200220011085808080002002290300a70d002000200229030842011080808080001a200241206a24808080800042020f0b00000b2401017f2000200137030820002001a741ff01712202410e47200241ca004771ad3703000b7b02017f017e23808080800041206b2201248080808000200141106a200010858080800002402001290310a70d004202210002402001290318220242011081808080004201520d002001200242011082808080001085808080002001290300a70d01200129030821000b200141206a24808080800020000f0b00000b4801017f23808080800041106b22012480808080002001200010858080800002402001290300a7450d0000000b200129030842011083808080001a200141106a24808080800042020b02000b009f010e636f6e7472616374737065637630000000000000000000000003707574000000000200000000000000036b65790000000011000000000000000376616c000000001100000000000000000000000000000003676574000000000100000000000000036b6579000000001100000001000003e80000001100000000000000000000000364656c000000000100000000000000036b6579000000001100000000001e11636f6e7472616374656e766d657461763000000000000000160000000000770e636f6e74726163746d65746176300000000000000005727376657200000000000006312e38312e3000000000000000000008727373646b7665720000003532322e302e3223646665383939626331326332323937353531303633653330313531666636353466393762383265382d6469727479000000" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-sdk/test_wasms/README.md b/soroban-sdk/test_wasms/README.md new file mode 100644 index 00000000..ef52cfd9 --- /dev/null +++ b/soroban-sdk/test_wasms/README.md @@ -0,0 +1,6 @@ +# test_wasms + +Files contained in this directory are used in a few SDK tests that are sensitive +to Wasm content changes. + +`test_contract_data.wasm` is a build of `contract_data` test contract. diff --git a/soroban-sdk/test_wasms/test_contract_data.wasm b/soroban-sdk/test_wasms/test_contract_data.wasm new file mode 100644 index 00000000..b5806097 Binary files /dev/null and b/soroban-sdk/test_wasms/test_contract_data.wasm differ