From 5b226bdfee969d83d8e06c8d686b725c8c90a5a2 Mon Sep 17 00:00:00 2001 From: Dmytro Kozhevin Date: Wed, 11 Dec 2024 19:32:10 -0500 Subject: [PATCH] Expose invocation metering in the SDK. --- soroban-sdk/Cargo.toml | 1 + soroban-sdk/src/env.rs | 87 +++++++++ soroban-sdk/src/tests.rs | 1 + soroban-sdk/src/tests/invocation_metering.rs | 184 ++++++++++++++++++ ...st_invocation_metering_with_storage.1.json | 128 ++++++++++++ 5 files changed, 401 insertions(+) create mode 100644 soroban-sdk/src/tests/invocation_metering.rs create mode 100644 soroban-sdk/test_snapshots/tests/invocation_metering/test_invocation_metering_with_storage.1.json diff --git a/soroban-sdk/Cargo.toml b/soroban-sdk/Cargo.toml index dd530a67b..752fbf326 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/env.rs b/soroban-sdk/src/env.rs index 8fd002ba4..4dd42883a 100644 --- a/soroban-sdk/src/env.rs +++ b/soroban-sdk/src/env.rs @@ -464,6 +464,8 @@ use core::{cell::RefCell, cell::RefMut}; #[cfg(any(test, feature = "testutils"))] use internal::ContractInvocationEvent; #[cfg(any(test, feature = "testutils"))] +pub use soroban_env_host::{fees::FeeConfiguration, FeeEstimate, InvocationResources}; +#[cfg(any(test, feature = "testutils"))] use soroban_ledger_snapshot::LedgerSnapshot; #[cfg(any(test, feature = "testutils"))] use std::{path::Path, rc::Rc}; @@ -576,6 +578,91 @@ impl Env { env } + /// Enables detailed metering of contract invocations. + /// + /// The top-level contract invocations and lifecycle operations (such as + /// `register` or `env.deployer()` operations) will be metered and the + /// budget 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 + /// `get_last_invocation_resources()` and the estimated fee corresponding + /// to these resources can be estimated with + /// `estimate_last_invocation_fee()`. + /// + /// 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_invocation_metering(&self) { + self.env_impl.enable_invocation_metering(); + } + + /// 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 get_last_invocation_resources(&self) -> Option { + self.env_impl.get_last_invocation_resources() + } + + /// Estimates the fee for the last invocation's resources, i.e. the + /// resources returned by `get_last_invocation_resources`. + /// + /// In order to get non-`None` results, `enable_invocation_metering` 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 estimate_last_invocation_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.get_last_invocation_resources() { + Some(resources.estimate_fees( + &pubnet_fee_config, + pubnet_persistent_rent_rate_denominator, + pubnet_temp_rent_rate_denominator, + )) + } else { + None + } + } + /// 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/tests.rs b/soroban-sdk/src/tests.rs index 054c58672..1de2d2836 100644 --- a/soroban-sdk/src/tests.rs +++ b/soroban-sdk/src/tests.rs @@ -32,6 +32,7 @@ mod crypto_secp256k1; mod crypto_secp256r1; mod crypto_sha256; mod env; +mod invocation_metering; mod max_ttl; mod prng; mod proptest_scval_cmp; diff --git a/soroban-sdk/src/tests/invocation_metering.rs b/soroban-sdk/src/tests/invocation_metering.rs new file mode 100644 index 000000000..c537d4013 --- /dev/null +++ b/soroban-sdk/src/tests/invocation_metering.rs @@ -0,0 +1,184 @@ +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 = "../target/wasm32-unknown-unknown/release/test_contract_data.wasm" + ); +} + +// Update the test data in this test via running it with `UPDATE_EXPECT=1`. +#[test] +fn test_invocation_metering_with_storage() { + let e = Env::default(); + e.enable_invocation_metering(); + + let contract_id = e.register(contract_data::WASM, ()); + + // Register operations does both Wasm upload and creating the contract + // instance. The latter is the last invocation, i.e. the following resources + // were metered for creating a contract instance. + expect![[r#" + InvocationResources { + instructions: 918961, + mem_bytes: 2325733, + read_entries: 2, + write_entries: 2, + read_bytes: 928, + write_bytes: 176, + contract_events_size_bytes: 0, + persistent_rent_ledger_bytes: 425880, + persistent_entry_rent_bumps: 1, + temporary_rent_ledger_bytes: 454463928, + temporary_entry_rent_bumps: 1, + }"#]] + .assert_eq(format!("{:#?}", e.get_last_invocation_resources().unwrap()).as_str()); + // Note, that the fee mostly comes from bumping the temporary nonce entry + // to the maximum possible TTL, which is the worst possible case for the + // signatures that don't expire for as long as possible. + expect![[r#" + FeeEstimate { + total: 1340707, + instructions: 2298, + read_entries: 25000, + write_entries: 20000, + read_bytes: 1619, + write_bytes: 2063, + contract_events: 0, + persistent_entry_rent: 12937, + temporary_entry_rent: 1276790, + }"#]] + .assert_eq(format!("{:#?}", e.estimate_last_invocation_fee().unwrap()).as_str()); + + 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: 1162245, + read_entries: 2, + write_entries: 1, + read_bytes: 1032, + 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.get_last_invocation_resources().unwrap()).as_str()); + expect![[r#" + FeeEstimate { + total: 45017, + instructions: 1140, + read_entries: 18750, + write_entries: 10000, + read_bytes: 1800, + write_bytes: 938, + contract_events: 0, + persistent_entry_rent: 12389, + temporary_entry_rent: 0, + }"#]] + .assert_eq(format!("{:#?}", e.estimate_last_invocation_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: 1161342, + read_entries: 3, + write_entries: 0, + read_bytes: 1112, + 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.get_last_invocation_resources().unwrap()).as_str()); + expect![[r#" + FeeEstimate { + total: 21826, + instructions: 1136, + read_entries: 18750, + write_entries: 0, + read_bytes: 1940, + write_bytes: 0, + contract_events: 0, + persistent_entry_rent: 0, + temporary_entry_rent: 0, + }"#]] + .assert_eq(format!("{:#?}", e.estimate_last_invocation_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: 1161562, + read_entries: 2, + write_entries: 1, + read_bytes: 1112, + 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.get_last_invocation_resources().unwrap()).as_str()); + expect![[r#" + FeeEstimate { + total: 31822, + instructions: 1132, + read_entries: 18750, + write_entries: 10000, + read_bytes: 1940, + write_bytes: 0, + contract_events: 0, + persistent_entry_rent: 0, + temporary_entry_rent: 0, + }"#]] + .assert_eq(format!("{:#?}", e.estimate_last_invocation_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: 1161206, + read_entries: 3, + write_entries: 0, + read_bytes: 1032, + 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.get_last_invocation_resources().unwrap()).as_str()); + expect![[r#" + FeeEstimate { + total: 21682, + instructions: 1132, + read_entries: 18750, + write_entries: 0, + read_bytes: 1800, + write_bytes: 0, + contract_events: 0, + persistent_entry_rent: 0, + temporary_entry_rent: 0, + }"#]] + .assert_eq(format!("{:#?}", e.estimate_last_invocation_fee().unwrap()).as_str()); +} diff --git a/soroban-sdk/test_snapshots/tests/invocation_metering/test_invocation_metering_with_storage.1.json b/soroban-sdk/test_snapshots/tests/invocation_metering/test_invocation_metering_with_storage.1.json new file mode 100644 index 000000000..34473e873 --- /dev/null +++ b/soroban-sdk/test_snapshots/tests/invocation_metering/test_invocation_metering_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": "0935fd59e7295d19c4ab019af1012fd975d89d87f643bc65516ff59c54bba4a9" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "0935fd59e7295d19c4ab019af1012fd975d89d87f643bc65516ff59c54bba4a9" + } + }, + [ + { + "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": "0935fd59e7295d19c4ab019af1012fd975d89d87f643bc65516ff59c54bba4a9", + "code": "0061736d01000000011b0560037e7e7e017e60027e7e017e60027f7e0060017e017e600000021904016c015f0000016c01300001016c01310001016c01320001030605010203030405030100100619037f01418080c0000b7f00418080c0000b7f00418080c0000b073b07066d656d6f727902000370757400040367657400060364656c0007015f00080a5f5f646174615f656e6403010b5f5f686561705f6261736503020ad502056601017f23808080800041206b2202248080808000200241106a200010858080800002402002290310a70d0020022903182100200220011085808080002002290300a70d002000200229030842011080808080001a200241206a24808080800042020f0b00000b2401017f2000200137030820002001a741ff01712202410e47200241ca004771ad3703000b7b02017f017e23808080800041206b2201248080808000200141106a200010858080800002402001290310a70d004202210002402001290318220242011081808080004201520d002001200242011082808080001085808080002001290300a70d01200129030821000b200141206a24808080800020000f0b00000b4801017f23808080800041106b22012480808080002001200010858080800002402001290300a7450d0000000b200129030842011083808080001a200141106a24808080800042020b02000b009f010e636f6e7472616374737065637630000000000000000000000003707574000000000200000000000000036b65790000000011000000000000000376616c000000001100000000000000000000000000000003676574000000000100000000000000036b6579000000001100000001000003e80000001100000000000000000000000364656c000000000100000000000000036b6579000000001100000000001e11636f6e7472616374656e766d6574617630000000000000001600000000007b0e636f6e74726163746d65746176300000000000000005727376657200000000000006312e38312e3000000000000000000008727373646b7665720000003a32322e302e302d72632e3323623762383235356162323764633064383535373062356465656436393766346339303037393636312d64697274790000" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file