Skip to content

Commit

Permalink
Expose invocation metering in the SDK.
Browse files Browse the repository at this point in the history
  • Loading branch information
dmkozh committed Dec 13, 2024
1 parent e176daa commit b454ab5
Show file tree
Hide file tree
Showing 10 changed files with 434 additions and 0 deletions.
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions soroban-sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
111 changes: 111 additions & 0 deletions soroban-sdk/src/cost_estimate.rs
Original file line number Diff line number Diff line change
@@ -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<InvocationResources> {
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<FeeEstimate> {
// 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())
}
}
22 changes: 22 additions & 0 deletions soroban-sdk/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,8 @@ impl Env {
}
}

#[cfg(any(test, feature = "testutils"))]
use crate::cost_estimate::CostEstimate;
#[cfg(any(test, feature = "testutils"))]
use crate::{
auth,
Expand Down Expand Up @@ -576,6 +578,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
Expand Down
2 changes: 2 additions & 0 deletions soroban-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions soroban-sdk/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
146 changes: 146 additions & 0 deletions soroban-sdk/src/tests/cost_estimate.rs
Original file line number Diff line number Diff line change
@@ -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());
}
Loading

0 comments on commit b454ab5

Please sign in to comment.