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..68b076882b5 --- /dev/null +++ b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/mod.rs @@ -0,0 +1,65 @@ +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: 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 + /// 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..0263663b132 --- /dev/null +++ b/packages/rs-drive/src/drive/address_funds/estimate_funding_fee/v0/mod.rs @@ -0,0 +1,1032 @@ +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 { + // 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. + 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 (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", + ))); + } + } + + // 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, + 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 { + // 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", + ))); + } + + // 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, + )) + } +} + +/// 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, + 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 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. + #[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, + ); + } + } + + /// 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 + /// 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..8d63cb4561f 100644 --- a/packages/rs-drive/src/error/drive.rs +++ b/packages/rs-drive/src/error/drive.rs @@ -215,6 +215,17 @@ 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), + + /// 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/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..8dcf15c1610 --- /dev/null +++ b/packages/rs-drive/src/util/proof_depth.rs @@ -0,0 +1,469 @@ +//! 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, MerkOnlyLayerProof, 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); 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]], + 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 + )))); + } + // 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 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 + } + }; + merk_single_key_levels(merk_bytes, 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 ops = Vec::new(); + for op in MerkProofDecoder::new(merk_proof_bytes) { + 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)?); + } + 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 }) +} + +#[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"); + } +} 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, }