diff --git a/packages/dapi-grpc/protos/platform/v0/platform.proto b/packages/dapi-grpc/protos/platform/v0/platform.proto index 937962817c3..9d3dbc0511f 100644 --- a/packages/dapi-grpc/protos/platform/v0/platform.proto +++ b/packages/dapi-grpc/protos/platform/v0/platform.proto @@ -116,6 +116,8 @@ service Platform { returns (GetAddressesTrunkStateResponse); rpc getAddressesBranchState(GetAddressesBranchStateRequest) returns (GetAddressesBranchStateResponse); + rpc getAddressFundingFeeQuote(GetAddressFundingFeeQuoteRequest) + returns (GetAddressFundingFeeQuoteResponse); rpc getRecentAddressBalanceChanges(GetRecentAddressBalanceChangesRequest) returns (GetRecentAddressBalanceChangesResponse); rpc getRecentCompactedAddressBalanceChanges(GetRecentCompactedAddressBalanceChangesRequest) @@ -2989,6 +2991,41 @@ message GetAddressesBranchStateResponse { oneof version { GetAddressesBranchStateResponseV0 v0 = 1; } } +message GetAddressFundingFeeQuoteRequest { + message GetAddressFundingFeeQuoteRequestV0 { + // The funding recipient: a serialized platform address (21 bytes) + bytes address = 1; + // Optional 36-byte asset lock outpoint (txid || vout LE) of the planned + // fresh lock. Empty: the node derives a deterministic placeholder — for a + // fresh (absent) outpoint both have the same expected search depth. + bytes asset_lock_outpoint = 2; + // The user fee increase the quote should include (percent of the + // processing fee per unit); must fit in 16 bits + uint32 user_fee_increase = 3; + // Length hint for the future transition's signable bytes; 0 uses the + // node's default. Clamped server-side, so it cannot understate the fee. + uint32 signable_bytes_len_hint = 4; + } + oneof version { GetAddressFundingFeeQuoteRequestV0 v0 = 1; } +} + +message GetAddressFundingFeeQuoteResponse { + message GetAddressFundingFeeQuoteResponseV0 { + // State-aware estimate of the fee the network would charge for a + // 0-input / 1-output funding executed near this state. A computed + // value, not state — there is no proof; treat it as planning data. + uint64 estimated_fee_credits = 1 [ jstype = JS_STRING ]; + // The consensus admission floor for the lock (calculate_min_required_fee) + uint64 minimum_required_lock_credits = 2 [ jstype = JS_STRING ]; + // The protocol version the node quoted with + uint32 protocol_version = 3; + // The committed block height the quote was computed on + uint64 state_height = 4 [ jstype = JS_STRING ]; + ResponseMetadata metadata = 5; + } + oneof version { GetAddressFundingFeeQuoteResponseV0 v0 = 1; } +} + message GetRecentAddressBalanceChangesRequest { message GetRecentAddressBalanceChangesRequestV0 { uint64 start_height = 1 [ jstype = JS_STRING ]; diff --git a/packages/rs-dapi/src/services/platform_service/mod.rs b/packages/rs-dapi/src/services/platform_service/mod.rs index 00e77eb6132..a39b7e42ca3 100644 --- a/packages/rs-dapi/src/services/platform_service/mod.rs +++ b/packages/rs-dapi/src/services/platform_service/mod.rs @@ -632,6 +632,12 @@ impl Platform for PlatformServiceImpl { dapi_grpc::platform::v0::GetAddressesBranchStateResponse ); + drive_method!( + get_address_funding_fee_quote, + dapi_grpc::platform::v0::GetAddressFundingFeeQuoteRequest, + dapi_grpc::platform::v0::GetAddressFundingFeeQuoteResponse + ); + drive_method!( get_recent_address_balance_changes, dapi_grpc::platform::v0::GetRecentAddressBalanceChangesRequest, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/mod.rs index 14554230ae1..e031911aa1b 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/mod.rs @@ -5,6 +5,7 @@ mod proved; #[cfg(all(test, feature = "state-transition-signing"))] mod signing_tests; mod state_transition_estimated_fee_validation; +pub use state_transition_estimated_fee_validation::calculate_address_funding_min_required_fee_for_counts; mod state_transition_fee_strategy; mod state_transition_like; mod state_transition_validation; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/state_transition_estimated_fee_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/state_transition_estimated_fee_validation.rs index c6ce0933da0..7974d056ee6 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/state_transition_estimated_fee_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/state_transition_estimated_fee_validation.rs @@ -8,30 +8,48 @@ use crate::state_transition::{ use crate::ProtocolError; use platform_version::version::PlatformVersion; +/// The consensus admission floor for an address funding with the given +/// input/output counts (the output count is clamped to at least one — a +/// funding always credits at least the remainder output). +/// +/// Shared by the transition's [`StateTransitionEstimatedFeeValidation`] impl +/// and by the fee-quote query path, so a floor reported without a built +/// transition can never drift from the one the transition enforces. +pub fn calculate_address_funding_min_required_fee_for_counts( + input_count: usize, + output_count: usize, + platform_version: &PlatformVersion, +) -> Result { + let min_fees = &platform_version.fee_version.state_transition_min_fees; + let asset_lock_base_cost = platform_version + .dpp + .state_transitions + .identities + .asset_locks + .required_asset_lock_duff_balance_for_processing_start_for_address_funding + * CREDITS_PER_DUFF; + let output_count = output_count.max(1); + Ok(asset_lock_base_cost.saturating_add( + min_fees + .address_funds_transfer_input_cost + .saturating_mul(input_count as u64) + .saturating_add( + min_fees + .address_funds_transfer_output_cost + .saturating_mul(output_count as u64), + ), + )) +} + impl StateTransitionEstimatedFeeValidation for AddressFundingFromAssetLockTransition { fn calculate_min_required_fee( &self, platform_version: &PlatformVersion, ) -> Result { - let min_fees = &platform_version.fee_version.state_transition_min_fees; - let asset_lock_base_cost = platform_version - .dpp - .state_transitions - .identities - .asset_locks - .required_asset_lock_duff_balance_for_processing_start_for_address_funding - * CREDITS_PER_DUFF; - let input_count = self.inputs().len(); - let output_count = self.outputs().len().max(1); - Ok(asset_lock_base_cost.saturating_add( - min_fees - .address_funds_transfer_input_cost - .saturating_mul(input_count as u64) - .saturating_add( - min_fees - .address_funds_transfer_output_cost - .saturating_mul(output_count as u64), - ), - )) + calculate_address_funding_min_required_fee_for_counts( + self.inputs().len(), + self.outputs().len(), + platform_version, + ) } } diff --git a/packages/rs-drive-abci/src/execution/types/mod.rs b/packages/rs-drive-abci/src/execution/types/mod.rs index ba0208c1743..4f6667eeb31 100644 --- a/packages/rs-drive-abci/src/execution/types/mod.rs +++ b/packages/rs-drive-abci/src/execution/types/mod.rs @@ -7,7 +7,7 @@ pub mod block_state_info; /// An execution event pub(in crate::execution) mod execution_event; /// A structure representing the context of the execution of a state transition -pub(in crate::execution) mod execution_operation; +pub(crate) mod execution_operation; /// A structure showing the storage and processing fees in a pool pub(in crate::execution) mod fees_in_pools; /// The outcome of processing block fees diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs index 942da423aa5..05c89909fa0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs @@ -9883,4 +9883,527 @@ mod tests { ); } } + + // ========================================== + // ADDRESS FUNDING FEE QUOTE QUERY + // getAddressFundingFeeQuote priced against real apply=true executions on + // the same committed state. Bands are regression headroom for these + // specific scenarios and protocol versions — not an upper-bound claim. + // ========================================== + + mod address_funding_fee_quote_query { + use super::*; + use crate::query::address_funds::address_funding_fee_quote::v0::{ + DEFAULT_SIGNABLE_BYTES_LEN_HINT, MAX_SIGNABLE_BYTES_LEN_HINT, + MIN_SIGNABLE_BYTES_LEN_HINT, + }; + use crate::test::helpers::setup::TempPlatform; + use dapi_grpc::platform::v0::get_address_funding_fee_quote_request::{ + GetAddressFundingFeeQuoteRequestV0, Version as QuoteRequestVersion, + }; + use dapi_grpc::platform::v0::get_address_funding_fee_quote_response::{ + GetAddressFundingFeeQuoteResponseV0, Version as QuoteResponseVersion, + }; + use dapi_grpc::platform::v0::GetAddressFundingFeeQuoteRequest; + use dpp::fee::fee_result::FeeResult; + use dpp::serialization::{PlatformSerializable, Signable}; + use dpp::state_transition::StateTransitionEstimatedFeeValidation; + + fn build_quote_platform() -> TempPlatform { + let platform_config = PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }; + TestPlatformBuilder::new() + .with_config(platform_config) + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state() + } + + fn quote_raw( + platform: &TempPlatform, + request_v0: GetAddressFundingFeeQuoteRequestV0, + ) -> crate::query::QueryValidationResult< + dapi_grpc::platform::v0::GetAddressFundingFeeQuoteResponse, + > { + let platform_state = platform.state.load(); + platform + .platform + .query_address_funding_fee_quote( + GetAddressFundingFeeQuoteRequest { + version: Some(QuoteRequestVersion::V0(request_v0)), + }, + &platform_state, + PlatformVersion::latest(), + ) + .expect("quote query should not error") + } + + fn quote( + platform: &TempPlatform, + request_v0: GetAddressFundingFeeQuoteRequestV0, + ) -> GetAddressFundingFeeQuoteResponseV0 { + let result = quote_raw(platform, request_v0); + assert!( + result.errors.is_empty(), + "quote must succeed, got {:?}", + result.errors + ); + match result + .data + .expect("quote response data") + .version + .expect("quote response version") + { + QuoteResponseVersion::V0(v0) => v0, + } + } + + /// Builds a signed 0-input/1-output funding for `recipient`, then + /// executes it with apply=true. Returns the charged fee, the + /// transition's signable-bytes length, its outpoint, and the built + /// transition. `commit` controls whether the execution is committed + /// (deepening the trees) or rolled back (a probe). + async fn execute_funding( + platform: &TempPlatform, + recipient: PlatformAddress, + user_fee_increase: u16, + commit: bool, + rng: &mut StdRng, + ) -> (FeeResult, u32, [u8; 36], StateTransition) { + let platform_version = PlatformVersion::latest(); + let signer = TestAddressSigner::new(); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(rng); + let outpoint: [u8; 36] = asset_lock_proof + .out_point() + .expect("asset lock outpoint") + .into(); + + let outputs = BTreeMap::from([(recipient, None)]); + let transition = + create_signed_address_funding_from_asset_lock_transition_with_fee_increase( + asset_lock_proof, + &asset_lock_pk, + &signer, + BTreeMap::new(), + outputs, + vec![AddressFundsFeeStrategyStep::ReduceOutput(0)], + user_fee_increase, + ) + .await; + let signable_len = transition.signable_bytes().expect("signable bytes").len() as u32; + let serialized = transition.serialize_to_bytes().expect("serialize"); + + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("process state transition"); + let fee_result = match processing_result.execution_results().as_slice() { + [StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. }] => { + fee_result.clone() + } + other => panic!("expected successful execution, got {other:?}"), + }; + if commit { + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commit transaction"); + } else { + drop(transaction); + } + (fee_result, signable_len, outpoint, transition) + } + + fn assert_quote_brackets(quoted: u64, actual: u64, what: &str) { + assert!( + quoted >= actual.saturating_mul(85) / 100 + && quoted <= actual.saturating_mul(115) / 100, + "{what}: quoted {quoted} not within [85%, 115%] of actual {actual}" + ); + } + + /// The quote brackets the real charged fee at genesis, the exact and + /// placeholder outpoints quote nearly identically, and the reported + /// lock floor equals the built transition's consensus floor. + #[tokio::test] + async fn test_quote_brackets_actual_fee_at_genesis() { + let platform = build_quote_platform(); + let mut rng = StdRng::seed_from_u64(90_001); + let recipient = create_platform_address(200); + + // Build the transition first (state untouched), quote on committed + // state, then execute the very same transition. + let signer = TestAddressSigner::new(); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + let outpoint: [u8; 36] = asset_lock_proof + .out_point() + .expect("asset lock outpoint") + .into(); + let transition = create_signed_address_funding_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + &signer, + BTreeMap::new(), + BTreeMap::from([(recipient, None)]), + vec![AddressFundsFeeStrategyStep::ReduceOutput(0)], + ) + .await; + let signable_len = transition.signable_bytes().expect("signable bytes").len() as u32; + + let quoted_exact = quote( + &platform, + GetAddressFundingFeeQuoteRequestV0 { + address: recipient.to_bytes(), + asset_lock_outpoint: outpoint.to_vec(), + user_fee_increase: 0, + signable_bytes_len_hint: signable_len, + }, + ); + let quoted_placeholder = quote( + &platform, + GetAddressFundingFeeQuoteRequestV0 { + address: recipient.to_bytes(), + asset_lock_outpoint: vec![], + user_fee_increase: 0, + signable_bytes_len_hint: signable_len, + }, + ); + + let serialized = transition.serialize_to_bytes().expect("serialize"); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + PlatformVersion::latest(), + false, + None, + ) + .expect("process state transition"); + let actual = match processing_result.execution_results().as_slice() { + [StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. }] => { + fee_result.total_base_fee() + } + other => panic!("expected successful execution, got {other:?}"), + }; + drop(transaction); + + assert_quote_brackets(quoted_exact.estimated_fee_credits, actual, "genesis, exact"); + assert_quote_brackets( + quoted_placeholder.estimated_fee_credits, + actual, + "genesis, placeholder", + ); + // A placeholder differs from the exact key by at most one AVL + // level of the absence boundary. + let (lo, hi) = ( + quoted_exact + .estimated_fee_credits + .min(quoted_placeholder.estimated_fee_credits), + quoted_exact + .estimated_fee_credits + .max(quoted_placeholder.estimated_fee_credits), + ); + assert!( + hi - lo <= hi / 20, + "placeholder and exact quotes must agree within 5%: {lo} vs {hi}" + ); + + let StateTransition::AddressFundingFromAssetLock(concrete_transition) = &transition + else { + panic!("expected an address funding transition"); + }; + let floor = concrete_transition + .calculate_min_required_fee(PlatformVersion::latest()) + .expect("transition floor"); + assert_eq!( + quoted_exact.minimum_required_lock_credits, floor, + "quoted lock floor must equal the built transition's floor" + ); + + println!( + "genesis: quoted exact {} / placeholder {} vs actual {}", + quoted_exact.estimated_fee_credits, + quoted_placeholder.estimated_fee_credits, + actual + ); + } + + /// Eight committed fundings deepen the trees; the quote for the ninth + /// still brackets its real charged fee, for a new and for an existing + /// recipient. + #[tokio::test] + async fn test_quote_brackets_actual_fee_on_populated_state() { + let platform = build_quote_platform(); + let mut rng = StdRng::seed_from_u64(90_002); + + for n in 0..8u8 { + execute_funding( + &platform, + create_platform_address(10 + n), + 0, + true, + &mut rng, + ) + .await; + } + + // New recipient. + let new_recipient = create_platform_address(200); + let quoted_new = quote( + &platform, + GetAddressFundingFeeQuoteRequestV0 { + address: new_recipient.to_bytes(), + asset_lock_outpoint: vec![], + user_fee_increase: 0, + signable_bytes_len_hint: 0, + }, + ); + let (actual_new, _, _, _) = + execute_funding(&platform, new_recipient, 0, false, &mut rng).await; + assert_quote_brackets( + quoted_new.estimated_fee_credits, + actual_new.total_base_fee(), + "populated, new recipient", + ); + + // Existing recipient (seeded above): the balance write is a + // replace, so both the quote and the actual fee drop. + let existing_recipient = create_platform_address(10); + let quoted_existing = quote( + &platform, + GetAddressFundingFeeQuoteRequestV0 { + address: existing_recipient.to_bytes(), + asset_lock_outpoint: vec![], + user_fee_increase: 0, + signable_bytes_len_hint: 0, + }, + ); + let (actual_existing, _, _, _) = + execute_funding(&platform, existing_recipient, 0, false, &mut rng).await; + assert_quote_brackets( + quoted_existing.estimated_fee_credits, + actual_existing.total_base_fee(), + "populated, existing recipient", + ); + assert!( + quoted_existing.estimated_fee_credits < quoted_new.estimated_fee_credits, + "a replace must quote below an insert: {} vs {}", + quoted_existing.estimated_fee_credits, + quoted_new.estimated_fee_credits + ); + + println!( + "populated: new quoted {} vs actual {}; existing quoted {} vs actual {}", + quoted_new.estimated_fee_credits, + actual_new.total_base_fee(), + quoted_existing.estimated_fee_credits, + actual_existing.total_base_fee(), + ); + } + + /// The quote at the SDK retry ceiling (user_fee_increase = 14) still + /// brackets the real charged fee of an execution at the same increase. + #[tokio::test] + async fn test_quote_brackets_actual_fee_at_max_retry_user_fee_increase() { + let platform = build_quote_platform(); + let mut rng = StdRng::seed_from_u64(90_003); + let recipient = create_platform_address(201); + + let quoted_base = quote( + &platform, + GetAddressFundingFeeQuoteRequestV0 { + address: recipient.to_bytes(), + asset_lock_outpoint: vec![], + user_fee_increase: 0, + signable_bytes_len_hint: 0, + }, + ); + let quoted_at_max_retry = quote( + &platform, + GetAddressFundingFeeQuoteRequestV0 { + address: recipient.to_bytes(), + asset_lock_outpoint: vec![], + user_fee_increase: 14, + signable_bytes_len_hint: 0, + }, + ); + assert!( + quoted_at_max_retry.estimated_fee_credits > quoted_base.estimated_fee_credits, + "a user fee increase must raise the quote" + ); + + let (actual, _, _, _) = + execute_funding(&platform, recipient, 14, false, &mut rng).await; + assert_quote_brackets( + quoted_at_max_retry.estimated_fee_credits, + actual.total_base_fee(), + "user_fee_increase 14", + ); + } + + /// A spent outpoint is refused with a validation error — the quote + /// models a fresh lock only. + #[tokio::test] + async fn test_quote_rejects_spent_outpoint() { + let platform = build_quote_platform(); + let mut rng = StdRng::seed_from_u64(90_004); + + let (_, _, spent_outpoint, _) = + execute_funding(&platform, create_platform_address(10), 0, true, &mut rng).await; + + let result = quote_raw( + &platform, + GetAddressFundingFeeQuoteRequestV0 { + address: create_platform_address(200).to_bytes(), + asset_lock_outpoint: spent_outpoint.to_vec(), + user_fee_increase: 0, + signable_bytes_len_hint: 0, + }, + ); + assert!( + !result.errors.is_empty(), + "a spent outpoint must be refused" + ); + } + + /// The quote is read-only (root hash byte-identical across calls) and + /// deterministic (identical requests produce identical responses). + #[tokio::test] + async fn test_quote_is_read_only_and_deterministic() { + let platform = build_quote_platform(); + let mut rng = StdRng::seed_from_u64(90_005); + let platform_version = PlatformVersion::latest(); + + for n in 0..3u8 { + execute_funding( + &platform, + create_platform_address(10 + n), + 0, + true, + &mut rng, + ) + .await; + } + + let before = platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash"); + + let request = GetAddressFundingFeeQuoteRequestV0 { + address: create_platform_address(200).to_bytes(), + asset_lock_outpoint: vec![], + user_fee_increase: 0, + signable_bytes_len_hint: 0, + }; + let first = quote(&platform, request.clone()); + let second = quote(&platform, request); + assert_eq!( + first, second, + "identical quote requests must produce identical responses" + ); + + let after = platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash"); + assert_eq!(before, after, "quoting must not change the root hash"); + } + + /// Invalid arguments are refused as validation errors: a malformed + /// address, a wrong-size outpoint, and an oversized fee increase. + #[tokio::test] + async fn test_quote_rejects_invalid_arguments() { + let platform = build_quote_platform(); + + let bad_address = quote_raw( + &platform, + GetAddressFundingFeeQuoteRequestV0 { + address: vec![0xAB; 7], + asset_lock_outpoint: vec![], + user_fee_increase: 0, + signable_bytes_len_hint: 0, + }, + ); + assert!(!bad_address.errors.is_empty(), "malformed address"); + + let bad_outpoint = quote_raw( + &platform, + GetAddressFundingFeeQuoteRequestV0 { + address: create_platform_address(1).to_bytes(), + asset_lock_outpoint: vec![0xCD; 35], + user_fee_increase: 0, + signable_bytes_len_hint: 0, + }, + ); + assert!(!bad_outpoint.errors.is_empty(), "wrong-size outpoint"); + + let bad_fee_increase = quote_raw( + &platform, + GetAddressFundingFeeQuoteRequestV0 { + address: create_platform_address(1).to_bytes(), + asset_lock_outpoint: vec![], + user_fee_increase: u32::from(u16::MAX) + 1, + signable_bytes_len_hint: 0, + }, + ); + assert!( + !bad_fee_increase.errors.is_empty(), + "oversized fee increase" + ); + } + + /// The default signable-length hint stays anchored to the measured + /// signable length of a real instant-proof 0-input/1-output funding — + /// a transition format change forces a conscious constant update. + #[tokio::test] + async fn test_default_signable_len_hint_brackets_real_fixture() { + let mut rng = StdRng::seed_from_u64(90_006); + let (asset_lock_proof, _) = create_asset_lock_proof_with_key(&mut rng); + let measured = get_signable_bytes_for_transition( + &asset_lock_proof, + &BTreeMap::new(), + &BTreeMap::from([(create_platform_address(1), None)]), + ) + .len() as u32; + + assert!( + (MIN_SIGNABLE_BYTES_LEN_HINT..=MAX_SIGNABLE_BYTES_LEN_HINT).contains(&measured), + "measured signable length {measured} must sit inside the clamp bounds" + ); + assert!( + DEFAULT_SIGNABLE_BYTES_LEN_HINT >= measured / 2 + && DEFAULT_SIGNABLE_BYTES_LEN_HINT <= measured.saturating_mul(2), + "default hint {DEFAULT_SIGNABLE_BYTES_LEN_HINT} must stay within 2x of the \ + measured instant-proof signable length {measured}" + ); + println!("measured instant-proof signable length: {measured}"); + } + } } diff --git a/packages/rs-drive-abci/src/query/address_funds/address_funding_fee_quote/mod.rs b/packages/rs-drive-abci/src/query/address_funds/address_funding_fee_quote/mod.rs new file mode 100644 index 00000000000..d4b5fbe3e45 --- /dev/null +++ b/packages/rs-drive-abci/src/query/address_funds/address_funding_fee_quote/mod.rs @@ -0,0 +1,64 @@ +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_address_funding_fee_quote_request::Version as RequestVersion; +use dapi_grpc::platform::v0::get_address_funding_fee_quote_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{ + GetAddressFundingFeeQuoteRequest, GetAddressFundingFeeQuoteResponse, +}; +use dpp::version::PlatformVersion; +pub(crate) mod v0; + +impl Platform { + /// Querying of a state-aware address funding fee quote + pub fn query_address_funding_fee_quote( + &self, + GetAddressFundingFeeQuoteRequest { version }: GetAddressFundingFeeQuoteRequest, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(version) = version else { + return Ok(QueryValidationResult::new_with_error( + QueryError::DecodingError( + "could not decode address funding fee quote query".to_string(), + ), + )); + }; + + let feature_version_bounds = &platform_version + .drive_abci + .query + .address_funds_queries + .address_funding_fee_quote; + + let feature_version = match &version { + RequestVersion::V0(_) => 0, + }; + if !feature_version_bounds.check_version(feature_version) { + return Ok(QueryValidationResult::new_with_error( + QueryError::UnsupportedQueryVersion( + "address_funding_fee_quote".to_string(), + feature_version_bounds.min_version, + feature_version_bounds.max_version, + platform_version.protocol_version, + feature_version, + ), + )); + } + + match version { + RequestVersion::V0(request_v0) => { + let result = self.query_address_funding_fee_quote_v0( + request_v0, + platform_state, + platform_version, + )?; + Ok(result.map(|response_v0| GetAddressFundingFeeQuoteResponse { + version: Some(ResponseVersion::V0(response_v0)), + })) + } + } + } +} diff --git a/packages/rs-drive-abci/src/query/address_funds/address_funding_fee_quote/v0/mod.rs b/packages/rs-drive-abci/src/query/address_funds/address_funding_fee_quote/v0/mod.rs new file mode 100644 index 00000000000..97d648b3708 --- /dev/null +++ b/packages/rs-drive-abci/src/query/address_funds/address_funding_fee_quote/v0/mod.rs @@ -0,0 +1,180 @@ +use crate::error::query::QueryError; +use crate::error::Error; +use crate::execution::types::execution_operation::signature_verification_operation::SignatureVerificationOperation; +use crate::execution::types::execution_operation::{ValidationOperation, SHA256_BLOCK_SIZE}; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::query::response_metadata::CheckpointUsed; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_address_funding_fee_quote_request::GetAddressFundingFeeQuoteRequestV0; +use dapi_grpc::platform::v0::get_address_funding_fee_quote_response::GetAddressFundingFeeQuoteResponseV0; +use dpp::address_funds::PlatformAddress; +use dpp::block::block_info::BlockInfo; +use dpp::identity::KeyType; +use dpp::platform_value::Bytes36; +use dpp::prelude::UserFeeIncrease; +use dpp::state_transition::address_funding_from_asset_lock_transition::calculate_address_funding_min_required_fee_for_counts; +use dpp::util::hash::hash_double; +use dpp::version::PlatformVersion; +use drive::error::drive::DriveError; + +/// Clamp bounds and default for the signable-bytes length hint. The hint only +/// sizes the quote's `DoubleSha256` charge (5 000 credits per 64-byte block, +/// well under 1% of the total), and it is clamped so a client cannot +/// understate the fee. The default is the measured signable length of the +/// single-input instant-proof 0-input/1-output funding fixture (390 bytes); +/// wallets whose L1 funding transaction is larger should pass the real +/// length. The calibration test in `address_funding_from_asset_lock/tests.rs` +/// pins the default against the real fixture so a transition format change +/// forces a conscious update here. +pub(crate) const MIN_SIGNABLE_BYTES_LEN_HINT: u32 = 128; +pub(crate) const DEFAULT_SIGNABLE_BYTES_LEN_HINT: u32 = 390; +pub(crate) const MAX_SIGNABLE_BYTES_LEN_HINT: u32 = 8_192; + +/// Domain tag for the deterministic placeholder outpoint. +const PLACEHOLDER_OUTPOINT_TAG: &[u8] = b"address_funding_fee_quote_placeholder"; + +impl Platform { + /// Version 0 of the address funding fee quote. + /// + /// Read-only: prices the exact production operations of a 0-input / + /// 1-output funding of a FRESH asset lock against committed state + /// (measured tree depths replace the worst-case layer counts), then adds + /// the same validation-operation fees `transform_into_action` records and + /// applies the requested `user_fee_increase`. + /// + /// The quoted fee does not depend on the lock amount (sum values are + /// charged at a fixed width), so the engine is run with the admission + /// floor as the modeled lock value. The response is a computed value, not + /// state — it carries metadata but no proof. + pub(super) fn query_address_funding_fee_quote_v0( + &self, + GetAddressFundingFeeQuoteRequestV0 { + address, + asset_lock_outpoint, + user_fee_increase, + signable_bytes_len_hint, + }: GetAddressFundingFeeQuoteRequestV0, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Ok(recipient) = PlatformAddress::from_bytes(&address) else { + return Ok(QueryValidationResult::new_with_error( + QueryError::InvalidArgument( + "address must be a serialized platform address".to_string(), + ), + )); + }; + + let Ok(user_fee_increase): Result = user_fee_increase.try_into() else { + return Ok(QueryValidationResult::new_with_error( + QueryError::InvalidArgument("user_fee_increase must fit in 16 bits".to_string()), + )); + }; + + let outpoint = if asset_lock_outpoint.is_empty() { + // Deterministic placeholder: sha256d over a domain tag, the + // address and the committed height. Outpoint keys are uniformly + // distributed (txids), so for a fresh lock the placeholder's + // absence boundary has the same expected search depth as the real + // key would. + let mut seed = Vec::with_capacity( + PLACEHOLDER_OUTPOINT_TAG.len() + address.len() + core::mem::size_of::(), + ); + seed.extend_from_slice(PLACEHOLDER_OUTPOINT_TAG); + seed.extend_from_slice(&address); + seed.extend_from_slice(&platform_state.last_committed_block_height().to_be_bytes()); + let txid = hash_double(seed); + let mut outpoint_bytes = [0u8; 36]; + outpoint_bytes[..32].copy_from_slice(&txid); + // the remaining four bytes are vout 0 in little endian + Bytes36::new(outpoint_bytes) + } else { + let Ok(outpoint_bytes): Result<[u8; 36], _> = asset_lock_outpoint.as_slice().try_into() + else { + return Ok(QueryValidationResult::new_with_error( + QueryError::InvalidArgument( + "asset_lock_outpoint must be 36 bytes (txid || vout)".to_string(), + ), + )); + }; + Bytes36::new(outpoint_bytes) + }; + + let minimum_required_lock_credits = + calculate_address_funding_min_required_fee_for_counts(0, 1, platform_version)?; + + let block_info = BlockInfo { + time_ms: platform_state + .last_committed_block_time_ms() + .unwrap_or_default(), + height: platform_state.last_committed_block_height(), + core_height: platform_state.last_committed_core_height(), + epoch: platform_state.last_committed_block_epoch(), + }; + + let estimate = match self.drive.estimate_address_funding_fee( + &recipient, + outpoint, + minimum_required_lock_credits, + &block_info, + platform_version, + ) { + Ok(estimate) => estimate, + Err(drive::error::Error::Drive(DriveError::AssetLockOutpointAlreadyPresent(_))) => { + return Ok(QueryValidationResult::new_with_error( + QueryError::InvalidArgument( + "asset_lock_outpoint is already present in the state (spent or partially \ + used); the quote models a fresh lock" + .to_string(), + ), + )); + } + Err(drive::error::Error::Drive( + error @ DriveError::CommittedStateChangedDuringOperation(_), + )) => { + // A transient condition on a busy node, not a server fault: + // surface it as a client-visible error so the caller retries. + return Ok(QueryValidationResult::new_with_error(QueryError::Drive( + drive::error::Error::Drive(error), + ))); + } + Err(error) => return Err(error.into()), + }; + let mut fee_result = estimate.fee_result; + + // The same validation operations transform_into_action records for a + // fresh 0-input/1-output funding: hashing the signable bytes and one + // ECDSA_HASH160 verification of the one-time key signature. The block + // count deliberately uses the same integer division. + let signable_bytes_len = if signable_bytes_len_hint == 0 { + DEFAULT_SIGNABLE_BYTES_LEN_HINT + } else { + signable_bytes_len_hint.clamp(MIN_SIGNABLE_BYTES_LEN_HINT, MAX_SIGNABLE_BYTES_LEN_HINT) + }; + let block_count = signable_bytes_len as u16 / SHA256_BLOCK_SIZE; + ValidationOperation::add_many_to_fee_result( + &[ + ValidationOperation::DoubleSha256(block_count), + ValidationOperation::SignatureVerification(SignatureVerificationOperation::new( + KeyType::ECDSA_HASH160, + )), + ], + &mut fee_result, + platform_version, + )?; + + fee_result.apply_user_fee_increase(user_fee_increase); + + let response = GetAddressFundingFeeQuoteResponseV0 { + estimated_fee_credits: fee_result.total_base_fee(), + minimum_required_lock_credits, + protocol_version: platform_version.protocol_version, + state_height: platform_state.last_committed_block_height(), + metadata: Some(self.response_metadata_v0(platform_state, CheckpointUsed::Current)), + }; + + Ok(QueryValidationResult::new_with_data(response)) + } +} diff --git a/packages/rs-drive-abci/src/query/address_funds/mod.rs b/packages/rs-drive-abci/src/query/address_funds/mod.rs index 2f4cd25662c..04dc6a76f5d 100644 --- a/packages/rs-drive-abci/src/query/address_funds/mod.rs +++ b/packages/rs-drive-abci/src/query/address_funds/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod address_funding_fee_quote; mod address_info; mod addresses_branch_state; mod addresses_infos; diff --git a/packages/rs-drive-abci/src/query/mod.rs b/packages/rs-drive-abci/src/query/mod.rs index 9e1dab2b082..303399a7eea 100644 --- a/packages/rs-drive-abci/src/query/mod.rs +++ b/packages/rs-drive-abci/src/query/mod.rs @@ -1,4 +1,4 @@ -mod address_funds; +pub(crate) mod address_funds; mod data_contract_based_queries; mod document_history; mod document_query; diff --git a/packages/rs-drive-abci/src/query/service.rs b/packages/rs-drive-abci/src/query/service.rs index 3c6ce47b5a3..fcd42675f2f 100644 --- a/packages/rs-drive-abci/src/query/service.rs +++ b/packages/rs-drive-abci/src/query/service.rs @@ -13,7 +13,8 @@ use dapi_grpc::drive::v0::{GetProofsRequest, GetProofsResponse}; use dapi_grpc::platform::v0::get_path_elements_request; use dapi_grpc::platform::v0::platform_server::Platform as PlatformService; use dapi_grpc::platform::v0::{ - BroadcastStateTransitionRequest, BroadcastStateTransitionResponse, GetAddressInfoRequest, + BroadcastStateTransitionRequest, BroadcastStateTransitionResponse, + GetAddressFundingFeeQuoteRequest, GetAddressFundingFeeQuoteResponse, GetAddressInfoRequest, GetAddressInfoResponse, GetAddressesBranchStateRequest, GetAddressesBranchStateResponse, GetAddressesInfosRequest, GetAddressesInfosResponse, GetAddressesTrunkStateRequest, GetAddressesTrunkStateResponse, GetConsensusParamsRequest, GetConsensusParamsResponse, @@ -874,6 +875,18 @@ impl PlatformService for QueryService { .await } + async fn get_address_funding_fee_quote( + &self, + request: Request, + ) -> Result, Status> { + self.handle_blocking_query( + request, + Platform::::query_address_funding_fee_quote, + "get_address_funding_fee_quote", + ) + .await + } + async fn get_recent_address_balance_changes( &self, request: Request, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/mod.rs index b0594decefc..78356b02306 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/mod.rs @@ -76,6 +76,7 @@ pub struct DriveAbciQueryAddressFundsVersions { pub addresses_branch_state: FeatureVersionBounds, pub recent_address_balance_changes: FeatureVersionBounds, pub recent_compacted_address_balance_changes: FeatureVersionBounds, + pub address_funding_fee_quote: FeatureVersionBounds, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v0.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v0.rs index de3b1cccfd1..05264962211 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v0.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v0.rs @@ -325,5 +325,10 @@ pub const DRIVE_ABCI_QUERY_VERSIONS_V0: DriveAbciQueryVersions = DriveAbciQueryV max_version: 0, default_current_version: 0, }, + address_funding_fee_quote: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, }, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v1.rs index 31ea6e4b7da..a75ef8226ca 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v1.rs @@ -327,5 +327,10 @@ pub const DRIVE_ABCI_QUERY_VERSIONS_V1: DriveAbciQueryVersions = DriveAbciQueryV max_version: 0, default_current_version: 0, }, + address_funding_fee_quote: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, }, }; diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index 4286f9fb97d..f385e1ed7e3 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -475,6 +475,11 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_version: 0, default_current_version: 0, }, + address_funding_fee_quote: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, }, }, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1,