From 85e2dd207320a9e0fd96eeccb1652d763c526b0d Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 21 Aug 2026 14:25:14 +0200 Subject: [PATCH 1/5] feat(drive): state-aware address funding fee estimation engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Drive::estimate_address_funding_fee(recipient, outpoint, lock_credits, block_info, platform_version) — a read-only, versioned estimate of the GroveDB-batch fee of a 0-input/1-output AddressFundingFromAssetLock, priced from the current shape of the state trees instead of the PotentiallyAtMaxElements (=32 levels) worst-case hardcodes. How it works: - The exact production operations: the engine builds a real AddressFundingFromAssetLockTransitionActionV0 and runs it through the production high-level converter, then converts to low-level operations STATEFULLY (estimated layer info = None), so the insert-vs-replace branch for the recipient's balance and the element bytes come from committed state — byte-identical to apply=true execution. (In the apply=false path the stateless existence read returns None and the insert branch is taken unconditionally; the estimator must not inherit that.) - Measured layer counts: the node generates single-key proofs for the recipient address ([56,'c']) and the asset lock outpoint ([72]) against its own committed state and decodes them (new util::proof_depth, op semantics mirrored from merk::proofs::tree::execute) into search-path levels; the two data-dependent layer counts in the server's own add_estimation_costs_* maps are replaced with the measured levels — tree types, element sizes and all other layers stay the server model. - Pricing: the unchanged estimated-costs pipeline (grove_batch_operations_costs → estimated_case_operations_for_batch → Drive::calculate_fee). The result covers the batch only; validation operations and user_fee_increase are the caller's concern. v0 scope is pinned fail-closed: the estimate models a FRESH asset lock consumed in full. An outpoint already present in [72] (fully or partially consumed) is rejected with the new DriveError::AssetLockOutpointAlreadyPresent, never silently priced. Tests pin the load-bearing properties: the grove root hash is byte-identical before/after estimating (read-only), a new address builds an InsertOrReplace with a zero nonce while an existing one builds a Replace with the summed balance, the layer map differs from the server model only in the two measured counts, measured levels grow with tree population, and the estimate brackets the real apply=true metered fee within [85%, 115%] on empty and populated state. Observed samples (protocol latest): empty 12_460_620 vs 12_551_520 (-0.7%), populated insert 13_175_300 vs 13_044_880 (+1.0%), replace 7_051_860 vs 6_802_640 (+3.7%), population 40 13_457_700 vs 13_330_880 (+1.0%) — vs the static 17.5M display heuristic's ~+34%. Bands are regression headroom for specific scenarios, not an upper-bound claim. Co-Authored-By: Claude Fable 5 --- .../address_funds/estimate_funding_fee/mod.rs | 62 ++ .../estimate_funding_fee/v0/mod.rs | 829 ++++++++++++++++++ .../rs-drive/src/drive/address_funds/mod.rs | 3 + packages/rs-drive/src/error/drive.rs | 6 + packages/rs-drive/src/util/mod.rs | 3 + packages/rs-drive/src/util/proof_depth.rs | 207 +++++ .../drive_address_funds_method_versions/v1.rs | 1 + .../drive_address_funds_method_versions/v2.rs | 1 + .../drive_group_method_versions/mod.rs | 1 + 9 files changed, 1113 insertions(+) create mode 100644 packages/rs-drive/src/drive/address_funds/estimate_funding_fee/mod.rs create mode 100644 packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs create mode 100644 packages/rs-drive/src/util/proof_depth.rs diff --git a/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/mod.rs b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/mod.rs new file mode 100644 index 00000000000..5dfdce313fd --- /dev/null +++ b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/mod.rs @@ -0,0 +1,62 @@ +mod v0; + +pub use v0::AddressFundingFeeEstimate; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::address_funds::PlatformAddress; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::platform_value::Bytes36; +use dpp::version::PlatformVersion; + +impl Drive { + /// Estimates the fee of a 0-input / 1-output address funding from a fresh + /// asset lock, using the current shape of the state trees. + /// + /// The estimate builds the exact production drive operations (through the + /// action converter, with stateful reads so the insert-vs-replace branch + /// and element bytes come from committed state), then prices them with the + /// server's own average-case layer models where the two data-dependent + /// layer counts are replaced by search-path levels measured from locally + /// generated proofs. + /// + /// Read-only: no transaction is opened and nothing is written. The whole + /// estimate reads committed state (GroveDB proving does not support + /// transactions). The result covers the GroveDB batch only — validation + /// operations and `user_fee_increase` are the caller's concern. + /// + /// Fails with [`DriveError::AssetLockOutpointAlreadyPresent`] when the + /// outpoint is already in the state: a spent or partially used lock would + /// execute through the partial-use path, which this estimator does not + /// model. + pub fn estimate_address_funding_fee( + &self, + recipient: &PlatformAddress, + asset_lock_outpoint: Bytes36, + lock_credits: Credits, + block_info: &BlockInfo, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .address_funds + .estimate_funding_fee + { + 0 => self.estimate_address_funding_fee_v0( + recipient, + asset_lock_outpoint, + lock_credits, + block_info, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "Drive::estimate_address_funding_fee".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs new file mode 100644 index 00000000000..6a2727a86cf --- /dev/null +++ b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs @@ -0,0 +1,829 @@ +use crate::drive::asset_lock::asset_lock_storage_path; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; +use crate::state_transition_action::address_funds::address_funding_from_asset_lock::v0::AddressFundingFromAssetLockTransitionActionV0; +use crate::state_transition_action::address_funds::address_funding_from_asset_lock::AddressFundingFromAssetLockTransitionAction; +use crate::util::batch::drive_op_batch::finalize_task::DriveOperationFinalizationTasks; +use crate::util::batch::drive_op_batch::DriveLowLevelOperationConverter; +use crate::util::proof_depth::{single_key_proof_levels, SingleKeyProofLevels}; +use dpp::address_funds::PlatformAddress; +use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; +use dpp::asset_lock::StoredAssetLockInfo; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::fee::Credits; +use dpp::platform_value::Bytes36; +use dpp::state_transition::signable_bytes_hasher::SignableBytesHasher; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::EstimatedLevel; +use grovedb::{EstimatedLayerInformation, PathQuery}; +use std::collections::{BTreeMap, HashMap}; + +/// The outcome of a state-aware address funding fee estimation. +/// +/// `fee_result` prices the GroveDB batch only (the three drive operations of +/// a 0-input / 1-output funding); validation-operation fees and +/// `user_fee_increase` are added by the caller. +#[derive(Debug, Clone)] +pub struct AddressFundingFeeEstimate { + /// The estimated fee for the GroveDB batch. + pub fee_result: FeeResult, + /// Whether the recipient address already exists (the balance write is a + /// replace) or is new (the write is an insert). + pub address_exists: bool, + /// Measured search-path levels for the recipient address in the clear + /// address pool. + pub address_layer_levels: u8, + /// Measured search-path levels for the asset lock outpoint in the spent + /// asset lock transactions tree. + pub spent_asset_lock_layer_levels: u8, +} + +impl Drive { + /// Version 0 of the state-aware address funding fee estimation. + /// + /// See [`Drive::estimate_address_funding_fee`] for the contract. + pub(super) fn estimate_address_funding_fee_v0( + &self, + recipient: &PlatformAddress, + asset_lock_outpoint: Bytes36, + lock_credits: Credits, + block_info: &BlockInfo, + platform_version: &PlatformVersion, + ) -> Result { + let (low_level_operations, layer_map, address_levels, outpoint_levels) = self + .estimate_address_funding_fee_parts_v0( + recipient, + asset_lock_outpoint, + lock_credits, + block_info, + platform_version, + )?; + + let (grove_batch, mut cost_operations) = + LowLevelDriveOperation::grovedb_operations_batch_consume_with_leftovers( + low_level_operations, + ); + self.grove_batch_operations_costs( + grove_batch, + layer_map, + false, + &mut cost_operations, + &platform_version.drive, + )?; + let fee_result = Drive::calculate_fee( + None, + Some(cost_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + + Ok(AddressFundingFeeEstimate { + fee_result, + address_exists: address_levels.present, + address_layer_levels: address_levels.levels, + spent_asset_lock_layer_levels: outpoint_levels.levels, + }) + } + + /// Builds the production operations (stateful) and the layer-info map + /// (server models with measured counts) that + /// [`Drive::estimate_address_funding_fee_v0`] prices. + /// + /// Split out so tests can inspect the exact operations and layer map. + #[allow(clippy::type_complexity)] + fn estimate_address_funding_fee_parts_v0( + &self, + recipient: &PlatformAddress, + asset_lock_outpoint: Bytes36, + lock_credits: Credits, + block_info: &BlockInfo, + platform_version: &PlatformVersion, + ) -> Result< + ( + Vec, + HashMap, + SingleKeyProofLevels, + SingleKeyProofLevels, + ), + Error, + > { + // v0 scope: the estimate models a FRESH asset lock consumed in full + // (0 address inputs / 1 remainder output). An outpoint already in the + // state would execute through the partial-use path, which this + // estimator does not model — fail closed. + match self.fetch_asset_lock_outpoint_info( + &asset_lock_outpoint, + None, + &platform_version.drive, + )? { + StoredAssetLockInfo::NotPresent => {} + _ => { + return Err(Error::Drive(DriveError::AssetLockOutpointAlreadyPresent( + "address funding fee estimation requires a fresh (unspent) asset lock outpoint", + ))); + } + } + + // Measured search-path levels from locally generated proofs. GroveDB + // proving does not support transactions, so the whole estimate reads + // committed state. + let address_query = Drive::balance_for_clear_address_query(recipient); + let address_proof = self.grove_get_proved_path_query( + &address_query, + None, + &mut vec![], + &platform_version.drive, + )?; + let clear_addresses_path = Self::clear_addresses_path(); + let clear_addresses_segments: Vec<&[u8]> = clear_addresses_path + .iter() + .map(|segment| segment.as_slice()) + .collect(); + let address_levels = single_key_proof_levels( + &address_proof, + &clear_addresses_segments, + recipient.to_bytes().as_slice(), + )?; + + let mut outpoint_query = PathQuery::new_single_key( + vec![asset_lock_storage_path()[0].to_vec()], + asset_lock_outpoint.to_vec(), + ); + outpoint_query.query.limit = Some(1); + let outpoint_proof = self.grove_get_proved_path_query( + &outpoint_query, + None, + &mut vec![], + &platform_version.drive, + )?; + let outpoint_levels = single_key_proof_levels( + &outpoint_proof, + &[asset_lock_storage_path()[0]], + asset_lock_outpoint.as_slice(), + )?; + if outpoint_levels.present { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "asset lock outpoint reported absent by fetch but present in the local proof" + .to_string(), + ))); + } + + // The exact production operations: build the real action and run it + // through the production high-level converter, then convert to + // low-level operations STATEFULLY (estimated layer info = None) so + // the insert-vs-replace branch and the element bytes come from + // committed state, exactly as during apply=true execution. + let action = AddressFundingFromAssetLockTransitionAction::V0( + AddressFundingFromAssetLockTransitionActionV0 { + signable_bytes_hasher: SignableBytesHasher::Bytes(vec![]), + asset_lock_value_to_be_consumed: AssetLockValue::new( + lock_credits, + vec![], + lock_credits, + vec![], + platform_version, + )?, + asset_lock_outpoint, + inputs_with_remaining_balance: BTreeMap::new(), + outputs: BTreeMap::from([(*recipient, None)]), + input_contributions_total: 0, + fee_strategy: vec![], + user_fee_increase: 0, + }, + ); + let high_level_operations = + action.into_high_level_drive_operations(&block_info.epoch, platform_version)?; + + let mut stateful_marker: Option> = None; + let mut low_level_operations = vec![]; + for operation in high_level_operations { + if operation.finalization_tasks(platform_version)?.is_some() { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "address funding fee estimation encountered an operation with finalization tasks", + ))); + } + low_level_operations.append(&mut operation.into_low_level_drive_operations( + self, + &mut stateful_marker, + block_info, + None, + platform_version, + )?); + } + + // The server's own layer models, inserted in the same order the + // converters would insert them in the apply=false path, so entries + // shared between them (the root path) resolve to the same winner. + let mut layer_map: HashMap = HashMap::new(); + Self::add_estimation_costs_for_total_system_credits_update( + &mut layer_map, + &platform_version.drive, + )?; + Self::add_estimation_costs_for_adding_asset_lock(&mut layer_map, &platform_version.drive)?; + Self::add_estimation_costs_for_address_balance_update( + &mut layer_map, + &platform_version.drive, + )?; + // Replace ONLY the layer counts of the two data-dependent layers with + // the measured levels; tree types and element sizes stay the server's + // own models. + set_measured_layer_count( + &mut layer_map, + KeyInfoPath::from_known_path(asset_lock_storage_path()), + outpoint_levels.levels, + )?; + set_measured_layer_count( + &mut layer_map, + KeyInfoPath::from_known_owned_path(Self::clear_addresses_path()), + address_levels.levels, + )?; + + Ok(( + low_level_operations, + layer_map, + address_levels, + outpoint_levels, + )) + } +} + +fn set_measured_layer_count( + layer_map: &mut HashMap, + layer: KeyInfoPath, + levels: u8, +) -> Result<(), Error> { + let entry = + layer_map + .get_mut(&layer) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "estimated layer info is missing an expected layer", + )))?; + entry.estimated_layer_count = EstimatedLevel(levels as u32, false); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::batch::DriveOperation; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use grovedb::batch::key_info::KeyInfo::KnownKey; + use grovedb::batch::GroveOp; + use grovedb::Element; + + fn address(n: u8) -> PlatformAddress { + PlatformAddress::P2pkh([n; 20]) + } + + fn outpoint(n: u8) -> Bytes36 { + Bytes36([n; 36]) + } + + /// The production high-level operations for a 0-input / 1-output funding. + fn funding_operations<'a>( + recipient: &PlatformAddress, + asset_lock_outpoint: Bytes36, + lock_credits: Credits, + platform_version: &PlatformVersion, + ) -> Vec> { + let action = AddressFundingFromAssetLockTransitionAction::V0( + AddressFundingFromAssetLockTransitionActionV0 { + signable_bytes_hasher: SignableBytesHasher::Bytes(vec![]), + asset_lock_value_to_be_consumed: AssetLockValue::new( + lock_credits, + vec![], + lock_credits, + vec![], + platform_version, + ) + .expect("asset lock value"), + asset_lock_outpoint, + inputs_with_remaining_balance: BTreeMap::new(), + outputs: BTreeMap::from([(*recipient, None)]), + input_contributions_total: 0, + fee_strategy: vec![], + user_fee_increase: 0, + }, + ); + action + .into_high_level_drive_operations(&BlockInfo::default().epoch, platform_version) + .expect("high level operations") + } + + /// Applies a funding to committed state. + fn seed_funding( + drive: &Drive, + recipient: &PlatformAddress, + asset_lock_outpoint: Bytes36, + lock_credits: Credits, + platform_version: &PlatformVersion, + ) { + let operations = funding_operations( + recipient, + asset_lock_outpoint, + lock_credits, + platform_version, + ); + drive + .apply_drive_operations( + operations, + true, + &BlockInfo::default(), + None, + platform_version, + None, + ) + .expect("seed funding"); + } + + /// The real metered fee for a funding, measured inside a transaction that + /// is dropped afterwards, so committed state is untouched. + fn actual_fee_probe( + drive: &Drive, + recipient: &PlatformAddress, + asset_lock_outpoint: Bytes36, + lock_credits: Credits, + platform_version: &PlatformVersion, + ) -> FeeResult { + let operations = funding_operations( + recipient, + asset_lock_outpoint, + lock_credits, + platform_version, + ); + let transaction = drive.grove.start_transaction(); + let fee_result = drive + .apply_drive_operations( + operations, + true, + &BlockInfo::default(), + Some(&transaction), + platform_version, + None, + ) + .expect("actual fee probe"); + drop(transaction); + fee_result + } + + fn root_hash(drive: &Drive, platform_version: &PlatformVersion) -> [u8; 32] { + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash") + } + + const LOCK_CREDITS: Credits = 56_000_000; + + /// The estimate must not write anything: the grove root hash is + /// byte-identical before and after estimating for a new address, an + /// existing address, and a rejected present outpoint. + #[test] + fn test_estimate_is_read_only_root_hash_unchanged() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + seed_funding( + &drive, + &address(1), + outpoint(1), + LOCK_CREDITS, + platform_version, + ); + seed_funding( + &drive, + &address(2), + outpoint(2), + LOCK_CREDITS, + platform_version, + ); + + let before = root_hash(&drive, platform_version); + + drive + .estimate_address_funding_fee( + &address(200), + outpoint(200), + LOCK_CREDITS, + &BlockInfo::default(), + platform_version, + ) + .expect("estimate for new address"); + drive + .estimate_address_funding_fee( + &address(1), + outpoint(201), + LOCK_CREDITS, + &BlockInfo::default(), + platform_version, + ) + .expect("estimate for existing address"); + drive + .estimate_address_funding_fee( + &address(202), + outpoint(1), + LOCK_CREDITS, + &BlockInfo::default(), + platform_version, + ) + .expect_err("present outpoint must be rejected"); + + let after = root_hash(&drive, platform_version); + assert_eq!( + before, after, + "estimation must not change the grove root hash" + ); + } + + /// Stateful op-building picks the branch from committed state: an insert + /// (InsertOrReplace with a zero nonce) for a new address, and a replace + /// (with the summed balance and the existing nonce) for an existing one — + /// byte-identical to what apply=true execution would write. + #[test] + fn test_new_address_builds_insert_and_existing_builds_replace() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let existing = address(1); + seed_funding( + &drive, + &existing, + outpoint(1), + LOCK_CREDITS, + platform_version, + ); + + let clear_path = KeyInfoPath::from_known_owned_path(Drive::clear_addresses_path()); + + let balance_write_for = |recipient: &PlatformAddress, op_n: u8| { + let (low_level, _, _, _) = drive + .estimate_address_funding_fee_parts_v0( + recipient, + outpoint(op_n), + LOCK_CREDITS, + &BlockInfo::default(), + platform_version, + ) + .expect("estimate parts"); + low_level + .into_iter() + .find_map(|operation| match operation { + LowLevelDriveOperation::GroveOperation(op) + if op.path == clear_path + && op.key == Some(KnownKey(recipient.to_bytes())) => + { + Some(op.op) + } + _ => None, + }) + .expect("balance write for the recipient") + }; + + match balance_write_for(&address(200), 200) { + GroveOp::InsertOrReplace { + element: Element::ItemWithSumItem(nonce, sum, _), + } => { + assert_eq!(nonce, 0u32.to_be_bytes().to_vec(), "new address nonce"); + assert_eq!(sum, LOCK_CREDITS as i64, "new address balance"); + } + other => panic!("expected an insert for a new address, got {other:?}"), + } + + match balance_write_for(&existing, 201) { + GroveOp::Replace { + element: Element::ItemWithSumItem(_, sum, _), + } => { + assert_eq!( + sum, + (LOCK_CREDITS * 2) as i64, + "existing address balance must be summed from committed state" + ); + } + other => panic!("expected a replace for an existing address, got {other:?}"), + } + } + + /// The estimate brackets the real metered fee on the same state, for both + /// the insert (new address) and the replace (existing address) branch, and + /// orders them correctly: a replace carries no new storage bytes for the + /// balance element, so both its estimate and its actual fee are lower. + #[test] + fn test_estimated_fee_brackets_actual_for_new_and_existing_address() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + for n in 1..=8u8 { + seed_funding( + &drive, + &address(n), + outpoint(n), + LOCK_CREDITS, + platform_version, + ); + } + + // Observed samples on this test's state (protocol latest, 2026-08): + // new address 13_175_300 vs 13_044_880 (+1.0%), existing address + // 7_051_860 vs 6_802_640 (+3.7%). The band is regression headroom, + // not a bound claim. + let assert_brackets = |estimated: u64, actual: u64, what: &str| { + assert!( + estimated >= actual.saturating_mul(85) / 100 + && estimated <= actual.saturating_mul(115) / 100, + "{what}: estimated {estimated} not within [85%, 115%] of actual {actual}" + ); + }; + + let estimate_new = drive + .estimate_address_funding_fee( + &address(200), + outpoint(200), + LOCK_CREDITS, + &BlockInfo::default(), + platform_version, + ) + .expect("estimate new"); + let actual_new = actual_fee_probe( + &drive, + &address(200), + outpoint(200), + LOCK_CREDITS, + platform_version, + ); + assert!(!estimate_new.address_exists); + assert_brackets( + estimate_new.fee_result.total_base_fee(), + actual_new.total_base_fee(), + "new address", + ); + + let estimate_existing = drive + .estimate_address_funding_fee( + &address(1), + outpoint(201), + LOCK_CREDITS, + &BlockInfo::default(), + platform_version, + ) + .expect("estimate existing"); + let actual_existing = actual_fee_probe( + &drive, + &address(1), + outpoint(201), + LOCK_CREDITS, + platform_version, + ); + assert!(estimate_existing.address_exists); + assert_brackets( + estimate_existing.fee_result.total_base_fee(), + actual_existing.total_base_fee(), + "existing address", + ); + + assert!( + actual_existing.storage_fee < actual_new.storage_fee, + "replace must carry less storage fee than insert: {} vs {}", + actual_existing.storage_fee, + actual_new.storage_fee + ); + assert!( + estimate_existing.fee_result.storage_fee < estimate_new.fee_result.storage_fee, + "estimated storage fee must order the same way: {} vs {}", + estimate_existing.fee_result.storage_fee, + estimate_new.fee_result.storage_fee + ); + + println!( + "new address: estimated {} vs actual {} (storage {} vs {})", + estimate_new.fee_result.total_base_fee(), + actual_new.total_base_fee(), + estimate_new.fee_result.storage_fee, + actual_new.storage_fee, + ); + println!( + "existing address: estimated {} vs actual {} (storage {} vs {})", + estimate_existing.fee_result.total_base_fee(), + actual_existing.total_base_fee(), + estimate_existing.fee_result.storage_fee, + actual_existing.storage_fee, + ); + } + + /// v0 scope pin: the estimate models a FRESH asset lock. An outpoint that + /// is already in the spent-asset-lock tree — fully consumed (empty item) + /// or partially consumed (serialized remainder) — is rejected with + /// `AssetLockOutpointAlreadyPresent`, never silently priced. + #[test] + fn test_estimate_rejects_present_outpoint() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // Fully consumed: a committed funding stores an empty item. + seed_funding( + &drive, + &address(1), + outpoint(1), + LOCK_CREDITS, + platform_version, + ); + + // Partially consumed: a used asset lock with a non-zero remainder + // stores the serialized AssetLockValue. + use crate::util::batch::DriveOperation::SystemOperation; + use crate::util::batch::SystemOperationType; + drive + .apply_drive_operations( + vec![SystemOperation(SystemOperationType::AddUsedAssetLock { + asset_lock_outpoint: outpoint(2), + asset_lock_value: AssetLockValue::new( + LOCK_CREDITS, + vec![0xAB; 25], + LOCK_CREDITS / 2, + vec![], + platform_version, + ) + .expect("asset lock value"), + })], + true, + &BlockInfo::default(), + None, + platform_version, + None, + ) + .expect("seed partially consumed outpoint"); + + for present in [outpoint(1), outpoint(2)] { + let result = drive.estimate_address_funding_fee( + &address(200), + present, + LOCK_CREDITS, + &BlockInfo::default(), + platform_version, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::AssetLockOutpointAlreadyPresent(_))) + ), + "present outpoint must be rejected, got {result:?}" + ); + } + } + + /// The measured layer levels grow with tree population and the estimate + /// keeps bracketing the real metered fee as the trees deepen. + #[test] + fn test_estimate_tracks_actual_as_population_grows() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let mut seeded: u8 = 0; + let mut last_levels = 0u8; + for population in [0u8, 8, 40] { + while seeded < population { + seeded += 1; + seed_funding( + &drive, + &address(seeded), + outpoint(seeded), + LOCK_CREDITS, + platform_version, + ); + } + + let estimate = drive + .estimate_address_funding_fee( + &address(200), + outpoint(200), + LOCK_CREDITS, + &BlockInfo::default(), + platform_version, + ) + .expect("estimate"); + let actual = actual_fee_probe( + &drive, + &address(200), + outpoint(200), + LOCK_CREDITS, + platform_version, + ); + + // Observed samples (protocol latest, 2026-08): population 0 → + // 12_460_620 vs 12_551_520 (-0.7%), 8 → 13_175_300 vs 13_044_880 + // (+1.0%), 40 → 13_457_700 vs 13_330_880 (+1.0%). The band is + // regression headroom, not a bound claim. + let estimated = estimate.fee_result.total_base_fee(); + let actual_total = actual.total_base_fee(); + assert!( + estimated >= actual_total.saturating_mul(85) / 100 + && estimated <= actual_total.saturating_mul(115) / 100, + "population {population}: estimated {estimated} not within [85%, 115%] of actual {actual_total}" + ); + assert!( + estimate.address_layer_levels >= last_levels, + "measured levels must not shrink as the tree grows: {} then {}", + last_levels, + estimate.address_layer_levels + ); + last_levels = estimate.address_layer_levels; + + println!( + "population {population}: estimated {estimated} vs actual {actual_total}, \ + address levels {}, outpoint levels {}", + estimate.address_layer_levels, estimate.spent_asset_lock_layer_levels, + ); + } + } + + /// The layer map is the server's own model with ONLY the two + /// data-dependent layer counts replaced by measured levels: tree types, + /// element sizes, and every other layer stay byte-identical to the + /// server's `add_estimation_costs_*` output. + #[test] + fn test_layer_map_keeps_server_shapes_and_measured_counts() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + for n in 1..=4u8 { + seed_funding( + &drive, + &address(n), + outpoint(n), + LOCK_CREDITS, + platform_version, + ); + } + + let (_, layer_map, address_levels, outpoint_levels) = drive + .estimate_address_funding_fee_parts_v0( + &address(200), + outpoint(200), + LOCK_CREDITS, + &BlockInfo::default(), + platform_version, + ) + .expect("estimate parts"); + + let mut server_map: HashMap = HashMap::new(); + Drive::add_estimation_costs_for_total_system_credits_update( + &mut server_map, + &platform_version.drive, + ) + .expect("system credits estimation"); + Drive::add_estimation_costs_for_adding_asset_lock(&mut server_map, &platform_version.drive) + .expect("asset lock estimation"); + Drive::add_estimation_costs_for_address_balance_update( + &mut server_map, + &platform_version.drive, + ) + .expect("address balance estimation"); + + assert_eq!( + layer_map.len(), + server_map.len(), + "the engine must not add or drop layers" + ); + + let asset_lock_layer = KeyInfoPath::from_known_path(asset_lock_storage_path()); + let clear_addresses_layer = + KeyInfoPath::from_known_owned_path(Drive::clear_addresses_path()); + + for (layer, engine_info) in &layer_map { + let server_info = server_map.get(layer).expect("layer known to the server"); + let is_patched = *layer == asset_lock_layer || *layer == clear_addresses_layer; + if is_patched { + let measured = if *layer == asset_lock_layer { + outpoint_levels.levels + } else { + address_levels.levels + }; + assert_eq!( + engine_info.estimated_layer_count, + EstimatedLevel(measured as u32, false), + "patched layer must carry the measured level count" + ); + assert_eq!( + engine_info.tree_type, server_info.tree_type, + "patched layer must keep the server tree type" + ); + assert_eq!( + engine_info.estimated_layer_sizes, server_info.estimated_layer_sizes, + "patched layer must keep the server element sizes" + ); + } else { + assert_eq!( + engine_info, server_info, + "unpatched layer must stay byte-identical to the server model" + ); + } + } + } +} diff --git a/packages/rs-drive/src/drive/address_funds/mod.rs b/packages/rs-drive/src/drive/address_funds/mod.rs index 0e49497e280..548c23ea2e8 100644 --- a/packages/rs-drive/src/drive/address_funds/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/mod.rs @@ -1,5 +1,8 @@ #[cfg(feature = "server")] mod add_balance_to_address; +/// State-aware fee estimation for address funding from an asset lock. +#[cfg(feature = "server")] +pub mod estimate_funding_fee; /// Cost estimation for address balance operations. #[cfg(feature = "server")] mod estimated_costs; diff --git a/packages/rs-drive/src/error/drive.rs b/packages/rs-drive/src/error/drive.rs index 3412c9df1aa..e992025a5cc 100644 --- a/packages/rs-drive/src/error/drive.rs +++ b/packages/rs-drive/src/error/drive.rs @@ -215,6 +215,12 @@ pub enum DriveError { #[error("no checkpoints available")] NoCheckpointsAvailable, + /// The asset lock outpoint is already present in the state (fully or + /// partially consumed), so an operation that requires a fresh outpoint + /// cannot proceed + #[error("asset lock outpoint already present in state: {0}")] + AssetLockOutpointAlreadyPresent(&'static str), + /// Checkpoint not found for specified block height #[error("checkpoint not found for block height: {0}")] CheckpointNotFound(u64), diff --git a/packages/rs-drive/src/util/mod.rs b/packages/rs-drive/src/util/mod.rs index 4f15e99d0a6..f3fa4babaa2 100644 --- a/packages/rs-drive/src/util/mod.rs +++ b/packages/rs-drive/src/util/mod.rs @@ -7,6 +7,9 @@ pub mod grove_operations; /// Structures used by drive #[cfg(any(feature = "server", feature = "verify"))] pub mod object_size_info; +/// Merk search-path structure from locally generated GroveDB proofs +#[cfg(feature = "server")] +pub(crate) mod proof_depth; /// Common #[cfg(any(feature = "server", feature = "verify"))] diff --git a/packages/rs-drive/src/util/proof_depth.rs b/packages/rs-drive/src/util/proof_depth.rs new file mode 100644 index 00000000000..a8a815a8a42 --- /dev/null +++ b/packages/rs-drive/src/util/proof_depth.rs @@ -0,0 +1,207 @@ +//! Merk search-path structure extracted from locally generated GroveDB proofs. +//! +//! The node generates a proof for a single key with +//! [`Drive::grove_get_proved_path_query`](crate::drive::Drive) against its own +//! committed state and immediately decodes it here to learn the *structure* of +//! the terminal merk layer: how many levels the search path for that key +//! traverses, and whether the key is present. No cryptographic verification is +//! performed — the proof never leaves the node that produced it. +//! +//! The op-stream semantics mirror `merk::proofs::tree::execute`: +//! `Push`/`PushInverted` push a node, `Parent`/`ParentInverted` pop the parent +//! then the child, `Child`/`ChildInverted` pop the child then the parent; in +//! every case the child is attached one level below the parent. Only depths +//! matter here, so left/right orientation is ignored. + +use crate::error::drive::DriveError; +use crate::error::Error; +use grovedb::operations::proof::{GroveDBProof, LayerProof, ProofBytes}; +use grovedb::{MerkProofDecoder, MerkProofNode, MerkProofOp}; + +/// Structure information about a single key's search path inside one merk +/// layer, read from a locally generated proof. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SingleKeyProofLevels { + /// Whether the key is present in the tree. + pub present: bool, + /// The number of merk levels the operation on this key touches: for a + /// present key, the number of nodes on the root→key path (a replace); for + /// an absent key, the number of nodes on the root→boundary path plus one + /// (an insert hangs the new node below the deepest absence boundary). + pub levels: u8, +} + +/// The key-bearing nodes of a partially assembled proof subtree, recorded as +/// `(depth from the subtree root, is the target key)`. +struct SubtreeKeyedNodes { + keyed: Vec<(u8, bool)>, +} + +/// Decodes a locally generated GroveDB proof and returns the search-path +/// structure for `key` in the merk layer at `path`. +/// +/// `proof_bytes` must be the exact output of a single-key +/// `grove_get_proved_path_query` over `path`/`key` on this node — the decode +/// is canonical (trailing bytes rejected) and only `GroveDBProof::V1` +/// envelopes with a plain merk terminal layer are supported. +pub(crate) fn single_key_proof_levels( + proof_bytes: &[u8], + path: &[&[u8]], + key: &[u8], +) -> Result { + // The same bincode configuration the prover writes out. + let config = bincode::config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (proof, consumed): (GroveDBProof, usize) = bincode::decode_from_slice(proof_bytes, config) + .map_err(|e| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "unable to decode local grovedb proof: {e}" + ))) + })?; + if consumed != proof_bytes.len() { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "local grovedb proof has {} trailing bytes", + proof_bytes.len() - consumed + )))); + } + let GroveDBProof::V1(v1) = proof else { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "local grovedb proof is not a V1 envelope".to_string(), + ))); + }; + let mut layer: &LayerProof = &v1.root_layer; + for segment in path { + match layer.lower_layers.get(*segment) { + Some(lower_layer) => layer = lower_layer, + None => { + // The prover does not descend into an empty subtree: for a + // locally generated single-key proof over the queried path, a + // missing lower layer means the terminal tree is empty, so + // the operation lands at its root. + return Ok(SingleKeyProofLevels { + present: false, + levels: 1, + }); + } + } + } + let ProofBytes::Merk(merk_bytes) = &layer.merk_proof else { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "local grovedb proof terminal layer is not a merk proof".to_string(), + ))); + }; + merk_single_key_levels(merk_bytes, key) +} + +/// Runs the merk proof op stream through a depth-only reconstruction and +/// derives [`SingleKeyProofLevels`] for `key`. +fn merk_single_key_levels( + merk_proof_bytes: &[u8], + key: &[u8], +) -> Result { + let mut stack: Vec = Vec::new(); + for op in MerkProofDecoder::new(merk_proof_bytes) { + let op = op.map_err(|e| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "unable to decode local merk proof op: {e}" + ))) + })?; + match op { + MerkProofOp::Push(node) | MerkProofOp::PushInverted(node) => { + stack.push(subtree_from_node(node, key)?); + } + MerkProofOp::Parent | MerkProofOp::ParentInverted => { + let parent = pop_subtree(&mut stack)?; + let child = pop_subtree(&mut stack)?; + stack.push(attach_child(parent, child)?); + } + MerkProofOp::Child | MerkProofOp::ChildInverted => { + let child = pop_subtree(&mut stack)?; + let parent = pop_subtree(&mut stack)?; + stack.push(attach_child(parent, child)?); + } + } + } + + if stack.is_empty() { + // An empty tree: the operation lands at the root. + return Ok(SingleKeyProofLevels { + present: false, + levels: 1, + }); + } + if stack.len() != 1 { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "local merk proof op stream did not assemble into a single tree".to_string(), + ))); + } + let keyed = stack.pop().expect("checked non-empty above").keyed; + + if let Some((depth, _)) = keyed.iter().find(|(_, is_target)| *is_target) { + // Present: a replace touches every node on the root→key path. + Ok(SingleKeyProofLevels { + present: true, + levels: depth.saturating_add(1), + }) + } else { + // Absent: an insert hangs the new node below the deepest boundary. + let levels = match keyed.iter().map(|(depth, _)| *depth).max() { + Some(boundary_depth) => boundary_depth.saturating_add(2), + None => 1, + }; + Ok(SingleKeyProofLevels { + present: false, + levels, + }) + } +} + +fn pop_subtree(stack: &mut Vec) -> Result { + stack.pop().ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState( + "local merk proof op stream underflowed its stack".to_string(), + )) + }) +} + +fn attach_child( + mut parent: SubtreeKeyedNodes, + child: SubtreeKeyedNodes, +) -> Result { + for (depth, is_target) in child.keyed { + let bumped = depth.checked_add(1).ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState( + "local merk proof path depth overflowed".to_string(), + )) + })?; + parent.keyed.push((bumped, is_target)); + } + Ok(parent) +} + +fn subtree_from_node(node: MerkProofNode, target: &[u8]) -> Result { + let keyed = match node { + MerkProofNode::Hash(_) | MerkProofNode::KVHash(_) | MerkProofNode::KVHashCount(_, _) => { + vec![] + } + MerkProofNode::KV(key, _) + | MerkProofNode::KVValueHash(key, _, _) + | MerkProofNode::KVValueHashFeatureType(key, _, _, _) + | MerkProofNode::KVRefValueHash(key, _, _) + | MerkProofNode::KVCount(key, _, _) + | MerkProofNode::KVRefValueHashCount(key, _, _, _) + | MerkProofNode::KVValueHashFeatureTypeWithChildHash(key, _, _, _, _) => { + vec![(0, key.as_slice() == target)] + } + MerkProofNode::KVDigest(key, _) | MerkProofNode::KVDigestCount(key, _, _) => { + vec![(0, key.as_slice() == target)] + } + other => { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "unexpected node type in local single-key merk proof: {other:?}" + )))); + } + }; + Ok(SubtreeKeyedNodes { keyed }) +} diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_address_funds_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_address_funds_method_versions/v1.rs index 0400765118b..ffddb4975f9 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_address_funds_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_address_funds_method_versions/v1.rs @@ -15,6 +15,7 @@ pub const DRIVE_ADDRESS_FUNDS_METHOD_VERSIONS_V1: DriveAddressFundsMethodVersion prove_address_funds_branch_query: 0, address_funds_query_min_depth: 6, address_funds_query_max_depth: 9, + estimate_funding_fee: 0, cost_estimation: DriveAddressFundsCostEstimationMethodVersions { for_address_balance_update: 0, }, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_address_funds_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_address_funds_method_versions/v2.rs index 0c5c680b7bb..4d6c1b25e06 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_address_funds_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_address_funds_method_versions/v2.rs @@ -22,6 +22,7 @@ pub const DRIVE_ADDRESS_FUNDS_METHOD_VERSIONS_V2: DriveAddressFundsMethodVersion prove_address_funds_branch_query: 0, address_funds_query_min_depth: 6, address_funds_query_max_depth: 9, + estimate_funding_fee: 0, cost_estimation: DriveAddressFundsCostEstimationMethodVersions { for_address_balance_update: 1, }, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs index 5494feaba84..24e420b20bc 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs @@ -23,6 +23,7 @@ pub struct DriveAddressFundsMethodVersions { pub prove_address_funds_branch_query: FeatureVersion, pub address_funds_query_min_depth: u8, pub address_funds_query_max_depth: u8, + pub estimate_funding_fee: FeatureVersion, pub cost_estimation: DriveAddressFundsCostEstimationMethodVersions, } From 0d6fdc685e4d1f57fa24ad0fc6c2b3a4d28a47db Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sat, 22 Aug 2026 11:52:23 +0200 Subject: [PATCH 2/5] fix(drive): decode V0 proof envelopes and take coherent snapshot reads in fee estimation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the address funding fee estimation engine: - Support the legacy GroveDBProof::V0 (MerkOnlyLayerProof) envelope in the proof-depth decoder. Protocol v11 selects DRIVE_VERSION_V6, which pins GROVE_V2 whose prove path emits V0 — previously the first local proof failed every v11 estimate with "not a V1 envelope". A protocol v11 regression test asserts the envelope really is V0 (so the test keeps exercising the fixed path) and that the estimate still brackets the real apply=true fee under that version. - Coherent committed-state reads: the estimation's several reads (outpoint fetch, two proofs, the stateful conversion) now count only when the grove root hash is byte-identical before and after all of them, retrying up to three times; persistent instability returns the new retriable DriveError::CommittedStateChangedDuringOperation instead of mixed-root results. The fetch-absent/proof-present race is reclassified from CorruptedDriveState to AssetLockOutpointAlreadyPresent — a benign concurrent commit is what the second read actually observed, not corruption. - Exact-depth coverage for the merk op-stream reconstruction, both synthetic and real: direct op-stream tests pin the Parent/Child pop order, the absent-key "+2" rule, the empty stream, stack underflow and leftover-subtree errors with exact SingleKeyProofLevels values; generated-proof tests walk known rotation-free AVL shapes (1, 3 and 7 nodes) asserting exact levels for present keys on both sides at every depth and for absences below the minimum, in every interior gap, and above the maximum. Co-Authored-By: Claude Fable 5 --- .../estimate_funding_fee/v0/mod.rs | 105 +++++- packages/rs-drive/src/error/drive.rs | 5 + packages/rs-drive/src/util/proof_depth.rs | 317 ++++++++++++++++-- 3 files changed, 394 insertions(+), 33 deletions(-) diff --git a/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs index 6a2727a86cf..496372d71e2 100644 --- a/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs @@ -55,14 +55,42 @@ impl Drive { block_info: &BlockInfo, platform_version: &PlatformVersion, ) -> Result { - let (low_level_operations, layer_map, address_levels, outpoint_levels) = self - .estimate_address_funding_fee_parts_v0( + // The estimation reads committed state several times (outpoint + // fetch, two proofs, the stateful conversion) with no transaction. A + // block committing in between could make those reads describe + // different roots — the API promises pricing from one coherent + // committed state, so accept an attempt only when the root hash is + // byte-identical before and after all the reads, retrying otherwise. + const SNAPSHOT_ATTEMPTS: usize = 3; + let mut parts = None; + for _ in 0..SNAPSHOT_ATTEMPTS { + let root_before = self + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .map_err(Error::from)?; + let attempt = self.estimate_address_funding_fee_parts_v0( recipient, asset_lock_outpoint, lock_credits, block_info, platform_version, )?; + let root_after = self + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .map_err(Error::from)?; + if root_before == root_after { + parts = Some(attempt); + break; + } + } + let Some((low_level_operations, layer_map, address_levels, outpoint_levels)) = parts else { + return Err(Error::Drive(DriveError::CommittedStateChangedDuringOperation( + "address funding fee estimation could not observe a stable committed state; retry", + ))); + }; let (grove_batch, mut cost_operations) = LowLevelDriveOperation::grovedb_operations_batch_consume_with_leftovers( @@ -169,9 +197,12 @@ impl Drive { asset_lock_outpoint.as_slice(), )?; if outpoint_levels.present { - return Err(Error::Drive(DriveError::CorruptedDriveState( - "asset lock outpoint reported absent by fetch but present in the local proof" - .to_string(), + // Not corruption: a block can commit between the fetch and the + // proof, so the outpoint can legitimately appear in between. + // Fail closed with what the second read actually observed. + return Err(Error::Drive(DriveError::AssetLockOutpointAlreadyPresent( + "the asset lock outpoint appeared in the state while the estimate was being \ + computed", ))); } @@ -743,6 +774,70 @@ mod tests { } } + /// Protocol v11 regression: DRIVE_VERSION_V6 pins GROVE_V2, whose prove + /// path emits the legacy `GroveDBProof::V0` envelope — the depth decoder + /// must accept it, and the estimate must still bracket the real metered + /// fee under that protocol version. + #[test] + fn test_estimate_works_under_protocol_v11_v0_proof_envelope() { + let platform_version = PlatformVersion::get(11).expect("protocol v11"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + + for n in 1..=4u8 { + seed_funding( + &drive, + &address(n), + outpoint(n), + LOCK_CREDITS, + platform_version, + ); + } + + // Sanity for the regression itself: v11 must actually produce the + // legacy V0 envelope, otherwise this test would not be exercising + // the V0 decoding path. + let probe_query = Drive::balance_for_clear_address_query(&address(200)); + let probe_proof = drive + .grove_get_proved_path_query(&probe_query, None, &mut vec![], &platform_version.drive) + .expect("prove under v11"); + let config = bincode::config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (envelope, _): (grovedb::operations::proof::GroveDBProof, usize) = + bincode::decode_from_slice(&probe_proof, config).expect("decode proof envelope"); + assert!( + matches!(envelope, grovedb::operations::proof::GroveDBProof::V0(_)), + "protocol v11 is expected to produce the legacy V0 proof envelope" + ); + + let estimate = drive + .estimate_address_funding_fee( + &address(200), + outpoint(200), + LOCK_CREDITS, + &BlockInfo::default(), + platform_version, + ) + .expect("the estimate must decode the V0 proof envelope under protocol v11"); + let actual = actual_fee_probe( + &drive, + &address(200), + outpoint(200), + LOCK_CREDITS, + platform_version, + ); + + let estimated = estimate.fee_result.total_base_fee(); + let actual_total = actual.total_base_fee(); + assert!( + estimated >= actual_total.saturating_mul(85) / 100 + && estimated <= actual_total.saturating_mul(115) / 100, + "protocol v11: estimated {estimated} not within [85%, 115%] of actual {actual_total}" + ); + assert!(!estimate.address_exists); + assert!(estimate.address_layer_levels >= 1); + } + /// The layer map is the server's own model with ONLY the two /// data-dependent layer counts replaced by measured levels: tree types, /// element sizes, and every other layer stay byte-identical to the diff --git a/packages/rs-drive/src/error/drive.rs b/packages/rs-drive/src/error/drive.rs index e992025a5cc..8d63cb4561f 100644 --- a/packages/rs-drive/src/error/drive.rs +++ b/packages/rs-drive/src/error/drive.rs @@ -221,6 +221,11 @@ pub enum DriveError { #[error("asset lock outpoint already present in state: {0}")] AssetLockOutpointAlreadyPresent(&'static str), + /// Committed state kept changing underneath a read-only operation that + /// needs several reads of one coherent snapshot; the caller should retry + #[error("committed state changed during a multi-read operation: {0}")] + CommittedStateChangedDuringOperation(&'static str), + /// Checkpoint not found for specified block height #[error("checkpoint not found for block height: {0}")] CheckpointNotFound(u64), diff --git a/packages/rs-drive/src/util/proof_depth.rs b/packages/rs-drive/src/util/proof_depth.rs index a8a815a8a42..fccf0f72411 100644 --- a/packages/rs-drive/src/util/proof_depth.rs +++ b/packages/rs-drive/src/util/proof_depth.rs @@ -15,7 +15,7 @@ use crate::error::drive::DriveError; use crate::error::Error; -use grovedb::operations::proof::{GroveDBProof, LayerProof, ProofBytes}; +use grovedb::operations::proof::{GroveDBProof, LayerProof, MerkOnlyLayerProof, ProofBytes}; use grovedb::{MerkProofDecoder, MerkProofNode, MerkProofOp}; /// Structure information about a single key's search path inside one merk @@ -65,48 +65,72 @@ pub(crate) fn single_key_proof_levels( proof_bytes.len() - consumed )))); } - let GroveDBProof::V1(v1) = proof else { - return Err(Error::Drive(DriveError::CorruptedDriveState( - "local grovedb proof is not a V1 envelope".to_string(), - ))); + // A missing lower layer in either envelope means the prover did not + // descend into an empty subtree: for a locally generated single-key + // proof over the queried path, the terminal tree is empty and the + // operation lands at its root. + let empty_terminal = SingleKeyProofLevels { + present: false, + levels: 1, }; - let mut layer: &LayerProof = &v1.root_layer; - for segment in path { - match layer.lower_layers.get(*segment) { - Some(lower_layer) => layer = lower_layer, - None => { - // The prover does not descend into an empty subtree: for a - // locally generated single-key proof over the queried path, a - // missing lower layer means the terminal tree is empty, so - // the operation lands at its root. - return Ok(SingleKeyProofLevels { - present: false, - levels: 1, - }); + let merk_bytes: &[u8] = match &proof { + // Current envelope (GROVE_V3+, protocol v12+). + GroveDBProof::V1(v1) => { + let mut layer: &LayerProof = &v1.root_layer; + for segment in path { + match layer.lower_layers.get(*segment) { + Some(lower_layer) => layer = lower_layer, + None => return Ok(empty_terminal), + } } + let ProofBytes::Merk(bytes) = &layer.merk_proof else { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "local grovedb proof terminal layer is not a merk proof".to_string(), + ))); + }; + bytes + } + // Legacy merk-only envelope, still produced by GROVE_V2's prove + // path — selected by protocol v11 through DRIVE_VERSION_V6. + GroveDBProof::V0(v0) => { + let mut layer: &MerkOnlyLayerProof = &v0.root_layer; + for segment in path { + match layer.lower_layers.get(*segment) { + Some(lower_layer) => layer = lower_layer, + None => return Ok(empty_terminal), + } + } + &layer.merk_proof } - } - let ProofBytes::Merk(merk_bytes) = &layer.merk_proof else { - return Err(Error::Drive(DriveError::CorruptedDriveState( - "local grovedb proof terminal layer is not a merk proof".to_string(), - ))); }; merk_single_key_levels(merk_bytes, key) } -/// Runs the merk proof op stream through a depth-only reconstruction and -/// derives [`SingleKeyProofLevels`] for `key`. +/// Decodes a merk proof byte stream and derives [`SingleKeyProofLevels`] +/// for `key`. fn merk_single_key_levels( merk_proof_bytes: &[u8], key: &[u8], ) -> Result { - let mut stack: Vec = Vec::new(); + let mut ops = Vec::new(); for op in MerkProofDecoder::new(merk_proof_bytes) { - let op = op.map_err(|e| { + ops.push(op.map_err(|e| { Error::Drive(DriveError::CorruptedDriveState(format!( "unable to decode local merk proof op: {e}" ))) - })?; + })?); + } + single_key_levels_from_ops(ops, key) +} + +/// Runs an already-decoded merk proof op stream through a depth-only +/// reconstruction and derives [`SingleKeyProofLevels`] for `key`. +fn single_key_levels_from_ops( + ops: impl IntoIterator, + key: &[u8], +) -> Result { + let mut stack: Vec = Vec::new(); + for op in ops { match op { MerkProofOp::Push(node) | MerkProofOp::PushInverted(node) => { stack.push(subtree_from_node(node, key)?); @@ -180,6 +204,243 @@ fn attach_child( Ok(parent) } +#[cfg(test)] +mod tests { + use super::*; + use crate::drive::Drive; + use crate::util::batch::drive_op_batch::AddressFundsOperationType; + use crate::util::batch::DriveOperation; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::address_funds::PlatformAddress; + use dpp::block::block_info::BlockInfo; + use dpp::version::PlatformVersion; + + // --------------------------------------------------------------- + // Synthetic op streams: pin the two subtle rules of the + // reconstruction — the Parent/Child pop order and the absent-key + // "+2" rule — plus the failure modes, with exact values. + // --------------------------------------------------------------- + + fn kv(key: &[u8]) -> MerkProofNode { + MerkProofNode::KV(key.to_vec(), vec![0xAA]) + } + + #[test] + fn test_empty_op_stream_is_an_empty_tree() { + let levels = single_key_levels_from_ops([], b"x").expect("empty stream"); + assert_eq!( + levels, + SingleKeyProofLevels { + present: false, + levels: 1 + } + ); + } + + #[test] + fn test_single_leaf_present_and_absent() { + let present = + single_key_levels_from_ops([MerkProofOp::Push(kv(b"a"))], b"a").expect("present"); + assert_eq!( + present, + SingleKeyProofLevels { + present: true, + levels: 1 + } + ); + + // The absent key hangs below the single boundary node at depth 0. + let absent = + single_key_levels_from_ops([MerkProofOp::Push(kv(b"a"))], b"x").expect("absent"); + assert_eq!( + absent, + SingleKeyProofLevels { + present: false, + levels: 2 + } + ); + } + + #[test] + fn test_parent_attaches_the_previously_pushed_child() { + // Push(child a), Push(parent), Parent: pops the parent first, then + // the child — a must land one level below the KVHash root. + let ops = [ + MerkProofOp::Push(kv(b"a")), + MerkProofOp::Push(MerkProofNode::KVHash([0u8; 32])), + MerkProofOp::Parent, + MerkProofOp::Push(MerkProofNode::Hash([1u8; 32])), + MerkProofOp::Child, + ]; + let levels = single_key_levels_from_ops(ops, b"a").expect("present"); + assert_eq!( + levels, + SingleKeyProofLevels { + present: true, + levels: 2 + } + ); + } + + #[test] + fn test_child_attaches_the_top_of_stack_below_the_parent() { + // Push(parent), Push(child b), Child: pops the child first — b must + // land one level below the KVHash root. + let ops = [ + MerkProofOp::Push(MerkProofNode::KVHash([0u8; 32])), + MerkProofOp::Push(kv(b"b")), + MerkProofOp::Child, + ]; + let levels = single_key_levels_from_ops(ops, b"b").expect("present"); + assert_eq!( + levels, + SingleKeyProofLevels { + present: true, + levels: 2 + } + ); + } + + #[test] + fn test_absence_boundary_via_digest_uses_the_plus_two_rule() { + // Root is a keyless KVHash; the only key-bearing node is a KVDigest + // boundary at depth 1 — an absent key hangs below it: 1 + 2 = 3. + let ops = [ + MerkProofOp::Push(MerkProofNode::KVDigest(b"a".to_vec(), [2u8; 32])), + MerkProofOp::Push(MerkProofNode::KVHash([0u8; 32])), + MerkProofOp::Parent, + ]; + let levels = single_key_levels_from_ops(ops, b"x").expect("absent"); + assert_eq!( + levels, + SingleKeyProofLevels { + present: false, + levels: 3 + } + ); + } + + #[test] + fn test_stack_underflow_and_leftover_subtrees_are_errors() { + for underflow in [ + vec![MerkProofOp::Parent], + vec![MerkProofOp::Child], + vec![MerkProofOp::Push(kv(b"a")), MerkProofOp::Parent], + ] { + assert!( + single_key_levels_from_ops(underflow.clone(), b"a").is_err(), + "stack underflow must be rejected: {underflow:?}" + ); + } + + let leftover = [MerkProofOp::Push(kv(b"a")), MerkProofOp::Push(kv(b"b"))]; + assert!( + single_key_levels_from_ops(leftover, b"a").is_err(), + "an op stream leaving two subtrees must be rejected" + ); + } + + // --------------------------------------------------------------- + // Real generated proofs over known AVL shapes: keys are inserted + // one batch at a time in an order that never triggers a rotation, + // so every depth is derivable by hand. Levels are exact, covering + // present keys on both sides at several depths and absences below + // the minimum, in interior gaps, and above the maximum. + // --------------------------------------------------------------- + + fn address(n: u8) -> PlatformAddress { + PlatformAddress::P2pkh([n; 20]) + } + + fn seed_balance(drive: &Drive, n: u8, platform_version: &PlatformVersion) { + drive + .apply_drive_operations( + vec![DriveOperation::AddressFundsOperation( + AddressFundsOperationType::SetBalanceToAddress { + address: address(n), + nonce: 0, + balance: 1_000, + }, + )], + true, + &BlockInfo::default(), + None, + platform_version, + None, + ) + .expect("seed balance"); + } + + fn levels_for( + drive: &Drive, + n: u8, + platform_version: &PlatformVersion, + ) -> SingleKeyProofLevels { + let queried = address(n); + let query = Drive::balance_for_clear_address_query(&queried); + let proof = drive + .grove_get_proved_path_query(&query, None, &mut vec![], &platform_version.drive) + .expect("prove"); + let path = Drive::clear_addresses_path(); + let segments: Vec<&[u8]> = path.iter().map(|segment| segment.as_slice()).collect(); + single_key_proof_levels(&proof, &segments, queried.to_bytes().as_slice()) + .expect("decode levels") + } + + #[test] + fn test_exact_levels_on_known_avl_shapes() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + + let expect = |n: u8, present: bool, levels: u8, what: &str| { + let decoded = levels_for(&drive, n, platform_version); + assert_eq!( + decoded, + SingleKeyProofLevels { present, levels }, + "{what}: key {n}" + ); + }; + + // Empty tree: any key lands at the root. + expect(40, false, 1, "empty tree"); + + // {40}: a single root. + seed_balance(&drive, 40, platform_version); + expect(40, true, 1, "single node, present root"); + expect(10, false, 2, "single node, absent below"); + expect(70, false, 2, "single node, absent above"); + + // {20, 40, 60}: inserting 20 then 60 hangs them under 40 with no + // rotation — root 40 at level 1, both children at level 2. + seed_balance(&drive, 20, platform_version); + seed_balance(&drive, 60, platform_version); + expect(40, true, 1, "three nodes, root"); + expect(20, true, 2, "three nodes, left child"); + expect(60, true, 2, "three nodes, right child"); + expect(10, false, 3, "three nodes, absent below min"); + expect(30, false, 3, "three nodes, absent in left gap"); + expect(50, false, 3, "three nodes, absent in right gap"); + expect(70, false, 3, "three nodes, absent above max"); + + // {10..70}: the four leaves slot under 20 and 60 with no rotation, + // giving the complete three-level tree. + for n in [10u8, 30, 50, 70] { + seed_balance(&drive, n, platform_version); + } + expect(40, true, 1, "seven nodes, root"); + expect(20, true, 2, "seven nodes, left inner"); + expect(60, true, 2, "seven nodes, right inner"); + for n in [10u8, 30, 50, 70] { + expect(n, true, 3, "seven nodes, leaf"); + } + expect(5, false, 4, "seven nodes, absent below min"); + for n in [15u8, 25, 35, 45, 55, 65] { + expect(n, false, 4, "seven nodes, absent in interior gap"); + } + expect(75, false, 4, "seven nodes, absent above max"); + } +} + fn subtree_from_node(node: MerkProofNode, target: &[u8]) -> Result { let keyed = match node { MerkProofNode::Hash(_) | MerkProofNode::KVHash(_) | MerkProofNode::KVHashCount(_, _) => { From 4e5392dab2db4c3bc06fa567b0bf0b183fa47fa8 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sat, 22 Aug 2026 11:59:31 +0200 Subject: [PATCH 3/5] style(drive): keep the proof-depth test module last in the file clippy 1.92 with --all-targets -D warnings rejects items after a test module (clippy::items_after_test_module); the helpers the tests exercise now precede the module. Co-Authored-By: Claude Fable 5 --- packages/rs-drive/src/util/proof_depth.rs | 52 +++++++++++------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/rs-drive/src/util/proof_depth.rs b/packages/rs-drive/src/util/proof_depth.rs index fccf0f72411..1f34d3d9182 100644 --- a/packages/rs-drive/src/util/proof_depth.rs +++ b/packages/rs-drive/src/util/proof_depth.rs @@ -204,6 +204,32 @@ fn attach_child( Ok(parent) } +fn subtree_from_node(node: MerkProofNode, target: &[u8]) -> Result { + let keyed = match node { + MerkProofNode::Hash(_) | MerkProofNode::KVHash(_) | MerkProofNode::KVHashCount(_, _) => { + vec![] + } + MerkProofNode::KV(key, _) + | MerkProofNode::KVValueHash(key, _, _) + | MerkProofNode::KVValueHashFeatureType(key, _, _, _) + | MerkProofNode::KVRefValueHash(key, _, _) + | MerkProofNode::KVCount(key, _, _) + | MerkProofNode::KVRefValueHashCount(key, _, _, _) + | MerkProofNode::KVValueHashFeatureTypeWithChildHash(key, _, _, _, _) => { + vec![(0, key.as_slice() == target)] + } + MerkProofNode::KVDigest(key, _) | MerkProofNode::KVDigestCount(key, _, _) => { + vec![(0, key.as_slice() == target)] + } + other => { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "unexpected node type in local single-key merk proof: {other:?}" + )))); + } + }; + Ok(SubtreeKeyedNodes { keyed }) +} + #[cfg(test)] mod tests { use super::*; @@ -440,29 +466,3 @@ mod tests { expect(75, false, 4, "seven nodes, absent above max"); } } - -fn subtree_from_node(node: MerkProofNode, target: &[u8]) -> Result { - let keyed = match node { - MerkProofNode::Hash(_) | MerkProofNode::KVHash(_) | MerkProofNode::KVHashCount(_, _) => { - vec![] - } - MerkProofNode::KV(key, _) - | MerkProofNode::KVValueHash(key, _, _) - | MerkProofNode::KVValueHashFeatureType(key, _, _, _) - | MerkProofNode::KVRefValueHash(key, _, _) - | MerkProofNode::KVCount(key, _, _) - | MerkProofNode::KVRefValueHashCount(key, _, _, _) - | MerkProofNode::KVValueHashFeatureTypeWithChildHash(key, _, _, _, _) => { - vec![(0, key.as_slice() == target)] - } - MerkProofNode::KVDigest(key, _) | MerkProofNode::KVDigestCount(key, _, _) => { - vec![(0, key.as_slice() == target)] - } - other => { - return Err(Error::Drive(DriveError::CorruptedDriveState(format!( - "unexpected node type in local single-key merk proof: {other:?}" - )))); - } - }; - Ok(SubtreeKeyedNodes { keyed }) -} From 8ff52d40b0761051e0381b3fad1f83fc0e5c4cc9 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sat, 22 Aug 2026 13:58:20 +0200 Subject: [PATCH 4/5] docs(drive): correct stale comments on proof envelopes and estimation reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two comments contradicted the implementation after the review fixes: the proof-depth decoder's contract still said only GroveDBProof::V1 is supported (it now handles the legacy V0 envelope protocol v11 produces), and the estimator claimed GroveDB proving does not support transactions — the intended statement is the actual design: committed-state reads guarded by the outer root-hash stability loop. Co-Authored-By: Claude Fable 5 --- .../drive/address_funds/estimate_funding_fee/mod.rs | 11 +++++++---- .../address_funds/estimate_funding_fee/v0/mod.rs | 7 ++++--- packages/rs-drive/src/util/proof_depth.rs | 5 +++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/mod.rs b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/mod.rs index 5dfdce313fd..68b076882b5 100644 --- a/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/mod.rs @@ -22,10 +22,13 @@ impl Drive { /// layer counts are replaced by search-path levels measured from locally /// generated proofs. /// - /// Read-only: no transaction is opened and nothing is written. The whole - /// estimate reads committed state (GroveDB proving does not support - /// transactions). The result covers the GroveDB batch only — validation - /// operations and `user_fee_increase` are the caller's concern. + /// Read-only: nothing is written. The whole estimate reads committed + /// state, and the reads count only when the GroveDB root hash is + /// identical before and after the complete state-dependent operation + /// (retried a few times, then failing with + /// [`DriveError::CommittedStateChangedDuringOperation`]). The result + /// covers the GroveDB batch only — validation operations and + /// `user_fee_increase` are the caller's concern. /// /// Fails with [`DriveError::AssetLockOutpointAlreadyPresent`] when the /// outpoint is already in the state: a spent or partially used lock would diff --git a/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs index 496372d71e2..87fd64c24c8 100644 --- a/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs @@ -159,9 +159,10 @@ impl Drive { } } - // Measured search-path levels from locally generated proofs. GroveDB - // proving does not support transactions, so the whole estimate reads - // committed state. + // Measure search-path levels from locally generated proofs against + // committed state. The outer estimator accepts these reads only when + // the GroveDB root hash is identical before and after the complete + // state-dependent operation. let address_query = Drive::balance_for_clear_address_query(recipient); let address_proof = self.grove_get_proved_path_query( &address_query, diff --git a/packages/rs-drive/src/util/proof_depth.rs b/packages/rs-drive/src/util/proof_depth.rs index 1f34d3d9182..8dcf15c1610 100644 --- a/packages/rs-drive/src/util/proof_depth.rs +++ b/packages/rs-drive/src/util/proof_depth.rs @@ -42,8 +42,9 @@ struct SubtreeKeyedNodes { /// /// `proof_bytes` must be the exact output of a single-key /// `grove_get_proved_path_query` over `path`/`key` on this node — the decode -/// is canonical (trailing bytes rejected) and only `GroveDBProof::V1` -/// envelopes with a plain merk terminal layer are supported. +/// is canonical (trailing bytes rejected); both legacy `GroveDBProof::V0` and +/// current `GroveDBProof::V1` envelopes with a plain merk terminal layer are +/// supported (protocol v11 selects GROVE_V2, whose prove path still emits V0). pub(crate) fn single_key_proof_levels( proof_bytes: &[u8], path: &[&[u8]], From 5d4e56344272179d3c172fbf310e7ec193796c77 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sat, 22 Aug 2026 16:46:59 +0200 Subject: [PATCH 5/5] test(drive): pin the committed-root stability retry contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the estimation's root-before/root-after loop into stable_committed_read(root_sample, attempt) — behavior unchanged — and pin its contract deterministically with scripted root samples: a stable first attempt is returned without a re-run, an unstable attempt's value is discarded in favor of a later stable one, three unstable attempts fail with the retriable CommittedStateChangedDuringOperation, and an attempt error propagates immediately without a retry. The drive-backed tests only ever exercise the quiescent first-attempt branch. Co-Authored-By: Claude Fable 5 --- .../estimate_funding_fee/v0/mod.rs | 165 +++++++++++++++--- 1 file changed, 136 insertions(+), 29 deletions(-) diff --git a/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs index 87fd64c24c8..0263663b132 100644 --- a/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs +++ b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs @@ -61,36 +61,24 @@ impl Drive { // different roots — the API promises pricing from one coherent // committed state, so accept an attempt only when the root hash is // byte-identical before and after all the reads, retrying otherwise. - const SNAPSHOT_ATTEMPTS: usize = 3; - let mut parts = None; - for _ in 0..SNAPSHOT_ATTEMPTS { - let root_before = self - .grove - .root_hash(None, &platform_version.drive.grove_version) - .unwrap() - .map_err(Error::from)?; - let attempt = self.estimate_address_funding_fee_parts_v0( - recipient, - asset_lock_outpoint, - lock_credits, - block_info, - platform_version, + let (low_level_operations, layer_map, address_levels, outpoint_levels) = + stable_committed_read( + || { + self.grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .map_err(Error::from) + }, + || { + self.estimate_address_funding_fee_parts_v0( + recipient, + asset_lock_outpoint, + lock_credits, + block_info, + platform_version, + ) + }, )?; - let root_after = self - .grove - .root_hash(None, &platform_version.drive.grove_version) - .unwrap() - .map_err(Error::from)?; - if root_before == root_after { - parts = Some(attempt); - break; - } - } - let Some((low_level_operations, layer_map, address_levels, outpoint_levels)) = parts else { - return Err(Error::Drive(DriveError::CommittedStateChangedDuringOperation( - "address funding fee estimation could not observe a stable committed state; retry", - ))); - }; let (grove_batch, mut cost_operations) = LowLevelDriveOperation::grovedb_operations_batch_consume_with_leftovers( @@ -286,6 +274,35 @@ impl Drive { } } +/// How many times a multi-read committed-state operation may observe an +/// unstable root before giving up. +const SNAPSHOT_ATTEMPTS: usize = 3; + +/// Runs `attempt` and accepts its output only when `root_sample` returns the +/// same value before and after it — i.e. no block committed underneath the +/// attempt's reads. An unstable attempt's output is discarded and the attempt +/// re-run, up to [`SNAPSHOT_ATTEMPTS`] times; persistent instability fails +/// with the retriable [`DriveError::CommittedStateChangedDuringOperation`]. +/// Errors from either closure propagate immediately, without a retry. +fn stable_committed_read( + mut root_sample: impl FnMut() -> Result<[u8; 32], Error>, + mut attempt: impl FnMut() -> Result, +) -> Result { + for _ in 0..SNAPSHOT_ATTEMPTS { + let root_before = root_sample()?; + let value = attempt()?; + let root_after = root_sample()?; + if root_before == root_after { + return Ok(value); + } + } + Err(Error::Drive( + DriveError::CommittedStateChangedDuringOperation( + "address funding fee estimation could not observe a stable committed state; retry", + ), + )) +} + fn set_measured_layer_count( layer_map: &mut HashMap, layer: KeyInfoPath, @@ -415,6 +432,96 @@ mod tests { const LOCK_CREDITS: Credits = 56_000_000; + // --------------------------------------------------------------- + // The committed-root stability contract, pinned deterministically + // with scripted root samples — the drive-backed tests below only + // ever exercise the quiescent first-attempt branch. + // --------------------------------------------------------------- + + /// A scripted root sampler: returns the next hash from the list on each + /// call, counting attempts as pairs of samples. + fn scripted_roots(samples: Vec<[u8; 32]>) -> impl FnMut() -> Result<[u8; 32], Error> { + let mut remaining = samples.into_iter(); + move || Ok(remaining.next().expect("script exhausted")) + } + + #[test] + fn test_stable_read_returns_the_first_stable_attempt() { + let mut attempts = 0u32; + let value = stable_committed_read(scripted_roots(vec![[1; 32], [1; 32]]), || { + attempts += 1; + Ok(attempts) + }) + .expect("stable first attempt"); + assert_eq!(value, 1); + assert_eq!(attempts, 1, "a stable attempt must not be re-run"); + } + + #[test] + fn test_stable_read_discards_an_unstable_attempt_and_returns_a_later_stable_one() { + // Attempt 1 sees roots 1→2 (unstable), attempt 2 sees 2→2 (stable). + let mut attempts = 0u32; + let value = stable_committed_read( + scripted_roots(vec![[1; 32], [2; 32], [2; 32], [2; 32]]), + || { + attempts += 1; + Ok(attempts) + }, + ) + .expect("second attempt is stable"); + assert_eq!( + value, 2, + "the unstable attempt's value must be discarded, not returned" + ); + assert_eq!(attempts, 2); + } + + #[test] + fn test_stable_read_fails_after_three_unstable_attempts() { + let mut next_root = 0u8; + let mut attempts = 0u32; + let result = stable_committed_read( + || { + next_root += 1; + Ok([next_root; 32]) + }, + || { + attempts += 1; + Ok(attempts) + }, + ); + assert!( + matches!( + result, + Err(Error::Drive( + DriveError::CommittedStateChangedDuringOperation(_) + )) + ), + "persistent instability must fail with the retriable error, got {result:?}" + ); + assert_eq!(attempts, 3, "exactly SNAPSHOT_ATTEMPTS attempts must run"); + } + + #[test] + fn test_stable_read_propagates_attempt_errors_without_retry() { + let mut attempts = 0u32; + let result: Result = + stable_committed_read(scripted_roots(vec![[1; 32], [1; 32]]), || { + attempts += 1; + Err(Error::Drive(DriveError::CorruptedDriveState( + "boom".to_string(), + ))) + }); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedDriveState(_))) + ), + "an attempt error must propagate as-is, got {result:?}" + ); + assert_eq!(attempts, 1, "an errored attempt must not be retried"); + } + /// The estimate must not write anything: the grove root hash is /// byte-identical before and after estimating for a new address, an /// existing address, and a rejected present outpoint.