From eec0d4e1ffc704e1a152e05337a49f176b24a42a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 1 Oct 2026 09:54:37 +0700 Subject: [PATCH 1/7] feat(platform)!: withdrawals also fit a Core-anchored limit (PV14) Platform counted an asset lock's credits from the Platform block that consumed it, Core v24 from the Core block that mined it. An asset lock published to Platform long after Core mined it, or a whole epoch of Core rewards minted in one block, let Platform pool more than Core will mine; over Core's limit an unlock waits unmined and is re-signed, and while Core's mempool holds more than the limit Core InstantSend-locks no withdrawal at all. Pooling (v2) now admits withdrawals up to the smaller of the daily limit and a stricter copy of Core v24's relative net unlock rule, read from Core's credit pool balances at chain locked heights: 15% (Core: 20%) of the highest balance among window starts 552 to 600 Core blocks back (Core: 576), at least 1500 Dash (Core: 2000), less what is queued or broadcast and not mined yet. Platform reads each chain locked Core block once (credit pool balance and asset locks) and stores the balances. Asset lock credits now count as inflows from the Core block that mined them, for 552 Core blocks; an asset lock consumed before Core mined it waits in a pending tree until a read Core block holds it. The daily limit's fixed 4000 Dash cap is dropped. Co-Authored-By: Claude Opus 5.5 --- book/src/versioning/feature-versions.md | 5 + .../core_credit_pool_unlock_limit/mod.rs | 72 ++++ .../core_credit_pool_unlock_limit/v0/mod.rs | 167 ++++++++ .../withdrawal/daily_withdrawal_limit/mod.rs | 10 +- .../daily_withdrawal_limit/v2/mod.rs | 83 ++-- packages/rs-dpp/src/withdrawal/mod.rs | 1 + .../src/execution/check_tx/v0/mod.rs | 6 +- .../engine/run_block_proposal/v0/mod.rs | 5 +- .../v0/mod.rs | 58 ++- .../execute_event/mod.rs | 4 +- .../execute_event/v0/mod.rs | 41 +- .../execute_event/v1/mod.rs | 9 +- .../process_raw_state_transitions/v0/mod.rs | 13 +- .../process_validation_result/mod.rs | 8 +- .../process_validation_result/v0/mod.rs | 4 +- .../process_validation_result/v1/mod.rs | 4 +- .../mod.rs | 66 ++++ .../v0/mod.rs | 345 ++++++++++++++++ .../v1/mod.rs | 224 ++++++++++- .../platform_events/withdrawals/mod.rs | 2 + .../mod.rs | 8 +- .../v2/mod.rs | 329 +++++++++++++++ .../mod.rs | 26 +- .../v0/mod.rs | 203 +++++++++- .../scan_core_blocks_for_withdrawals/mod.rs | 62 +++ .../v0/mod.rs | 294 ++++++++++++++ .../tests.rs | 5 +- .../state_transitions/identity_top_up/mod.rs | 10 +- packages/rs-drive-abci/src/main.rs | 8 +- packages/rs-drive-abci/src/metrics.rs | 8 + .../platform_types/block_credit_mints/mod.rs | 115 ++++++ .../rs-drive-abci/src/platform_types/mod.rs | 2 + .../src/platform_types/platform/mock.rs | 18 +- .../mod.rs | 15 +- packages/rs-drive-abci/src/rpc/core.rs | 93 +++++ .../test_cases/withdrawal_tests.rs | 77 ++-- packages/rs-drive/grovedb-structure.json | 141 ++++++- .../v1/mod.rs | 374 +++++++++++++++++- .../fetch_core_credit_pool_balances/mod.rs | 55 +++ .../fetch_core_credit_pool_balances/v0/mod.rs | 119 ++++++ .../fetch_in_flight_withdrawal_amount/mod.rs | 51 +++ .../v0/mod.rs | 175 ++++++++ .../mod.rs | 51 +++ .../v0/mod.rs | 45 +++ .../src/drive/identity/withdrawals/mod.rs | 99 +++++ .../src/drive/identity/withdrawals/paths.rs | 78 ++++ .../record_asset_lock_credit_inflow/mod.rs | 71 ++++ .../record_asset_lock_credit_inflow/v0/mod.rs | 199 ++++++++++ .../record_core_credit_pool_block/mod.rs | 70 ++++ .../record_core_credit_pool_block/v0/mod.rs | 138 +++++++ .../drive/identity/withdrawals/structure.rs | 84 +++- packages/rs-drive/src/structure/tests.rs | 37 ++ .../src/util/batch/drive_op_batch/mod.rs | 65 +++ .../dpp_versions/dpp_method_versions/mod.rs | 6 +- .../dpp_versions/dpp_method_versions/v1.rs | 1 + .../dpp_versions/dpp_method_versions/v2.rs | 1 + .../dpp_versions/dpp_method_versions/v3.rs | 4 +- .../drive_abci_method_versions/mod.rs | 9 + .../drive_abci_method_versions/v1.rs | 2 + .../drive_abci_method_versions/v10.rs | 8 +- .../drive_abci_method_versions/v2.rs | 2 + .../drive_abci_method_versions/v3.rs | 2 + .../drive_abci_method_versions/v4.rs | 2 + .../drive_abci_method_versions/v5.rs | 2 + .../drive_abci_method_versions/v6.rs | 2 + .../drive_abci_method_versions/v7.rs | 2 + .../drive_abci_method_versions/v8.rs | 2 + .../drive_abci_method_versions/v9.rs | 2 + .../drive_abci_withdrawal_constants/mod.rs | 4 + .../drive_abci_withdrawal_constants/v1.rs | 1 + .../drive_abci_withdrawal_constants/v2.rs | 1 + .../drive_abci_withdrawal_constants/v3.rs | 4 +- .../drive_identity_method_versions/mod.rs | 11 + .../drive_identity_method_versions/v1.rs | 4 + .../drive_identity_method_versions/v2.rs | 20 +- .../src/version/mocks/v2_test.rs | 4 + .../src/version/mocks/v3_test.rs | 2 + .../src/version/system_limits/mod.rs | 40 +- .../src/version/system_limits/v1.rs | 4 + .../src/version/system_limits/v2.rs | 4 + .../src/version/system_limits/v3.rs | 4 + .../src/version/system_limits/v4.rs | 24 +- .../rs-platform-version/src/version/v14.rs | 55 ++- 83 files changed, 4271 insertions(+), 210 deletions(-) create mode 100644 packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs create mode 100644 packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/v0/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs create mode 100644 packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/withdrawals/fetch_last_recorded_core_credit_pool_height/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/withdrawals/fetch_last_recorded_core_credit_pool_height/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/v0/mod.rs diff --git a/book/src/versioning/feature-versions.md b/book/src/versioning/feature-versions.md index f2395089285..6fcc64c444d 100644 --- a/book/src/versioning/feature-versions.md +++ b/book/src/versioning/feature-versions.md @@ -333,6 +333,10 @@ pub struct SystemLimits { /// `None` for the protocol versions that predate the relative rule. pub daily_withdrawal_limit_percent: Option, pub max_daily_withdrawal_amount: Option, + pub core_credit_pool_unlock_limit_percent: Option, + pub core_credit_pool_unlock_limit_floor: Option, + pub core_credit_pool_window_min_blocks: Option, + pub core_credit_pool_window_max_blocks: Option, pub min_withdrawal_amount: u64, pub max_contract_group_size: u16, pub max_token_redemption_cycles: u32, @@ -355,6 +359,7 @@ pub struct DriveAbciWithdrawalConstants { pub core_expiration_blocks: u32, pub cleanup_expired_locks_of_withdrawal_amounts_limit: u16, pub total_credits_history_prune_limit: u16, + pub core_blocks_scanned_per_block_limit: u16, } // drive_abci_versions/drive_abci_validation_versions/mod.rs diff --git a/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs b/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs new file mode 100644 index 00000000000..69645a28c6f --- /dev/null +++ b/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs @@ -0,0 +1,72 @@ +use crate::fee::Credits; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +/// Returns how much Core's credit pool may still give up to asset unlocks, given its balance +/// now and its balance at the start of the window the limit is measured over, both in credits. +/// +/// This is the Core-anchored half of the withdrawal limit: a stricter copy of Core v24's own +/// asset unlock rule, so Platform never pools a withdrawal Core will refuse to mine. The pool +/// may end no lower than its window start balance minus an allowed drop +/// (`core_credit_pool_unlock_limit_percent` of that balance, at least +/// `core_credit_pool_unlock_limit_floor`); what it gained since the window start (asset locks, +/// the per-block Platform reward) is withdrawable on top, and the pool can never go negative. +/// +/// # Parameters +/// +/// * `balance`: Core's credit pool balance now, in credits. +/// * `window_start_balance`: Core's credit pool balance at the window start, in credits; `0` +/// when that block has no credit pool. +/// * `platform_version`: The platform version. +/// +/// # Returns +/// +/// * `Ok(Credits)`: The credits that may still be unlocked, between `0` and `balance`. +/// * `Err(ProtocolError)`: When the method version is unknown or not active, or the system +/// limits it reads are not configured. +pub fn core_credit_pool_unlock_limit( + balance: Credits, + window_start_balance: Credits, + platform_version: &PlatformVersion, +) -> Result { + match platform_version.dpp.methods.core_credit_pool_unlock_limit { + Some(0) => { + v0::core_credit_pool_unlock_limit_v0(balance, window_start_balance, platform_version) + } + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "core_credit_pool_unlock_limit".to_string(), + known_versions: vec![0], + received: version, + }), + None => Err(ProtocolError::UnknownVersionError( + "core_credit_pool_unlock_limit is not active in this protocol version".to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dash_to_credits; + + #[test] + fn should_only_exist_from_protocol_version_14() { + let v13 = PlatformVersion::get(13).expect("expected protocol version 13"); + assert!(core_credit_pool_unlock_limit( + dash_to_credits!(10000), + dash_to_credits!(10000), + v13 + ) + .is_err()); + + let v14 = PlatformVersion::get(14).expect("expected protocol version 14"); + // 15% of a 20,000 Dash pool that has not moved. + assert_eq!( + core_credit_pool_unlock_limit(dash_to_credits!(20000), dash_to_credits!(20000), v14) + .expect("expected the limit"), + dash_to_credits!(3000) + ); + } +} diff --git a/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/v0/mod.rs b/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/v0/mod.rs new file mode 100644 index 00000000000..0b615d45ed0 --- /dev/null +++ b/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/v0/mod.rs @@ -0,0 +1,167 @@ +use crate::fee::Credits; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +/// The pool may not end below `window_start_balance - allowed_drop`, where +/// `allowed_drop = max(window_start_balance * percent / 100, floor)`; everything above that +/// line is withdrawable, but never more than the pool holds: +/// +/// `limit = min(max(0, allowed_drop - (window_start_balance - balance)), balance)` +/// +/// Core v24 applies the same shape with 20% and a 2000 Dash floor over its 576-block window; +/// the system limits of protocol version 14 set a lower percent and floor, and the caller picks +/// the highest window start balance of a band around Core's window, so the result never +/// exceeds what Core admits. Integer arithmetic in u128 throughout; truncation only ever makes +/// the limit stricter. +pub(super) fn core_credit_pool_unlock_limit_v0( + balance: Credits, + window_start_balance: Credits, + platform_version: &PlatformVersion, +) -> Result { + let percent = platform_version + .system_limits + .core_credit_pool_unlock_limit_percent + .ok_or_else(|| { + ProtocolError::CorruptedCodeExecution( + "core_credit_pool_unlock_limit v0 requires system_limits.core_credit_pool_unlock_limit_percent" + .to_string(), + ) + })?; + + let floor = platform_version + .system_limits + .core_credit_pool_unlock_limit_floor + .ok_or_else(|| { + ProtocolError::CorruptedCodeExecution( + "core_credit_pool_unlock_limit v0 requires system_limits.core_credit_pool_unlock_limit_floor" + .to_string(), + ) + })?; + + let allowed_drop = + ((window_start_balance as u128) * (percent as u128) / 100).max(floor as u128); + + // What may leave: the allowed drop plus whatever the pool gained since the window start, + // or minus whatever it already lost. u128 holds the sum of any two u64 values. + let withdrawable = + (allowed_drop + balance as u128).saturating_sub(window_start_balance as u128); + + let limit = withdrawable.min(balance as u128); + + Credits::try_from(limit).map_err(|_| ProtocolError::Overflow("core credit pool unlock limit")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dash_to_credits; + + fn limit(balance: Credits, window_start_balance: Credits) -> Credits { + core_credit_pool_unlock_limit_v0(balance, window_start_balance, PlatformVersion::latest()) + .expect("expected the limit") + } + + #[test] + fn should_allow_the_percent_of_an_unchanged_pool() { + // 15% of 37,000 Dash, the mainnet pool #7712 measured. + assert_eq!( + limit(dash_to_credits!(37000), dash_to_credits!(37000)), + dash_to_credits!(5550) + ); + } + + #[test] + fn should_apply_the_floor_to_a_small_pool() { + // 15% of 5,000 Dash is 750 Dash, below the 1,500 Dash floor. + assert_eq!( + limit(dash_to_credits!(5000), dash_to_credits!(5000)), + dash_to_credits!(1500) + ); + } + + #[test] + fn should_add_what_the_pool_gained_inside_the_window() { + // A 4,000 Dash deposit inside the window is withdrawable on top of the allowed drop. + assert_eq!( + limit(dash_to_credits!(41000), dash_to_credits!(37000)), + dash_to_credits!(9550) + ); + } + + #[test] + fn should_subtract_what_the_pool_already_lost_inside_the_window() { + // 2,000 Dash already unlocked inside the window leaves 3,550 of the 5,550 allowed. + assert_eq!( + limit(dash_to_credits!(35000), dash_to_credits!(37000)), + dash_to_credits!(3550) + ); + // Once the drop reaches the allowance nothing is left, and it never goes negative. + assert_eq!(limit(dash_to_credits!(31450), dash_to_credits!(37000)), 0); + assert_eq!(limit(dash_to_credits!(20000), dash_to_credits!(37000)), 0); + } + + #[test] + fn should_never_exceed_the_pool() { + // A pool that grew from nothing inside the window: everything in it is withdrawable, + // but no more than it holds. + assert_eq!(limit(dash_to_credits!(1000), 0), dash_to_credits!(1000)); + assert_eq!(limit(0, 0), 0); + } + + #[test] + fn should_stay_below_cores_own_v24_limit() { + // Core v24: max(20% of the window start, 2000 Dash) - (window start - balance), at most + // the balance, over the same window. + fn core_v24(balance: Credits, window_start_balance: Credits) -> Credits { + let allowed_drop = (window_start_balance / 5).max(dash_to_credits!(2000)); + (allowed_drop + balance) + .saturating_sub(window_start_balance) + .min(balance) + } + + for (balance, window_start_balance) in [ + (dash_to_credits!(37000), dash_to_credits!(37000)), + (dash_to_credits!(41000), dash_to_credits!(37000)), + (dash_to_credits!(35000), dash_to_credits!(37000)), + (dash_to_credits!(5000), dash_to_credits!(5000)), + (dash_to_credits!(12000), dash_to_credits!(11000)), + (dash_to_credits!(1000), 0), + ] { + assert!( + limit(balance, window_start_balance) <= core_v24(balance, window_start_balance), + "balance {balance}, window start {window_start_balance}" + ); + } + } + + #[test] + fn should_not_overflow_at_the_largest_balances() { + assert_eq!( + limit(Credits::MAX, Credits::MAX), + ((Credits::MAX as u128) * 15 / 100) as Credits + ); + assert_eq!(limit(Credits::MAX, 0), Credits::MAX); + assert_eq!(limit(0, Credits::MAX), 0); + } + + #[test] + fn should_fail_when_the_limits_are_not_configured() { + let mut platform_version = PlatformVersion::latest().clone(); + platform_version + .system_limits + .core_credit_pool_unlock_limit_percent = None; + assert!(matches!( + core_credit_pool_unlock_limit_v0(1, 1, &platform_version), + Err(ProtocolError::CorruptedCodeExecution(_)) + )); + + let mut platform_version = PlatformVersion::latest().clone(); + platform_version + .system_limits + .core_credit_pool_unlock_limit_floor = None; + assert!(matches!( + core_credit_pool_unlock_limit_v0(1, 1, &platform_version), + Err(ProtocolError::CorruptedCodeExecution(_)) + )); + } +} diff --git a/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/mod.rs b/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/mod.rs index a4a777cd499..ae472dc950b 100644 --- a/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/mod.rs +++ b/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/mod.rs @@ -14,8 +14,8 @@ mod v2; /// total credits in Platform for version 0 (10% of it, bounded; required), ignored /// by version 1 (a flat 2000 Dash), and the total credits Platform held a day ago /// for version 2 (`daily_withdrawal_limit_percent` of it, never below one maximal -/// withdrawal nor above `max_daily_withdrawal_amount`; the flat limit of version 1 -/// while that day-old total is not known yet). +/// withdrawal nor above `max_daily_withdrawal_amount` when that is set; the flat +/// limit of version 1 while that day-old total is not known yet). pub fn daily_withdrawal_limit( reference_total_credits: Option, platform_version: &PlatformVersion, @@ -52,9 +52,9 @@ mod tests { (dash_to_credits!(50), dash_to_credits!(500)), (dash_to_credits!(2000), dash_to_credits!(500)), (dash_to_credits!(20000), dash_to_credits!(3000)), - // Above Core's unlock capacity per day (4000 Dash) the limit is capped there. - (dash_to_credits!(30000), dash_to_credits!(4000)), - (dash_to_credits!(1000000), dash_to_credits!(4000)), + // No fixed cap: what Core will mine is bounded by the Core-anchored limit instead. + (dash_to_credits!(30000), dash_to_credits!(4500)), + (dash_to_credits!(1000000), dash_to_credits!(150000)), ] { // v13 keeps the flat 2000 Dash whatever the total is. assert_eq!( diff --git a/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/v2/mod.rs b/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/v2/mod.rs index 1ae243bd992..98dc3944256 100644 --- a/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/v2/mod.rs +++ b/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/v2/mod.rs @@ -12,9 +12,10 @@ use platform_version::version::PlatformVersion; /// * it is never below `max_withdrawal_amount`, so every withdrawal Platform /// accepts eventually fits the daily maximum and cannot block the pooling /// queue behind it; -/// * it is never above `max_daily_withdrawal_amount`, Core's credit-pool unlock -/// capacity per day: pooling more than Core will mine only cycles those -/// unlocks through expiry and re-signing; +/// * it is never above `max_daily_withdrawal_amount` when the protocol version +/// sets one. Protocol version 14 sets none: pooling more than Core will mine +/// is prevented instead by the Core-anchored limit pooling also applies, +/// which follows Core's own credit pool rather than a fixed figure; /// * while the total credits a day ago are not known (`None`: the history is /// younger than a day, i.e. right after this rule activates), the flat limit /// of version 1 applies, so the lag cannot be skipped by inflating the total @@ -37,23 +38,19 @@ pub fn daily_withdrawal_limit_v2( ) })?; - let max_daily_withdrawal_amount = platform_version - .system_limits - .max_daily_withdrawal_amount - .ok_or_else(|| { - ProtocolError::CorruptedCodeExecution( - "daily_withdrawal_limit v2 requires system_limits.max_daily_withdrawal_amount" - .to_string(), - ) - })?; + // Optional: protocol version 14 leaves it unset, and this generation is selected by no + // shipped protocol version, so making the cap optional changes no replayed block. + let max_daily_withdrawal_amount = platform_version.system_limits.max_daily_withdrawal_amount; let max_withdrawal_amount = platform_version.system_limits.max_withdrawal_amount; - if max_daily_withdrawal_amount < max_withdrawal_amount { - // A cap below one maximal withdrawal would let an accepted withdrawal never fit the - // daily maximum; that is a contradictory configuration, not a limit to apply. - return Err(ProtocolError::CorruptedCodeExecution(format!( - "daily_withdrawal_limit v2 requires system_limits.max_daily_withdrawal_amount ({max_daily_withdrawal_amount}) to be at least max_withdrawal_amount ({max_withdrawal_amount})" - ))); + if let Some(max_daily_withdrawal_amount) = max_daily_withdrawal_amount { + if max_daily_withdrawal_amount < max_withdrawal_amount { + // A cap below one maximal withdrawal would let an accepted withdrawal never fit the + // daily maximum; that is a contradictory configuration, not a limit to apply. + return Err(ProtocolError::CorruptedCodeExecution(format!( + "daily_withdrawal_limit v2 requires system_limits.max_daily_withdrawal_amount ({max_daily_withdrawal_amount}) to be at least max_withdrawal_amount ({max_withdrawal_amount})" + ))); + } } // u128 keeps `total * percent` from overflowing for any u64 total. @@ -61,9 +58,12 @@ pub fn daily_withdrawal_limit_v2( let relative_limit = Credits::try_from(relative_limit) .map_err(|_| ProtocolError::Overflow("daily withdrawal limit overflow"))?; - Ok(relative_limit - .max(max_withdrawal_amount) - .min(max_daily_withdrawal_amount)) + let relative_limit = relative_limit.max(max_withdrawal_amount); + + Ok(match max_daily_withdrawal_amount { + Some(max_daily_withdrawal_amount) => relative_limit.min(max_daily_withdrawal_amount), + None => relative_limit, + }) } #[cfg(test)] @@ -121,10 +121,10 @@ mod tests { } #[test] - fn should_never_exceed_cores_unlock_capacity_per_day() { + fn should_never_exceed_the_cap_when_one_is_set() { let platform_version = platform_version_with(Some(15)); - // 15% of 30000 Dash is 4500 Dash, above what Core mines per day. + // 15% of 30000 Dash is 4500 Dash, above the configured 4000 Dash cap. assert_eq!( daily_withdrawal_limit_v2(Some(dash_to_credits!(30000)), &platform_version) .expect("expected limit"), @@ -154,19 +154,46 @@ mod tests { } #[test] - fn should_fail_when_the_percent_or_the_cap_is_not_configured() { + fn should_fail_when_the_percent_is_not_configured() { let platform_version = platform_version_with(None); assert!(matches!( daily_withdrawal_limit_v2(Some(dash_to_credits!(100)), &platform_version), Err(ProtocolError::CorruptedCodeExecution(_)) )); + } + #[test] + fn should_apply_no_cap_when_none_is_configured() { let mut platform_version = platform_version_with(Some(15)); platform_version.system_limits.max_daily_withdrawal_amount = None; - assert!(matches!( - daily_withdrawal_limit_v2(Some(dash_to_credits!(100)), &platform_version), - Err(ProtocolError::CorruptedCodeExecution(_)) - )); + + // 15% of 30000 Dash is 4500 Dash, uncapped. + assert_eq!( + daily_withdrawal_limit_v2(Some(dash_to_credits!(30000)), &platform_version) + .expect("expected limit"), + dash_to_credits!(4500) + ); + // The floor still applies. + assert_eq!( + daily_withdrawal_limit_v2(Some(dash_to_credits!(100)), &platform_version) + .expect("expected limit"), + dash_to_credits!(500) + ); + } + + #[test] + fn should_leave_the_latest_relative_limit_uncapped() { + let platform_version = PlatformVersion::latest(); + + assert_eq!( + platform_version.system_limits.max_daily_withdrawal_amount, + None + ); + assert_eq!( + daily_withdrawal_limit_v2(Some(dash_to_credits!(100000)), platform_version) + .expect("expected limit"), + dash_to_credits!(15000) + ); } #[test] diff --git a/packages/rs-dpp/src/withdrawal/mod.rs b/packages/rs-dpp/src/withdrawal/mod.rs index 7eb217edbe7..b33fc05f110 100644 --- a/packages/rs-dpp/src/withdrawal/mod.rs +++ b/packages/rs-dpp/src/withdrawal/mod.rs @@ -1,3 +1,4 @@ +pub mod core_credit_pool_unlock_limit; mod core_dust_threshold; pub mod daily_withdrawal_limit; #[cfg(all(feature = "withdrawals-contract", feature = "system_contracts"))] diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index d6ea3b552b8..c65791c6ddf 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -4,6 +4,8 @@ use crate::execution::validation::state_transition::check_tx_verification::state use crate::execution::validation::state_transition::processor::traits::shielded_proof::{ StateTransitionHasShieldedProofValidationV0, StateTransitionShieldedProofValidationV0, }; +#[cfg(test)] +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::check_tx_proof_verifier::IdentityProofVerification; #[cfg(test)] @@ -72,8 +74,8 @@ where errors, state_read_guard.last_block_info(), transaction, - None, // address_balances_in_update not needed for check_tx - &mut 0, // check_tx's transaction is discarded, its mints are never recorded + None, // address_balances_in_update not needed for check_tx + &mut BlockCreditMints::default(), // check_tx's transaction is discarded, its mints are never recorded platform_ref.state.current_platform_version()?, platform_ref.state.previous_fee_versions(), ) diff --git a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs index 15d8242f2f3..bd24c52d366 100644 --- a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs @@ -487,9 +487,8 @@ where // inflows younger than its day-old base to the daily maximum, so it limits net outflow. // A system event, so nobody pays fees for the write. self.record_credit_inflows_for_withdrawals( - state_transitions_result - .credit_mints() - .saturating_add(processed_block_fees.credit_mints), + state_transitions_result.credit_mints(), + processed_block_fees.credit_mints, &block_info, transaction, platform_version, diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 229787b8b78..5af1dc88332 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -19,9 +19,10 @@ use drive::drive::identity::key::fetch::{ IdentityKeysRequest, KeyIDIdentityPublicKeyPairBTreeMap, KeyRequestType, }; use drive::drive::identity::withdrawals::paths::{ - get_withdrawal_root_path, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, - WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY, WITHDRAWAL_TRANSACTIONS_BROADCASTED_KEY, - WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, + get_withdrawal_root_path, WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, + WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, + WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY, + WITHDRAWAL_TRANSACTIONS_BROADCASTED_KEY, WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, }; use drive::drive::prefunded_specialized_balances::prefunded_specialized_balances_for_voting_path_vec; use drive::drive::saved_block_transactions::{ @@ -792,6 +793,34 @@ impl Platform { &platform_version.drive, )?; + // The Core-anchored withdrawal limit: Core's credit pool balance per Core block read, + // the asset locks consumed before Core mined them, and the asset lock credit inflows + // dated by the Core block that mined them. Created in the same order as the initial + // structure creates them. + for (key, tree) in [ + ( + WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, + Element::empty_tree(), + ), + ( + WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, + Element::empty_tree(), + ), + ( + WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, + Element::empty_sum_tree(), + ), + ] { + self.drive.grove_insert_if_not_exists( + get_withdrawal_root_path().as_slice().into(), + &key, + tree, + Some(transaction), + None, + &platform_version.drive, + )?; + } + // Contract version items: from this version the storage writer stores every // contract's version as a four-byte item beside it, and // `getDataContractsLatestVersions` reads and proves that item instead of the @@ -2309,6 +2338,9 @@ mod tests { for key in [ &WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY, &WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, + &WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, + &WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, + &WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, ] { assert!(platform .drive @@ -2359,6 +2391,26 @@ mod tests { .expect("credit inflows sum tree should exist after the v14 transition"); assert!(element.is_sum_tree()); + for (key, is_sum_tree) in [ + (&WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, false), + (&WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, false), + (&WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, true), + ] { + let element = platform + .drive + .grove + .get( + SubtreePath::from(&get_withdrawal_root_path()), + key, + Some(&transaction), + &platform_version.drive.grove_version, + ) + .value + .expect("the Core-anchored withdrawal limit trees should exist after the v14 transition"); + assert!(element.is_any_tree()); + assert_eq!(element.is_sum_tree(), is_sum_tree); + } + // Running it again is harmless and the tree stays usable platform .transition_to_version_14(&block_info, &transaction, platform_version) diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/mod.rs index 6ef82d537fd..8ef44781b26 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/mod.rs @@ -4,6 +4,7 @@ mod v1; use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::execution_event::ExecutionEvent; +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EventExecutionResult; use crate::platform_types::platform::Platform; use std::collections::BTreeMap; @@ -14,7 +15,6 @@ use dpp::balances::credits::CreditOperation; use dpp::block::block_info::BlockInfo; use dpp::consensus::ConsensusError; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; -use dpp::fee::Credits; use dpp::version::PlatformVersion; use drive::grovedb::Transaction; @@ -54,7 +54,7 @@ where block_info: &BlockInfo, transaction: &Transaction, address_balances_in_update: Option<&mut BTreeMap>, - block_credit_mints: &mut Credits, + block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index 6bab00c6722..b3eb802afe0 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -3,6 +3,7 @@ use crate::error::Error; use crate::execution::platform_events::state_transition_processing::record_added_balance_outputs::AddedBalanceOutputsOrigin; use crate::execution::types::execution_event::ExecutionEvent; use crate::execution::types::execution_operation::ValidationOperation; +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EventExecutionResult; use crate::platform_types::event_execution_result::EventExecutionResult::{ SuccessfulFreeExecution, SuccessfulPaidExecution, UnpaidConsensusExecutionError, @@ -43,12 +44,12 @@ where block_info: &BlockInfo, mut consensus_errors: Vec, transaction: &Transaction, - block_credit_mints: &mut Credits, + block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { if fee_validation_result.is_valid_with_data() { - let credit_mints = DriveOperation::credit_mints(&operations); + let credit_mints = BlockCreditMints::of_operations(&operations); //todo: make this into an atomic event with partial batches let mut individual_fee_result = self .drive @@ -62,7 +63,7 @@ where ) .map_err(Error::Drive)?; - *block_credit_mints = block_credit_mints.saturating_add(credit_mints); + block_credit_mints.add(credit_mints); ValidationOperation::add_many_to_fee_result( &execution_operations, @@ -120,12 +121,12 @@ where mut consensus_errors: Vec, transaction: &Transaction, mut address_balances_in_update: Option<&mut BTreeMap>, - block_credit_mints: &mut Credits, + block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { if fee_validation_result.is_valid_with_data() { - let credit_mints = DriveOperation::credit_mints(&operations); + let credit_mints = BlockCreditMints::of_operations(&operations); // Apply the drive operations first to calculate the fee let mut individual_fee_result = self .drive @@ -139,7 +140,7 @@ where ) .map_err(Error::Drive)?; - *block_credit_mints = block_credit_mints.saturating_add(credit_mints); + block_credit_mints.add(credit_mints); ValidationOperation::add_many_to_fee_result( &execution_operations, @@ -378,7 +379,7 @@ where block_info: &BlockInfo, transaction: &Transaction, address_balances_in_update: Option<&mut BTreeMap>, - block_credit_mints: &mut Credits, + block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { @@ -500,7 +501,7 @@ where processing_fees, operations, } => { - let credit_mints = DriveOperation::credit_mints(&operations); + let credit_mints = BlockCreditMints::of_operations(&operations); self.drive .apply_drive_operations( operations, @@ -512,7 +513,7 @@ where ) .map_err(Error::Drive)?; - *block_credit_mints = block_credit_mints.saturating_add(credit_mints); + block_credit_mints.add(credit_mints); if consensus_errors.is_empty() { Ok(SuccessfulPaidExecution( @@ -532,7 +533,7 @@ where fees_to_add_to_pool, } => { if consensus_errors.is_empty() { - let credit_mints = DriveOperation::credit_mints(&operations); + let credit_mints = BlockCreditMints::of_operations(&operations); self.drive .apply_drive_operations( operations, @@ -544,7 +545,7 @@ where ) .map_err(Error::Drive)?; - *block_credit_mints = block_credit_mints.saturating_add(credit_mints); + block_credit_mints.add(credit_mints); Ok(SuccessfulPaidExecution( None, @@ -585,7 +586,7 @@ where return Ok(UnpaidConsensusExecutionError(consensus_errors)); } - let credit_mints = DriveOperation::credit_mints(&operations); + let credit_mints = BlockCreditMints::of_operations(&operations); let applied_fees = self .drive .apply_drive_operations( @@ -598,7 +599,7 @@ where ) .map_err(Error::Drive)?; - *block_credit_mints = block_credit_mints.saturating_add(credit_mints); + block_credit_mints.add(credit_mints); // The ops just applied credited any transparent output address (an Unshield's // recipient, including the chargeable-failure fallback address). Record that credit @@ -647,7 +648,7 @@ where all_errors.extend(consensus_errors); if all_errors.is_empty() { - let credit_mints = DriveOperation::credit_mints(&operations); + let credit_mints = BlockCreditMints::of_operations(&operations); let applied_fees = self .drive .apply_drive_operations( @@ -660,7 +661,7 @@ where ) .map_err(Error::Drive)?; - *block_credit_mints = block_credit_mints.saturating_add(credit_mints); + block_credit_mints.add(credit_mints); // The ops just applied credited the shield's transparent surplus-output address // (when set). Record that credit so incremental client sync sees it. ShieldedSpend @@ -727,7 +728,7 @@ where ) } ExecutionEvent::Free { operations } => { - let credit_mints = DriveOperation::credit_mints(&operations); + let credit_mints = BlockCreditMints::of_operations(&operations); self.drive .apply_drive_operations( operations, @@ -738,7 +739,7 @@ where Some(previous_fee_versions), ) .map_err(Error::Drive)?; - *block_credit_mints = block_credit_mints.saturating_add(credit_mints); + block_credit_mints.add(credit_mints); Ok(SuccessfulFreeExecution) } } @@ -785,7 +786,7 @@ mod tests { &BlockInfo::default(), &transaction, Some(&mut address_balances), - &mut 0, + &mut BlockCreditMints::default(), platform_version, &fee_versions, ) @@ -831,7 +832,7 @@ mod tests { &BlockInfo::default(), &transaction, Some(&mut address_balances), - &mut 0, + &mut BlockCreditMints::default(), platform_version, &fee_versions, ) @@ -874,7 +875,7 @@ mod tests { &BlockInfo::default(), &transaction, Some(&mut address_balances), - &mut 0, + &mut BlockCreditMints::default(), platform_version, &fee_versions, ) diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs index 9c32fec557e..a3bd4818849 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs @@ -5,6 +5,7 @@ use crate::execution::platform_events::state_transition_processing::validate_fee use crate::execution::types::execution_event::ExecutionEvent; use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::signing_key_limits::SigningKeyLimits; +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EventExecutionResult; use crate::platform_types::event_execution_result::EventExecutionResult::{ SuccessfulPaidExecution, UnpaidConsensusExecutionError, UnsuccessfulPaidExecution, @@ -17,12 +18,10 @@ use dpp::block::block_info::BlockInfo; use dpp::consensus::ConsensusError; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::fee::fee_result::BalanceChange; -use dpp::fee::Credits; use dpp::version::PlatformVersion; use drive::drive::identity::update::apply_balance_change_outcome::ApplyBalanceChangeOutcomeV0Methods; use drive::grovedb::Transaction; use drive::state_transition_action::batch::{action_fee_operations, action_fees_total}; -use drive::util::batch::DriveOperation; use std::collections::BTreeMap; impl Platform @@ -62,7 +61,7 @@ where block_info: &BlockInfo, transaction: &Transaction, address_balances_in_update: Option<&mut BTreeMap>, - block_credit_mints: &mut Credits, + block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { @@ -162,7 +161,7 @@ where action_fees_total(&identity.id, &action_fees)? }; - let credit_mints = DriveOperation::credit_mints(&operations); + let credit_mints = BlockCreditMints::of_operations(&operations); let mut individual_fee_result = self .drive .apply_drive_operations( @@ -175,7 +174,7 @@ where ) .map_err(Error::Drive)?; - *block_credit_mints = block_credit_mints.saturating_add(credit_mints); + block_credit_mints.add(credit_mints); ValidationOperation::add_many_to_fee_result( &execution_operations, diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs index 41ee5c9a904..4d0db904a75 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs @@ -1,10 +1,10 @@ use crate::error::Error; +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::platform::{Platform, PlatformRef}; use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; use dpp::consensus::codes::ErrorWithCode; -use dpp::fee::Credits; use crate::execution::types::state_transition_container::v0::{ DecodedStateTransition, InvalidStateTransition, InvalidWithProtocolErrorStateTransition, @@ -141,7 +141,7 @@ where // Credits the block's applied operations mint into Platform (asset locks), summed // across state transitions and recorded once per block as a credit inflow the net // daily withdrawal limit adds to its daily maximum. - let mut block_credit_mints: Credits = 0; + let mut block_credit_mints = BlockCreditMints::default(); for decoded_state_transition in state_transition_container.into_iter() { // If we propose state transitions, we need to check if we have a time limit for processing @@ -186,7 +186,8 @@ where if rollback_dropped_transitions { transaction.set_savepoint(); } - let credit_mints_at_savepoint = block_credit_mints; + let credit_mints_at_savepoint = + rollback_dropped_transitions.then(|| block_credit_mints.clone()); // Validate state transition and produce an execution event let execution_result = process_state_transition( @@ -238,7 +239,11 @@ where // its mints with them, or the block would record a // credit inflow for a transition the proposal omits and // validators re-executing it would compute other state. - block_credit_mints = credit_mints_at_savepoint; + if let Some(credit_mints_at_savepoint) = + credit_mints_at_savepoint + { + block_credit_mints = credit_mints_at_savepoint; + } // Any contract the transition rewrote was re-seeded into // the block cache as it was applied, and the rollback // just reverted it in state. Drop those copies so the diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs index f1cb18611ed..99e9de6b3ee 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs @@ -5,12 +5,12 @@ use super::StateTransitionAwareError; use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::execution_event::ExecutionEvent; +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::platform::Platform; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; -use dpp::fee::Credits; use dpp::validation::ConsensusValidationResult; use dpp::version::PlatformVersion; use drive::grovedb::Transaction; @@ -57,7 +57,7 @@ where validation_result: ConsensusValidationResult, block_info: &BlockInfo, transaction: &Transaction, - block_credit_mints: &mut Credits, + block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result> { @@ -150,7 +150,7 @@ mod tests { validation_result, &BlockInfo::default(), &transaction, - &mut 0, + &mut BlockCreditMints::default(), platform_version, &fee_versions, ) @@ -161,7 +161,7 @@ mod tests { validation_result, &BlockInfo::default(), &transaction, - &mut 0, + &mut BlockCreditMints::default(), platform_version, &fee_versions, ) diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs index c5ff68078dc..bfaf262441a 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs @@ -1,5 +1,6 @@ use super::super::StateTransitionAwareError; use crate::execution::types::execution_event::ExecutionEvent; +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EventExecutionResult; use crate::platform_types::platform::Platform; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; @@ -7,7 +8,6 @@ use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::fee::fee_result::FeeResult; -use dpp::fee::Credits; use dpp::util::hash::hash_single; use dpp::validation::ConsensusValidationResult; use dpp::version::PlatformVersion; @@ -33,7 +33,7 @@ where mut validation_result: ConsensusValidationResult, block_info: &BlockInfo, transaction: &Transaction, - block_credit_mints: &mut Credits, + block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result> { diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs index d0ddd84cb6b..81857ef9a58 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs @@ -1,5 +1,6 @@ use super::super::StateTransitionAwareError; use crate::execution::types::execution_event::ExecutionEvent; +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EventExecutionResult; use crate::platform_types::platform::Platform; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; @@ -7,7 +8,6 @@ use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::fee::fee_result::FeeResult; -use dpp::fee::Credits; use dpp::util::hash::hash_single; use dpp::validation::ConsensusValidationResult; use dpp::version::PlatformVersion; @@ -32,7 +32,7 @@ where mut validation_result: ConsensusValidationResult, block_info: &BlockInfo, transaction: &Transaction, - block_credit_mints: &mut Credits, + block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result> { diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs new file mode 100644 index 00000000000..446f487bca9 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs @@ -0,0 +1,66 @@ +mod v0; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; + +impl Platform +where + C: CoreRPCLike, +{ + /// How many more credits withdrawals pooled now may take out of Core's credit pool: a + /// stricter copy of Core's own asset unlock limit (`core_credit_pool_unlock_limit`), less + /// what is pooled and not mined yet. It reads Core's credit pool balance at the block's + /// chain locked height, and the highest balance among the window starts of a band around + /// Core's own window (`core_credit_pool_window_min_blocks` to + /// `core_credit_pool_window_max_blocks` back): the far edge leaves room around Core's 576 + /// blocks, and the near edge covers the blocks an unlock waits to be mined while Core's + /// window moves on and older deposits leave it. A balance the scan has not recorded yet is + /// read from Core, which every node answers alike for a chain locked height. + /// + /// # Parameters + /// + /// * `block_info`: The block being executed; its Core chain locked height is the newest + /// Core block the limit reads. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Credits)`: The credits still available on the Core side. + /// * `Err(Error)` when the method version is unknown or not active, a system limit it + /// reads is not configured, Core cannot be asked, or a read fails. + pub(in crate::execution) fn calculate_core_anchored_withdrawal_limit( + &self, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .methods + .withdrawals + .calculate_core_anchored_withdrawal_limit + { + Some(0) => self.calculate_core_anchored_withdrawal_limit_v0( + block_info, + transaction, + platform_version, + ), + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "calculate_core_anchored_withdrawal_limit".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "calculate_core_anchored_withdrawal_limit".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs new file mode 100644 index 00000000000..db4b0b14c1c --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs @@ -0,0 +1,345 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::balances::credits::CREDITS_PER_DUFF; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use dpp::withdrawal::core_credit_pool_unlock_limit::core_credit_pool_unlock_limit; +use drive::grovedb::TransactionArg; + +impl Platform +where + C: CoreRPCLike, +{ + pub(super) fn calculate_core_anchored_withdrawal_limit_v0( + &self, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let system_limits = &platform_version.system_limits; + let window_min_blocks = system_limits.core_credit_pool_window_min_blocks.ok_or( + Error::Execution(ExecutionError::CorruptedCodeExecution( + "calculate_core_anchored_withdrawal_limit v0 requires system_limits.core_credit_pool_window_min_blocks", + )), + )?; + let window_max_blocks = system_limits.core_credit_pool_window_max_blocks.ok_or( + Error::Execution(ExecutionError::CorruptedCodeExecution( + "calculate_core_anchored_withdrawal_limit v0 requires system_limits.core_credit_pool_window_max_blocks", + )), + )?; + + let chain_locked_height = block_info.core_height; + + // The window starts of the band that exist on the chain. One before the chain's start + // has no credit pool, which Core reads as a balance of 0: it never raises the highest. + let window_start_balance = match chain_locked_height.checked_sub(window_min_blocks) { + None => 0, + Some(nearest_window_start) => { + let farthest_window_start = chain_locked_height.saturating_sub(window_max_blocks); + let recorded = self.drive.fetch_core_credit_pool_balances( + farthest_window_start..=nearest_window_start, + transaction, + platform_version, + )?; + let mut highest: Credits = 0; + for core_height in farthest_window_start..=nearest_window_start { + let balance = match recorded.get(&core_height) { + Some(balance) => *balance, + None => self.core_credit_pool_balance_from_core(core_height)?, + }; + highest = highest.max(balance); + } + highest + } + }; + + let balance = match self + .drive + .fetch_core_credit_pool_balances( + chain_locked_height..=chain_locked_height, + transaction, + platform_version, + )? + .get(&chain_locked_height) + { + Some(balance) => *balance, + None => self.core_credit_pool_balance_from_core(chain_locked_height)?, + }; + + let limit = core_credit_pool_unlock_limit(balance, window_start_balance, platform_version)?; + + // Core's own limit only reflects unlocks already mined. + let in_flight = self + .drive + .fetch_in_flight_withdrawal_amount(transaction, platform_version)?; + + Ok(limit.saturating_sub(in_flight)) + } + + /// Core's credit pool balance after the chain locked Core block at `core_height`, in + /// credits, read from Core because the scan has not recorded it (yet). + fn core_credit_pool_balance_from_core(&self, core_height: u32) -> Result { + self.core_rpc + .get_credit_pool_block(core_height)? + .credit_pool_balance + .checked_mul(CREDITS_PER_DUFF) + .ok_or(Error::Execution(ExecutionError::Overflow( + "core credit pool balance in credits", + ))) + } +} + +#[cfg(test)] +mod tests { + use crate::rpc::core::{CoreCreditPoolBlock, MockCoreRPCLike}; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::block::block_info::BlockInfo; + use dpp::dash_to_credits; + use dpp::dashcore::consensus::Encodable; + use dpp::dashcore::transaction::special_transaction::asset_unlock::unqualified_asset_unlock::{ + AssetUnlockBasePayload, AssetUnlockBaseTransactionInfo, + }; + use dpp::dashcore::{ScriptBuf, TxOut}; + use dpp::version::PlatformVersion; + use drive::util::batch::DriveOperation; + + const DUFFS_PER_DASH: u64 = 100_000_000; + + /// A Core whose credit pool balance at each height is `balance_at(height)` Dash. + fn core_with_balances(balance_at: fn(u32) -> u64) -> MockCoreRPCLike { + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc + .expect_get_credit_pool_block() + .returning(move |core_height| { + Ok(CoreCreditPoolBlock { + credit_pool_balance: balance_at(core_height) * DUFFS_PER_DASH, + asset_lock_txids: vec![], + }) + }); + core_rpc + } + + fn block(core_height: u32) -> BlockInfo { + BlockInfo { + core_height, + ..Default::default() + } + } + + #[test] + fn should_allow_the_percent_of_an_unchanged_pool() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + platform.core_rpc = core_with_balances(|_| 37_000); + let transaction = platform.drive.grove.start_transaction(); + + assert_eq!( + platform + .calculate_core_anchored_withdrawal_limit( + &block(10_000), + Some(&transaction), + PlatformVersion::latest() + ) + .expect("expected the limit"), + dash_to_credits!(5550) + ); + } + + /// The edge case the whole limit exists for: a large deposit mined a day ago is inside + /// the window start balance, so it adds only the percent of itself, whenever Platform + /// learns of it. + #[test] + fn should_add_only_the_percent_of_a_deposit_mined_before_the_band() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + // 37,000 Dash, plus a 5,000 Dash asset lock mined at Core height 9,000. + platform.core_rpc = core_with_balances( + |core_height| { + if core_height >= 9_000 { + 42_000 + } else { + 37_000 + } + }, + ); + let transaction = platform.drive.grove.start_transaction(); + + // 15% of 42,000: the deposit adds 750, not 5,000. + assert_eq!( + platform + .calculate_core_anchored_withdrawal_limit( + &block(10_000), + Some(&transaction), + PlatformVersion::latest() + ) + .expect("expected the limit"), + dash_to_credits!(6300) + ); + } + + /// A deposit Core still counts in full is withdrawable on top; one about to leave Core's + /// window (inside the band's near edge) already counts as if it had. + #[test] + fn should_count_a_deposit_in_full_only_while_it_is_younger_than_the_band() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + // 37,000 Dash, plus a 5,000 Dash asset lock mined at Core height 9,500. + platform.core_rpc = core_with_balances( + |core_height| { + if core_height >= 9_500 { + 42_000 + } else { + 37_000 + } + }, + ); + let transaction = platform.drive.grove.start_transaction(); + let limit = |core_height: u32| { + platform + .calculate_core_anchored_withdrawal_limit( + &block(core_height), + Some(&transaction), + PlatformVersion::latest(), + ) + .expect("expected the limit") + }; + + // At 10,000 the band is 9,400..=9,448: the deposit counts in full. + assert_eq!(limit(10_000), dash_to_credits!(10550)); + // At 10,052 the band's near edge reaches 9,500: Core still counts it for 24 more + // blocks, but an unlock pooled now may be mined after it leaves Core's window. + assert_eq!(limit(10_052), dash_to_credits!(6300)); + } + + #[test] + fn should_subtract_what_is_pooled_and_not_mined_yet() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + platform.core_rpc = core_with_balances(|_| 37_000); + let platform_version = PlatformVersion::latest(); + let transaction = platform.drive.grove.start_transaction(); + + // A queued withdrawal paying out 1,000 Dash with a 1,000 duff fee. + let untied = AssetUnlockBaseTransactionInfo { + version: 1, + lock_time: 0, + output: vec![TxOut { + value: 1_000 * DUFFS_PER_DASH, + script_pubkey: ScriptBuf::new(), + }], + base_payload: AssetUnlockBasePayload { + version: 1, + index: 0, + fee: 1_000, + }, + }; + let mut bytes = vec![]; + untied + .consensus_encode(&mut bytes) + .expect("expected to encode"); + let mut drive_operations: Vec = vec![]; + platform + .drive + .add_enqueue_untied_withdrawal_transaction_operations( + vec![(0, bytes)], + dash_to_credits!(1000), + &mut drive_operations, + platform_version, + ) + .expect("expected to enqueue"); + platform + .drive + .apply_drive_operations( + drive_operations, + true, + &BlockInfo::default(), + Some(&transaction), + platform_version, + None, + ) + .expect("expected to apply"); + + assert_eq!( + platform + .calculate_core_anchored_withdrawal_limit( + &block(10_000), + Some(&transaction), + platform_version + ) + .expect("expected the limit"), + dash_to_credits!(4550) - 1_000_000 + ); + } + + /// Recorded balances are read from state; Core is asked only for what is missing. + #[test] + fn should_prefer_recorded_balances_to_asking_core() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + let platform_version = PlatformVersion::latest(); + // Core says 37,000 everywhere. + platform.core_rpc = core_with_balances(|_| 37_000); + let transaction = platform.drive.grove.start_transaction(); + + // The scan recorded 40,000 Dash at one window start of the band. + platform + .drive + .record_core_credit_pool_block( + 9_420, + dash_to_credits!(40000), + &[], + &block(10_000), + Some(&transaction), + platform_version, + ) + .expect("expected to record the block"); + + // The highest window start is the recorded 40,000: 15% of it, minus the 3,000 drop. + assert_eq!( + platform + .calculate_core_anchored_withdrawal_limit( + &block(10_000), + Some(&transaction), + platform_version + ) + .expect("expected the limit"), + dash_to_credits!(3000) + ); + } + + #[test] + fn should_treat_window_starts_before_the_chain_as_an_empty_pool() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + platform.core_rpc = core_with_balances(|_| 1_000); + let transaction = platform.drive.grove.start_transaction(); + + // Height 100 has no window start in the chain: the whole pool entered inside the + // window and is withdrawable. + assert_eq!( + platform + .calculate_core_anchored_withdrawal_limit( + &block(100), + Some(&transaction), + PlatformVersion::latest() + ) + .expect("expected the limit"), + dash_to_credits!(1000) + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs index 8ff497632f6..21611aec865 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs @@ -4,21 +4,39 @@ use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; +use crate::error::execution::ExecutionError; use dpp::version::PlatformVersion; use drive::drive::identity::withdrawals::paths::{ - get_withdrawal_credit_inflows_sum_tree_path_vec, get_withdrawal_transactions_sum_tree_path_vec, + get_withdrawal_core_credit_pool_balances_path_vec, + get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec, + get_withdrawal_credit_inflows_sum_tree_path_vec, + get_withdrawal_pending_asset_lock_inflows_path, + get_withdrawal_pending_asset_lock_inflows_path_vec, + get_withdrawal_transactions_sum_tree_path_vec, }; -use drive::grovedb::{MaybeTree, PathQuery, QueryItem, Transaction}; +use drive::drive::identity::withdrawals::{ + core_dated_credit_inflow_key, PendingAssetLockCreditInflow, +}; +use drive::error::drive::DriveError; +use drive::grovedb::query_result_type::QueryResultType; +use drive::grovedb::{Element, MaybeTree, PathQuery, Query, QueryItem, SizedQuery, Transaction}; use drive::util::grove_operations::BatchDeleteApplyType; impl Platform where C: CoreRPCLike, { - /// Version 1 differs from version 0 in also pruning the expired entries of the credit - /// inflows sum tree, which exists from protocol version 14: both trees are keyed by the - /// block time their entries stop counting toward the daily withdrawal limit, on the same - /// 25 hour schedule, and both are pruned with the same per-block limit. + /// Version 1 differs from version 0 in also pruning the trees of the withdrawal limit that + /// exist from protocol version 14, each with the same per-block limit: + /// + /// * the expired entries of the credit inflows sum tree, keyed like the reservations by + /// the block time they stop counting toward the daily withdrawal limit; + /// * the Core-dated credit inflows whose Core height has been reached; + /// * the recorded Core credit pool balances older than the band of window starts the + /// Core-anchored limit reads (`core_credit_pool_window_max_blocks` back); + /// * the asset locks consumed before Core mined them that waited that many Core blocks + /// without Core mining them: they never count. In practice Core mines an InstantSend + /// locked transaction within a block or two, so this only bounds the tree. pub(super) fn cleanup_expired_locks_of_withdrawal_amounts_v1( &self, block_info: &BlockInfo, @@ -61,6 +79,87 @@ where )?; } + let window_max_blocks = platform_version + .system_limits + .core_credit_pool_window_max_blocks + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "cleanup_expired_locks_of_withdrawal_amounts v1 requires system_limits.core_credit_pool_window_max_blocks", + )))?; + let chain_locked_height = block_info.core_height; + + // Keys sort by the Core height an entry stops counting at, then the time it was + // recorded at: every key below (chain locked height + 1, 0) has stopped counting. + let mut range_prunes = vec![( + get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec(), + core_dated_credit_inflow_key(chain_locked_height.saturating_add(1), 0), + )]; + // The Core-anchored limit never reads a balance older than its farthest window start. + if let Some(oldest_read_height) = chain_locked_height.checked_sub(window_max_blocks) { + range_prunes.push(( + get_withdrawal_core_credit_pool_balances_path_vec(), + oldest_read_height.to_be_bytes().to_vec(), + )); + } + + for (path, first_kept_key) in range_prunes { + let mut path_query = + PathQuery::new_single_query_item(path, QueryItem::RangeTo(..first_kept_key)); + path_query.query.limit = Some(limit); + + self.drive.batch_delete_items_in_path_query( + &path_query, + true, + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + Some(transaction), + &mut batch_operations, + &platform_version.drive, + )?; + } + + // Pending entries are keyed by transaction id, so their age is in the value: read a + // bounded batch and drop the ones that waited a whole band of Core blocks. + let mut pending_query = Query::new(); + pending_query.insert_all(); + let (pending, _) = self.drive.grove_get_raw_path_query( + &PathQuery::new( + get_withdrawal_pending_asset_lock_inflows_path_vec(), + SizedQuery::new(pending_query, Some(limit), None), + ), + Some(transaction), + QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + )?; + let pending_path = get_withdrawal_pending_asset_lock_inflows_path(); + for (asset_lock_txid, element) in pending.to_key_elements() { + let Element::Item(value, _) = element else { + return Err(Error::Drive(drive::error::Error::Drive( + DriveError::CorruptedElementType( + "pending asset lock credit inflow is not an item", + ), + ))); + }; + let pending = PendingAssetLockCreditInflow::from_bytes(&value)?; + if pending + .recorded_at_core_height + .saturating_add(window_max_blocks) + <= chain_locked_height + { + self.drive.batch_delete( + (&pending_path).into(), + &asset_lock_txid, + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + Some(transaction), + &mut batch_operations, + &platform_version.drive, + )?; + } + } + self.drive.apply_batch_low_level_drive_operations( None, Some(transaction), @@ -173,4 +272,117 @@ mod tests { assert_eq!(keys, vec![now_ms.to_be_bytes().to_vec()]); } } + + /// The trees of the Core-anchored withdrawal limit are pruned by Core height: Core-dated + /// inflows once their Core height is reached, balances older than the band the limit reads, + /// and asset locks that waited a whole band of Core blocks without Core mining them. + #[test] + fn should_prune_the_core_anchored_trees_by_core_height() { + use drive::drive::identity::withdrawals::paths::{ + get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec, + get_withdrawal_pending_asset_lock_inflows_path_vec, + }; + + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + let transaction = platform.drive.grove.start_transaction(); + let block = |core_height: u32| BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height, + epoch: Epoch::default(), + }; + + // Balances at Core heights 399, 400 and 401. + for core_height in [399, 400, 401] { + platform + .drive + .record_core_credit_pool_block( + core_height, + 1, + &[], + &block(core_height), + Some(&transaction), + platform_version, + ) + .expect("expected to record the block"); + } + // Core-dated inflows mined at 447 and 448: they stop counting at 999 and 1000. + for (asset_lock, mined_at) in [(1u8, 447u32), (2, 448)] { + platform + .drive + .record_asset_lock_credit_inflow( + [asset_lock; 32], + 10, + Some(mined_at), + &block(450), + Some(&transaction), + platform_version, + ) + .expect("expected to record the inflow"); + } + // Pending asset locks recorded at Core heights 400 and 401: dropped at 1000 and 1001. + for (asset_lock, recorded_at) in [(3u8, 400u32), (4, 401)] { + platform + .drive + .record_asset_lock_credit_inflow( + [asset_lock; 32], + 10, + None, + &block(recorded_at), + Some(&transaction), + platform_version, + ) + .expect("expected to record the inflow"); + } + + platform + .cleanup_expired_locks_of_withdrawal_amounts_v1( + &block(1000), + &transaction, + platform_version, + ) + .expect("expected the cleanup to succeed"); + + let keys = |path: Vec>| { + let mut query = Query::new(); + query.insert_all(); + platform + .drive + .grove_get_raw_path_query( + &PathQuery::new(path, SizedQuery::new(query, None, None)), + Some(&transaction), + drive::grovedb::query_result_type::QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to query") + .0 + .to_key_elements() + .into_iter() + .map(|(key, _)| key) + .collect::>() + }; + + // The band at 1000 starts at 400: 399 goes. + assert_eq!( + platform + .drive + .fetch_core_credit_pool_balances(0..=1000, Some(&transaction), platform_version) + .expect("expected the balances") + .into_keys() + .collect::>(), + vec![400, 401] + ); + // The inflow that stops counting at 999 goes, the one at 1000 has just stopped too. + assert!(keys(get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec()).is_empty()); + // The asset lock pending since 400 waited 600 Core blocks; the one since 401 stays. + assert_eq!( + keys(get_withdrawal_pending_asset_lock_inflows_path_vec()), + vec![vec![4u8; 32]] + ); + } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/mod.rs index 5b167ad7818..3d7b93ab02f 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/mod.rs @@ -1,5 +1,6 @@ pub(in crate::execution) mod append_signatures_and_broadcast_withdrawal_transactions; pub(in crate::execution) mod build_untied_withdrawal_transactions_from_documents; +pub(in crate::execution) mod calculate_core_anchored_withdrawal_limit; pub(in crate::execution) mod cleanup_expired_locks_of_withdrawal_amounts; pub(in crate::execution) mod dequeue_and_build_unsigned_withdrawal_transactions; pub(in crate::execution) mod fetch_transactions_block_inclusion_status; @@ -8,4 +9,5 @@ pub(in crate::execution) mod pool_withdrawals_into_transactions_queue; pub(in crate::execution) mod rebroadcast_expired_withdrawal_documents; pub(in crate::execution) mod record_credit_inflows_for_withdrawals; pub(in crate::execution) mod record_total_credits_history_for_withdrawals; +pub(in crate::execution) mod scan_core_blocks_for_withdrawals; pub(in crate::execution) mod update_broadcasted_withdrawal_statuses; diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/mod.rs index 66be57e5e0e..7db143cce67 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/mod.rs @@ -10,6 +10,7 @@ use drive::grovedb::TransactionArg; mod v0; mod v1; +mod v2; impl Platform where @@ -52,9 +53,14 @@ where transaction, platform_version, ), + 2 => self.pool_withdrawals_into_transactions_queue_v2( + block_info, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "pool_withdrawals_into_transactions_queue".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs new file mode 100644 index 00000000000..a6e94c212fc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs @@ -0,0 +1,329 @@ +use dpp::block::block_info::BlockInfo; +use metrics::gauge; + +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::document::DocumentV0Getters; +use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; + +use dpp::system_data_contracts::withdrawals_contract; +use dpp::system_data_contracts::withdrawals_contract::v1::document_types::withdrawal; + +use crate::metrics::{ + GAUGE_CREDIT_WITHDRAWAL_LIMIT_AVAILABLE, GAUGE_CREDIT_WITHDRAWAL_LIMIT_CORE_AVAILABLE, + GAUGE_CREDIT_WITHDRAWAL_LIMIT_TOTAL, +}; +use crate::{ + error::{execution::ExecutionError, Error}, + platform_types::platform::Platform, + rpc::core::CoreRPCLike, +}; + +impl Platform +where + C: CoreRPCLike, +{ + /// Pool withdrawal documents into transactions. + /// + /// Version 2 differs from version 1 only in the amount it pools up to: the smaller of the + /// daily withdrawal limit and the Core-anchored limit, a stricter copy of Core's own asset + /// unlock rule read from Core's credit pool balances. Platform's own accounting can grant + /// more than Core will mine (an asset lock published to Platform long after Core mined it, + /// the epoch Core rewards minted in one block); an unlock over Core's limit waits unmined, + /// expires and is re-signed, and while Core's mempool holds more than the limit, Core + /// InstantSend-locks no withdrawal at all. What the Core side holds back stays queued on + /// Platform instead. It first reads the Core blocks the chain locked height passed + /// (`scan_core_blocks_for_withdrawals`). + pub(super) fn pool_withdrawals_into_transactions_queue_v2( + &self, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + // Bring the Core blocks up to date first, every block whether or not anything is + // queued: their credit pool balances feed the Core-anchored limit below, the asset + // locks they mined are dated for the daily limit, and a long jump of the chain locked + // height is read over several blocks. + self.scan_core_blocks_for_withdrawals(block_info, transaction, platform_version)?; + + let documents = self.drive.fetch_oldest_withdrawal_documents_by_status( + withdrawals_contract::WithdrawalStatus::QUEUED.into(), + platform_version + .system_limits + .withdrawal_transactions_per_block_limit, + transaction, + platform_version, + )?; + + if documents.is_empty() { + tracing::debug!( + height = block_info.height, + withdrawal_limit = platform_version + .system_limits + .withdrawal_transactions_per_block_limit, + "No queued withdrawal documents found to pool into transactions" + ); + return Ok(()); + } + + // Only take documents up to the withdrawal amount + let withdrawals_info = self.drive.calculate_current_withdrawal_limit( + block_info, + transaction, + platform_version, + )?; + + tracing::trace!( + ?withdrawals_info, + documents_count = documents.len(), + "Calculated withdrawal limit info" + ); + + let core_anchored_withdrawal_limit = self.calculate_core_anchored_withdrawal_limit( + block_info, + transaction, + platform_version, + )?; + + tracing::trace!( + core_anchored_withdrawal_limit, + "Calculated Core-anchored withdrawal limit" + ); + + let current_withdrawal_limit = withdrawals_info + .available() + .min(core_anchored_withdrawal_limit); + + // Store prometheus metrics + gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_AVAILABLE).set(withdrawals_info.available() as f64); + gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_TOTAL).set(withdrawals_info.daily_maximum as f64); + gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_CORE_AVAILABLE) + .set(core_anchored_withdrawal_limit as f64); + + // Only process documents up to the current withdrawal limit. + let mut total_withdrawal_amount = 0u64; + + // Iterate over the documents and accumulate their withdrawal amounts. + let mut documents_to_process = vec![]; + for document in documents { + // Get the withdrawal amount from the document properties. + let amount: u64 = document + .properties() + .get_integer(withdrawal::properties::AMOUNT)?; + + // Check if adding this amount would exceed the current withdrawal limit. + let potential_total_withdrawal_amount = + total_withdrawal_amount.checked_add(amount).ok_or_else(|| { + Error::Execution(ExecutionError::Overflow( + "overflow in total withdrawal amount", + )) + })?; + + // If adding this withdrawal would exceed the limit, stop further processing. + if potential_total_withdrawal_amount > current_withdrawal_limit { + tracing::debug!( + "Pooling is limited due to daily withdrawals limit. {} credits left", + current_withdrawal_limit + ); + break; + } + + total_withdrawal_amount = potential_total_withdrawal_amount; + + // Add this document to the list of documents to be processed. + documents_to_process.push(document); + } + + if documents_to_process.is_empty() { + tracing::debug!( + block_info = %block_info, + "No withdrawal documents to process" + ); + return Ok(()); + } + + let start_transaction_index = self + .drive + .fetch_next_withdrawal_transaction_index(transaction, platform_version)?; + + let (withdrawal_transactions, total_amount) = self + .build_untied_withdrawal_transactions_from_documents( + &mut documents_to_process, + start_transaction_index, + block_info, + platform_version, + )?; + + let withdrawal_transactions_count = withdrawal_transactions.len(); + + let mut drive_operations = vec![]; + + self.drive + .add_enqueue_untied_withdrawal_transaction_operations( + withdrawal_transactions, + total_amount, + &mut drive_operations, + platform_version, + )?; + + let end_transaction_index = start_transaction_index + withdrawal_transactions_count as u64; + + self.drive + .add_update_next_withdrawal_transaction_index_operation( + end_transaction_index, + &mut drive_operations, + platform_version, + )?; + + tracing::debug!( + "Pooled {} withdrawal documents into {} transactions with indices from {} to {}", + documents_to_process.len(), + withdrawal_transactions_count, + start_transaction_index, + end_transaction_index, + ); + + let withdrawals_contract = self + .drive + .cache + .system_data_contracts + .load_withdrawals(platform_version)?; + + self.drive.add_update_multiple_documents_operations( + &documents_to_process, + &withdrawals_contract, + withdrawals_contract + .document_type_for_name(withdrawal::NAME) + .map_err(|_| { + Error::Execution(ExecutionError::CorruptedCodeExecution( + "Can't fetch withdrawal data contract", + )) + })?, + &mut drive_operations, + &platform_version.drive, + )?; + + self.drive.apply_drive_operations( + drive_operations, + true, + block_info, + transaction, + platform_version, + None, + )?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rpc::core::{CoreCreditPoolBlock, MockCoreRPCLike}; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::block::epoch::Epoch; + use dpp::data_contracts::SystemDataContract; + use dpp::identifier::Identifier; + use dpp::identity::core_script::CoreScript; + use dpp::platform_value::platform_value; + use dpp::system_data_contracts::load_system_data_contract; + use dpp::tests::fixtures::get_withdrawal_document_fixture; + use dpp::withdrawal::Pooling; + use drive::config::DEFAULT_QUERY_LIMIT; + use drive::util::test_helpers::setup::{setup_document, setup_system_data_contract}; + + /// Pools two queued withdrawals of 1,000 credits each against a Core whose credit pool + /// holds `pool_duffs` at every height, and returns how many were pooled. + fn pooled_with_a_core_pool_of(pool_duffs: u64) -> usize { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc.expect_get_credit_pool_block().returning(move |_| { + Ok(CoreCreditPoolBlock { + credit_pool_balance: pool_duffs, + asset_lock_txids: vec![], + }) + }); + platform.core_rpc = core_rpc; + + let transaction = platform.drive.grove.start_transaction(); + + let block_info = BlockInfo { + time_ms: 1, + height: 1, + core_height: 10_000, + epoch: Epoch::default(), + }; + + let data_contract = + load_system_data_contract(SystemDataContract::Withdrawals, platform_version) + .expect("to load system data contract"); + setup_system_data_contract(&platform.drive, &data_contract, Some(&transaction)); + let document_type = data_contract + .document_type_for_name(withdrawal::NAME) + .expect("expected to get document type"); + + for transaction_index in [1u64, 2] { + let document = get_withdrawal_document_fixture( + &data_contract, + Identifier::new([1u8; 32]), + platform_value!({ + "amount": 1000u64, + "coreFeePerByte": 1u32, + "pooling": Pooling::Never as u8, + "outputScript": CoreScript::from_bytes((0..23).collect::>()), + "status": withdrawals_contract::WithdrawalStatus::QUEUED as u8, + "transactionIndex": transaction_index, + }), + None, + platform_version.protocol_version, + ) + .expect("expected withdrawal document"); + setup_document( + &platform.drive, + &document, + &data_contract, + document_type, + Some(&transaction), + ); + } + + platform + .pool_withdrawals_into_transactions_queue_v2( + &block_info, + Some(&transaction), + platform_version, + ) + .expect("to pool withdrawal documents into transactions"); + + platform + .drive + .fetch_oldest_withdrawal_documents_by_status( + withdrawals_contract::WithdrawalStatus::POOLED.into(), + DEFAULT_QUERY_LIMIT, + Some(&transaction), + platform_version, + ) + .expect("to fetch withdrawal documents") + .len() + } + + #[test] + fn should_pool_what_fits_both_the_daily_limit_and_cores_credit_pool() { + // Plenty in Core's pool: both withdrawals pool. + assert_eq!(pooled_with_a_core_pool_of(1_000_000_000_000), 2); + } + + #[test] + fn should_leave_queued_what_cores_credit_pool_could_not_give_up() { + // A pool of 1 duff, 1,000 credits: it fits one 1,000 credit withdrawal, not two, + // although the daily limit (2,000 Dash before any history) would allow both. + assert_eq!(pooled_with_a_core_pool_of(1), 1); + assert_eq!(pooled_with_a_core_pool_of(0), 0); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs index 8e27a077d9b..61a64eb33bd 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs @@ -2,6 +2,7 @@ mod v0; use crate::error::execution::ExecutionError; use crate::error::Error; +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; @@ -14,30 +15,36 @@ where C: CoreRPCLike, { /// Records the credits this block minted into Platform (asset locks funding state - /// transitions, and the epoch Core block rewards on an epoch change) as a credit inflow + /// transitions, and the epoch Core block rewards on an epoch change) as credit inflows /// the net daily withdrawal limit adds to its daily maximum, so money that entered /// Platform within the window may leave again without consuming the withdrawal budget of - /// other users. + /// other users. Asset lock credits are dated by the Core block that mined each asset lock, + /// the way Core's own unlock limit counts them; one Core has not mined at or below the + /// block's chain locked height waits as pending. The other mints are dated by the block. /// /// Runs as a system event once per block, so nobody pays fees for the write; a block that /// minted nothing writes nothing. /// /// # Parameters /// - /// * `credit_mints`: The credits the block minted into Platform. - /// * `block_info`: The block being executed; its time sets when the inflow expires. + /// * `state_transition_mints`: The credits the block's state transitions minted, per asset + /// lock. + /// * `block_fee_mints`: The credits the block's fee processing minted (epoch Core rewards). + /// * `block_info`: The block being executed; its time dates the other mints, and its Core + /// chain locked height bounds which Core blocks count as mined. /// * `transaction`: The GroveDB transaction. /// * `platform_version`: The platform version. /// /// # Returns /// - /// * `Ok(())` once the inflow is recorded, or at once when `credit_mints` is zero or the + /// * `Ok(())` once the inflows are recorded, or at once when nothing was minted or the /// protocol version has no credit inflows (the method version is `None`). - /// * `Err(Error)` when the method version (or the Drive method it calls) is unknown or not - /// active, or the write fails. + /// * `Err(Error)` when the method version (or a Drive method it calls) is unknown or not + /// active, Core cannot be asked, or a write fails. pub(in crate::execution) fn record_credit_inflows_for_withdrawals( &self, - credit_mints: Credits, + state_transition_mints: &BlockCreditMints, + block_fee_mints: Credits, block_info: &BlockInfo, transaction: &Transaction, platform_version: &PlatformVersion, @@ -50,7 +57,8 @@ where { None => Ok(()), Some(0) => self.record_credit_inflows_for_withdrawals_v0( - credit_mints, + state_transition_mints, + block_fee_mints, block_info, transaction, platform_version, diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs index 789f8cf5542..f3d13adbf90 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs @@ -1,7 +1,10 @@ use crate::error::Error; +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; +use dpp::dashcore::hashes::Hash; +use dpp::dashcore::Txid; use dpp::fee::Credits; use dpp::version::PlatformVersion; use drive::grovedb::Transaction; @@ -10,39 +13,117 @@ impl Platform where C: CoreRPCLike, { - /// Delegates to `Drive::record_credit_inflow`, which records nothing for a zero amount. + /// Mints that name no asset lock (the epoch Core rewards, and in principle a state + /// transition mint without one) are recorded by the block time through + /// `Drive::record_credit_inflow`, which records nothing for a zero amount. Asset lock + /// mints are dated by the Core block that mined each asset lock: Core is asked, once for the + /// whole block, where it mined them, and only a height at or below the block's chain locked + /// height is taken (every node agrees on those); any other asset lock waits as pending until + /// a scanned Core block holds it (`Drive::record_asset_lock_credit_inflow`). pub(super) fn record_credit_inflows_for_withdrawals_v0( &self, - credit_mints: Credits, + state_transition_mints: &BlockCreditMints, + block_fee_mints: Credits, block_info: &BlockInfo, transaction: &Transaction, platform_version: &PlatformVersion, ) -> Result<(), Error> { - self.drive - .record_credit_inflow( - credit_mints, + self.drive.record_credit_inflow( + block_fee_mints + .saturating_add(state_transition_mints.not_attributed_to_an_asset_lock()), + block_info, + Some(transaction), + platform_version, + )?; + + let asset_lock_mints: Vec<([u8; 32], Credits)> = state_transition_mints + .by_asset_lock() + .iter() + .filter(|(_, amount)| **amount > 0) + .map(|(asset_lock_txid, amount)| (*asset_lock_txid, *amount)) + .collect(); + + if asset_lock_mints.is_empty() { + return Ok(()); + } + + let txids: Vec = asset_lock_mints + .iter() + .map(|(asset_lock_txid, _)| Txid::from_byte_array(*asset_lock_txid)) + .collect(); + + let mined_heights = self.core_rpc.get_transactions_mined_heights(&txids)?; + + for ((asset_lock_txid, amount), mined_height) in + asset_lock_mints.into_iter().zip(mined_heights) + { + // A height above the block's chain locked one is not final, and may differ between + // nodes whose Core is further ahead: such an asset lock is not mined yet here. + let mined_at_core_height = + mined_height.filter(|mined_height| *mined_height <= block_info.core_height); + + self.drive.record_asset_lock_credit_inflow( + asset_lock_txid, + amount, + mined_at_core_height, block_info, Some(transaction), platform_version, - ) - .map_err(Error::Drive) + )?; + } + + Ok(()) } } #[cfg(test)] mod tests { + use crate::platform_types::block_credit_mints::BlockCreditMints; + use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; use dpp::block::block_info::BlockInfo; use dpp::block::epoch::Epoch; use dpp::dash_to_credits; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::{OutPoint, Txid}; + use dpp::fee::Credits; + use dpp::platform_value::Bytes36; use dpp::version::PlatformVersion; use drive::drive::identity::withdrawals::paths::{ - get_withdrawal_root_path, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, + get_withdrawal_pending_asset_lock_inflows_path, get_withdrawal_root_path, + WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, }; + use drive::util::batch::{DriveOperation, SystemOperationType}; use drive::util::grove_operations::DirectQueryType; - /// The event records the block's mints in the credit inflows sum tree, accumulating - /// within a block time, and records nothing for a block that minted nothing. + fn asset_lock_mints(spends: &[(u8, Credits)]) -> BlockCreditMints { + let mut mints = BlockCreditMints::default(); + for (asset_lock, amount) in spends { + mints.add(BlockCreditMints::of_operations(&[ + DriveOperation::SystemOperation(SystemOperationType::AddToSystemCredits { + amount: *amount, + }), + DriveOperation::SystemOperation(SystemOperationType::AddUsedAssetLock { + asset_lock_outpoint: Bytes36::new( + OutPoint::new(Txid::from_byte_array([*asset_lock; 32]), 0).into(), + ), + asset_lock_value: AssetLockValue::new( + *amount, + vec![], + 0, + vec![], + PlatformVersion::latest(), + ) + .expect("expected an asset lock value"), + }), + ])); + } + mints + } + + /// The event records the block's other mints in the credit inflows sum tree, + /// accumulating within a block time, and records nothing for a block that minted nothing. #[test] fn should_record_the_blocks_mints_and_skip_zero() { let platform = TestPlatformBuilder::new() @@ -74,12 +155,19 @@ mod tests { }; platform - .record_credit_inflows_for_withdrawals(0, &block_info, &transaction, platform_version) + .record_credit_inflows_for_withdrawals( + &BlockCreditMints::default(), + 0, + &block_info, + &transaction, + platform_version, + ) .expect("expected to record nothing"); assert_eq!(inflows(&transaction), 0); platform .record_credit_inflows_for_withdrawals( + &BlockCreditMints::default(), dash_to_credits!(3), &block_info, &transaction, @@ -88,6 +176,7 @@ mod tests { .expect("expected to record"); platform .record_credit_inflows_for_withdrawals( + &BlockCreditMints::default(), dash_to_credits!(2), &block_info, &transaction, @@ -98,6 +187,97 @@ mod tests { assert_eq!(inflows(&transaction), dash_to_credits!(5) as i64); } + /// Asset lock mints are dated by the Core block that mined them: one mined at or below the + /// block's chain locked height counts from that Core block (and adds nothing once a window + /// old), one Core reports above it or does not know waits as pending, and none of them is + /// recorded by the block time. + #[test] + fn should_date_asset_lock_mints_by_the_core_block_that_mined_them() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + let platform_version = PlatformVersion::latest(); + + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc + .expect_get_transactions_mined_heights() + .times(1) + .returning(|tx_ids| { + Ok(tx_ids + .iter() + .map(|txid| match txid.to_byte_array()[0] { + 1 => Some(995), // mined recently: counts until 995 + 552 + 2 => Some(400), // mined a window ago: adds nothing + 3 => Some(1001), // above the chain locked height: pending + _ => None, // unknown or in the mempool: pending + }) + .collect()) + }); + platform.core_rpc = core_rpc; + + let transaction = platform.drive.grove.start_transaction(); + let block_info = BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 1000, + epoch: Epoch::default(), + }; + + platform + .record_credit_inflows_for_withdrawals( + &asset_lock_mints(&[(1, 100), (2, 200), (3, 300), (4, 400)]), + dash_to_credits!(1), + &block_info, + &transaction, + platform_version, + ) + .expect("expected to record"); + + let sum_tree = |key: &[u8; 1]| { + platform + .drive + .grove_get_sum_tree_total_value( + (&get_withdrawal_root_path()).into(), + key, + DirectQueryType::StatefulDirectQuery, + Some(&transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected the sum") + }; + // Only the block fee mint is dated by the block time. + assert_eq!( + sum_tree(&WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY), + dash_to_credits!(1) as i64 + ); + // Only the recently mined asset lock is dated by Core. + assert_eq!( + sum_tree(&WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY), + 100 + ); + + let pending = |asset_lock: u8| { + platform + .drive + .grove_get_raw_optional( + (&get_withdrawal_pending_asset_lock_inflows_path()).into(), + &[asset_lock; 32], + DirectQueryType::StatefulDirectQuery, + Some(&transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected to read") + .is_some() + }; + assert!(!pending(1)); + assert!(!pending(2)); + assert!(pending(3)); + assert!(pending(4)); + } + /// Before protocol version 14 the version slot is `None` and the event does nothing. #[test] fn should_do_nothing_before_the_feature_exists() { @@ -111,6 +291,7 @@ mod tests { platform .record_credit_inflows_for_withdrawals( + &asset_lock_mints(&[(1, 100)]), dash_to_credits!(3), &BlockInfo::default(), &transaction, diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/mod.rs new file mode 100644 index 00000000000..46dd47f9d02 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/mod.rs @@ -0,0 +1,62 @@ +mod v0; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; + +impl Platform +where + C: CoreRPCLike, +{ + /// Reads the Core blocks the chain locked height has passed since the last one read: for + /// each, records Core's credit pool balance after it (the Core-anchored withdrawal limit + /// reads these) and dates the asset locks Platform consumed before Core mined them, which + /// that block holds. Reads at most `core_blocks_scanned_per_block_limit` Core blocks per + /// block, oldest first, and never one older than the band the limit reads + /// (`core_credit_pool_window_max_blocks` back); the rest follow in the next blocks. + /// + /// Only chain locked Core blocks are read, so every node reads the same. Pooling calls it + /// every block before it reads the withdrawal limits, so they see the newest Core blocks; + /// a long jump of the chain locked height is read over several blocks. + /// + /// # Parameters + /// + /// * `block_info`: The block being executed; its Core chain locked height is the newest + /// Core block read. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once the Core blocks are recorded, or at once when the protocol version has + /// no Core-anchored limit (the method version is `None`). + /// * `Err(Error)` when the method version (or a Drive method it calls) is unknown or not + /// active, Core cannot be asked, or a write fails. + pub(in crate::execution) fn scan_core_blocks_for_withdrawals( + &self, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive_abci + .methods + .withdrawals + .scan_core_blocks_for_withdrawals + { + None => Ok(()), + Some(0) => { + self.scan_core_blocks_for_withdrawals_v0(block_info, transaction, platform_version) + } + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "scan_core_blocks_for_withdrawals".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs new file mode 100644 index 00000000000..17495c3c013 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs @@ -0,0 +1,294 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::balances::credits::CREDITS_PER_DUFF; +use dpp::block::block_info::BlockInfo; +use dpp::dashcore::hashes::Hash; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; + +impl Platform +where + C: CoreRPCLike, +{ + pub(super) fn scan_core_blocks_for_withdrawals_v0( + &self, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let limit = platform_version + .drive_abci + .withdrawal_constants + .core_blocks_scanned_per_block_limit; + if limit == 0 { + return Ok(()); + } + + let window_max_blocks = platform_version + .system_limits + .core_credit_pool_window_max_blocks + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "scan_core_blocks_for_withdrawals v0 requires system_limits.core_credit_pool_window_max_blocks", + )))?; + + let chain_locked_height = block_info.core_height; + + // Nothing older than the band the limit reads is worth reading: its balance is never + // read again, and an asset lock it mined is out of the window anyway (the pending + // entry is dropped by the cleanup). + let oldest_useful_height = chain_locked_height.saturating_sub(window_max_blocks); + + let first_height = match self + .drive + .fetch_last_recorded_core_credit_pool_height(transaction, platform_version)? + { + Some(last_recorded_height) => match last_recorded_height.checked_add(1) { + Some(next_height) => next_height.max(oldest_useful_height), + None => return Ok(()), + }, + None => oldest_useful_height, + }; + + if first_height > chain_locked_height { + return Ok(()); + } + + let last_height = + chain_locked_height.min(first_height.saturating_add(u32::from(limit) - 1)); + + for core_height in first_height..=last_height { + let core_block = self.core_rpc.get_credit_pool_block(core_height)?; + + let credit_pool_balance = core_block + .credit_pool_balance + .checked_mul(CREDITS_PER_DUFF) + .ok_or(Error::Execution(ExecutionError::Overflow( + "core credit pool balance in credits", + )))?; + + let asset_lock_txids: Vec<[u8; 32]> = core_block + .asset_lock_txids + .iter() + .map(|txid| txid.to_byte_array()) + .collect(); + + self.drive.record_core_credit_pool_block( + core_height, + credit_pool_balance, + &asset_lock_txids, + block_info, + transaction, + platform_version, + )?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::rpc::core::{CoreCreditPoolBlock, MockCoreRPCLike}; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::block::block_info::BlockInfo; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::Txid; + use dpp::version::PlatformVersion; + use drive::drive::identity::withdrawals::paths::{ + get_withdrawal_root_path, WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, + }; + use drive::util::grove_operations::DirectQueryType; + use std::collections::BTreeMap; + use std::sync::{Arc, Mutex}; + + /// The Core heights read, in order, by a mock that answers every height with a balance of + /// `height * 1000` duffs and, at height 1003, one asset lock. + fn recording_core(read: Arc>>) -> MockCoreRPCLike { + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc + .expect_get_credit_pool_block() + .returning(move |core_height| { + read.lock().expect("lock").push(core_height); + Ok(CoreCreditPoolBlock { + credit_pool_balance: u64::from(core_height) * 1000, + asset_lock_txids: if core_height == 1003 { + vec![Txid::from_byte_array([3; 32])] + } else { + vec![] + }, + }) + }); + core_rpc + } + + #[test] + fn should_read_each_core_block_once_oldest_first_and_bounded_per_block() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + let platform_version = PlatformVersion::latest(); + let read = Arc::new(Mutex::new(vec![])); + platform.core_rpc = recording_core(read.clone()); + let transaction = platform.drive.grove.start_transaction(); + let block = |core_height: u32| BlockInfo { + core_height, + ..Default::default() + }; + + // First run: starts at the far edge of the band (1000 - 600) and reads 32 blocks. + platform + .scan_core_blocks_for_withdrawals(&block(1000), Some(&transaction), platform_version) + .expect("expected to scan"); + assert_eq!(*read.lock().expect("lock"), (400..=431).collect::>()); + + // It goes on from the next unread block. + read.lock().expect("lock").clear(); + platform + .scan_core_blocks_for_withdrawals(&block(1000), Some(&transaction), platform_version) + .expect("expected to scan"); + assert_eq!(*read.lock().expect("lock"), (432..=463).collect::>()); + + // A jump past the band skips what is too old to matter. + read.lock().expect("lock").clear(); + platform + .scan_core_blocks_for_withdrawals(&block(2000), Some(&transaction), platform_version) + .expect("expected to scan"); + assert_eq!( + *read.lock().expect("lock"), + (1400..=1431).collect::>() + ); + + // Balances are recorded in credits. + assert_eq!( + platform + .drive + .fetch_core_credit_pool_balances(1400..=1401, Some(&transaction), platform_version) + .expect("expected the balances"), + BTreeMap::from([(1400, 1_400_000_000), (1401, 1_401_000_000)]) + ); + } + + #[test] + fn should_read_nothing_once_caught_up_with_the_chain_locked_height() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + let platform_version = PlatformVersion::latest(); + let read = Arc::new(Mutex::new(vec![])); + platform.core_rpc = recording_core(read.clone()); + let transaction = platform.drive.grove.start_transaction(); + let block = |core_height: u32| BlockInfo { + core_height, + ..Default::default() + }; + + // A young chain: everything from height 0 is in the band. + platform + .scan_core_blocks_for_withdrawals(&block(5), Some(&transaction), platform_version) + .expect("expected to scan"); + assert_eq!(*read.lock().expect("lock"), (0..=5).collect::>()); + + read.lock().expect("lock").clear(); + platform + .scan_core_blocks_for_withdrawals(&block(5), Some(&transaction), platform_version) + .expect("expected to scan"); + assert!(read.lock().expect("lock").is_empty()); + + platform + .scan_core_blocks_for_withdrawals(&block(7), Some(&transaction), platform_version) + .expect("expected to scan"); + assert_eq!(*read.lock().expect("lock"), vec![6, 7]); + } + + #[test] + fn should_date_a_pending_asset_lock_by_the_core_block_that_holds_it() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + let platform_version = PlatformVersion::latest(); + let read = Arc::new(Mutex::new(vec![])); + platform.core_rpc = recording_core(read.clone()); + let transaction = platform.drive.grove.start_transaction(); + let block = |core_height: u32| BlockInfo { + time_ms: 5_000, + core_height, + ..Default::default() + }; + + // Caught up to Core height 1001, then the asset lock is consumed before Core mines it. + platform + .drive + .record_core_credit_pool_block( + 1001, + 0, + &[], + &block(1001), + Some(&transaction), + platform_version, + ) + .expect("expected to record the block"); + platform + .drive + .record_asset_lock_credit_inflow( + [3; 32], + 700, + None, + &block(1001), + Some(&transaction), + platform_version, + ) + .expect("expected to record the pending inflow"); + + let core_dated_inflows = |transaction| { + platform + .drive + .grove_get_sum_tree_total_value( + (&get_withdrawal_root_path()).into(), + &WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, + DirectQueryType::StatefulDirectQuery, + Some(transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected the sum") + }; + assert_eq!(core_dated_inflows(&transaction), 0); + + // Core height 1003 mined it. + platform + .scan_core_blocks_for_withdrawals(&block(1003), Some(&transaction), platform_version) + .expect("expected to scan"); + assert_eq!(*read.lock().expect("lock"), vec![1002, 1003]); + assert_eq!(core_dated_inflows(&transaction), 700); + } + + #[test] + fn should_do_nothing_before_the_feature_exists() { + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_initial_state_structure(); + let platform_version = + PlatformVersion::get(13).expect("expected to get platform version 13"); + let read = Arc::new(Mutex::new(vec![])); + platform.core_rpc = recording_core(read.clone()); + let transaction = platform.drive.grove.start_transaction(); + + platform + .scan_core_blocks_for_withdrawals( + &BlockInfo { + core_height: 1000, + ..Default::default() + }, + Some(&transaction), + platform_version, + ) + .expect("expected the event to be a no-op before v14"); + assert!(read.lock().expect("lock").is_empty()); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index 92e876c80e4..d3f86183e80 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -24,6 +24,7 @@ use super::state::v0::IdentityCreateFromShieldedPoolStateTransitionStateValidationV0; use super::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::execution::validation::state_transition::state_transitions::test_helpers::{ insert_anchor_into_state, insert_dummy_encrypted_notes, set_pool_total_balance, setup_platform, }; @@ -577,7 +578,7 @@ fn failure_path_charge_executes_through_execute_event() { &block_info, &transaction, None, - &mut 0, + &mut BlockCreditMints::default(), platform_version, &fee_versions, ) @@ -1047,7 +1048,7 @@ fn executed_transition_result_proof_roundtrips() { &block_info, &transaction, None, - &mut 0, + &mut BlockCreditMints::default(), platform_version, &fee_versions, ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs index d6a54ae5397..6e2d12e1b12 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs @@ -106,6 +106,7 @@ impl StateTransitionBasicStructureValidationV0 for IdentityTopUpTransition { #[cfg(test)] mod tests { use crate::config::{PlatformConfig, PlatformTestConfig}; + use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::test::helpers::setup::TestPlatformBuilder; use dpp::block::block_info::BlockInfo; use dpp::dashcore::{Network, PrivateKey}; @@ -532,9 +533,14 @@ mod tests { assert_eq!(control_result.valid_count(), 1); let expected_mint = control_result.credit_mints(); assert!( - expected_mint > 0, + expected_mint.total() > 0, "sanity: a successful asset-lock top up must report its mint" ); + assert_eq!( + expected_mint.by_asset_lock().values().sum::(), + expected_mint.total(), + "sanity: the top up's mint is attributed to the asset lock it spent" + ); platform .drive .grove @@ -573,7 +579,7 @@ mod tests { ); assert_eq!( processing_result.credit_mints(), - 0, + &BlockCreditMints::default(), "PHANTOM MINT: a transition dropped from the proposal left its mint in the \ block's credit-mint accumulator" ); diff --git a/packages/rs-drive-abci/src/main.rs b/packages/rs-drive-abci/src/main.rs index 347a21aeca1..f04e6019345 100644 --- a/packages/rs-drive-abci/src/main.rs +++ b/packages/rs-drive-abci/src/main.rs @@ -364,7 +364,7 @@ mod snapshot_bake_main { use dpp::version::PlatformVersion; use drive_abci::config::PlatformConfig; use drive_abci::platform_types::platform::Platform; - use drive_abci::rpc::core::CoreRPCLike; + use drive_abci::rpc::core::{CoreCreditPoolBlock, CoreRPCLike}; use serde_json::Value; /// Stub CoreRPCLike — Platform::open_with_client requires a CoreRPCLike, @@ -451,6 +451,12 @@ mod snapshot_bake_main { fn send_raw_transaction(&self, _: &[u8]) -> Result { unreachable!() } + fn get_credit_pool_block(&self, _: u32) -> Result { + unreachable!() + } + fn get_transactions_mined_heights(&self, _: &[Txid]) -> Result>, Error> { + unreachable!() + } } /// Produce a shielded-pool snapshot at `out_path` from a fresh temporary diff --git a/packages/rs-drive-abci/src/metrics.rs b/packages/rs-drive-abci/src/metrics.rs index 72b862a7381..fb0b79a8801 100644 --- a/packages/rs-drive-abci/src/metrics.rs +++ b/packages/rs-drive-abci/src/metrics.rs @@ -35,6 +35,9 @@ pub const LABEL_CHECK_TX_MODE: &str = "check_tx_mode"; pub const GAUGE_CREDIT_WITHDRAWAL_LIMIT_AVAILABLE: &str = "credit_withdrawal_limit_available"; /// Total withdrawal daily limit in credits pub const GAUGE_CREDIT_WITHDRAWAL_LIMIT_TOTAL: &str = "credit_withdrawal_limit_total"; +/// Credits still available to withdrawals on the Core-anchored side of the withdrawal limit +pub const GAUGE_CREDIT_WITHDRAWAL_LIMIT_CORE_AVAILABLE: &str = + "credit_withdrawal_limit_core_available"; /// Error returned by metrics subsystem #[derive(thiserror::Error, Debug)] @@ -237,6 +240,11 @@ impl Prometheus { GAUGE_CREDIT_WITHDRAWAL_LIMIT_TOTAL, "Total withdrawal limit for last 24 hours in credits" ); + + describe_gauge!( + GAUGE_CREDIT_WITHDRAWAL_LIMIT_CORE_AVAILABLE, + "Credits withdrawals may still take from Core's credit pool, by the stricter copy of Core's unlock limit" + ); }); } } diff --git a/packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs b/packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs new file mode 100644 index 00000000000..612e9be9913 --- /dev/null +++ b/packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs @@ -0,0 +1,115 @@ +use dpp::fee::Credits; +use drive::util::batch::DriveOperation; +use std::collections::BTreeMap; + +/// The credits a block's applied state transitions minted into Platform, in total and per asset +/// lock transaction they came from. The daily withdrawal limit counts the asset lock mints from +/// the Core block that mined each asset lock, and any other mint from the Platform block. +/// +/// Mirrors applied state: the block loop rewinds it with the state a dropped transition rolls +/// back, or the block would record an inflow for a transition the proposal omits. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct BlockCreditMints { + total: Credits, + by_asset_lock: BTreeMap<[u8; 32], Credits>, +} + +impl BlockCreditMints { + /// The mints of one batch of operations (one executed state transition): their + /// `AddToSystemCredits` total, attributed to the asset lock they spent when they name one. + pub fn of_operations(operations: &[DriveOperation]) -> Self { + let mut mints = BlockCreditMints { + total: DriveOperation::credit_mints(operations), + by_asset_lock: BTreeMap::new(), + }; + if let Some((asset_lock_txid, amount)) = DriveOperation::asset_lock_credit_mints(operations) + { + mints.by_asset_lock.insert(asset_lock_txid, amount); + } + mints + } + + /// Adds the mints of another batch, saturating like the total always has. + pub fn add(&mut self, other: BlockCreditMints) { + self.total = self.total.saturating_add(other.total); + for (asset_lock_txid, amount) in other.by_asset_lock { + let minted = self.by_asset_lock.entry(asset_lock_txid).or_default(); + *minted = minted.saturating_add(amount); + } + } + + /// Every credit the block's state transitions minted. + pub fn total(&self) -> Credits { + self.total + } + + /// The credits minted per asset lock transaction id (in the byte order `Txid` holds it). + pub fn by_asset_lock(&self) -> &BTreeMap<[u8; 32], Credits> { + &self.by_asset_lock + } + + /// The credits minted without naming one asset lock: none in practice, as every state + /// transition that mints spends one. + pub fn not_attributed_to_an_asset_lock(&self) -> Credits { + let attributed = self + .by_asset_lock + .values() + .fold(0u64, |sum, amount| sum.saturating_add(*amount)); + self.total.saturating_sub(attributed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; + use dpp::platform_value::Bytes36; + use dpp::version::PlatformVersion; + use drive::util::batch::SystemOperationType; + + fn spend(asset_lock: u8, amount: Credits) -> Vec> { + let mut outpoint = [asset_lock; 36]; + outpoint[32..].copy_from_slice(&0u32.to_le_bytes()); + vec![ + DriveOperation::SystemOperation(SystemOperationType::AddToSystemCredits { amount }), + DriveOperation::SystemOperation(SystemOperationType::AddUsedAssetLock { + asset_lock_outpoint: Bytes36::new(outpoint), + asset_lock_value: AssetLockValue::new( + amount, + vec![], + 0, + vec![], + PlatformVersion::latest(), + ) + .expect("expected an asset lock value"), + }), + ] + } + + #[test] + fn should_sum_mints_per_asset_lock_and_in_total() { + let mut mints = BlockCreditMints::default(); + mints.add(BlockCreditMints::of_operations(&spend(1, 300))); + mints.add(BlockCreditMints::of_operations(&spend(2, 500))); + mints.add(BlockCreditMints::of_operations(&spend(1, 200))); + + assert_eq!(mints.total(), 1_000); + assert_eq!( + mints.by_asset_lock(), + &BTreeMap::from([([1; 32], 500), ([2; 32], 500)]) + ); + assert_eq!(mints.not_attributed_to_an_asset_lock(), 0); + } + + #[test] + fn should_count_a_mint_naming_no_asset_lock_in_the_total_only() { + let mut mints = BlockCreditMints::default(); + mints.add(BlockCreditMints::of_operations(&[ + DriveOperation::SystemOperation(SystemOperationType::AddToSystemCredits { amount: 70 }), + ])); + mints.add(BlockCreditMints::of_operations(&spend(3, 30))); + + assert_eq!(mints.total(), 100); + assert_eq!(mints.not_attributed_to_an_asset_lock(), 70); + } +} diff --git a/packages/rs-drive-abci/src/platform_types/mod.rs b/packages/rs-drive-abci/src/platform_types/mod.rs index 0f3b33981de..89253354d30 100644 --- a/packages/rs-drive-abci/src/platform_types/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/mod.rs @@ -1,3 +1,5 @@ +/// The credits a block's state transitions minted into Platform, per asset lock +pub mod block_credit_mints; /// The outcome of a block execution pub mod block_execution_outcome; /// The block proposal diff --git a/packages/rs-drive-abci/src/platform_types/platform/mock.rs b/packages/rs-drive-abci/src/platform_types/platform/mock.rs index 9157e2a8ff5..95496ccf505 100644 --- a/packages/rs-drive-abci/src/platform_types/platform/mock.rs +++ b/packages/rs-drive-abci/src/platform_types/platform/mock.rs @@ -2,7 +2,7 @@ use crate::config::PlatformConfig; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; -use crate::rpc::core::MockCoreRPCLike; +use crate::rpc::core::{CoreCreditPoolBlock, MockCoreRPCLike}; use dpp::dashcore::BlockHash; use dpp::serialization::PlatformDeserializableFromVersionedStructureTrusted; use dpp::version::PlatformVersionCurrentVersion; @@ -34,6 +34,22 @@ impl Platform { "tx": [], })) }); + + // A credit pool of 10 million Dash at every height, holding no asset locks: the + // Core-anchored withdrawal limit never binds unless a test sets its own answers. + core_rpc_mock.expect_get_credit_pool_block().returning(|_| { + Ok(CoreCreditPoolBlock { + credit_pool_balance: 1_000_000_000_000_000, + asset_lock_txids: vec![], + }) + }); + + // Every asset lock mined at Core height 0, so its credits count as an inflow until + // Core height 552, like the inflows of a recently mined asset lock. + core_rpc_mock + .expect_get_transactions_mined_heights() + .returning(|tx_ids| Ok(vec![Some(0); tx_ids.len()])); + Self::open_with_client(path, config, core_rpc_mock, initial_protocol_version) } diff --git a/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs b/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs index 1c26adde5f2..9509859efb0 100644 --- a/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs @@ -1,10 +1,10 @@ use dpp::address_funds::PlatformAddress; use dpp::balances::credits::CreditOperation; use dpp::consensus::ConsensusError; -use dpp::fee::Credits; use std::collections::BTreeMap; use crate::error::Error; +use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EstimatedFeeResult; use dpp::fee::fee_result::FeeResult; @@ -69,7 +69,7 @@ pub struct StateTransitionsProcessingResult { valid_count: usize, failed_count: usize, fees: FeeResult, - credit_mints: Credits, + credit_mints: BlockCreditMints, } impl StateTransitionsProcessingResult { @@ -177,15 +177,16 @@ impl StateTransitionsProcessingResult { } /// Sets the credits the block's applied state transitions minted into Platform - pub fn set_credit_mints(&mut self, credit_mints: Credits) { + pub fn set_credit_mints(&mut self, credit_mints: BlockCreditMints) { self.credit_mints = credit_mints; } /// Returns the credits the block's applied state transitions minted into Platform - /// (their `AddToSystemCredits` operations): the state-transition share of the block's - /// credit inflow, recorded for the net daily withdrawal limit. - pub fn credit_mints(&self) -> Credits { - self.credit_mints + /// (their `AddToSystemCredits` operations), in total and per asset lock: the + /// state-transition share of the block's credit inflow, recorded for the net daily + /// withdrawal limit. + pub fn credit_mints(&self) -> &BlockCreditMints { + &self.credit_mints } /// Returns the aggregated fees diff --git a/packages/rs-drive-abci/src/rpc/core.rs b/packages/rs-drive-abci/src/rpc/core.rs index cf9a387616f..cee5b29e169 100644 --- a/packages/rs-drive-abci/src/rpc/core.rs +++ b/packages/rs-drive-abci/src/rpc/core.rs @@ -1,5 +1,6 @@ use crate::rpc::prefetch::CorePrefetcher; use dpp::dashcore::ephemerealdata::chain_lock::ChainLock; +use dpp::dashcore::transaction::special_transaction::TransactionPayload; use dpp::dashcore::{Block, BlockHash, QuorumHash, Transaction, Txid}; use dpp::dashcore::{Header, InstantLock}; use dpp::dashcore_rpc::dashcore_rpc_json::{ @@ -16,6 +17,54 @@ use std::time::Duration; /// Information returned by QuorumListExtended pub type QuorumListExtendedInfo = HashMap; +/// The most transaction ids Core's `gettxchainlocks` answers in one call. +const MAX_TRANSACTIONS_PER_CHAIN_LOCK_STATUS_REQUEST: usize = 100; + +/// What one Core block tells the Core-anchored withdrawal limit. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CoreCreditPoolBlock { + /// Core's credit pool balance after the block, in duffs, as its coinbase records it; `0` + /// when the block has no credit pool (before the credit pool existed), which is how Core's + /// own unlock limit reads such a block. + pub credit_pool_balance: u64, + /// The ids of the asset lock transactions the block holds. + pub asset_lock_txids: Vec, +} + +impl CoreCreditPoolBlock { + /// Reads the credit pool balance from the block's coinbase payload and collects its asset + /// lock transactions. + pub fn from_block(block: &Block) -> Self { + let credit_pool_balance = block + .txdata + .first() + .and_then(|coinbase| match &coinbase.special_transaction_payload { + Some(TransactionPayload::CoinbasePayloadType(payload)) => { + payload.asset_locked_amount + } + _ => None, + }) + .unwrap_or_default(); + + let asset_lock_txids = block + .txdata + .iter() + .filter(|transaction| { + matches!( + transaction.special_transaction_payload, + Some(TransactionPayload::AssetLockPayloadType(_)) + ) + }) + .map(Transaction::txid) + .collect(); + + CoreCreditPoolBlock { + credit_pool_balance, + asset_lock_txids, + } + } +} + /// Core height must be of type u32 (Platform heights are u64) pub type CoreHeight = u32; /// Core RPC interface @@ -126,6 +175,20 @@ pub trait CoreRPCLike { /// Sends raw transaction to the network fn send_raw_transaction(&self, transaction: &[u8]) -> Result; + + /// Get Core's credit pool balance after the block at `height` and the asset lock + /// transactions it holds. Only ask for a chain locked height: the answer is then the same + /// on every node. + fn get_credit_pool_block(&self, height: CoreHeight) -> Result; + + /// Get the height of the active chain block each transaction was mined in, in the order of + /// `tx_ids`; `None` for one Core does not know, or holds in its mempool only. A height is + /// the same on every node only when it is at or below a chain locked height, so callers + /// treat anything above theirs as not mined yet. + fn get_transactions_mined_heights( + &self, + tx_ids: &[Txid], + ) -> Result>, Error>; } #[derive(Debug)] @@ -387,4 +450,34 @@ impl CoreRPCLike for DefaultCoreRPC { .inner .get_asset_unlock_statuses(indices, Some(core_chain_locked_height))) } + + fn get_credit_pool_block(&self, height: CoreHeight) -> Result { + let block_hash = self.get_block_hash(height)?; + let block = self.get_block(&block_hash)?; + Ok(CoreCreditPoolBlock::from_block(&block)) + } + + fn get_transactions_mined_heights( + &self, + tx_ids: &[Txid], + ) -> Result>, Error> { + let mut heights = Vec::with_capacity(tx_ids.len()); + for chunk in tx_ids.chunks(MAX_TRANSACTIONS_PER_CHAIN_LOCK_STATUS_REQUEST) { + let statuses: Vec<_> = retry!(self.inner.get_transaction_are_locked(chunk))?; + if statuses.len() != chunk.len() { + return Err(Error::UnexpectedStructure(format!( + "gettxchainlocks answered {} of {} transactions", + statuses.len(), + chunk.len() + ))); + } + // Core reports -1 for a transaction it does not know or holds in its mempool only. + heights.extend( + statuses.into_iter().map(|status| { + status.and_then(|status| CoreHeight::try_from(status.height).ok()) + }), + ); + } + Ok(heights) + } } diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs index 835b60e7e4a..72772e6f5b3 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs @@ -11,14 +11,15 @@ mod tests { use dpp::dashcore_rpc::dashcore_rpc_json::{AssetUnlockStatus, AssetUnlockStatusResult}; use dpp::data_contracts::withdrawals_contract; use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::withdrawal::daily_withdrawal_limit::daily_withdrawal_limit; use dpp::withdrawal::WithdrawalTransactionIndex; use dpp::{dash_to_credits, dash_to_duffs}; use drive::config::DEFAULT_QUERY_LIMIT; use drive::drive::balances::TOTAL_SYSTEM_CREDITS_STORAGE_KEY; use drive::drive::identity::withdrawals::fetch_total_credits_in_platform_a_day_ago::DAY_IN_MS; use drive::drive::identity::withdrawals::paths::{ - get_withdrawal_root_path, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, - WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, + get_withdrawal_root_path, WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, + WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, }; use drive::drive::system::misc_path; use drive::util::grove_operations::DirectQueryType; @@ -26,6 +27,7 @@ mod tests { ChainLockConfig, ExecutionConfig, InstantLockConfig, PlatformConfig, PlatformTestConfig, ValidatorSetConfig, }; + use drive_abci::platform_types::platform_state::PlatformStateV0Methods; use drive_abci::test::helpers::setup::TestPlatformBuilder; use platform_version::version::mocks::v3_test::TEST_PLATFORM_V3; use platform_version::version::PlatformVersion; @@ -2446,22 +2448,30 @@ mod tests { assert_eq!(total_credits_in_platform, Some(1010000000000000)); // Every credit entered through an asset lock, so the whole 10,100 Dash is - // recorded as credit inflows the daily withdrawal limit would add to its maximum. - let credit_inflows = outcome - .abci_app - .platform - .drive - .grove_get_sum_tree_total_value( - (&get_withdrawal_root_path()).into(), - &WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, - DirectQueryType::StatefulDirectQuery, - None, - &mut vec![], - &platform_version.drive, - ) - .expect("expected to get the credit inflows"); + // recorded as credit inflows the daily withdrawal limit would add to its maximum, + // dated by the Core block that mined each asset lock (the mock Core reports height + // 0, so they count until Core height 552) rather than by the block time. + let inflows_of = |key: &[u8; 1]| { + outcome + .abci_app + .platform + .drive + .grove_get_sum_tree_total_value( + (&get_withdrawal_root_path()).into(), + key, + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to get the credit inflows") + }; - assert_eq!(credit_inflows, 1010000000000000); + assert_eq!( + inflows_of(&WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY), + 1010000000000000 + ); + assert_eq!(inflows_of(&WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY), 0); outcome }; @@ -2581,23 +2591,34 @@ mod tests { ) .await; - // The funding inflows are 25 hours old and pruned; nothing extends the daily - // maximum any more. - let credit_inflows = outcome - .abci_app - .platform + // The funding inflows were minted before the day-old base snapshot, so they are + // inside the base and nothing extends the daily maximum any more: it is the + // percentage of the unchanged total alone. (The Core height never moves in this + // test, so the Core-dated entries stay stored until Core height 552; they no + // longer count.) + let platform = &outcome.abci_app.platform; + let last_block_info = platform.state.load().last_block_info().clone(); + let total_credits_in_platform = platform .drive - .grove_get_sum_tree_total_value( - (&get_withdrawal_root_path()).into(), - &WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, + .grove_get_raw_value_u64_from_encoded_var_vec( + (&misc_path()).into(), + TOTAL_SYSTEM_CREDITS_STORAGE_KEY, DirectQueryType::StatefulDirectQuery, None, &mut vec![], &platform_version.drive, ) - .expect("expected to get the credit inflows"); - - assert_eq!(credit_inflows, 0); + .expect("expected to get total credits in platform") + .expect("expected total credits in platform"); + let limit = platform + .drive + .calculate_current_withdrawal_limit(&last_block_info, None, platform_version) + .expect("expected the withdrawal limit"); + assert_eq!( + limit.daily_maximum, + daily_withdrawal_limit(Some(total_credits_in_platform), platform_version) + .expect("expected the base") + ); outcome }; diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 2bdcafa4e63..32043226918 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -3832,6 +3832,126 @@ "children": [] } ] + }, + { + "id": "withdrawals.core_credit_pool_balances", + "key": { + "type": "fixed", + "hex": "06", + "label": "CoreCreditPoolBalances", + "constant": "WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY" + }, + "kinds": [ + "Tree" + ], + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/identity/withdrawals/paths.rs", + "description": "Core's credit pool balance after each Core block read, for the Core-anchored withdrawal limit.", + "children": [ + { + "id": "withdrawals.core_credit_pool_balances.balance", + "key": { + "type": "dynamic", + "name": "core_height", + "matcher": { + "type": "len", + "len": 4 + }, + "encoding": "u32_be", + "description": "The Core block height" + }, + "kinds": [ + "Item" + ], + "value": "credits, u64 big endian", + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/identity/withdrawals/paths.rs", + "description": "The credit pool balance after that Core block.", + "children": [] + } + ] + }, + { + "id": "withdrawals.pending_asset_lock_inflows", + "key": { + "type": "fixed", + "hex": "07", + "label": "PendingAssetLockInflows", + "constant": "WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY" + }, + "kinds": [ + "Tree" + ], + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/identity/withdrawals/paths.rs", + "description": "Asset locks consumed before Core mined them, waiting to be dated by the Core block that does.", + "children": [ + { + "id": "withdrawals.pending_asset_lock_inflows.asset_lock", + "key": { + "type": "dynamic", + "name": "txid", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "hash32", + "description": "The asset lock transaction id" + }, + "kinds": [ + "Item" + ], + "value": "credits u64, block time u64 and Core height u32, big endian", + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/identity/withdrawals/paths.rs", + "description": "The credits the asset lock minted, and the block that minted them first.", + "children": [] + } + ] + }, + { + "id": "withdrawals.core_dated_credit_inflows", + "key": { + "type": "fixed", + "hex": "08", + "label": "CoreDatedCreditInflows", + "constant": "WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY" + }, + "kinds": [ + "SumTree" + ], + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/identity/withdrawals/paths.rs", + "description": "Asset lock credit inflows, dated by the Core block that mined them, which raise the relative withdrawal limit.", + "children": [ + { + "id": "withdrawals.core_dated_credit_inflows.inflow", + "key": { + "type": "dynamic", + "name": "expiry_and_time", + "matcher": { + "type": "len", + "len": 12 + }, + "encoding": "composite", + "description": "The Core height the entry stops counting at (u32 big endian), then the block time in milliseconds it was recorded at (u64 big endian)" + }, + "kinds": [ + "SumItem" + ], + "value": "credits", + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/identity/withdrawals/paths.rs", + "description": "Credits asset locks minted, counting until that Core height.", + "children": [] + } + ] } ] }, @@ -6006,20 +6126,29 @@ "withdrawals": { "origin": "genesis@14", "tree": { - "hex": "03", + "hex": "04", "left": { - "hex": "01", + "hex": "02", "left": { - "hex": "00" + "hex": "01", + "left": { + "hex": "00" + } }, "right": { - "hex": "02" + "hex": "03" } }, "right": { - "hex": "05", + "hex": "07", "left": { - "hex": "04" + "hex": "06", + "left": { + "hex": "05" + } + }, + "right": { + "hex": "08" } } } diff --git a/packages/rs-drive/src/drive/identity/withdrawals/calculate_current_withdrawal_limit/v1/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/calculate_current_withdrawal_limit/v1/mod.rs index 523cbe6d075..25f51e58286 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/calculate_current_withdrawal_limit/v1/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/calculate_current_withdrawal_limit/v1/mod.rs @@ -1,8 +1,11 @@ use crate::drive::identity::withdrawals::calculate_current_withdrawal_limit::WithdrawalLimitInfo; use crate::drive::identity::withdrawals::paths::{ + get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec, get_withdrawal_credit_inflows_sum_tree_path_vec, get_withdrawal_transactions_sum_tree_path_vec, }; -use crate::drive::identity::withdrawals::DAY_AND_A_HOUR_IN_MS; +use crate::drive::identity::withdrawals::{ + core_dated_credit_inflow_key, decode_core_dated_credit_inflow_key, DAY_AND_A_HOUR_IN_MS, +}; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; @@ -49,17 +52,23 @@ impl Drive { /// other users. Only inflows younger than the snapshot count — an older one is already /// inside the base, and adding it again would allow the pool level to drop below the /// guaranteed share of the day-old total — and only unexpired ones, so an entry the - /// bounded cleanup has not deleted yet cannot outlive its 25 hours here. The base - /// stays capped by `max_daily_withdrawal_amount` (Core's unlock capacity per day) but - /// the inflows ride above the cap: capping the sum would hand the whole capped budget - /// back to a deposit-withdraw cycle whenever the base reaches the cap, and outflow - /// funded by same-window deposits mirrors the net credit pool rule Core adopts - /// alongside this; + /// bounded cleanup has not deleted yet cannot outlive its 25 hours here. When the + /// protocol version caps the base (`max_daily_withdrawal_amount`; protocol version 14 + /// sets no cap) the inflows ride above the cap: capping the sum would hand the whole + /// capped budget back to a deposit-withdraw cycle whenever the base reaches the cap. + /// Outflow funded by same-window deposits mirrors Core v24's net credit pool rule; /// * the withdrawal reservations are bounded the same way: one pooled at or before the /// snapshot describes an outflow the base already reflects (the history is recorded /// after state transitions executed), so subtracting it again would deny budget the /// guarantee does not require — a deposit-withdraw cycle would stay debited for the - /// hour its reservation outlives the snapshot instead of cancelling exactly. + /// hour its reservation outlives the snapshot instead of cancelling exactly; + /// * asset lock inflows are dated by the Core block that mined them, the way Core counts + /// them, not by the Platform block that consumed them: they sit in their own tree and + /// count while the chain locked height is below the Core height they stop counting at + /// (`core_credit_pool_window_min_blocks` after the mining block) and only when recorded + /// after the snapshot, for the same reason as the other inflows. An asset lock published + /// to Platform long after Core mined it therefore adds nothing. This tree is written from + /// protocol version 14 only, the one version that selects this generation. /// /// The formula stays `daily_maximum - withdrawals_amount`, floored at zero, with both /// sides counted over the interval after the snapshot. @@ -106,14 +115,23 @@ impl Drive { let total_credits_a_day_ago = recorded_a_day_ago.map(|recorded| recorded.total_credits); - // The base is capped at Core's unlock capacity inside `daily_withdrawal_limit`; the - // inflows ride on top of the capped base, not under the cap. Capping the sum would - // discard the inflows exactly when the base reaches the cap — at that point a - // deposit-withdraw cycle would consume the whole capped budget again (#4471 under - // mainnet totals). Outflow funded by same-window deposits mirrors the net credit - // pool rule Core adopts alongside this (see #4471), so it may exceed the cap. + // A cap on the base, when the protocol version sets one, applies inside + // `daily_withdrawal_limit`; the inflows ride on top of the capped base, not under the + // cap. Capping the sum would discard the inflows exactly when the base reaches the + // cap — at that point a deposit-withdraw cycle would consume the whole capped budget + // again (#4471). What Core will mine bounds pooling separately, through the + // Core-anchored limit. + let core_dated_credit_inflows_since_the_snapshot = self + .sum_core_dated_credit_inflows_counting_at( + block_info.core_height, + recorded_a_day_ago.as_ref().map(|recorded| recorded.time_ms), + transaction, + platform_version, + )?; + let daily_maximum = daily_withdrawal_limit(total_credits_a_day_ago, platform_version)? - .saturating_add(credit_inflows_since_the_snapshot); + .saturating_add(credit_inflows_since_the_snapshot) + .saturating_add(core_dated_credit_inflows_since_the_snapshot); let withdrawals_since_the_snapshot = self.sum_expiry_keyed_entries_at_or_after( get_withdrawal_transactions_sum_tree_path_vec(), @@ -129,6 +147,61 @@ impl Drive { }) } + /// Sums the Core-dated credit inflows still counting at `core_height` (their key's Core + /// height is above it) that were recorded after the base snapshot taken at + /// `snapshot_time_ms` (all of them while no snapshot exists yet). The tree holds the + /// inflows of one window plus whatever the bounded cleanup has not deleted yet. + fn sum_core_dated_credit_inflows_counting_at( + &self, + core_height: u32, + snapshot_time_ms: Option, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let Some(first_counting_height) = core_height.checked_add(1) else { + return Ok(0); + }; + + let path_query = PathQuery::new_unsized( + get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec(), + Query::new_single_query_item(QueryItem::RangeFrom( + core_dated_credit_inflow_key(first_counting_height, 0).., + )), + ); + + let (results, _) = self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + )?; + + let mut total: u64 = 0; + for (key, element) in results.to_key_elements() { + let (_, recorded_at_time_ms) = decode_core_dated_credit_inflow_key(&key)?; + if snapshot_time_ms.is_some_and(|snapshot| recorded_at_time_ms <= snapshot) { + // Minted at or before the snapshot: already inside the base. + continue; + } + let Element::SumItem(amount, _) = element else { + return Err(Error::Drive(DriveError::CorruptedElementType( + "core-dated credit inflow entry is not a sum item", + ))); + }; + let amount: u64 = amount.try_into().map_err(|_| { + Error::Drive(DriveError::CriticalCorruptedState( + "core-dated credit inflow entry is negative", + )) + })?; + total = total.checked_add(amount).ok_or(Error::Drive( + DriveError::CriticalCorruptedState("core-dated credit inflow entries overflow"), + ))?; + } + + Ok(total) + } + /// Sums the sum-item entries of `path` whose expiration key is at or after /// `from_time_ms`. Each tree only ever holds the recording blocks of one 25-hour window /// plus whatever the bounded cleanup has not deleted yet, so walking the range stays @@ -183,6 +256,8 @@ impl Drive { #[cfg(test)] mod tests { use crate::drive::identity::withdrawals::fetch_total_credits_in_platform_a_day_ago::DAY_IN_MS; + + const HOUR_IN_MS: u64 = DAY_IN_MS / 24; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; use dpp::dash_to_credits; @@ -364,11 +439,11 @@ mod tests { assert_eq!(info.available(), dash_to_credits!(1500)); } - /// Inflows extend the daily maximum past the capped base: the cap bounds what Core mines - /// out of the standing pool per day, and capping the sum instead would hand the whole - /// capped budget back to a deposit-withdraw cycle whenever the base reaches the cap — - /// #4471 under mainnet totals. At a 30,000 Dash total the base is capped at 4,000; a - /// 4,000 Dash deposit-withdraw cycle must leave the full 4,000 available to others. + /// Inflows extend the daily maximum past a capped base: capping the sum instead would hand + /// the whole capped budget back to a deposit-withdraw cycle whenever the base reaches the + /// cap — #4471. Protocol version 14 sets no cap, but the rule holds for any that does: at a + /// 30,000 Dash total a base capped at 4,000 must leave the full 4,000 available to others + /// after a 4,000 Dash deposit-withdraw cycle. #[test] fn a_cycle_at_the_capped_base_should_not_consume_the_budget_of_others() { let drive = setup_drive_with_initial_state_structure(None); @@ -376,6 +451,7 @@ mod tests { platform_version .system_limits .daily_withdrawal_limit_percent = Some(15); + platform_version.system_limits.max_daily_withdrawal_amount = Some(dash_to_credits!(4000)); let transaction = drive.grove.start_transaction(); let t0 = 10 * DAY_IN_MS; @@ -698,4 +774,262 @@ mod tests { assert_eq!(info.withdrawals_amount, 0); assert_eq!(info.available(), dash_to_credits!(3000)); } + + /// An asset lock counts from the Core block that mined it, for + /// `core_credit_pool_window_min_blocks` (552) Core blocks, the way Core's own limit counts + /// it, not for a day after the Platform block that consumed it. + #[test] + fn an_asset_lock_inflow_should_count_until_a_window_after_the_core_block_that_mined_it() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let t0 = 10 * DAY_IN_MS; + let t1 = t0 + DAY_IN_MS; + let block = |time_ms: u64, core_height: u32| BlockInfo { + time_ms, + core_height, + ..Default::default() + }; + let limit = |time_ms: u64, core_height: u32| { + drive + .calculate_current_withdrawal_limit( + &block(time_ms, core_height), + Some(&transaction), + platform_version, + ) + .expect("expected the limit") + .daily_maximum + }; + + drive + .add_to_system_credits( + dash_to_credits!(20000), + Some(&transaction), + platform_version, + ) + .expect("expected to add credits"); + drive + .record_total_credits_history(&block(t0, 400), 64, Some(&transaction), platform_version) + .expect("expected to record"); + assert_eq!(limit(t1, 1000), dash_to_credits!(3000)); + + // Consumed on Platform at Core height 1000, mined by Core at 990: it counts until Core + // height 990 + 552. + drive + .record_asset_lock_credit_inflow( + [1; 32], + dash_to_credits!(500), + Some(990), + &block(t1, 1000), + Some(&transaction), + platform_version, + ) + .expect("expected to record the inflow"); + assert_eq!(limit(t1, 1000), dash_to_credits!(3500)); + assert_eq!(limit(t1 + HOUR_IN_MS, 1541), dash_to_credits!(3500)); + assert_eq!(limit(t1 + HOUR_IN_MS, 1542), dash_to_credits!(3000)); + } + + /// The edge case the Core dating closes: an asset lock published to Platform a whole + /// window after Core mined it sits in the balance Core's limit starts from, so it must not + /// add budget on Platform's side either. + #[test] + fn an_asset_lock_published_a_window_after_core_mined_it_should_add_nothing() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let t0 = 10 * DAY_IN_MS; + let t1 = t0 + DAY_IN_MS; + let block = |time_ms: u64, core_height: u32| BlockInfo { + time_ms, + core_height, + ..Default::default() + }; + + drive + .add_to_system_credits( + dash_to_credits!(20000), + Some(&transaction), + platform_version, + ) + .expect("expected to add credits"); + drive + .record_total_credits_history(&block(t0, 400), 64, Some(&transaction), platform_version) + .expect("expected to record"); + + // Mined at Core height 400, consumed at 1000: 400 + 552 is already behind. + drive + .record_asset_lock_credit_inflow( + [1; 32], + dash_to_credits!(5000), + Some(400), + &block(t1, 1000), + Some(&transaction), + platform_version, + ) + .expect("expected to record the inflow"); + + let info = drive + .calculate_current_withdrawal_limit( + &block(t1, 1000), + Some(&transaction), + platform_version, + ) + .expect("expected the limit"); + assert_eq!(info.daily_maximum, dash_to_credits!(3000)); + } + + /// An asset lock Platform consumed before Core mined it adds nothing until a Core block + /// holding it is read, and then counts from that block. + #[test] + fn a_pending_asset_lock_inflow_should_count_once_a_core_block_holding_it_is_read() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let t0 = 10 * DAY_IN_MS; + let t1 = t0 + DAY_IN_MS; + let block = |time_ms: u64, core_height: u32| BlockInfo { + time_ms, + core_height, + ..Default::default() + }; + let limit = |time_ms: u64, core_height: u32| { + drive + .calculate_current_withdrawal_limit( + &block(time_ms, core_height), + Some(&transaction), + platform_version, + ) + .expect("expected the limit") + .daily_maximum + }; + + drive + .add_to_system_credits( + dash_to_credits!(20000), + Some(&transaction), + platform_version, + ) + .expect("expected to add credits"); + drive + .record_total_credits_history(&block(t0, 400), 64, Some(&transaction), platform_version) + .expect("expected to record"); + + drive + .record_asset_lock_credit_inflow( + [7; 32], + dash_to_credits!(500), + None, + &block(t1, 1000), + Some(&transaction), + platform_version, + ) + .expect("expected to record the inflow"); + assert_eq!(limit(t1, 1000), dash_to_credits!(3000)); + + // A Core block that does not hold it changes nothing. + drive + .record_core_credit_pool_block( + 1001, + dash_to_credits!(30000), + &[[9; 32]], + &block(t1 + 1, 1001), + Some(&transaction), + platform_version, + ) + .expect("expected to record the block"); + assert_eq!(limit(t1 + 1, 1001), dash_to_credits!(3000)); + + // The block that mined it dates it: it counts until 1002 + 552. + drive + .record_core_credit_pool_block( + 1002, + dash_to_credits!(30500), + &[[7; 32]], + &block(t1 + 2, 1002), + Some(&transaction), + platform_version, + ) + .expect("expected to record the block"); + assert_eq!(limit(t1 + 2, 1002), dash_to_credits!(3500)); + assert_eq!(limit(t1 + HOUR_IN_MS, 1553), dash_to_credits!(3500)); + assert_eq!(limit(t1 + HOUR_IN_MS, 1554), dash_to_credits!(3000)); + + // Reading the same block again finds nothing pending: no double count. + drive + .record_core_credit_pool_block( + 1002, + dash_to_credits!(30500), + &[[7; 32]], + &block(t1 + 3, 1002), + Some(&transaction), + platform_version, + ) + .expect("expected to record the block"); + assert_eq!(limit(t1 + 3, 1002), dash_to_credits!(3500)); + } + + /// Like the other inflows, a Core-dated one minted at or before the day-old snapshot is + /// inside the base and must not count a second time, even while Core still counts it. + #[test] + fn a_core_dated_inflow_inside_the_day_old_base_should_not_count_again() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let t0 = 10 * DAY_IN_MS; + let t1 = t0 + HOUR_IN_MS; + let block = |time_ms: u64, core_height: u32| BlockInfo { + time_ms, + core_height, + ..Default::default() + }; + let limit = |time_ms: u64, core_height: u32| { + drive + .calculate_current_withdrawal_limit( + &block(time_ms, core_height), + Some(&transaction), + platform_version, + ) + .expect("expected the limit") + .daily_maximum + }; + + drive + .add_to_system_credits( + dash_to_credits!(20000), + Some(&transaction), + platform_version, + ) + .expect("expected to add credits"); + drive + .record_total_credits_history(&block(t0, 100), 64, Some(&transaction), platform_version) + .expect("expected to record"); + + drive + .add_to_system_credits(dash_to_credits!(500), Some(&transaction), platform_version) + .expect("expected to add the deposit"); + drive + .record_asset_lock_credit_inflow( + [1; 32], + dash_to_credits!(500), + Some(120), + &block(t1, 120), + Some(&transaction), + platform_version, + ) + .expect("expected to record the inflow"); + drive + .record_total_credits_history(&block(t1, 120), 64, Some(&transaction), platform_version) + .expect("expected to record"); + + // Before the deposit block is the snapshot the inflow counts: 15% of 20,000 + 500. + assert_eq!(limit(t1 + 23 * HOUR_IN_MS, 600), dash_to_credits!(3500)); + // Once it is the snapshot the 500 Dash sit inside the 20,500 base; Core still counts + // it (120 + 552 is ahead), Platform does not count it twice. + assert_eq!(limit(t1 + DAY_IN_MS, 610), dash_to_credits!(3075)); + } } diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/mod.rs new file mode 100644 index 00000000000..9f974d749b2 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/mod.rs @@ -0,0 +1,55 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::fee::Credits; +use grovedb::TransactionArg; +use platform_version::version::PlatformVersion; +use std::collections::BTreeMap; +use std::ops::RangeInclusive; + +impl Drive { + /// Fetches the recorded Core credit pool balances (in credits) of the Core heights in + /// `core_heights`. A height that was never recorded, or was pruned, is absent from the + /// result. + /// + /// # Parameters + /// + /// * `core_heights`: The Core heights to read. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(BTreeMap)`: The balance of every recorded height in the range. + /// * `Err(Error)` when the method version is unknown or not active, an entry is corrupted, + /// or the read fails. + pub fn fetch_core_credit_pool_balances( + &self, + core_heights: RangeInclusive, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .identity + .withdrawals + .fetch_core_credit_pool_balances + { + Some(0) => { + self.fetch_core_credit_pool_balances_v0(core_heights, transaction, platform_version) + } + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_core_credit_pool_balances".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_core_credit_pool_balances".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs new file mode 100644 index 00000000000..9543a9785e1 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs @@ -0,0 +1,119 @@ +use crate::drive::identity::withdrawals::paths::get_withdrawal_core_credit_pool_balances_path_vec; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::fee::Credits; +use grovedb::query_result_type::QueryResultType; +use grovedb::{Element, PathQuery, Query, QueryItem, TransactionArg}; +use platform_version::version::PlatformVersion; +use std::collections::BTreeMap; +use std::ops::RangeInclusive; + +impl Drive { + pub(super) fn fetch_core_credit_pool_balances_v0( + &self, + core_heights: RangeInclusive, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if core_heights.is_empty() { + return Ok(BTreeMap::new()); + } + + let path_query = PathQuery::new_unsized( + get_withdrawal_core_credit_pool_balances_path_vec(), + Query::new_single_query_item(QueryItem::RangeInclusive( + core_heights.start().to_be_bytes().to_vec() + ..=core_heights.end().to_be_bytes().to_vec(), + )), + ); + + let (results, _) = self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + )?; + + results + .to_key_elements() + .into_iter() + .map(|(key, element)| { + let core_height = u32::from_be_bytes(key.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedSerialization( + "core credit pool balance key is not 4 bytes".to_string(), + )) + })?); + let Element::Item(value, _) = element else { + return Err(Error::Drive(DriveError::CorruptedElementType( + "core credit pool balance is not an item", + ))); + }; + let balance = u64::from_be_bytes(value.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedSerialization( + "core credit pool balance is not 8 bytes".to_string(), + )) + })?); + Ok((core_height, balance)) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + #[test] + fn should_return_the_recorded_balances_of_a_range_and_the_last_recorded_height() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + assert_eq!( + drive + .fetch_last_recorded_core_credit_pool_height(Some(&transaction), platform_version) + .expect("expected the last height"), + None + ); + + for (core_height, balance) in [(10u32, 1_000u64), (11, 1_100), (12, 1_200)] { + drive + .record_core_credit_pool_block( + core_height, + balance, + &[], + &BlockInfo { + core_height, + ..Default::default() + }, + Some(&transaction), + platform_version, + ) + .expect("expected to record the block"); + } + + assert_eq!( + drive + .fetch_core_credit_pool_balances(11..=20, Some(&transaction), platform_version) + .expect("expected the balances"), + BTreeMap::from([(11, 1_100), (12, 1_200)]) + ); + assert_eq!( + drive + .fetch_core_credit_pool_balances(0..=9, Some(&transaction), platform_version) + .expect("expected the balances"), + BTreeMap::new() + ); + assert_eq!( + drive + .fetch_last_recorded_core_credit_pool_height(Some(&transaction), platform_version) + .expect("expected the last height"), + Some(12) + ); + } +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs new file mode 100644 index 00000000000..8c27664c7e0 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs @@ -0,0 +1,51 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::fee::Credits; +use grovedb::TransactionArg; +use platform_version::version::PlatformVersion; + +impl Drive { + /// Sums what the pooled withdrawal transactions not completed yet (the queue and the + /// broadcast tree) will take out of Core's credit pool once mined, in credits: each + /// transaction's outputs plus its fee, as Core counts an asset unlock. Core's own unlock + /// limit only reflects unlocks already mined, so the Core-anchored withdrawal limit + /// subtracts these. + /// + /// # Parameters + /// + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Credits)`: The sum, in credits. + /// * `Err(Error)` when the method version is unknown or not active, a stored transaction + /// cannot be decoded, or the sum overflows. + pub fn fetch_in_flight_withdrawal_amount( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .identity + .withdrawals + .fetch_in_flight_withdrawal_amount + { + Some(0) => self.fetch_in_flight_withdrawal_amount_v0(transaction, platform_version), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_in_flight_withdrawal_amount".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_in_flight_withdrawal_amount".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs new file mode 100644 index 00000000000..154cf8d9b00 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs @@ -0,0 +1,175 @@ +use crate::drive::identity::withdrawals::paths::{ + get_withdrawal_transactions_broadcasted_path_vec, get_withdrawal_transactions_queue_path_vec, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::balances::credits::CREDITS_PER_DUFF; +use dpp::dashcore::consensus::Decodable; +use dpp::dashcore::transaction::special_transaction::asset_unlock::unqualified_asset_unlock::AssetUnlockBaseTransactionInfo; +use dpp::fee::Credits; +use grovedb::query_result_type::QueryResultType; +use grovedb::{Element, PathQuery, Query, TransactionArg}; +use platform_version::version::PlatformVersion; + +impl Drive { + pub(super) fn fetch_in_flight_withdrawal_amount_v0( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let overflow = || { + Error::Drive(DriveError::CriticalCorruptedState( + "in-flight withdrawal amount overflow", + )) + }; + + let mut total_duffs: u64 = 0; + + for path in [ + get_withdrawal_transactions_queue_path_vec(), + get_withdrawal_transactions_broadcasted_path_vec(), + ] { + let mut query = Query::new(); + query.insert_all(); + let path_query = PathQuery::new_unsized(path, query); + + let (results, _) = self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryElementResultType, + &mut vec![], + &platform_version.drive, + )?; + + for element in results.to_elements() { + let Element::Item(bytes, _) = element else { + return Err(Error::Drive(DriveError::CorruptedElementType( + "withdrawal transaction is not an item", + ))); + }; + // Both trees hold the untied transaction as it was pooled; the request height + // and quorum signature added when signing do not change what it unlocks. + let untied = + AssetUnlockBaseTransactionInfo::consensus_decode(&mut bytes.as_slice()) + .map_err(|_| { + Error::Drive(DriveError::CorruptedSerialization( + "withdrawal transaction cannot be decoded".to_string(), + )) + })?; + + // What Core counts against its limit: the outputs plus the fee. + total_duffs = total_duffs + .checked_add(untied.base_payload.fee as u64) + .ok_or_else(overflow)?; + for output in &untied.output { + total_duffs = total_duffs.checked_add(output.value).ok_or_else(overflow)?; + } + } + } + + total_duffs + .checked_mul(CREDITS_PER_DUFF) + .ok_or_else(overflow) + } +} + +#[cfg(test)] +mod tests { + use crate::util::batch::DriveOperation; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::dashcore::consensus::Encodable; + use dpp::dashcore::transaction::special_transaction::asset_unlock::unqualified_asset_unlock::{ + AssetUnlockBasePayload, AssetUnlockBaseTransactionInfo, + }; + use dpp::dashcore::{ScriptBuf, TxOut}; + use dpp::version::PlatformVersion; + + fn untied_transaction(index: u64, payout_duffs: u64, fee_duffs: u32) -> Vec { + let transaction = AssetUnlockBaseTransactionInfo { + version: 1, + lock_time: 0, + output: vec![TxOut { + value: payout_duffs, + script_pubkey: ScriptBuf::new(), + }], + base_payload: AssetUnlockBasePayload { + version: 1, + index, + fee: fee_duffs, + }, + }; + let mut bytes = vec![]; + transaction + .consensus_encode(&mut bytes) + .expect("expected to encode"); + bytes + } + + #[test] + fn should_sum_the_outputs_and_fees_of_queued_and_broadcast_transactions_in_credits() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + assert_eq!( + drive + .fetch_in_flight_withdrawal_amount(Some(&transaction), platform_version) + .expect("expected the amount"), + 0 + ); + + let mut drive_operations: Vec = vec![]; + drive + .add_enqueue_untied_withdrawal_transaction_operations( + vec![ + (0, untied_transaction(0, 100_000, 2_000)), + (1, untied_transaction(1, 50_000, 1_000)), + ], + 153_000_000, + &mut drive_operations, + platform_version, + ) + .expect("expected to enqueue"); + drive + .apply_drive_operations( + drive_operations, + true, + &BlockInfo::default(), + Some(&transaction), + platform_version, + None, + ) + .expect("expected to apply"); + + // Move one to the broadcast tree, as signing does. + let mut drive_operations: Vec = vec![]; + drive + .dequeue_untied_withdrawal_transactions( + 1, + Some(&transaction), + &mut drive_operations, + platform_version, + ) + .expect("expected to dequeue"); + drive + .apply_drive_operations( + drive_operations, + true, + &BlockInfo::default(), + Some(&transaction), + platform_version, + None, + ) + .expect("expected to apply"); + + // (100,000 + 2,000 + 50,000 + 1,000) duffs, in credits. + assert_eq!( + drive + .fetch_in_flight_withdrawal_amount(Some(&transaction), platform_version) + .expect("expected the amount"), + 153_000_000 + ); + } +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_last_recorded_core_credit_pool_height/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_last_recorded_core_credit_pool_height/mod.rs new file mode 100644 index 00000000000..65966b38a74 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_last_recorded_core_credit_pool_height/mod.rs @@ -0,0 +1,51 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use grovedb::TransactionArg; +use platform_version::version::PlatformVersion; + +impl Drive { + /// Fetches the highest Core height whose credit pool balance was recorded: the last Core + /// block the scan of `scan_core_blocks_for_withdrawals` read. Shares the + /// `fetch_core_credit_pool_balances` method version. + /// + /// # Parameters + /// + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Some(u32))`: The highest recorded Core height. + /// * `Ok(None)`: When no balance was recorded yet. + /// * `Err(Error)` when the method version is unknown or not active, the entry is corrupted, + /// or the read fails. + pub fn fetch_last_recorded_core_credit_pool_height( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .identity + .withdrawals + .fetch_core_credit_pool_balances + { + Some(0) => { + self.fetch_last_recorded_core_credit_pool_height_v0(transaction, platform_version) + } + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_last_recorded_core_credit_pool_height".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_last_recorded_core_credit_pool_height".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_last_recorded_core_credit_pool_height/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_last_recorded_core_credit_pool_height/v0/mod.rs new file mode 100644 index 00000000000..ca7654a0257 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_last_recorded_core_credit_pool_height/v0/mod.rs @@ -0,0 +1,45 @@ +use crate::drive::identity::withdrawals::paths::get_withdrawal_core_credit_pool_balances_path_vec; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use grovedb::query_result_type::QueryResultType; +use grovedb::{PathQuery, Query, SizedQuery, TransactionArg}; +use platform_version::version::PlatformVersion; + +impl Drive { + pub(super) fn fetch_last_recorded_core_credit_pool_height_v0( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut query = Query::new(); + query.insert_all(); + query.left_to_right = false; + + let path_query = PathQuery::new( + get_withdrawal_core_credit_pool_balances_path_vec(), + SizedQuery::new(query, Some(1), None), + ); + + let (results, _) = self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + )?; + + results + .to_key_elements() + .into_iter() + .next() + .map(|(key, _)| { + key.try_into().map(u32::from_be_bytes).map_err(|_| { + Error::Drive(DriveError::CorruptedSerialization( + "core credit pool balance key is not 4 bytes".to_string(), + )) + }) + }) + .transpose() + } +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/mod.rs index 22db7a870c1..3a566dc5be4 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/mod.rs @@ -10,13 +10,112 @@ mod calculate_current_withdrawal_limit; /// same schedule so a deposit and the withdrawal it funds cancel exactly over the whole window. // Best to use a constant here and not a versioned item as this most likely will not change pub const DAY_AND_A_HOUR_IN_MS: dpp::prelude::TimestampMillis = 90_000_000; //25 hours +/// Functions related to the Core credit pool balances the Core-anchored withdrawal limit reads +pub mod fetch_core_credit_pool_balances; +/// Functions related to what pooled withdrawals take out of Core's credit pool once mined +pub mod fetch_in_flight_withdrawal_amount; +/// Functions related to the Core credit pool balances the Core-anchored withdrawal limit reads +pub mod fetch_last_recorded_core_credit_pool_height; /// Functions related to the per-block record of total credits the daily withdrawal limit reads pub mod fetch_total_credits_in_platform_a_day_ago; /// Functions and constants related to GroveDB paths pub mod paths; +/// Functions related to the credit inflows of asset locks, dated by the Core block that mined them +pub mod record_asset_lock_credit_inflow; +/// Functions related to the Core blocks the Core-anchored withdrawal limit reads +pub mod record_core_credit_pool_block; /// Functions related to the per-block record of credit inflows the daily withdrawal limit adds pub mod record_credit_inflow; /// Functions related to the per-block record of total credits the daily withdrawal limit reads pub mod record_total_credits_history; /// Functions related to withdrawal transactions pub mod transaction; + +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::fee::Credits; +use dpp::prelude::TimestampMillis; + +/// An asset lock Platform consumed before Core mined it, stored under its transaction id until +/// a Core block that holds it is read. Its credits count as a credit inflow only from then, +/// dated by that Core block, like those of an asset lock Core had already mined. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PendingAssetLockCreditInflow { + /// The credits the asset lock minted into Platform. + pub amount: Credits, + /// The block time the credits were minted at, in milliseconds. Of several mints of one + /// asset lock, the earliest: the daily withdrawal limit counts an inflow only when it was + /// recorded after its day-old base snapshot, so the earliest is the strictest. + pub recorded_at_time_ms: TimestampMillis, + /// The Core chain locked height of the block the credits were minted in; the entry is + /// dropped once Core is `core_credit_pool_window_max_blocks` past it. + pub recorded_at_core_height: u32, +} + +impl PendingAssetLockCreditInflow { + /// The encoded size: the amount, the time and the Core height, big-endian. + pub const ENCODED_LEN: usize = 8 + 8 + 4; + + /// Encodes the entry as the value of its item. + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(Self::ENCODED_LEN); + bytes.extend_from_slice(&self.amount.to_be_bytes()); + bytes.extend_from_slice(&self.recorded_at_time_ms.to_be_bytes()); + bytes.extend_from_slice(&self.recorded_at_core_height.to_be_bytes()); + bytes + } + + /// Decodes the value of an item written by [`Self::to_bytes`]. + pub fn from_bytes(bytes: &[u8]) -> Result { + let corrupted = || { + Error::Drive(DriveError::CorruptedSerialization( + "pending asset lock credit inflow is not 20 bytes".to_string(), + )) + }; + let bytes: &[u8; Self::ENCODED_LEN] = bytes.try_into().map_err(|_| corrupted())?; + let (amount, rest) = bytes.split_at(8); + let (recorded_at_time_ms, recorded_at_core_height) = rest.split_at(8); + Ok(Self { + amount: u64::from_be_bytes(amount.try_into().map_err(|_| corrupted())?), + recorded_at_time_ms: u64::from_be_bytes( + recorded_at_time_ms.try_into().map_err(|_| corrupted())?, + ), + recorded_at_core_height: u32::from_be_bytes( + recorded_at_core_height + .try_into() + .map_err(|_| corrupted())?, + ), + }) + } +} + +/// The key of a Core-dated credit inflow entry: the Core height it stops counting at, then the +/// block time it was recorded at, both big-endian, so a range from a height selects every entry +/// still counting there. +pub fn core_dated_credit_inflow_key( + expires_at_core_height: u32, + recorded_at_time_ms: TimestampMillis, +) -> Vec { + let mut key = Vec::with_capacity(12); + key.extend_from_slice(&expires_at_core_height.to_be_bytes()); + key.extend_from_slice(&recorded_at_time_ms.to_be_bytes()); + key +} + +/// Splits a key written by [`core_dated_credit_inflow_key`] into the Core height the entry +/// stops counting at and the block time it was recorded at. +pub fn decode_core_dated_credit_inflow_key(key: &[u8]) -> Result<(u32, TimestampMillis), Error> { + let corrupted = || { + Error::Drive(DriveError::CorruptedSerialization( + "core-dated credit inflow key is not 12 bytes".to_string(), + )) + }; + if key.len() != 12 { + return Err(corrupted()); + } + let (expires_at_core_height, recorded_at_time_ms) = key.split_at(4); + Ok(( + u32::from_be_bytes(expires_at_core_height.try_into().map_err(|_| corrupted())?), + u64::from_be_bytes(recorded_at_time_ms.try_into().map_err(|_| corrupted())?), + )) +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/paths.rs b/packages/rs-drive/src/drive/identity/withdrawals/paths.rs index d0b6d9d0843..5a6d286c124 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/paths.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/paths.rs @@ -22,6 +22,24 @@ pub const WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY: [u8; 1] = [4]; /// that entered Platform within the window may leave again without consuming the budget of /// other users. Exists from protocol version 14. pub const WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY: [u8; 1] = [5]; +/// constant id for the subtree recording Core's credit pool balance after each Core block +/// Platform read (key: Core block height, big-endian; value: the balance in credits, +/// big-endian). The Core-anchored withdrawal limit reads the balance at the chain locked height +/// and at the start of Core's unlock window. Exists from protocol version 14. +pub const WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY: [u8; 1] = [6]; +/// constant id for the subtree of asset locks Platform consumed before Core mined them (key: +/// the asset lock transaction id; value: a [`PendingAssetLockCreditInflow`]). Their credits +/// count as an inflow only once a Core block that holds them is read, dated by that block. +/// Exists from protocol version 14. +/// +/// [`PendingAssetLockCreditInflow`]: crate::drive::identity::withdrawals::PendingAssetLockCreditInflow +pub const WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY: [u8; 1] = [7]; +/// constant id for the sum tree of credit inflows from asset locks, dated by the Core block that +/// mined them (key: the Core height the entry stops counting at, big-endian, then the block time +/// in milliseconds it was recorded at, big-endian; value: credits, as a sum item). The daily +/// withdrawal limit adds the unexpired entries recorded after its day-old base snapshot to the +/// daily maximum. Exists from protocol version 14. +pub const WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY: [u8; 1] = [8]; impl Drive { /// Add operations for creating initial withdrawal state structure @@ -60,6 +78,18 @@ impl Drive { vec![vec![RootTree::WithdrawalTransactions as u8]], WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY.to_vec(), ); + batch.add_insert_empty_tree( + vec![vec![RootTree::WithdrawalTransactions as u8]], + WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY.to_vec(), + ); + batch.add_insert_empty_tree( + vec![vec![RootTree::WithdrawalTransactions as u8]], + WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY.to_vec(), + ); + batch.add_insert_empty_sum_tree( + vec![vec![RootTree::WithdrawalTransactions as u8]], + WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY.to_vec(), + ); } } } @@ -153,3 +183,51 @@ pub fn get_withdrawal_credit_inflows_sum_tree_path() -> [&'static [u8]; 2] { &WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, ] } + +/// Helper function to get the Core credit pool balances path as Vec +pub fn get_withdrawal_core_credit_pool_balances_path_vec() -> Vec> { + vec![ + vec![RootTree::WithdrawalTransactions as u8], + WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY.to_vec(), + ] +} + +/// Helper function to get the Core credit pool balances path as [u8] +pub fn get_withdrawal_core_credit_pool_balances_path() -> [&'static [u8]; 2] { + [ + Into::<&[u8; 1]>::into(RootTree::WithdrawalTransactions), + &WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, + ] +} + +/// Helper function to get the pending asset lock inflows path as Vec +pub fn get_withdrawal_pending_asset_lock_inflows_path_vec() -> Vec> { + vec![ + vec![RootTree::WithdrawalTransactions as u8], + WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY.to_vec(), + ] +} + +/// Helper function to get the pending asset lock inflows path as [u8] +pub fn get_withdrawal_pending_asset_lock_inflows_path() -> [&'static [u8]; 2] { + [ + Into::<&[u8; 1]>::into(RootTree::WithdrawalTransactions), + &WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, + ] +} + +/// Helper function to get the Core-dated credit inflows sum tree path as Vec +pub fn get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec() -> Vec> { + vec![ + vec![RootTree::WithdrawalTransactions as u8], + WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY.to_vec(), + ] +} + +/// Helper function to get the Core-dated credit inflows sum tree path as [u8] +pub fn get_withdrawal_core_dated_credit_inflows_sum_tree_path() -> [&'static [u8]; 2] { + [ + Into::<&[u8; 1]>::into(RootTree::WithdrawalTransactions), + &WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, + ] +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/mod.rs new file mode 100644 index 00000000000..ae972ee375c --- /dev/null +++ b/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/mod.rs @@ -0,0 +1,71 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use grovedb::TransactionArg; +use platform_version::version::PlatformVersion; + +impl Drive { + /// Records the credits an asset lock minted into Platform as a credit inflow dated by the + /// Core block that mined it, the way Core counts it, rather than by the Platform block that + /// consumed it. An asset lock Core mined so long ago that it left the window + /// (`core_credit_pool_window_min_blocks`) records nothing: a lock published late adds no + /// budget Core does not grant. One Core has not mined at or below the block's chain locked + /// height is recorded as pending, and counts once a Core block holding it is read + /// (`record_core_credit_pool_block`). + /// + /// # Parameters + /// + /// * `asset_lock_txid`: The id of the asset lock transaction. + /// * `amount`: The credits it minted in this block. + /// * `mined_at_core_height`: The height of the Core block that mined it, when that is at or + /// below the block's chain locked height; `None` otherwise. + /// * `block_info`: The Platform block being executed. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once the inflow is recorded (dated or pending), or at once when `amount` is + /// zero or the inflow is already out of the window. + /// * `Err(Error)` when the method version is unknown or not active, a stored entry is + /// corrupted, or the write fails. + pub fn record_asset_lock_credit_inflow( + &self, + asset_lock_txid: [u8; 32], + amount: Credits, + mined_at_core_height: Option, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .identity + .withdrawals + .record_asset_lock_credit_inflow + { + Some(0) => self.record_asset_lock_credit_inflow_v0( + asset_lock_txid, + amount, + mined_at_core_height, + block_info, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "record_asset_lock_credit_inflow".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "record_asset_lock_credit_inflow".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/v0/mod.rs new file mode 100644 index 00000000000..74804afd5f5 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/v0/mod.rs @@ -0,0 +1,199 @@ +use crate::drive::identity::withdrawals::paths::{ + get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec, + get_withdrawal_pending_asset_lock_inflows_path, + get_withdrawal_pending_asset_lock_inflows_path_vec, +}; +use crate::drive::identity::withdrawals::{ + core_dated_credit_inflow_key, PendingAssetLockCreditInflow, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::util::grove_operations::{BatchInsertApplyType, DirectQueryType}; +use crate::util::object_size_info::PathKeyElementInfo; +use dpp::block::block_info::BlockInfo; +use dpp::fee::{Credits, SignedCredits}; +use grovedb::{Element, TransactionArg}; +use platform_version::version::PlatformVersion; + +impl Drive { + pub(super) fn record_asset_lock_credit_inflow_v0( + &self, + asset_lock_txid: [u8; 32], + amount: Credits, + mined_at_core_height: Option, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + if amount == 0 { + return Ok(()); + } + + let mut drive_operations = vec![]; + + match mined_at_core_height { + Some(mined_at_core_height) => { + let window_min_blocks = platform_version + .system_limits + .core_credit_pool_window_min_blocks + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "record_asset_lock_credit_inflow v0 requires system_limits.core_credit_pool_window_min_blocks", + )))?; + + let expires_at_core_height = mined_at_core_height.saturating_add(window_min_blocks); + if expires_at_core_height <= block_info.core_height { + // Core mined it a whole window ago: its credits sit in the balance Core's + // limit starts from, and adding them again would grant budget Core does not. + return Ok(()); + } + + let amount = SignedCredits::try_from(amount).map_err(|_| { + Error::Drive(DriveError::CriticalCorruptedState( + "core-dated credit inflow does not fit a sum item", + )) + })?; + + self.batch_insert_sum_item_or_add_to_if_already_exists( + PathKeyElementInfo::PathKeyElement::<0>(( + get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec(), + core_dated_credit_inflow_key(expires_at_core_height, block_info.time_ms), + Element::SumItem(amount, None), + )), + BatchInsertApplyType::StatefulBatchInsert, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + } + None => { + let pending_path = get_withdrawal_pending_asset_lock_inflows_path(); + + // Another mint of the same asset lock may already wait (a partly used asset + // lock): add to it, keeping when it was first recorded. + let pending = match self.grove_get_raw_optional( + (&pending_path).into(), + &asset_lock_txid, + DirectQueryType::StatefulDirectQuery, + transaction, + &mut vec![], + &platform_version.drive, + )? { + Some(Element::Item(value, _)) => { + let mut pending = PendingAssetLockCreditInflow::from_bytes(&value)?; + pending.amount = pending.amount.checked_add(amount).ok_or(Error::Drive( + DriveError::CriticalCorruptedState( + "pending asset lock credit inflow overflow", + ), + ))?; + pending + } + Some(_) => { + return Err(Error::Drive(DriveError::CorruptedElementType( + "pending asset lock credit inflow is not an item", + ))) + } + None => PendingAssetLockCreditInflow { + amount, + recorded_at_time_ms: block_info.time_ms, + recorded_at_core_height: block_info.core_height, + }, + }; + + self.batch_insert( + PathKeyElementInfo::PathKeyElement::<0>(( + get_withdrawal_pending_asset_lock_inflows_path_vec(), + asset_lock_txid.to_vec(), + Element::new_item(pending.to_bytes()), + )), + &mut drive_operations, + &platform_version.drive, + )?; + } + } + + self.apply_batch_low_level_drive_operations( + None, + transaction, + drive_operations, + &mut vec![], + &platform_version.drive, + )?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::drive::identity::withdrawals::paths::get_withdrawal_pending_asset_lock_inflows_path; + use crate::drive::identity::withdrawals::PendingAssetLockCreditInflow; + use crate::util::grove_operations::DirectQueryType; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::version::PlatformVersion; + use grovedb::Element; + + #[test] + fn should_add_a_second_mint_to_a_pending_asset_lock_and_keep_when_it_was_first_recorded() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + for (time_ms, core_height, amount) in + [(1_000u64, 50u32, 300u64), (2_000, 51, 200), (3_000, 52, 0)] + { + drive + .record_asset_lock_credit_inflow( + [5; 32], + amount, + None, + &BlockInfo { + time_ms, + core_height, + ..Default::default() + }, + Some(&transaction), + platform_version, + ) + .expect("expected to record the inflow"); + } + + let element = drive + .grove_get_raw_optional( + (&get_withdrawal_pending_asset_lock_inflows_path()).into(), + &[5; 32], + DirectQueryType::StatefulDirectQuery, + Some(&transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected to read") + .expect("expected the pending entry"); + let Element::Item(value, _) = element else { + panic!("expected an item"); + }; + assert_eq!( + PendingAssetLockCreditInflow::from_bytes(&value).expect("expected to decode"), + PendingAssetLockCreditInflow { + amount: 500, + recorded_at_time_ms: 1_000, + recorded_at_core_height: 50, + } + ); + } + + #[test] + fn should_round_trip_a_pending_entry_and_refuse_other_lengths() { + let pending = PendingAssetLockCreditInflow { + amount: u64::MAX, + recorded_at_time_ms: 7, + recorded_at_core_height: u32::MAX, + }; + assert_eq!( + PendingAssetLockCreditInflow::from_bytes(&pending.to_bytes()).expect("decodes"), + pending + ); + assert!(PendingAssetLockCreditInflow::from_bytes(&[0; 19]).is_err()); + } +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/mod.rs new file mode 100644 index 00000000000..2963c8f327e --- /dev/null +++ b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/mod.rs @@ -0,0 +1,70 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use grovedb::TransactionArg; +use platform_version::version::PlatformVersion; + +impl Drive { + /// Records what a Core block tells the Core-anchored withdrawal limit: Core's credit pool + /// balance after it, and which of the asset locks Platform consumed before Core mined them + /// it holds. The balance is stored under the block's height; each such asset lock leaves the + /// pending tree and, while the block is still inside the window + /// (`core_credit_pool_window_min_blocks` past it), its credits are recorded as a credit + /// inflow dated by this block. + /// + /// # Parameters + /// + /// * `core_height`: The height of the Core block. + /// * `credit_pool_balance`: Core's credit pool balance after the block, in credits. + /// * `asset_lock_txids`: The ids of the asset lock transactions the block holds. + /// * `block_info`: The Platform block being executed; its Core height decides whether a + /// dated inflow still counts, and its time is not used (a resolved entry keeps the time + /// its credits were minted at). + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once the balance is stored and the pending asset locks of the block resolved. + /// * `Err(Error)` when the method version is unknown or not active, a stored entry is + /// corrupted, or the write fails. + pub fn record_core_credit_pool_block( + &self, + core_height: u32, + credit_pool_balance: Credits, + asset_lock_txids: &[[u8; 32]], + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .identity + .withdrawals + .record_core_credit_pool_block + { + Some(0) => self.record_core_credit_pool_block_v0( + core_height, + credit_pool_balance, + asset_lock_txids, + block_info, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "record_core_credit_pool_block".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "record_core_credit_pool_block".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/v0/mod.rs new file mode 100644 index 00000000000..98132c73f74 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/v0/mod.rs @@ -0,0 +1,138 @@ +use crate::drive::identity::withdrawals::paths::{ + get_withdrawal_core_credit_pool_balances_path_vec, + get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec, + get_withdrawal_pending_asset_lock_inflows_path, +}; +use crate::drive::identity::withdrawals::{ + core_dated_credit_inflow_key, PendingAssetLockCreditInflow, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::util::grove_operations::{BatchDeleteApplyType, BatchInsertApplyType, DirectQueryType}; +use crate::util::object_size_info::PathKeyElementInfo; +use dpp::block::block_info::BlockInfo; +use dpp::fee::{Credits, SignedCredits}; +use grovedb::{Element, MaybeTree, TransactionArg}; +use platform_version::version::PlatformVersion; +use std::collections::BTreeMap; + +impl Drive { + pub(super) fn record_core_credit_pool_block_v0( + &self, + core_height: u32, + credit_pool_balance: Credits, + asset_lock_txids: &[[u8; 32]], + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let window_min_blocks = platform_version + .system_limits + .core_credit_pool_window_min_blocks + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "record_core_credit_pool_block v0 requires system_limits.core_credit_pool_window_min_blocks", + )))?; + + let mut drive_operations = vec![]; + + self.batch_insert( + PathKeyElementInfo::PathKeyElement::<0>(( + get_withdrawal_core_credit_pool_balances_path_vec(), + core_height.to_be_bytes().to_vec(), + Element::new_item(credit_pool_balance.to_be_bytes().to_vec()), + )), + &mut drive_operations, + &platform_version.drive, + )?; + + // Core counts an asset lock in full for its window after the block that mined it, so + // the credits of one Platform consumed early count from this block, like those of one + // Core had mined already; past the window they no longer count at all. + let expires_at_core_height = core_height.saturating_add(window_min_blocks); + let still_counts = expires_at_core_height > block_info.core_height; + + let pending_path = get_withdrawal_pending_asset_lock_inflows_path(); + + // Summed per key first: two asset locks minted in one Platform block and mined in one + // Core block share a key, and the add-or-insert below reads the stored value, not the + // operations queued in this batch. + let mut dated_inflows: BTreeMap, Credits> = BTreeMap::new(); + + for txid in asset_lock_txids { + let Some(element) = self.grove_get_raw_optional( + (&pending_path).into(), + txid, + DirectQueryType::StatefulDirectQuery, + transaction, + &mut vec![], + &platform_version.drive, + )? + else { + // Not consumed by Platform yet, or consumed after Core mined it (then it was + // dated when it was consumed). + continue; + }; + + let Element::Item(value, _) = element else { + return Err(Error::Drive(DriveError::CorruptedElementType( + "pending asset lock credit inflow is not an item", + ))); + }; + let pending = PendingAssetLockCreditInflow::from_bytes(&value)?; + + self.batch_delete( + (&pending_path).into(), + txid, + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + + if still_counts { + let total = dated_inflows + .entry(core_dated_credit_inflow_key( + expires_at_core_height, + pending.recorded_at_time_ms, + )) + .or_default(); + *total = total.checked_add(pending.amount).ok_or(Error::Drive( + DriveError::CriticalCorruptedState("core-dated credit inflows overflow"), + ))?; + } + } + + let dated_path = get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec(); + for (key, amount) in dated_inflows { + let amount = SignedCredits::try_from(amount).map_err(|_| { + Error::Drive(DriveError::CriticalCorruptedState( + "core-dated credit inflow does not fit a sum item", + )) + })?; + self.batch_insert_sum_item_or_add_to_if_already_exists( + PathKeyElementInfo::PathKeyElement::<0>(( + dated_path.clone(), + key, + Element::SumItem(amount, None), + )), + BatchInsertApplyType::StatefulBatchInsert, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + } + + self.apply_batch_low_level_drive_operations( + None, + transaction, + drive_operations, + &mut vec![], + &platform_version.drive, + )?; + + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/structure.rs b/packages/rs-drive/src/drive/identity/withdrawals/structure.rs index 1334b4daa83..856e2fae424 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/structure.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/structure.rs @@ -1,7 +1,9 @@ use crate::drive::identity::withdrawals::paths::{ - WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY, - WITHDRAWAL_TRANSACTIONS_BROADCASTED_KEY, WITHDRAWAL_TRANSACTIONS_NEXT_INDEX_KEY, - WITHDRAWAL_TRANSACTIONS_QUEUE_KEY, WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, + WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, + WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, + WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY, WITHDRAWAL_TRANSACTIONS_BROADCASTED_KEY, + WITHDRAWAL_TRANSACTIONS_NEXT_INDEX_KEY, WITHDRAWAL_TRANSACTIONS_QUEUE_KEY, + WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, }; use crate::drive::RootTree; use crate::structure::{ElementKind, KeyEncoding, KeyMatcher, StructureNode}; @@ -158,5 +160,81 @@ pub(crate) fn structure() -> StructureNode { .value("credits") .describe("Credits that entered Platform at that time."), ), + StructureNode::fixed( + "core_credit_pool_balances", + &WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, + "CoreCreditPoolBalances", + "WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY", + ) + .kind(ElementKind::Tree) + .since(14) + .source("packages/rs-drive/src/drive/identity/withdrawals/paths.rs") + .describe( + "Core's credit pool balance after each Core block \ + read, for the Core-anchored withdrawal limit.", + ) + .child( + StructureNode::dynamic( + "balance", + "core_height", + KeyMatcher::Len(4), + KeyEncoding::U32Be, + "The Core block height", + ) + .kind(ElementKind::Item) + .value("credits, u64 big endian") + .describe("The credit pool balance after that Core block."), + ), + StructureNode::fixed( + "pending_asset_lock_inflows", + &WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, + "PendingAssetLockInflows", + "WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY", + ) + .kind(ElementKind::Tree) + .since(14) + .source("packages/rs-drive/src/drive/identity/withdrawals/paths.rs") + .describe( + "Asset locks consumed before Core mined them, \ + waiting to be dated by the Core block that does.", + ) + .child( + StructureNode::dynamic( + "asset_lock", + "txid", + KeyMatcher::Len(32), + KeyEncoding::Hash32, + "The asset lock transaction id", + ) + .kind(ElementKind::Item) + .value("credits u64, block time u64 and Core height u32, big endian") + .describe("The credits the asset lock minted, and the block that minted them first."), + ), + StructureNode::fixed( + "core_dated_credit_inflows", + &WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, + "CoreDatedCreditInflows", + "WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY", + ) + .kind(ElementKind::SumTree) + .since(14) + .source("packages/rs-drive/src/drive/identity/withdrawals/paths.rs") + .describe( + "Asset lock credit inflows, dated by the Core block \ + that mined them, which raise the relative withdrawal limit.", + ) + .child( + StructureNode::dynamic( + "inflow", + "expiry_and_time", + KeyMatcher::Len(12), + KeyEncoding::Composite, + "The Core height the entry stops counting at (u32 big endian), then the block \ + time in milliseconds it was recorded at (u64 big endian)", + ) + .kind(ElementKind::SumItem) + .value("credits") + .describe("Credits asset locks minted, counting until that Core height."), + ), ]) } diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index e220f77517a..6eef2ab91aa 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -1223,6 +1223,42 @@ mod fixtures { conformance_of(&drive, "address_balances", run); } + /// The Core-anchored withdrawal accounting: a recorded Core credit pool balance, an asset + /// lock waiting for Core to mine it, and one dated by the Core block that mined it. + fn core_anchored_withdrawal_accounting(run: &mut FixtureRun) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let block_info = BlockInfo { + time_ms: 1_000, + core_height: 100, + ..Default::default() + }; + drive + .record_core_credit_pool_block(100, 5_000_000, &[], &block_info, None, platform_version) + .expect("expected to record a Core block"); + drive + .record_asset_lock_credit_inflow( + [21; 32], + 300_000, + None, + &block_info, + None, + platform_version, + ) + .expect("expected to record a pending asset lock"); + drive + .record_asset_lock_credit_inflow( + [22; 32], + 400_000, + Some(99), + &block_info, + None, + platform_version, + ) + .expect("expected to record a dated asset lock"); + conformance_of(&drive, "core_anchored_withdrawal_accounting", run); + } + /// An epoch while it runs, then after it was paid out: payout deletes the /// proposers and both fee items and keeps the epoch tree. The finished /// epoch info is written at payout, so no epoch ever holds all nine keys. @@ -1793,6 +1829,7 @@ mod fixtures { contract_with_team_actions(&mut run); tokens_and_group_actions(&mut run); address_balances(&mut run); + core_anchored_withdrawal_accounting(&mut run); current_then_paid_epoch(&mut run); contested_documents(&mut run); token_distributions(&mut run); diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index 78a107ae3f5..b72549c97da 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -391,6 +391,37 @@ impl DriveOperation<'_> { .fold(0u64, |total, amount| total.saturating_add(amount)) } + /// The asset lock transaction a batch's mints came from, with those mints: every state + /// transition that mints credits spends exactly one asset lock and records it as used + /// (`AddUsedAssetLock`) in the same batch. `None` when the batch mints nothing, or does not + /// name exactly one asset lock (no such batch exists today), so the caller treats its mints + /// as it treats any other. + pub fn asset_lock_credit_mints(operations: &[DriveOperation]) -> Option<([u8; 32], Credits)> { + let minted = Self::credit_mints(operations); + if minted == 0 { + return None; + } + + let mut used_asset_locks = operations.iter().filter_map(|operation| match operation { + DriveOperation::SystemOperation(SystemOperationType::AddUsedAssetLock { + asset_lock_outpoint, + .. + }) => Some(asset_lock_outpoint), + _ => None, + }); + + let outpoint = used_asset_locks.next()?; + if used_asset_locks.next().is_some() { + return None; + } + + // An outpoint is the transaction id (32 bytes, in the order `Txid` holds it) followed + // by the output index. + let mut txid = [0u8; 32]; + txid.copy_from_slice(&outpoint.as_slice()[..32]); + Some((txid, minted)) + } + /// Merges every write of one identity balance, of one contract fee pot, and of one /// prefunded specialized balance, into a single net operation. /// @@ -1718,4 +1749,38 @@ mod tests { ]; assert_eq!(merged(operations.clone()), format!("{operations:?}")); } + + #[test] + fn should_attribute_a_batch_mint_to_the_one_asset_lock_it_spends() { + use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::{OutPoint, Txid}; + use dpp::platform_value::Bytes36; + + let platform_version = PlatformVersion::latest(); + let txid = Txid::from_byte_array([3; 32]); + let used = |vout: u32| { + DriveOperation::SystemOperation(SystemOperationType::AddUsedAssetLock { + asset_lock_outpoint: Bytes36::new(OutPoint::new(txid, vout).into()), + asset_lock_value: AssetLockValue::new(10, vec![], 0, vec![], platform_version) + .expect("expected an asset lock value"), + }) + }; + let mint = |amount: u64| { + DriveOperation::SystemOperation(SystemOperationType::AddToSystemCredits { amount }) + }; + + // The txid comes back in the byte order `Txid` holds, whatever the output index. + assert_eq!( + DriveOperation::asset_lock_credit_mints(&[mint(500), used(1)]), + Some((txid.to_byte_array(), 500)) + ); + // Nothing minted, or no single asset lock named: no attribution. + assert_eq!(DriveOperation::asset_lock_credit_mints(&[used(0)]), None); + assert_eq!(DriveOperation::asset_lock_credit_mints(&[mint(500)]), None); + assert_eq!( + DriveOperation::asset_lock_credit_mints(&[mint(500), used(0), used(1)]), + None + ); + } } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/mod.rs index 3c654283a53..52ec99d4c48 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/mod.rs @@ -1,4 +1,4 @@ -use versioned_feature_core::FeatureVersion; +use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v2; @@ -11,4 +11,8 @@ pub struct DPPMethodVersions { pub deduct_fee_from_outputs_or_remaining_balance_of_inputs: FeatureVersion, pub compute_minimum_shielded_fee: FeatureVersion, pub shielded_extra_sighash_data: FeatureVersion, + /// The Core-anchored withdrawal limit: how much Core's credit pool may still drop given its + /// balance now and at the window start (`SystemLimits::core_credit_pool_unlock_limit_percent` + /// and `core_credit_pool_unlock_limit_floor`). Exists from protocol version 14. + pub core_credit_pool_unlock_limit: OptionalFeatureVersion, } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v1.rs index e73b30c47f8..65c475040f5 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v1.rs @@ -5,4 +5,5 @@ pub const DPP_METHOD_VERSIONS_V1: DPPMethodVersions = DPPMethodVersions { deduct_fee_from_outputs_or_remaining_balance_of_inputs: 0, compute_minimum_shielded_fee: 0, shielded_extra_sighash_data: 0, + core_credit_pool_unlock_limit: None, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v2.rs index 12b88028ab5..43af886cbd7 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v2.rs @@ -5,4 +5,5 @@ pub const DPP_METHOD_VERSIONS_V2: DPPMethodVersions = DPPMethodVersions { deduct_fee_from_outputs_or_remaining_balance_of_inputs: 0, compute_minimum_shielded_fee: 0, shielded_extra_sighash_data: 0, + core_credit_pool_unlock_limit: None, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs index e2cc9994d40..855abdd9b57 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs @@ -2,11 +2,13 @@ use crate::version::dpp_versions::dpp_method_versions::DPPMethodVersions; /// DPP method versions 3. Introduced in protocol v14: `daily_withdrawal_limit` 1 → 2 replaces the /// flat daily withdrawal limit with a percentage of the total credits Platform held a day ago -/// (`SystemLimits::daily_withdrawal_limit_percent`). Everything else matches V2. +/// (`SystemLimits::daily_withdrawal_limit_percent`), and `core_credit_pool_unlock_limit` `None -> +/// Some(0)` adds the Core-anchored withdrawal limit. Everything else matches V2. pub const DPP_METHOD_VERSIONS_V3: DPPMethodVersions = DPPMethodVersions { epoch_core_reward_credits_for_distribution: 0, daily_withdrawal_limit: 2, deduct_fee_from_outputs_or_remaining_balance_of_inputs: 0, compute_minimum_shielded_fee: 0, shielded_extra_sighash_data: 0, + core_credit_pool_unlock_limit: Some(0), }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs index bfa34951428..28d82cf0b12 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs @@ -171,6 +171,15 @@ pub struct DriveAbciIdentityCreditWithdrawalMethodVersions { /// limit's daily maximum; exists from protocol version 14. pub record_credit_inflows_for_withdrawals: OptionalFeatureVersion, pub record_total_credits_history_for_withdrawals: OptionalFeatureVersion, + /// Reads the Core blocks the chain lock height advanced over: records each one's credit + /// pool balance and dates the consumed asset locks it mined. Feeds the Core-anchored + /// withdrawal limit; called by `pool_withdrawals_into_transactions_queue` 2; exists from + /// protocol version 14. + pub scan_core_blocks_for_withdrawals: OptionalFeatureVersion, + /// How much more Core's credit pool may give up to withdrawals pooled now: a stricter copy + /// of Core's own unlock limit, less what is pooled and not mined yet. Exists from protocol + /// version 14. + pub calculate_core_anchored_withdrawal_limit: OptionalFeatureVersion, /// Whether the next block has withdrawal work waiting (queued transactions to sign or expired /// documents to re-queue); drives the `propose_next_block_immediately` hint to Tenderdash. /// Not consensus: it never touches the state or the app hash. diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs index 934834e1a03..9cce385429b 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs @@ -91,6 +91,8 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V1: DriveAbciMethodVersions = DriveAbciMeth cleanup_expired_locks_of_withdrawal_amounts: 0, record_credit_inflows_for_withdrawals: None, record_total_credits_history_for_withdrawals: None, + scan_core_blocks_for_withdrawals: None, + calculate_core_anchored_withdrawal_limit: None, }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index 95493a875cf..dea47f7bddc 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -19,6 +19,10 @@ use crate::version::drive_abci_versions::drive_abci_method_versions::{ /// expired entries of the credit inflows sum tree the net daily withdrawal limit reads, and /// bumps `rebroadcast_expired_withdrawal_documents` to 2 so an expired withdrawal whose /// payout is below Core's dust threshold is marked FAILED instead of re-signed forever. +/// `pool_withdrawals_into_transactions_queue` 2 pools only what also fits the Core-anchored +/// withdrawal limit (`calculate_core_anchored_withdrawal_limit`, `Some(0)`), fed by +/// `scan_core_blocks_for_withdrawals` (`Some(0)`), which records Core's credit pool balance per +/// Core block and dates consumed asset locks by the Core block that mined them. /// `decode_raw_state_transitions` 1 refuses bytes left over after a raw state transition. /// `add_distribute_storage_fee_to_epochs_operations` 1 claws each pending storage refund back /// from the epochs it was priced for. @@ -94,7 +98,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet build_untied_withdrawal_transactions_from_documents: 0, dequeue_and_build_unsigned_withdrawal_transactions: 0, fetch_transactions_block_inclusion_status: 0, - pool_withdrawals_into_transactions_queue: 1, + pool_withdrawals_into_transactions_queue: 2, // changed in v14: pools only what also fits the Core-anchored limit update_broadcasted_withdrawal_statuses: 0, rebroadcast_expired_withdrawal_documents: 2, // changed in v14: an expired withdrawal whose payout is Core dust is marked FAILED instead of re-signed append_signatures_and_broadcast_withdrawal_transactions: 0, @@ -102,6 +106,8 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet cleanup_expired_locks_of_withdrawal_amounts: 1, // changed in v14: also prunes expired entries of the credit inflows sum tree record_credit_inflows_for_withdrawals: Some(0), // new in v14: the block's credit mints recorded as an inflow for the net daily withdrawal limit record_total_credits_history_for_withdrawals: Some(0), // changed in v14: per-block total credits history for the day-lagged daily withdrawal limit + scan_core_blocks_for_withdrawals: Some(0), // new in v14: Core credit pool balances and Core-dated asset lock inflows + calculate_core_anchored_withdrawal_limit: Some(0), // new in v14: withdrawals also fit a stricter copy of Core's unlock limit }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs index a12bfd06342..fa1ce98e0d9 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs @@ -92,6 +92,8 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V2: DriveAbciMethodVersions = DriveAbciMeth cleanup_expired_locks_of_withdrawal_amounts: 0, record_credit_inflows_for_withdrawals: None, record_total_credits_history_for_withdrawals: None, + scan_core_blocks_for_withdrawals: None, + calculate_core_anchored_withdrawal_limit: None, }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs index 74227e8fdd0..3f5f9f36c9e 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs @@ -91,6 +91,8 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V3: DriveAbciMethodVersions = DriveAbciMeth cleanup_expired_locks_of_withdrawal_amounts: 0, record_credit_inflows_for_withdrawals: None, record_total_credits_history_for_withdrawals: None, + scan_core_blocks_for_withdrawals: None, + calculate_core_anchored_withdrawal_limit: None, }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs index 6cbe48df5b1..cc109754783 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs @@ -91,6 +91,8 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V4: DriveAbciMethodVersions = DriveAbciMeth cleanup_expired_locks_of_withdrawal_amounts: 0, record_credit_inflows_for_withdrawals: None, record_total_credits_history_for_withdrawals: None, + scan_core_blocks_for_withdrawals: None, + calculate_core_anchored_withdrawal_limit: None, }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs index 3a618d0b040..36bc04aeea8 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs @@ -95,6 +95,8 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V5: DriveAbciMethodVersions = DriveAbciMeth cleanup_expired_locks_of_withdrawal_amounts: 0, record_credit_inflows_for_withdrawals: None, record_total_credits_history_for_withdrawals: None, + scan_core_blocks_for_withdrawals: None, + calculate_core_anchored_withdrawal_limit: None, }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs index 25a8b7dcf6f..837a7f13fba 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs @@ -93,6 +93,8 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V6: DriveAbciMethodVersions = DriveAbciMeth cleanup_expired_locks_of_withdrawal_amounts: 0, record_credit_inflows_for_withdrawals: None, record_total_credits_history_for_withdrawals: None, + scan_core_blocks_for_withdrawals: None, + calculate_core_anchored_withdrawal_limit: None, }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs index 12a73a6d249..d34c731a751 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs @@ -100,6 +100,8 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V7: DriveAbciMethodVersions = DriveAbciMeth cleanup_expired_locks_of_withdrawal_amounts: 0, record_credit_inflows_for_withdrawals: None, record_total_credits_history_for_withdrawals: None, + scan_core_blocks_for_withdrawals: None, + calculate_core_anchored_withdrawal_limit: None, }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs index 271f64a8a5f..720f93e53f5 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs @@ -102,6 +102,8 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V8: DriveAbciMethodVersions = DriveAbciMeth cleanup_expired_locks_of_withdrawal_amounts: 0, record_credit_inflows_for_withdrawals: None, record_total_credits_history_for_withdrawals: None, + scan_core_blocks_for_withdrawals: None, + calculate_core_anchored_withdrawal_limit: None, }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs index 01bcca128cb..f56a410b601 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs @@ -111,6 +111,8 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V9: DriveAbciMethodVersions = DriveAbciMeth cleanup_expired_locks_of_withdrawal_amounts: 0, record_credit_inflows_for_withdrawals: None, record_total_credits_history_for_withdrawals: None, + scan_core_blocks_for_withdrawals: None, + calculate_core_anchored_withdrawal_limit: None, }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/mod.rs index 9f5f046d8b6..56208d9b2f9 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/mod.rs @@ -9,4 +9,8 @@ pub struct DriveAbciWithdrawalConstants { /// Maximum number of entries `record_total_credits_history_for_withdrawals` prunes from /// the total credits history per block (`0` disables pruning). pub total_credits_history_prune_limit: u16, + /// Maximum number of Core blocks `scan_core_blocks_for_withdrawals` reads per Platform + /// block. When the chain lock height jumps further, the rest is read in the blocks after + /// (`0` disables the scan; protocol versions before 14 have no scan). + pub core_blocks_scanned_per_block_limit: u16, } diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v1.rs index 72e24c9305c..1d047eb7c4e 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v1.rs @@ -4,5 +4,6 @@ pub const DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V1: DriveAbciWithdrawalConstants = DriveAbciWithdrawalConstants { core_expiration_blocks: 48, total_credits_history_prune_limit: 0, + core_blocks_scanned_per_block_limit: 0, cleanup_expired_locks_of_withdrawal_amounts_limit: 0, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v2.rs index 66abe011053..5f970d64a62 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v2.rs @@ -4,5 +4,6 @@ pub const DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V2: DriveAbciWithdrawalConstants = DriveAbciWithdrawalConstants { core_expiration_blocks: 48, total_credits_history_prune_limit: 0, + core_blocks_scanned_per_block_limit: 0, cleanup_expired_locks_of_withdrawal_amounts_limit: 64, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v3.rs index 350f3b04280..68546db7cfc 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_withdrawal_constants/v3.rs @@ -3,10 +3,12 @@ use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::DriveA /// Withdrawal constants for protocol version 14 and above: identical to /// [`super::v2::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V2`] plus `total_credits_history_prune_limit`, /// bounding how many stale entries the per-block total credits history (the base of the -/// day-lagged daily withdrawal limit) drops per block. +/// day-lagged daily withdrawal limit) drops per block, and `core_blocks_scanned_per_block_limit`, +/// bounding how many Core blocks the Core-anchored withdrawal limit's scan reads per block. pub const DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3: DriveAbciWithdrawalConstants = DriveAbciWithdrawalConstants { core_expiration_blocks: 48, cleanup_expired_locks_of_withdrawal_amounts_limit: 64, total_credits_history_prune_limit: 64, + core_blocks_scanned_per_block_limit: 32, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs index 7b99529799e..7d27e03965b 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs @@ -27,6 +27,17 @@ pub struct DriveIdentityWithdrawalMethodVersions { /// tree the net daily withdrawal limit reads back. The subtree exists from protocol /// version 14. pub record_credit_inflows: OptionalFeatureVersion, + /// Record a Core block's credit pool balance and date the consumed asset locks it mined, + /// for the Core-anchored withdrawal limit. The subtrees exist from protocol version 14. + pub record_core_credit_pool_block: OptionalFeatureVersion, + /// Record the credits an asset lock minted as a credit inflow dated by the Core block that + /// mined it, or as pending until Core mines it. Exists from protocol version 14. + pub record_asset_lock_credit_inflow: OptionalFeatureVersion, + /// Read the recorded Core credit pool balances. Exists from protocol version 14. + pub fetch_core_credit_pool_balances: OptionalFeatureVersion, + /// Sum what the queued and broadcast withdrawal transactions take out of Core's credit + /// pool once mined. Exists from protocol version 14. + pub fetch_in_flight_withdrawal_amount: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs index c2e4ba4b933..bb33e9a4a24 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs @@ -171,5 +171,9 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V1: DriveIdentityMethodVersions = record_total_credits_history: None, fetch_total_credits_in_platform_a_day_ago: None, record_credit_inflows: None, + record_core_credit_pool_block: None, + record_asset_lock_credit_inflow: None, + fetch_core_credit_pool_balances: None, + fetch_in_flight_withdrawal_amount: None, }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs index 6b2cde9a209..0282801941f 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs @@ -51,9 +51,9 @@ use crate::version::drive_versions::drive_identity_method_versions::{ /// * `withdrawals.calculate_current_withdrawal_limit` 0 -> 1: the daily /// maximum derives from the total credits Platform held a day ago (the /// relative daily withdrawal limit) instead of the current total. The -/// `max_daily_withdrawal_amount` cap applies to that day-old base; credit -/// inflows from the active window are added after the cap so matching -/// deposit-withdraw cycles do not consume the capped budget. +/// optional `max_daily_withdrawal_amount` cap (unset in v14) applies to that +/// day-old base; credit inflows from the active window are added after it so +/// matching deposit-withdraw cycles do not consume the budget of others. /// * `withdrawals.record_total_credits_history` and /// `withdrawals.fetch_total_credits_in_platform_a_day_ago` `None -> Some(0)`: /// the per-block total credits history under the withdrawals tree that the @@ -64,6 +64,16 @@ use crate::version::drive_versions::drive_identity_method_versions::{ /// tree so the daily withdrawal limit counts net outflow instead of gross — /// a deposit -> withdraw cycle no longer consumes the budget of other users. /// The subtree does not exist before v14, so V1 keeps the slot `None`. +/// * `withdrawals.record_core_credit_pool_block`, +/// `withdrawals.record_asset_lock_credit_inflow`, +/// `withdrawals.fetch_core_credit_pool_balances` and +/// `withdrawals.fetch_in_flight_withdrawal_amount` `None -> Some(0)`: the +/// Core-anchored withdrawal limit. Core's credit pool balance is recorded per +/// Core block, and an asset lock's credits count as an inflow from the Core +/// block that mined it rather than the Platform block that consumed it; one +/// consumed before Core mined it waits in a pending tree until a scanned Core +/// block holds it. The subtrees do not exist before v14, so V1 keeps the +/// slots `None`. pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = DriveIdentityMethodVersions { fetch: DriveIdentityFetchMethodVersions { @@ -222,5 +232,9 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = record_total_credits_history: Some(0), // new in v14: total credits history for the day-lagged daily withdrawal limit fetch_total_credits_in_platform_a_day_ago: Some(0), // new in v14 record_credit_inflows: Some(0), // new in v14: credit inflows sum tree for the net daily withdrawal limit + record_core_credit_pool_block: Some(0), // new in v14: Core credit pool balances and Core-dated asset lock inflows + record_asset_lock_credit_inflow: Some(0), // new in v14: asset lock inflows dated by the Core block that mined them + fetch_core_credit_pool_balances: Some(0), // new in v14 + fetch_in_flight_withdrawal_amount: Some(0), // new in v14 }, }; 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 b5a8332714c..c1b3b70e5fc 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -599,6 +599,10 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_withdrawal_amount: 50_000_000_000_000, daily_withdrawal_limit_percent: None, max_daily_withdrawal_amount: None, + core_credit_pool_unlock_limit_percent: None, + core_credit_pool_unlock_limit_floor: None, + core_credit_pool_window_min_blocks: None, + core_credit_pool_window_max_blocks: None, min_withdrawal_amount: 190_000, core_dust_relay_fee_per_kb: None, max_core_fee_per_byte: None, diff --git a/packages/rs-platform-version/src/version/mocks/v3_test.rs b/packages/rs-platform-version/src/version/mocks/v3_test.rs index c0c3281a4c8..698a418031c 100644 --- a/packages/rs-platform-version/src/version/mocks/v3_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v3_test.rs @@ -126,6 +126,8 @@ pub const TEST_PLATFORM_V3: PlatformVersion = PlatformVersion { cleanup_expired_locks_of_withdrawal_amounts: 0, record_credit_inflows_for_withdrawals: None, record_total_credits_history_for_withdrawals: None, + scan_core_blocks_for_withdrawals: None, + calculate_core_anchored_withdrawal_limit: None, }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 014b75b034e..0aaac38a334 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -127,13 +127,41 @@ pub struct SystemLimits { /// version 1 applied a flat 2000 Dash. Versioned: see `daily_withdrawal_limit_percent` in /// each `SYSTEM_LIMITS_V*`. pub daily_withdrawal_limit_percent: Option, - /// Upper bound (in credits) of the relative daily withdrawal limit from protocol version 14: - /// Core's credit-pool unlock capacity per day, `LimitAmountV24` = 4000 Dash per 576-block - /// window (Core v24). Platform cannot usefully pool more than Core will mine — the excess - /// only cycles through expiry and re-signing — so the limit never exceeds this whatever the - /// total credits are; raise it together with Core. Must be at least `max_withdrawal_amount`. - /// `None` for the protocol versions that predate the relative rule. + /// Optional upper bound (in credits) of the relative daily withdrawal limit + /// (`daily_withdrawal_limit` method version 2). `None` leaves the relative limit uncapped, + /// which is what protocol version 14 does: what Core will mine is bounded instead by the + /// Core-anchored limit (`core_credit_pool_unlock_limit_percent` and the fields after it), + /// read from Core's own credit pool balances. When set it must be at least + /// `max_withdrawal_amount`. `None` too for the protocol versions that predate the relative + /// rule, which never read it. pub max_daily_withdrawal_amount: Option, + /// Allowed drop of Core's credit pool per window, as a percentage of its balance at the + /// window start, in the Core-anchored withdrawal limit of protocol version 14 + /// (`core_credit_pool_unlock_limit` method version 0). Platform pools a withdrawal only + /// while it also fits this limit, a stricter copy of Core v24's own unlock rule (20%, at + /// least 2000 Dash), so it never pools more than Core will mine. `None` for the protocol + /// versions that predate the Core-anchored limit. + pub core_credit_pool_unlock_limit_percent: Option, + /// Smallest allowed drop (in credits) of Core's credit pool per window in the Core-anchored + /// withdrawal limit, applied when `core_credit_pool_unlock_limit_percent` of the window start + /// balance is less. Below Core v24's own 2000 Dash floor, so small pools keep a margin too, + /// and at least `max_withdrawal_amount` so a queued withdrawal always fits eventually. + /// `None` for the protocol versions that predate the Core-anchored limit. + pub core_credit_pool_unlock_limit_floor: Option, + /// The nearest window start, in Core blocks before the chain locked height, the + /// Core-anchored withdrawal limit considers; with `core_credit_pool_window_max_blocks` it + /// bounds a band around Core's own 576-block window, and the limit takes the highest pool + /// balance among those window starts. The nearer edge covers the blocks an unlock may take + /// to be mined after it is pooled. An asset lock counts as a credit inflow for this many + /// Core blocks after the block that mined it. `None` for the protocol versions that predate + /// the Core-anchored limit. + pub core_credit_pool_window_min_blocks: Option, + /// The farthest window start, in Core blocks before the chain locked height, the + /// Core-anchored withdrawal limit considers (see `core_credit_pool_window_min_blocks`). + /// Also how many Core blocks an asset lock consumed before Core mined it waits to be dated + /// before it is dropped and never counts as a credit inflow. `None` for the protocol + /// versions that predate the Core-anchored limit. + pub core_credit_pool_window_max_blocks: Option, /// Minimum net amount (in credits) a withdrawal may send to Core, shared by the /// transparent (identity + address) and shielded withdrawal paths. The dust floor that /// keeps Core from rejecting the resulting `TxOut`. Versioned: see `min_withdrawal_amount` diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 85019028089..c77d741cd03 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -43,6 +43,10 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_withdrawal_amount: 50_000_000_000_000, //500 Dash daily_withdrawal_limit_percent: None, // relative daily withdrawal limit arrives in v14 max_daily_withdrawal_amount: None, + core_credit_pool_unlock_limit_percent: None, + core_credit_pool_unlock_limit_floor: None, + core_credit_pool_window_min_blocks: None, + core_credit_pool_window_max_blocks: None, // = dpp MIN_WITHDRAWAL_AMOUNT: ASSET_UNLOCK_TX_SIZE(190) * MIN_CORE_FEE_PER_BYTE(1) // * CREDITS_PER_DUFF(1000) = 190_000 credits = 190 duffs. min_withdrawal_amount: 190_000, diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index b7ac49ccd68..25e001584f8 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -26,6 +26,10 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_withdrawal_amount: 50_000_000_000_000, //500 Dash daily_withdrawal_limit_percent: None, // relative daily withdrawal limit arrives in v14 max_daily_withdrawal_amount: None, + core_credit_pool_unlock_limit_percent: None, + core_credit_pool_unlock_limit_floor: None, + core_credit_pool_window_min_blocks: None, + core_credit_pool_window_max_blocks: None, min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) core_dust_relay_fee_per_kb: None, // expired dust withdrawals fail from v14 max_core_fee_per_byte: None, diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 48c1ee4d2dc..279bbcda183 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -28,6 +28,10 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_withdrawal_amount: 50_000_000_000_000, //500 Dash daily_withdrawal_limit_percent: None, // relative daily withdrawal limit arrives in v14 max_daily_withdrawal_amount: None, + core_credit_pool_unlock_limit_percent: None, + core_credit_pool_unlock_limit_floor: None, + core_credit_pool_window_min_blocks: None, + core_credit_pool_window_max_blocks: None, min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) core_dust_relay_fee_per_kb: None, // expired dust withdrawals fail from v14 max_core_fee_per_byte: None, diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 8bbfad2aa0d..223753fc949 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -21,10 +21,18 @@ use crate::version::system_limits::SystemLimits; /// /// * The daily withdrawal limit becomes relative: `daily_withdrawal_limit_percent` is set to 15, /// so Platform pools at most 15% of the total credits it held a day ago into asset unlock -/// transactions per 24 hours — never below one maximal withdrawal and never above -/// `max_daily_withdrawal_amount`, Core's 4000 Dash unlock capacity per day — instead of the -/// flat 2000 Dash that applied from v8 (matching Core v22's `LimitAmountV22`). v13 is already -/// live on networks with the flat limit, so the change gates here. +/// transactions per 24 hours, never below one maximal withdrawal, instead of the flat 2000 +/// Dash that applied from v8 (matching Core v22's `LimitAmountV22`). v13 is already live on +/// networks with the flat limit, so the change gates here. `max_daily_withdrawal_amount` +/// stays `None`: the limit has no fixed cap. +/// * Withdrawals also fit a Core-anchored limit, a stricter copy of Core v24's relative net +/// unlock rule read from Core's own credit pool balances: the pool may drop by at most +/// `core_credit_pool_unlock_limit_percent` (15, Core allows 20) of its highest balance at a +/// window start 552 to 600 Core blocks back (`core_credit_pool_window_min_blocks`, +/// `core_credit_pool_window_max_blocks`; Core's window is 576), at least +/// `core_credit_pool_unlock_limit_floor` (1500 Dash, Core's floor is 2000), less what is +/// pooled and not yet mined. An asset lock counts as a credit inflow for 552 Core blocks +/// after the block that mined it. /// * `max_time_range_overlap_factor` is set: a `timeRange` index transform may declare at most /// 24 overlapping windows per timestamp (a day-long window sliding hourly). The rule cannot /// exist before v14 because the `timeRange` keyword itself is only admitted by the v14 @@ -113,8 +121,12 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { retry_signing_expired_withdrawal_documents_per_block_limit: 1, max_withdrawal_amount: 50_000_000_000_000, //500 Dash daily_withdrawal_limit_percent: Some(15), // 15% of the total credits a day ago (replaces the flat 2000 Dash in v14) - max_daily_withdrawal_amount: Some(400_000_000_000_000), // 4000 Dash: Core's unlock capacity per day (LimitAmountV24) - min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) + max_daily_withdrawal_amount: None, // uncapped: the Core-anchored limit below bounds what Core will mine + core_credit_pool_unlock_limit_percent: Some(15), // Core v24 allows 20% of the pool a window ago + core_credit_pool_unlock_limit_floor: Some(150_000_000_000_000), // 1500 Dash; Core v24's floor is 2000 Dash + core_credit_pool_window_min_blocks: Some(552), // Core's 576-block window minus 24 blocks for mining delay + core_credit_pool_window_max_blocks: Some(600), // Core's 576-block window plus 24 blocks + min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) core_dust_relay_fee_per_kb: Some(3000), // Core's default dust relay fee: 546-duff P2PKH threshold; expired withdrawals below it fail instead of re-signing max_core_fee_per_byte: Some(6_765), max_group_member_count: 256, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 526599c176e..ac34ab76625 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -77,17 +77,18 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// (`SYSTEM_LIMITS_V4.daily_withdrawal_limit_percent`, read by /// `daily_withdrawal_limit` v2 through `DPP_METHOD_VERSIONS_V3`), never below /// one maximal withdrawal (`max_withdrawal_amount`) so every accepted -/// withdrawal eventually fits and cannot block the pooling queue. The base is -/// capped at `max_daily_withdrawal_amount` (4000 Dash, Core's unlock capacity -/// per day under V24 as written); the credit inflows of the active window — -/// every credit mint, recorded per block by -/// `record_credit_inflows_for_withdrawals` in the credit inflows sum tree — -/// are added after the cap, so the limit counts net outflow and a matching -/// deposit -> withdraw cycle does not consume the capped budget of other -/// users (#4471). Outflow funded by same-window deposits may therefore -/// exceed the cap; this mirrors the net credit-pool rule Core adopts for V24 -/// alongside this change (tracked in #4471), which must land before V24 -/// activates. Both the inflows and the pooled reservations count over the +/// withdrawal eventually fits and cannot block the pooling queue. The base has +/// no fixed cap (`max_daily_withdrawal_amount` is `None`): what Core will mine +/// bounds pooling through the Core-anchored limit of note 70 instead. The +/// credit inflows of the active window — every credit mint, recorded per +/// block by `record_credit_inflows_for_withdrawals` — are added to the base, +/// so the limit counts net outflow and a matching deposit -> withdraw cycle +/// does not consume the budget of other users (#4471), mirroring Core v24's +/// net credit-pool rule. Asset lock credits count from the Core block that +/// mined each asset lock, for `core_credit_pool_window_min_blocks` (552) +/// Core blocks, in their own sum tree; the other mints (the epoch Core +/// rewards) count from the block, in the credit inflows sum tree. Both the +/// inflows and the pooled reservations count over the /// interval after the base snapshot only — an entry the snapshot already /// reflects is neither added nor subtracted again. The base is /// the total credits recorded at the latest block at least 24 hours before @@ -105,9 +106,8 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// already pooled in the last 24 hours keep counting against the maximum /// exactly as before. Pre-V24 Core caps unlocks at `LimitAmountV22` (2000 /// Dash) per *block*, with the amount checked only at block level, so any -/// daily total is still minable across blocks; V24's 4000 Dash per 576-block -/// window matches the capped base and is raised to the same net rule before -/// activation (see above). +/// daily total is still minable across blocks; V24 limits the net drop of +/// its credit pool per 576-block window, which note 70 follows. /// 5. **Time-range indexes**: an index can declare a `timeRange` transform /// that buckets a required system timestamp (`$createdAt` / /// `$updatedAt` / `$transferredAt`) into fixed-length, regularly-spaced, @@ -1865,6 +1865,33 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// the key of an index a delete clears that skips nothing, so no two /// documents in state share one of its entries. Inert for every contract /// without the keyword, which every earlier grammar refuses. +/// 70. **Withdrawals also fit a Core-anchored limit**: pooling +/// (`pool_withdrawals_into_transactions_queue` 2) admits withdrawals up to +/// the smaller of the daily withdrawal limit (note 4) and +/// `calculate_core_anchored_withdrawal_limit`, a stricter copy of Core v24's +/// relative net unlock rule (dash#7712) read from Core's own credit pool +/// balances at chain locked heights: the pool may drop by at most +/// `core_credit_pool_unlock_limit_percent` (15; Core allows 20) of its +/// highest balance at a window start 552 to 600 Core blocks back +/// (`core_credit_pool_window_min_blocks`, `core_credit_pool_window_max_blocks`; +/// Core's window is 576), at least `core_credit_pool_unlock_limit_floor` +/// (1500 Dash; Core's floor is 2000), less what is queued or broadcast and +/// not mined yet (`fetch_in_flight_withdrawal_amount`). The formula is +/// `core_credit_pool_unlock_limit` 0 in `DPP_METHOD_VERSIONS_V3`. Before +/// pooling, `scan_core_blocks_for_withdrawals` reads the Core blocks the chain +/// locked height passed (at most `core_blocks_scanned_per_block_limit`, 32, +/// per block) through `CoreRPCLike::get_credit_pool_block`: it records each +/// one's credit pool balance under the withdrawals tree and dates the asset +/// locks Platform consumed before Core mined them, which wait in a pending +/// tree until then (asked of Core once per block through +/// `get_transactions_mined_heights`, `gettxchainlocks`). The Platform-side +/// accounting can grant more than Core will mine (an asset lock published to +/// Platform long after Core mined it, a whole epoch of Core rewards minted in +/// one block); over Core's limit an unlock waits unmined and is re-signed, +/// and while Core's mempool holds more than the limit Core InstantSend-locks +/// no withdrawal at all. The trees are created at genesis and by +/// `transition_to_version_14`, and `cleanup_expired_locks_of_withdrawal_amounts` +/// 1 prunes them by Core height. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by From 407e34ddfb52cca78b24bd2b08826f2b3168f502 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 1 Oct 2026 12:19:58 +0700 Subject: [PATCH 2/7] fix(platform)!: review fixes for the Core-anchored withdrawal limit (PV14) - Read Core's credit pool balance from the raw block's coinbase only, within the payload's own length: the pinned rust-dashcore cannot decode Core v24 blocks (version 4 coinbase payload, new special transaction types), so a full block decode would fail on every validator. - Window starts follow Core's own credit pool window per network (576 blocks, 100 on regtest) and Core's 48-block unlock validity: the limit takes the highest balance from h - window to h - window + 48. - Drop the pending and Core-dated inflow trees: asset lock mints count by the block time again, so a deposit and the withdrawal it funds cancel exactly; a lock Core mined window - 48 blocks ago or more adds nothing. Core is asked once per block, after confirming it has the chain locked height, and the block's mints go into one write. - Subtract only the broadcast unlocks Core has not mined by the chain locked height. - Pooling 2 reuses version 1 through an extracted helper; credit_withdrawal_limit_available reports the poolable amount again. - Remove max_daily_withdrawal_amount; the scan dispatcher refuses an inactive version; reuse convert_duffs_to_credits; comment why the in-place edits of shipped generations are inert. - Build the withdrawal limit trees through one sequential helper at genesis and in the upgrade to 14, so both shape the withdrawals Merk alike, with an equivalence test. - New strategy test runs pooling against a small Core pool through run_block_proposal. Co-Authored-By: Claude Opus 5.5 --- book/src/contributing/coding-conventions.md | 4 +- book/src/versioning/feature-versions.md | 9 +- .../core_credit_pool_unlock_limit/mod.rs | 27 ++ .../core_credit_pool_unlock_limit/v0/mod.rs | 8 +- .../withdrawal/daily_withdrawal_limit/mod.rs | 4 +- .../daily_withdrawal_limit/v2/mod.rs | 103 +--- .../engine/run_block_proposal/v0/mod.rs | 4 +- .../v0/mod.rs | 165 ++++--- .../execute_event/v0/mod.rs | 19 + .../process_raw_state_transitions/v0/mod.rs | 7 +- .../process_validation_result/v0/mod.rs | 4 + .../process_validation_result/v1/mod.rs | 4 + .../mod.rs | 13 +- .../v0/mod.rs | 222 ++++++--- .../v1/mod.rs | 191 +------- .../v1/mod.rs | 38 +- .../v2/mod.rs | 218 ++------- .../mod.rs | 10 +- .../v0/mod.rs | 189 ++++---- .../scan_core_blocks_for_withdrawals/mod.rs | 19 +- .../v0/mod.rs | 159 ++---- packages/rs-drive-abci/src/main.rs | 4 +- .../platform_types/block_credit_mints/mod.rs | 4 +- .../src/platform_types/platform/mock.rs | 21 +- packages/rs-drive-abci/src/rpc/core.rs | 278 +++++++++-- .../test_cases/address_tests.rs | 27 ++ .../test_cases/withdrawal_tests.rs | 333 +++++++++++-- packages/rs-drive/grovedb-structure.json | 104 +--- .../v1/mod.rs | 453 +----------------- .../fetch_core_credit_pool_balances/v0/mod.rs | 6 - .../fetch_in_flight_withdrawal_amount/mod.rs | 21 +- .../v0/mod.rs | 103 ++-- .../src/drive/identity/withdrawals/mod.rs | 91 ---- .../src/drive/identity/withdrawals/paths.rs | 101 ++-- .../record_asset_lock_credit_inflow/mod.rs | 71 --- .../record_asset_lock_credit_inflow/v0/mod.rs | 199 -------- .../record_core_credit_pool_block/mod.rs | 22 +- .../record_core_credit_pool_block/v0/mod.rs | 105 +--- .../drive/identity/withdrawals/structure.rs | 54 +-- .../src/drive/initialization/v4/mod.rs | 7 + packages/rs-drive/src/structure/tests.rs | 30 +- .../drive_abci_method_versions/v10.rs | 4 +- .../drive_identity_method_versions/mod.rs | 7 +- .../drive_identity_method_versions/v1.rs | 1 - .../drive_identity_method_versions/v2.rs | 19 +- .../src/version/mocks/v2_test.rs | 4 +- .../src/version/system_limits/mod.rs | 45 +- .../src/version/system_limits/v1.rs | 4 +- .../src/version/system_limits/v2.rs | 4 +- .../src/version/system_limits/v3.rs | 4 +- .../src/version/system_limits/v4.rs | 17 +- .../rs-platform-version/src/version/v14.rs | 55 +-- 52 files changed, 1366 insertions(+), 2249 deletions(-) delete mode 100644 packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/mod.rs delete mode 100644 packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/v0/mod.rs diff --git a/book/src/contributing/coding-conventions.md b/book/src/contributing/coding-conventions.md index d564bab8f3b..ab5943fe5fd 100644 --- a/book/src/contributing/coding-conventions.md +++ b/book/src/contributing/coding-conventions.md @@ -154,8 +154,8 @@ behaviour-preserving. Add the next `SYSTEM_LIMITS_V{n+1}` for the unreleased protocol version with the new value. Keep a real method version only where the logic differs: in `packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/`, `v0` computes a tiered percentage of total credits and `v2` reads -`daily_withdrawal_limit_percent` and `max_daily_withdrawal_amount` from -`SystemLimits`; that is a logic change and earns its own version. Raising the +`daily_withdrawal_limit_percent` from `SystemLimits`; that is a logic change +and earns its own version. Raising the percentage later would be a table edit, not a `v3`. Why: reviewers look for limits in the tables. A constant hidden in a method diff --git a/book/src/versioning/feature-versions.md b/book/src/versioning/feature-versions.md index 6fcc64c444d..843e9208676 100644 --- a/book/src/versioning/feature-versions.md +++ b/book/src/versioning/feature-versions.md @@ -332,11 +332,9 @@ pub struct SystemLimits { pub max_withdrawal_amount: u64, /// `None` for the protocol versions that predate the relative rule. pub daily_withdrawal_limit_percent: Option, - pub max_daily_withdrawal_amount: Option, pub core_credit_pool_unlock_limit_percent: Option, pub core_credit_pool_unlock_limit_floor: Option, - pub core_credit_pool_window_min_blocks: Option, - pub core_credit_pool_window_max_blocks: Option, + pub core_credit_pool_unlock_mining_delay_blocks: Option, pub min_withdrawal_amount: u64, pub max_contract_group_size: u16, pub max_token_redemption_cycles: u32, @@ -410,9 +408,8 @@ the situation the tables exist to prevent, and it leaves a dead module behind every time the number moves. A new method version is warranted only when the *logic* changes. `daily_withdrawal_limit` in `rs-dpp` is the reference case: `v0` derives the limit from the current total credits, `v2` reads -`daily_withdrawal_limit_percent` and `max_daily_withdrawal_amount` from -`SystemLimits`. Raising the percentage later is a `SYSTEM_LIMITS_V5`, not a -`v3`. +`daily_withdrawal_limit_percent` from `SystemLimits`. Raising the percentage +later is a `SYSTEM_LIMITS_V5`, not a `v3`. ## How Subsystem Version Constants Compose diff --git a/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs b/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs index 69645a28c6f..205dd58b7d6 100644 --- a/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs +++ b/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs @@ -1,9 +1,28 @@ use crate::fee::Credits; use crate::ProtocolError; +use dashcore::Network; use platform_version::version::PlatformVersion; mod v0; +/// Core's credit pool window: how many Core blocks before an asset unlock's block lies the +/// balance Core v24 measures the unlock limit from (`CreditPoolPeriodBlocks` in Dash Core's +/// chain parameters). +pub trait NetworkCoreCreditPoolWindow { + fn core_credit_pool_window_blocks(&self) -> u32; +} + +impl NetworkCoreCreditPoolWindow for Network { + fn core_credit_pool_window_blocks(&self) -> u32 { + match self { + Network::Mainnet => 576, + Network::Testnet => 576, + Network::Devnet => 576, + Network::Regtest => 100, + } + } +} + /// Returns how much Core's credit pool may still give up to asset unlocks, given its balance /// now and its balance at the start of the window the limit is measured over, both in credits. /// @@ -51,6 +70,14 @@ mod tests { use super::*; use crate::dash_to_credits; + #[test] + fn should_use_cores_window_of_each_network() { + assert_eq!(Network::Mainnet.core_credit_pool_window_blocks(), 576); + assert_eq!(Network::Testnet.core_credit_pool_window_blocks(), 576); + assert_eq!(Network::Devnet.core_credit_pool_window_blocks(), 576); + assert_eq!(Network::Regtest.core_credit_pool_window_blocks(), 100); + } + #[test] fn should_only_exist_from_protocol_version_14() { let v13 = PlatformVersion::get(13).expect("expected protocol version 13"); diff --git a/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/v0/mod.rs b/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/v0/mod.rs index 0b615d45ed0..c6d9a953469 100644 --- a/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/v0/mod.rs +++ b/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/v0/mod.rs @@ -8,10 +8,10 @@ use platform_version::version::PlatformVersion; /// /// `limit = min(max(0, allowed_drop - (window_start_balance - balance)), balance)` /// -/// Core v24 applies the same shape with 20% and a 2000 Dash floor over its 576-block window; -/// the system limits of protocol version 14 set a lower percent and floor, and the caller picks -/// the highest window start balance of a band around Core's window, so the result never -/// exceeds what Core admits. Integer arithmetic in u128 throughout; truncation only ever makes +/// Core v24 applies the same shape with 20% and a 2000 Dash floor over its window (576 blocks, +/// 100 on regtest); the system limits of protocol version 14 set a lower percent and floor, and +/// the caller picks the highest balance among the window starts Core may use, so the result +/// never exceeds what Core admits. Integer arithmetic in u128 throughout; truncation only ever makes /// the limit stricter. pub(super) fn core_credit_pool_unlock_limit_v0( balance: Credits, diff --git a/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/mod.rs b/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/mod.rs index ae472dc950b..aea6979bf92 100644 --- a/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/mod.rs +++ b/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/mod.rs @@ -14,8 +14,8 @@ mod v2; /// total credits in Platform for version 0 (10% of it, bounded; required), ignored /// by version 1 (a flat 2000 Dash), and the total credits Platform held a day ago /// for version 2 (`daily_withdrawal_limit_percent` of it, never below one maximal -/// withdrawal nor above `max_daily_withdrawal_amount` when that is set; the flat -/// limit of version 1 while that day-old total is not known yet). +/// withdrawal; the flat limit of version 1 while that day-old total is not known +/// yet). pub fn daily_withdrawal_limit( reference_total_credits: Option, platform_version: &PlatformVersion, diff --git a/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/v2/mod.rs b/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/v2/mod.rs index 98dc3944256..ba842a67d92 100644 --- a/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/v2/mod.rs +++ b/packages/rs-dpp/src/withdrawal/daily_withdrawal_limit/v2/mod.rs @@ -8,18 +8,18 @@ use platform_version::version::PlatformVersion; /// Using a day-old base means a sudden jump in the total credits does not raise /// the limit for a day. /// -/// Three guards keep it usable: +/// Two guards keep it usable: /// * it is never below `max_withdrawal_amount`, so every withdrawal Platform /// accepts eventually fits the daily maximum and cannot block the pooling /// queue behind it; -/// * it is never above `max_daily_withdrawal_amount` when the protocol version -/// sets one. Protocol version 14 sets none: pooling more than Core will mine -/// is prevented instead by the Core-anchored limit pooling also applies, -/// which follows Core's own credit pool rather than a fixed figure; /// * while the total credits a day ago are not known (`None`: the history is /// younger than a day, i.e. right after this rule activates), the flat limit /// of version 1 applies, so the lag cannot be skipped by inflating the total /// before or at activation. +/// +/// There is no fixed upper bound: pooling more than Core will mine is prevented +/// by the Core-anchored limit pooling also applies, which follows Core's own +/// credit pool rather than a fixed figure. pub fn daily_withdrawal_limit_v2( total_credits_in_platform_a_day_ago: Option, platform_version: &PlatformVersion, @@ -38,32 +38,12 @@ pub fn daily_withdrawal_limit_v2( ) })?; - // Optional: protocol version 14 leaves it unset, and this generation is selected by no - // shipped protocol version, so making the cap optional changes no replayed block. - let max_daily_withdrawal_amount = platform_version.system_limits.max_daily_withdrawal_amount; - - let max_withdrawal_amount = platform_version.system_limits.max_withdrawal_amount; - if let Some(max_daily_withdrawal_amount) = max_daily_withdrawal_amount { - if max_daily_withdrawal_amount < max_withdrawal_amount { - // A cap below one maximal withdrawal would let an accepted withdrawal never fit the - // daily maximum; that is a contradictory configuration, not a limit to apply. - return Err(ProtocolError::CorruptedCodeExecution(format!( - "daily_withdrawal_limit v2 requires system_limits.max_daily_withdrawal_amount ({max_daily_withdrawal_amount}) to be at least max_withdrawal_amount ({max_withdrawal_amount})" - ))); - } - } - // u128 keeps `total * percent` from overflowing for any u64 total. let relative_limit = (total_credits_a_day_ago as u128) * (percent as u128) / 100; let relative_limit = Credits::try_from(relative_limit) .map_err(|_| ProtocolError::Overflow("daily withdrawal limit overflow"))?; - let relative_limit = relative_limit.max(max_withdrawal_amount); - - Ok(match max_daily_withdrawal_amount { - Some(max_daily_withdrawal_amount) => relative_limit.min(max_daily_withdrawal_amount), - None => relative_limit, - }) + Ok(relative_limit.max(platform_version.system_limits.max_withdrawal_amount)) } #[cfg(test)] @@ -77,7 +57,6 @@ mod tests { .system_limits .daily_withdrawal_limit_percent = percent; platform_version.system_limits.max_withdrawal_amount = dash_to_credits!(500); - platform_version.system_limits.max_daily_withdrawal_amount = Some(dash_to_credits!(4000)); platform_version } @@ -121,25 +100,18 @@ mod tests { } #[test] - fn should_never_exceed_the_cap_when_one_is_set() { + fn should_not_cap_a_large_lagged_total() { let platform_version = platform_version_with(Some(15)); - // 15% of 30000 Dash is 4500 Dash, above the configured 4000 Dash cap. assert_eq!( daily_withdrawal_limit_v2(Some(dash_to_credits!(30000)), &platform_version) .expect("expected limit"), - dash_to_credits!(4000) + dash_to_credits!(4500) ); assert_eq!( daily_withdrawal_limit_v2(Some(Credits::MAX), &platform_version) .expect("expected limit"), - dash_to_credits!(4000) - ); - // Just under the boundary the percent still applies. - assert_eq!( - daily_withdrawal_limit_v2(Some(dash_to_credits!(26666)), &platform_version) - .expect("expected limit"), - dash_to_credits!(3999.9) + ((Credits::MAX as u128) * 15 / 100) as Credits ); } @@ -161,61 +133,4 @@ mod tests { Err(ProtocolError::CorruptedCodeExecution(_)) )); } - - #[test] - fn should_apply_no_cap_when_none_is_configured() { - let mut platform_version = platform_version_with(Some(15)); - platform_version.system_limits.max_daily_withdrawal_amount = None; - - // 15% of 30000 Dash is 4500 Dash, uncapped. - assert_eq!( - daily_withdrawal_limit_v2(Some(dash_to_credits!(30000)), &platform_version) - .expect("expected limit"), - dash_to_credits!(4500) - ); - // The floor still applies. - assert_eq!( - daily_withdrawal_limit_v2(Some(dash_to_credits!(100)), &platform_version) - .expect("expected limit"), - dash_to_credits!(500) - ); - } - - #[test] - fn should_leave_the_latest_relative_limit_uncapped() { - let platform_version = PlatformVersion::latest(); - - assert_eq!( - platform_version.system_limits.max_daily_withdrawal_amount, - None - ); - assert_eq!( - daily_withdrawal_limit_v2(Some(dash_to_credits!(100000)), platform_version) - .expect("expected limit"), - dash_to_credits!(15000) - ); - } - - #[test] - fn should_fail_when_the_cap_is_below_one_maximal_withdrawal() { - let mut platform_version = platform_version_with(Some(15)); - platform_version.system_limits.max_daily_withdrawal_amount = - Some(dash_to_credits!(500) - 1); - - // Whatever the total, a cap below the floor is a contradictory configuration. - for total in [0, dash_to_credits!(100), dash_to_credits!(30000)] { - assert!(matches!( - daily_withdrawal_limit_v2(Some(total), &platform_version), - Err(ProtocolError::CorruptedCodeExecution(_)) - )); - } - - // Exactly the floor is allowed and the limit is that floor. - platform_version.system_limits.max_daily_withdrawal_amount = Some(dash_to_credits!(500)); - assert_eq!( - daily_withdrawal_limit_v2(Some(dash_to_credits!(30000)), &platform_version) - .expect("expected limit"), - dash_to_credits!(500) - ); - } } diff --git a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs index bd24c52d366..da1587fe3fa 100644 --- a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs @@ -485,7 +485,9 @@ where // Record the credits this block minted into Platform (asset locks funding state // transitions, epoch Core rewards) as a credit inflow: the daily withdrawal limit adds // inflows younger than its day-old base to the daily maximum, so it limits net outflow. - // A system event, so nobody pays fees for the write. + // A system event, so nobody pays fees for the write. Changed in place to pass the two + // mint sources apart instead of their sum, inert for protocol versions 1 to 13: the + // event is `None` there and reads neither. self.record_credit_inflows_for_withdrawals( state_transitions_result.credit_mints(), processed_block_fees.credit_mints, diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 5af1dc88332..45c1cc6c562 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -19,10 +19,8 @@ use drive::drive::identity::key::fetch::{ IdentityKeysRequest, KeyIDIdentityPublicKeyPairBTreeMap, KeyRequestType, }; use drive::drive::identity::withdrawals::paths::{ - get_withdrawal_root_path, WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, - WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, - WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY, - WITHDRAWAL_TRANSACTIONS_BROADCASTED_KEY, WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, + get_withdrawal_root_path, WITHDRAWAL_TRANSACTIONS_BROADCASTED_KEY, + WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, }; use drive::drive::prefunded_specialized_balances::prefunded_specialized_balances_for_voting_path_vec; use drive::drive::saved_block_transactions::{ @@ -770,56 +768,14 @@ impl Platform { platform_version, )?; - // Total credits history under the withdrawals tree: the daily withdrawal limit becomes - // a share of the total credits Platform held a day ago, recorded here every block. - self.drive.grove_insert_if_not_exists( - get_withdrawal_root_path().as_slice().into(), - &WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY, - Element::empty_tree(), - Some(transaction), - None, - &platform_version.drive, - )?; - - // Credit inflows sum tree: every credit mint is recorded here so the daily withdrawal - // limit counts net outflow instead of gross — credits that entered Platform within the - // window may leave again without consuming the withdrawal budget of other users. - self.drive.grove_insert_if_not_exists( - get_withdrawal_root_path().as_slice().into(), - &WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, - Element::empty_sum_tree(), - Some(transaction), - None, - &platform_version.drive, - )?; - - // The Core-anchored withdrawal limit: Core's credit pool balance per Core block read, - // the asset locks consumed before Core mined them, and the asset lock credit inflows - // dated by the Core block that mined them. Created in the same order as the initial - // structure creates them. - for (key, tree) in [ - ( - WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, - Element::empty_tree(), - ), - ( - WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, - Element::empty_tree(), - ), - ( - WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, - Element::empty_sum_tree(), - ), - ] { - self.drive.grove_insert_if_not_exists( - get_withdrawal_root_path().as_slice().into(), - &key, - tree, - Some(transaction), - None, - &platform_version.drive, - )?; - } + // Withdrawal limit trees under the withdrawals tree: the total credits history (the + // daily withdrawal limit becomes a share of the total credits Platform held a day ago, + // recorded every block), the credit inflows sum tree (every credit mint, so the daily + // limit counts net outflow instead of gross) and Core's credit pool balance per Core + // block read (the Core-anchored withdrawal limit). Through the same helper as genesis, + // so both build the withdrawals Merk by the same sequence of inserts. + self.drive + .insert_withdrawal_limit_trees(Some(transaction), platform_version)?; // Contract version items: from this version the storage writer stores every // contract's version as a four-byte item beside it, and @@ -887,6 +843,10 @@ mod tests { use drive::drive::credit_pools::epochs::epochs_root_tree_key_constants::KEY_LIFETIME_STORAGE_FEE_POOLS; use drive::drive::credit_pools::pools_path; use drive::drive::document::expiration::paths::DOCUMENTS_EXPIRATIONS_KEY; + use drive::drive::identity::withdrawals::paths::{ + WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, + WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY, + }; use drive::drive::shielded::paths::{ shielded_credit_pool_path, MAIN_SHIELDED_CREDIT_POOL_KEY_U8, SHIELDED_ANCHORS_IN_POOL_KEY, SHIELDED_NOTES_KEY, SHIELDED_NULLIFIERS_KEY, @@ -2322,6 +2282,69 @@ mod tests { } } + /// Genesis creates the withdrawal trees of version 14 in one batch, the upgrade adds the + /// new ones one insert at a time, and Merk's shape depends on insertion order: the + /// withdrawals tree element (its root key) and every element below it are the same on a + /// chain born at 14 and one upgraded to it. + #[test] + fn should_build_the_withdrawal_trees_as_a_chain_born_at_14_does() { + let platform_version = PlatformVersion::latest(); + let born_at_14 = TestPlatformBuilder::new() + .with_initial_protocol_version(14) + .build_with_mock_rpc() + .set_genesis_state(); + let upgraded = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = upgraded.drive.grove.start_transaction(); + let block_info = BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 100, + epoch: Epoch::new(1).expect("expected epoch"), + }; + upgraded + .transition_to_version_14(&block_info, &transaction, platform_version) + .expect("expected version 14 transition to succeed"); + + let withdrawals_element = + |platform: &crate::platform_types::platform::Platform, + transaction: Option<&Transaction>| { + platform + .drive + .grove_get_raw( + (&[] as &[&[u8]; 0]).into(), + &[RootTree::WithdrawalTransactions as u8], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to read the withdrawals tree") + .expect("expected the withdrawals tree to exist") + }; + assert_eq!( + withdrawals_element(&born_at_14, None), + withdrawals_element(&upgraded, Some(&transaction)), + "the withdrawals tree differs between a chain born at version 14 and one upgraded to it" + ); + + let diffs = collect_subtree_diffs( + &born_at_14, + &upgraded, + &transaction, + vec![vec![RootTree::WithdrawalTransactions as u8]], + ); + assert!( + diffs.is_empty(), + "the withdrawal trees differ between a chain born at version 14 and one upgraded \ + to it:\n{}", + diffs.join("\n"), + ); + } + #[test] fn test_transition_to_version_14_creates_total_credits_history_tree() { let platform_version = PlatformVersion::latest(); @@ -2339,8 +2362,6 @@ mod tests { &WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY, &WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, &WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, - &WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, - &WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, ] { assert!(platform .drive @@ -2391,25 +2412,19 @@ mod tests { .expect("credit inflows sum tree should exist after the v14 transition"); assert!(element.is_sum_tree()); - for (key, is_sum_tree) in [ - (&WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, false), - (&WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, false), - (&WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, true), - ] { - let element = platform - .drive - .grove - .get( - SubtreePath::from(&get_withdrawal_root_path()), - key, - Some(&transaction), - &platform_version.drive.grove_version, - ) - .value - .expect("the Core-anchored withdrawal limit trees should exist after the v14 transition"); - assert!(element.is_any_tree()); - assert_eq!(element.is_sum_tree(), is_sum_tree); - } + let element = platform + .drive + .grove + .get( + SubtreePath::from(&get_withdrawal_root_path()), + &WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, + Some(&transaction), + &platform_version.drive.grove_version, + ) + .value + .expect("the Core credit pool balances tree should exist after the v14 transition"); + assert!(element.is_any_tree()); + assert!(!element.is_sum_tree()); // Running it again is harmless and the tree stays usable platform diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index b3eb802afe0..0e9672c56da 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -44,11 +44,16 @@ where block_info: &BlockInfo, mut consensus_errors: Vec, transaction: &Transaction, + // Changed in place from `&mut Credits`, inert for protocol versions 1 to 13 (all that + // select this generation): the total is the same saturating sum of `credit_mints`, and + // the per-asset-lock part is read only by `record_credit_inflows_for_withdrawals`, + // which is `None` before 14. block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { if fee_validation_result.is_valid_with_data() { + // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. let credit_mints = BlockCreditMints::of_operations(&operations); //todo: make this into an atomic event with partial batches let mut individual_fee_result = self @@ -121,11 +126,16 @@ where mut consensus_errors: Vec, transaction: &Transaction, mut address_balances_in_update: Option<&mut BTreeMap>, + // Changed in place from `&mut Credits`, inert for protocol versions 1 to 13 (all that + // select this generation): the total is the same saturating sum of `credit_mints`, and + // the per-asset-lock part is read only by `record_credit_inflows_for_withdrawals`, + // which is `None` before 14. block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { if fee_validation_result.is_valid_with_data() { + // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. let credit_mints = BlockCreditMints::of_operations(&operations); // Apply the drive operations first to calculate the fee let mut individual_fee_result = self @@ -379,6 +389,10 @@ where block_info: &BlockInfo, transaction: &Transaction, address_balances_in_update: Option<&mut BTreeMap>, + // Changed in place from `&mut Credits`, inert for protocol versions 1 to 13 (all that + // select this generation): the total is the same saturating sum of `credit_mints`, and + // the per-asset-lock part is read only by `record_credit_inflows_for_withdrawals`, + // which is `None` before 14. block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, @@ -501,6 +515,7 @@ where processing_fees, operations, } => { + // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. let credit_mints = BlockCreditMints::of_operations(&operations); self.drive .apply_drive_operations( @@ -533,6 +548,7 @@ where fees_to_add_to_pool, } => { if consensus_errors.is_empty() { + // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. let credit_mints = BlockCreditMints::of_operations(&operations); self.drive .apply_drive_operations( @@ -586,6 +602,7 @@ where return Ok(UnpaidConsensusExecutionError(consensus_errors)); } + // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. let credit_mints = BlockCreditMints::of_operations(&operations); let applied_fees = self .drive @@ -648,6 +665,7 @@ where all_errors.extend(consensus_errors); if all_errors.is_empty() { + // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. let credit_mints = BlockCreditMints::of_operations(&operations); let applied_fees = self .drive @@ -728,6 +746,7 @@ where ) } ExecutionEvent::Free { operations } => { + // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. let credit_mints = BlockCreditMints::of_operations(&operations); self.drive .apply_drive_operations( diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs index 4d0db904a75..7d6c2889fe7 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs @@ -140,7 +140,10 @@ where // Credits the block's applied operations mint into Platform (asset locks), summed // across state transitions and recorded once per block as a credit inflow the net - // daily withdrawal limit adds to its daily maximum. + // daily withdrawal limit adds to its daily maximum. Changed in place from `Credits`, + // inert for protocol versions 1 to 14 (all select this generation): its total is the + // same sum, and only `record_credit_inflows_for_withdrawals`, `None` before 14, reads + // it (from 14 also per asset lock, which is new there). let mut block_credit_mints = BlockCreditMints::default(); for decoded_state_transition in state_transition_container.into_iter() { @@ -186,6 +189,8 @@ where if rollback_dropped_transitions { transaction.set_savepoint(); } + // In place and inert: the savepoint is only restored when + // `rollback_dropped_transitions` holds, so it is only taken then. let credit_mints_at_savepoint = rollback_dropped_transitions.then(|| block_credit_mints.clone()); diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs index bfaf262441a..ebf281e81c4 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs @@ -33,6 +33,10 @@ where mut validation_result: ConsensusValidationResult, block_info: &BlockInfo, transaction: &Transaction, + // Changed in place from `&mut Credits`, inert for protocol versions 1 to 12 (all that + // select this generation): it is only passed on to `execute_event`, whose total is + // the same sum, and the per-asset-lock part is read only by + // `record_credit_inflows_for_withdrawals` (`None` before 14, reading it is new in 14). block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs index 81857ef9a58..816173b8782 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs @@ -32,6 +32,10 @@ where mut validation_result: ConsensusValidationResult, block_info: &BlockInfo, transaction: &Transaction, + // Changed in place from `&mut Credits`, inert for protocol versions 13 and 14 (all that + // select this generation): it is only passed on to `execute_event`, whose total is + // the same sum, and the per-asset-lock part is read only by + // `record_credit_inflows_for_withdrawals` (`None` before 14, reading it is new in 14). block_credit_mints: &mut BlockCreditMints, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs index 446f487bca9..7bb781fe5b0 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs @@ -16,12 +16,13 @@ where /// How many more credits withdrawals pooled now may take out of Core's credit pool: a /// stricter copy of Core's own asset unlock limit (`core_credit_pool_unlock_limit`), less /// what is pooled and not mined yet. It reads Core's credit pool balance at the block's - /// chain locked height, and the highest balance among the window starts of a band around - /// Core's own window (`core_credit_pool_window_min_blocks` to - /// `core_credit_pool_window_max_blocks` back): the far edge leaves room around Core's 576 - /// blocks, and the near edge covers the blocks an unlock waits to be mined while Core's - /// window moves on and older deposits leave it. A balance the scan has not recorded yet is - /// read from Core, which every node answers alike for a chain locked height. + /// chain locked height, and the highest balance among the window starts Core may measure + /// an unlock pooled now from: Core's credit pool window (576 blocks, 100 on regtest) back + /// from the chain locked height, up to `core_credit_pool_unlock_mining_delay_blocks` + /// later, as Core mines an unlock until that many blocks past the height it is signed at + /// while its window moves on and older deposits leave it. A balance the scan has not + /// recorded yet is read from Core, which every node answers alike for a chain locked + /// height. /// /// # Parameters /// diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs index db4b0b14c1c..e14acd71342 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs @@ -2,13 +2,20 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; -use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::block::block_info::BlockInfo; +use dpp::dashcore_rpc::dashcore_rpc_json::AssetUnlockStatus; use dpp::fee::Credits; +use dpp::identity::convert_duffs_to_credits; use dpp::version::PlatformVersion; -use dpp::withdrawal::core_credit_pool_unlock_limit::core_credit_pool_unlock_limit; +use dpp::withdrawal::core_credit_pool_unlock_limit::{ + core_credit_pool_unlock_limit, NetworkCoreCreditPoolWindow, +}; +use dpp::withdrawal::WithdrawalTransactionIndex; use drive::grovedb::TransactionArg; +/// The most asset unlock indexes Core's `getassetunlockstatuses` answers in one call. +const MAX_ASSET_UNLOCK_STATUSES_PER_REQUEST: usize = 100; + impl Platform where C: CoreRPCLike, @@ -19,26 +26,27 @@ where transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { - let system_limits = &platform_version.system_limits; - let window_min_blocks = system_limits.core_credit_pool_window_min_blocks.ok_or( - Error::Execution(ExecutionError::CorruptedCodeExecution( - "calculate_core_anchored_withdrawal_limit v0 requires system_limits.core_credit_pool_window_min_blocks", - )), - )?; - let window_max_blocks = system_limits.core_credit_pool_window_max_blocks.ok_or( - Error::Execution(ExecutionError::CorruptedCodeExecution( - "calculate_core_anchored_withdrawal_limit v0 requires system_limits.core_credit_pool_window_max_blocks", - )), - )?; + let mining_delay_blocks = platform_version + .system_limits + .core_credit_pool_unlock_mining_delay_blocks + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "calculate_core_anchored_withdrawal_limit v0 requires system_limits.core_credit_pool_unlock_mining_delay_blocks", + )))?; + let window_blocks = self.config.network.core_credit_pool_window_blocks(); let chain_locked_height = block_info.core_height; - // The window starts of the band that exist on the chain. One before the chain's start - // has no credit pool, which Core reads as a balance of 0: it never raises the highest. - let window_start_balance = match chain_locked_height.checked_sub(window_min_blocks) { + // Core measures an unlock mined in block M from the balance after M - 1 - window; M is + // past the chain locked height and at most `mining_delay_blocks` past it (the unlock + // is signed at it or later). A window start before the chain's start has no credit + // pool, which Core reads as a balance of 0: it never raises the highest. + let window_start_balance = match chain_locked_height + .saturating_add(mining_delay_blocks) + .checked_sub(window_blocks) + { None => 0, Some(nearest_window_start) => { - let farthest_window_start = chain_locked_height.saturating_sub(window_max_blocks); + let farthest_window_start = chain_locked_height.saturating_sub(window_blocks); let recorded = self.drive.fetch_core_credit_pool_balances( farthest_window_start..=nearest_window_start, transaction, @@ -71,31 +79,45 @@ where let limit = core_credit_pool_unlock_limit(balance, window_start_balance, platform_version)?; - // Core's own limit only reflects unlocks already mined. + // Core's own limit only reflects unlocks already mined: subtract the queued ones and + // the broadcast ones Core has not mined by the chain locked height. The broadcast tree + // keeps mined ones until their documents are updated, a bounded number per Core block. let in_flight = self .drive .fetch_in_flight_withdrawal_amount(transaction, platform_version)?; + let broadcast_indices: Vec = + in_flight.broadcast.keys().copied().collect(); + let mut not_mined = in_flight.queued; + for indices in broadcast_indices.chunks(MAX_ASSET_UNLOCK_STATUSES_PER_REQUEST) { + let statuses = self.fetch_transactions_block_inclusion_status( + chain_locked_height, + indices, + platform_version, + )?; + for index in indices { + if statuses.get(index) != Some(&AssetUnlockStatus::Chainlocked) { + let amount = in_flight.broadcast.get(index).copied().unwrap_or_default(); + not_mined = not_mined.saturating_add(amount); + } + } + } - Ok(limit.saturating_sub(in_flight)) + Ok(limit.saturating_sub(not_mined)) } /// Core's credit pool balance after the chain locked Core block at `core_height`, in /// credits, read from Core because the scan has not recorded it (yet). fn core_credit_pool_balance_from_core(&self, core_height: u32) -> Result { - self.core_rpc - .get_credit_pool_block(core_height)? - .credit_pool_balance - .checked_mul(CREDITS_PER_DUFF) - .ok_or(Error::Execution(ExecutionError::Overflow( - "core credit pool balance in credits", - ))) + Ok(convert_duffs_to_credits( + self.core_rpc.get_credit_pool_balance(core_height)?, + )?) } } #[cfg(test)] mod tests { - use crate::rpc::core::{CoreCreditPoolBlock, MockCoreRPCLike}; - use crate::test::helpers::setup::TestPlatformBuilder; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; use dpp::block::block_info::BlockInfo; use dpp::dash_to_credits; use dpp::dashcore::consensus::Encodable; @@ -103,7 +125,9 @@ mod tests { AssetUnlockBasePayload, AssetUnlockBaseTransactionInfo, }; use dpp::dashcore::{ScriptBuf, TxOut}; + use dpp::dashcore_rpc::dashcore_rpc_json::{AssetUnlockStatus, AssetUnlockStatusResult}; use dpp::version::PlatformVersion; + use drive::grovedb::Transaction; use drive::util::batch::DriveOperation; const DUFFS_PER_DASH: u64 = 100_000_000; @@ -112,13 +136,8 @@ mod tests { fn core_with_balances(balance_at: fn(u32) -> u64) -> MockCoreRPCLike { let mut core_rpc = MockCoreRPCLike::new(); core_rpc - .expect_get_credit_pool_block() - .returning(move |core_height| { - Ok(CoreCreditPoolBlock { - credit_pool_balance: balance_at(core_height) * DUFFS_PER_DASH, - asset_lock_txids: vec![], - }) - }); + .expect_get_credit_pool_balance() + .returning(move |core_height| Ok(balance_at(core_height) * DUFFS_PER_DASH)); core_rpc } @@ -184,8 +203,8 @@ mod tests { ); } - /// A deposit Core still counts in full is withdrawable on top; one about to leave Core's - /// window (inside the band's near edge) already counts as if it had. + /// A deposit Core still counts in full is withdrawable on top; one that leaves Core's + /// window before an unlock pooled now may be mined already counts as if it had. #[test] fn should_count_a_deposit_in_full_only_while_it_is_younger_than_the_band() { let mut platform = TestPlatformBuilder::new() @@ -213,24 +232,16 @@ mod tests { .expect("expected the limit") }; - // At 10,000 the band is 9,400..=9,448: the deposit counts in full. + // At 10,000 the window starts are 9,424..=9,472: the deposit counts in full. assert_eq!(limit(10_000), dash_to_credits!(10550)); - // At 10,052 the band's near edge reaches 9,500: Core still counts it for 24 more - // blocks, but an unlock pooled now may be mined after it leaves Core's window. - assert_eq!(limit(10_052), dash_to_credits!(6300)); + assert_eq!(limit(10_027), dash_to_credits!(10550)); + // At 10,028 the nearest window start reaches 9,500: an unlock pooled now may be mined + // 48 blocks later, when Core's window no longer holds the deposit. + assert_eq!(limit(10_028), dash_to_credits!(6300)); } - #[test] - fn should_subtract_what_is_pooled_and_not_mined_yet() { - let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() - .build_with_mock_rpc() - .set_initial_state_structure(); - platform.core_rpc = core_with_balances(|_| 37_000); - let platform_version = PlatformVersion::latest(); - let transaction = platform.drive.grove.start_transaction(); - - // A queued withdrawal paying out 1,000 Dash with a 1,000 duff fee. + /// An untied withdrawal transaction paying out 1,000 Dash with a 1,000 duff fee. + fn untied_transaction(index: u64) -> Vec { let untied = AssetUnlockBaseTransactionInfo { version: 1, lock_time: 0, @@ -240,7 +251,7 @@ mod tests { }], base_payload: AssetUnlockBasePayload { version: 1, - index: 0, + index, fee: 1_000, }, }; @@ -248,27 +259,78 @@ mod tests { untied .consensus_encode(&mut bytes) .expect("expected to encode"); + bytes + } + + /// Pools the given 1,000 Dash withdrawals, then moves the first `broadcast` of them to the + /// broadcast tree as signing does. + fn pool_withdrawals( + platform: &TempPlatform, + indices: &[u64], + broadcast: u16, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) { let mut drive_operations: Vec = vec![]; platform .drive .add_enqueue_untied_withdrawal_transaction_operations( - vec![(0, bytes)], - dash_to_credits!(1000), + indices + .iter() + .map(|index| (*index, untied_transaction(*index))) + .collect(), + dash_to_credits!(1000) * indices.len() as u64, &mut drive_operations, platform_version, ) .expect("expected to enqueue"); + if broadcast > 0 { + platform + .drive + .apply_drive_operations( + drive_operations, + true, + &BlockInfo::default(), + Some(transaction), + platform_version, + None, + ) + .expect("expected to apply"); + drive_operations = vec![]; + platform + .drive + .dequeue_untied_withdrawal_transactions( + broadcast, + Some(transaction), + &mut drive_operations, + platform_version, + ) + .expect("expected to dequeue"); + } platform .drive .apply_drive_operations( drive_operations, true, &BlockInfo::default(), - Some(&transaction), + Some(transaction), platform_version, None, ) .expect("expected to apply"); + } + + #[test] + fn should_subtract_what_is_pooled_and_not_mined_yet() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + platform.core_rpc = core_with_balances(|_| 37_000); + let platform_version = PlatformVersion::latest(); + let transaction = platform.drive.grove.start_transaction(); + + pool_withdrawals(&platform, &[0], 0, &transaction, platform_version); assert_eq!( platform @@ -282,6 +344,50 @@ mod tests { ); } + /// Core's balance at the chain locked height already reflects a broadcast unlock it mined + /// by then, even while the broadcast tree still holds it. + #[test] + fn should_not_subtract_a_broadcast_unlock_core_already_mined() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + let mut core_rpc = core_with_balances(|_| 37_000); + core_rpc + .expect_get_asset_unlock_statuses() + .withf(|_, core_height| *core_height == 10_000) + .returning(|indices, _| { + Ok(indices + .iter() + .map(|index| AssetUnlockStatusResult { + index: *index, + status: if *index == 0 { + AssetUnlockStatus::Chainlocked + } else { + AssetUnlockStatus::Mempooled + }, + }) + .collect()) + }); + platform.core_rpc = core_rpc; + let platform_version = PlatformVersion::latest(); + let transaction = platform.drive.grove.start_transaction(); + + // Index 0 is mined, index 1 broadcast and not mined, index 2 still queued. + pool_withdrawals(&platform, &[0, 1, 2], 2, &transaction, platform_version); + + assert_eq!( + platform + .calculate_core_anchored_withdrawal_limit( + &block(10_000), + Some(&transaction), + platform_version + ) + .expect("expected the limit"), + dash_to_credits!(3550) - 2_000_000 + ); + } + /// Recorded balances are read from state; Core is asked only for what is missing. #[test] fn should_prefer_recorded_balances_to_asking_core() { @@ -298,10 +404,8 @@ mod tests { platform .drive .record_core_credit_pool_block( - 9_420, + 9_430, dash_to_credits!(40000), - &[], - &block(10_000), Some(&transaction), platform_version, ) diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs index 21611aec865..e77b928abae 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs @@ -4,22 +4,13 @@ use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; -use crate::error::execution::ExecutionError; use dpp::version::PlatformVersion; +use dpp::withdrawal::core_credit_pool_unlock_limit::NetworkCoreCreditPoolWindow; use drive::drive::identity::withdrawals::paths::{ get_withdrawal_core_credit_pool_balances_path_vec, - get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec, - get_withdrawal_credit_inflows_sum_tree_path_vec, - get_withdrawal_pending_asset_lock_inflows_path, - get_withdrawal_pending_asset_lock_inflows_path_vec, - get_withdrawal_transactions_sum_tree_path_vec, + get_withdrawal_credit_inflows_sum_tree_path_vec, get_withdrawal_transactions_sum_tree_path_vec, }; -use drive::drive::identity::withdrawals::{ - core_dated_credit_inflow_key, PendingAssetLockCreditInflow, -}; -use drive::error::drive::DriveError; -use drive::grovedb::query_result_type::QueryResultType; -use drive::grovedb::{Element, MaybeTree, PathQuery, Query, QueryItem, SizedQuery, Transaction}; +use drive::grovedb::{MaybeTree, PathQuery, QueryItem, Transaction}; use drive::util::grove_operations::BatchDeleteApplyType; impl Platform @@ -31,12 +22,8 @@ where /// /// * the expired entries of the credit inflows sum tree, keyed like the reservations by /// the block time they stop counting toward the daily withdrawal limit; - /// * the Core-dated credit inflows whose Core height has been reached; - /// * the recorded Core credit pool balances older than the band of window starts the - /// Core-anchored limit reads (`core_credit_pool_window_max_blocks` back); - /// * the asset locks consumed before Core mined them that waited that many Core blocks - /// without Core mining them: they never count. In practice Core mines an InstantSend - /// locked transaction within a block or two, so this only bounds the tree. + /// * the recorded Core credit pool balances older than the farthest window start the + /// Core-anchored limit reads (Core's credit pool window back). pub(super) fn cleanup_expired_locks_of_withdrawal_amounts_v1( &self, block_info: &BlockInfo, @@ -79,31 +66,15 @@ where )?; } - let window_max_blocks = platform_version - .system_limits - .core_credit_pool_window_max_blocks - .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( - "cleanup_expired_locks_of_withdrawal_amounts v1 requires system_limits.core_credit_pool_window_max_blocks", - )))?; - let chain_locked_height = block_info.core_height; - - // Keys sort by the Core height an entry stops counting at, then the time it was - // recorded at: every key below (chain locked height + 1, 0) has stopped counting. - let mut range_prunes = vec![( - get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec(), - core_dated_credit_inflow_key(chain_locked_height.saturating_add(1), 0), - )]; // The Core-anchored limit never reads a balance older than its farthest window start. - if let Some(oldest_read_height) = chain_locked_height.checked_sub(window_max_blocks) { - range_prunes.push(( + if let Some(oldest_read_height) = block_info + .core_height + .checked_sub(self.config.network.core_credit_pool_window_blocks()) + { + let mut path_query = PathQuery::new_single_query_item( get_withdrawal_core_credit_pool_balances_path_vec(), - oldest_read_height.to_be_bytes().to_vec(), - )); - } - - for (path, first_kept_key) in range_prunes { - let mut path_query = - PathQuery::new_single_query_item(path, QueryItem::RangeTo(..first_kept_key)); + QueryItem::RangeTo(..oldest_read_height.to_be_bytes().to_vec()), + ); path_query.query.limit = Some(limit); self.drive.batch_delete_items_in_path_query( @@ -118,48 +89,6 @@ where )?; } - // Pending entries are keyed by transaction id, so their age is in the value: read a - // bounded batch and drop the ones that waited a whole band of Core blocks. - let mut pending_query = Query::new(); - pending_query.insert_all(); - let (pending, _) = self.drive.grove_get_raw_path_query( - &PathQuery::new( - get_withdrawal_pending_asset_lock_inflows_path_vec(), - SizedQuery::new(pending_query, Some(limit), None), - ), - Some(transaction), - QueryResultType::QueryKeyElementPairResultType, - &mut vec![], - &platform_version.drive, - )?; - let pending_path = get_withdrawal_pending_asset_lock_inflows_path(); - for (asset_lock_txid, element) in pending.to_key_elements() { - let Element::Item(value, _) = element else { - return Err(Error::Drive(drive::error::Error::Drive( - DriveError::CorruptedElementType( - "pending asset lock credit inflow is not an item", - ), - ))); - }; - let pending = PendingAssetLockCreditInflow::from_bytes(&value)?; - if pending - .recorded_at_core_height - .saturating_add(window_max_blocks) - <= chain_locked_height - { - self.drive.batch_delete( - (&pending_path).into(), - &asset_lock_txid, - BatchDeleteApplyType::StatefulBatchDelete { - is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), - }, - Some(transaction), - &mut batch_operations, - &platform_version.drive, - )?; - } - } - self.drive.apply_batch_low_level_drive_operations( None, Some(transaction), @@ -273,101 +202,38 @@ mod tests { } } - /// The trees of the Core-anchored withdrawal limit are pruned by Core height: Core-dated - /// inflows once their Core height is reached, balances older than the band the limit reads, - /// and asset locks that waited a whole band of Core blocks without Core mining them. + /// Recorded Core credit pool balances older than the farthest window start the + /// Core-anchored limit reads are pruned by Core height. #[test] - fn should_prune_the_core_anchored_trees_by_core_height() { - use drive::drive::identity::withdrawals::paths::{ - get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec, - get_withdrawal_pending_asset_lock_inflows_path_vec, - }; - + fn should_prune_core_credit_pool_balances_older_than_the_window() { let platform_version = PlatformVersion::latest(); let platform = TestPlatformBuilder::new() .with_latest_protocol_version() .build_with_mock_rpc() .set_initial_state_structure(); let transaction = platform.drive.grove.start_transaction(); - let block = |core_height: u32| BlockInfo { - time_ms: 1_000_000, - height: 100, - core_height, - epoch: Epoch::default(), - }; - // Balances at Core heights 399, 400 and 401. - for core_height in [399, 400, 401] { + for core_height in [423, 424, 425] { platform .drive - .record_core_credit_pool_block( - core_height, - 1, - &[], - &block(core_height), - Some(&transaction), - platform_version, - ) + .record_core_credit_pool_block(core_height, 1, Some(&transaction), platform_version) .expect("expected to record the block"); } - // Core-dated inflows mined at 447 and 448: they stop counting at 999 and 1000. - for (asset_lock, mined_at) in [(1u8, 447u32), (2, 448)] { - platform - .drive - .record_asset_lock_credit_inflow( - [asset_lock; 32], - 10, - Some(mined_at), - &block(450), - Some(&transaction), - platform_version, - ) - .expect("expected to record the inflow"); - } - // Pending asset locks recorded at Core heights 400 and 401: dropped at 1000 and 1001. - for (asset_lock, recorded_at) in [(3u8, 400u32), (4, 401)] { - platform - .drive - .record_asset_lock_credit_inflow( - [asset_lock; 32], - 10, - None, - &block(recorded_at), - Some(&transaction), - platform_version, - ) - .expect("expected to record the inflow"); - } platform .cleanup_expired_locks_of_withdrawal_amounts_v1( - &block(1000), + &BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 1000, + epoch: Epoch::default(), + }, &transaction, platform_version, ) .expect("expected the cleanup to succeed"); - let keys = |path: Vec>| { - let mut query = Query::new(); - query.insert_all(); - platform - .drive - .grove_get_raw_path_query( - &PathQuery::new(path, SizedQuery::new(query, None, None)), - Some(&transaction), - drive::grovedb::query_result_type::QueryResultType::QueryKeyElementPairResultType, - &mut vec![], - &platform_version.drive, - ) - .expect("expected to query") - .0 - .to_key_elements() - .into_iter() - .map(|(key, _)| key) - .collect::>() - }; - - // The band at 1000 starts at 400: 399 goes. + // Core's mainnet window at 1000 starts at 424: 423 goes. assert_eq!( platform .drive @@ -375,14 +241,7 @@ mod tests { .expect("expected the balances") .into_keys() .collect::>(), - vec![400, 401] - ); - // The inflow that stops counting at 999 goes, the one at 1000 has just stopped too. - assert!(keys(get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec()).is_empty()); - // The asset lock pending since 400 waited 600 Core blocks; the one since 401 stays. - assert_eq!( - keys(get_withdrawal_pending_asset_lock_inflows_path_vec()), - vec![vec![4u8; 32]] + vec![424, 425] ); } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs index d0bb8c3c74a..90dc3f1cf56 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs @@ -3,6 +3,7 @@ use metrics::gauge; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::document::DocumentV0Getters; +use dpp::fee::Credits; use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; use dpp::version::PlatformVersion; use drive::grovedb::TransactionArg; @@ -31,6 +32,36 @@ where block_info: &BlockInfo, transaction: TransactionArg, platform_version: &PlatformVersion, + ) -> Result<(), Error> { + self.pool_withdrawals_up_to_limit_v1( + block_info, + transaction, + platform_version, + |available, daily_maximum| { + let current_withdrawal_limit = available; + + // Store prometheus metrics + gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_AVAILABLE) + .set(current_withdrawal_limit as f64); + gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_TOTAL).set(daily_maximum as f64); + + Ok(current_withdrawal_limit) + }, + ) + } + + /// Version 1's pooling, given the amount to pool up to once the daily withdrawal limit is + /// known (`current_withdrawal_limit`, called with its available amount and daily maximum + /// only when withdrawals are queued). Extracted in place, unchanged, so version 2 can + /// reuse it: for version 1 the closure returns the available daily limit and sets the + /// gauges exactly where they were set before, so every protocol version that selects + /// version 1 (8 to 13) pools the same documents and writes the same state. + pub(super) fn pool_withdrawals_up_to_limit_v1( + &self, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + current_withdrawal_limit: impl FnOnce(Credits, Credits) -> Result, ) -> Result<(), Error> { let documents = self.drive.fetch_oldest_withdrawal_documents_by_status( withdrawals_contract::WithdrawalStatus::QUEUED.into(), @@ -65,11 +96,8 @@ where "Calculated withdrawal limit info" ); - let current_withdrawal_limit = withdrawals_info.available(); - - // Store prometheus metrics - gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_AVAILABLE).set(current_withdrawal_limit as f64); - gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_TOTAL).set(withdrawals_info.daily_maximum as f64); + let current_withdrawal_limit = + current_withdrawal_limit(withdrawals_info.available(), withdrawals_info.daily_maximum)?; // Only process documents up to the current withdrawal limit. let mut total_withdrawal_amount = 0u64; diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs index a6e94c212fc..28dffe440f8 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs @@ -1,24 +1,14 @@ use dpp::block::block_info::BlockInfo; use metrics::gauge; -use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::document::DocumentV0Getters; -use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; use dpp::version::PlatformVersion; use drive::grovedb::TransactionArg; -use dpp::system_data_contracts::withdrawals_contract; -use dpp::system_data_contracts::withdrawals_contract::v1::document_types::withdrawal; - use crate::metrics::{ GAUGE_CREDIT_WITHDRAWAL_LIMIT_AVAILABLE, GAUGE_CREDIT_WITHDRAWAL_LIMIT_CORE_AVAILABLE, GAUGE_CREDIT_WITHDRAWAL_LIMIT_TOTAL, }; -use crate::{ - error::{execution::ExecutionError, Error}, - platform_types::platform::Platform, - rpc::core::CoreRPCLike, -}; +use crate::{error::Error, platform_types::platform::Platform, rpc::core::CoreRPCLike}; impl Platform where @@ -29,8 +19,8 @@ where /// Version 2 differs from version 1 only in the amount it pools up to: the smaller of the /// daily withdrawal limit and the Core-anchored limit, a stricter copy of Core's own asset /// unlock rule read from Core's credit pool balances. Platform's own accounting can grant - /// more than Core will mine (an asset lock published to Platform long after Core mined it, - /// the epoch Core rewards minted in one block); an unlock over Core's limit waits unmined, + /// more than Core will mine (an asset lock published to Platform after Core mined it, the + /// epoch Core rewards minted in one block); an unlock over Core's limit waits unmined, /// expires and is re-signed, and while Core's mempool holds more than the limit, Core /// InstantSend-locks no withdrawal at all. What the Core side holds back stays queued on /// Platform instead. It first reads the Core blocks the chain locked height passed @@ -42,192 +32,57 @@ where platform_version: &PlatformVersion, ) -> Result<(), Error> { // Bring the Core blocks up to date first, every block whether or not anything is - // queued: their credit pool balances feed the Core-anchored limit below, the asset - // locks they mined are dated for the daily limit, and a long jump of the chain locked - // height is read over several blocks. + // queued: their credit pool balances feed the Core-anchored limit below, and a long + // jump of the chain locked height is read over several blocks. self.scan_core_blocks_for_withdrawals(block_info, transaction, platform_version)?; - let documents = self.drive.fetch_oldest_withdrawal_documents_by_status( - withdrawals_contract::WithdrawalStatus::QUEUED.into(), - platform_version - .system_limits - .withdrawal_transactions_per_block_limit, - transaction, - platform_version, - )?; - - if documents.is_empty() { - tracing::debug!( - height = block_info.height, - withdrawal_limit = platform_version - .system_limits - .withdrawal_transactions_per_block_limit, - "No queued withdrawal documents found to pool into transactions" - ); - return Ok(()); - } - - // Only take documents up to the withdrawal amount - let withdrawals_info = self.drive.calculate_current_withdrawal_limit( - block_info, - transaction, - platform_version, - )?; - - tracing::trace!( - ?withdrawals_info, - documents_count = documents.len(), - "Calculated withdrawal limit info" - ); - - let core_anchored_withdrawal_limit = self.calculate_core_anchored_withdrawal_limit( + self.pool_withdrawals_up_to_limit_v1( block_info, transaction, platform_version, - )?; - - tracing::trace!( - core_anchored_withdrawal_limit, - "Calculated Core-anchored withdrawal limit" - ); - - let current_withdrawal_limit = withdrawals_info - .available() - .min(core_anchored_withdrawal_limit); - - // Store prometheus metrics - gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_AVAILABLE).set(withdrawals_info.available() as f64); - gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_TOTAL).set(withdrawals_info.daily_maximum as f64); - gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_CORE_AVAILABLE) - .set(core_anchored_withdrawal_limit as f64); - - // Only process documents up to the current withdrawal limit. - let mut total_withdrawal_amount = 0u64; - - // Iterate over the documents and accumulate their withdrawal amounts. - let mut documents_to_process = vec![]; - for document in documents { - // Get the withdrawal amount from the document properties. - let amount: u64 = document - .properties() - .get_integer(withdrawal::properties::AMOUNT)?; - - // Check if adding this amount would exceed the current withdrawal limit. - let potential_total_withdrawal_amount = - total_withdrawal_amount.checked_add(amount).ok_or_else(|| { - Error::Execution(ExecutionError::Overflow( - "overflow in total withdrawal amount", - )) - })?; - - // If adding this withdrawal would exceed the limit, stop further processing. - if potential_total_withdrawal_amount > current_withdrawal_limit { - tracing::debug!( - "Pooling is limited due to daily withdrawals limit. {} credits left", - current_withdrawal_limit + |available, daily_maximum| { + let core_anchored_withdrawal_limit = self + .calculate_core_anchored_withdrawal_limit( + block_info, + transaction, + platform_version, + )?; + + tracing::trace!( + core_anchored_withdrawal_limit, + "Calculated Core-anchored withdrawal limit" ); - break; - } - total_withdrawal_amount = potential_total_withdrawal_amount; - - // Add this document to the list of documents to be processed. - documents_to_process.push(document); - } + let current_withdrawal_limit = available.min(core_anchored_withdrawal_limit); - if documents_to_process.is_empty() { - tracing::debug!( - block_info = %block_info, - "No withdrawal documents to process" - ); - return Ok(()); - } - - let start_transaction_index = self - .drive - .fetch_next_withdrawal_transaction_index(transaction, platform_version)?; - - let (withdrawal_transactions, total_amount) = self - .build_untied_withdrawal_transactions_from_documents( - &mut documents_to_process, - start_transaction_index, - block_info, - platform_version, - )?; - - let withdrawal_transactions_count = withdrawal_transactions.len(); - - let mut drive_operations = vec![]; - - self.drive - .add_enqueue_untied_withdrawal_transaction_operations( - withdrawal_transactions, - total_amount, - &mut drive_operations, - platform_version, - )?; - - let end_transaction_index = start_transaction_index + withdrawal_transactions_count as u64; - - self.drive - .add_update_next_withdrawal_transaction_index_operation( - end_transaction_index, - &mut drive_operations, - platform_version, - )?; - - tracing::debug!( - "Pooled {} withdrawal documents into {} transactions with indices from {} to {}", - documents_to_process.len(), - withdrawal_transactions_count, - start_transaction_index, - end_transaction_index, - ); - - let withdrawals_contract = self - .drive - .cache - .system_data_contracts - .load_withdrawals(platform_version)?; - - self.drive.add_update_multiple_documents_operations( - &documents_to_process, - &withdrawals_contract, - withdrawals_contract - .document_type_for_name(withdrawal::NAME) - .map_err(|_| { - Error::Execution(ExecutionError::CorruptedCodeExecution( - "Can't fetch withdrawal data contract", - )) - })?, - &mut drive_operations, - &platform_version.drive, - )?; - - self.drive.apply_drive_operations( - drive_operations, - true, - block_info, - transaction, - platform_version, - None, - )?; + // Store prometheus metrics: what may be pooled, as in version 1, and the Core + // side on its own. + gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_AVAILABLE) + .set(current_withdrawal_limit as f64); + gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_TOTAL).set(daily_maximum as f64); + gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_CORE_AVAILABLE) + .set(core_anchored_withdrawal_limit as f64); - Ok(()) + Ok(current_withdrawal_limit) + }, + ) } } #[cfg(test)] mod tests { use super::*; - use crate::rpc::core::{CoreCreditPoolBlock, MockCoreRPCLike}; + use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TestPlatformBuilder; use dpp::block::epoch::Epoch; + use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contracts::SystemDataContract; use dpp::identifier::Identifier; use dpp::identity::core_script::CoreScript; use dpp::platform_value::platform_value; use dpp::system_data_contracts::load_system_data_contract; + use dpp::system_data_contracts::withdrawals_contract; + use dpp::system_data_contracts::withdrawals_contract::v1::document_types::withdrawal; use dpp::tests::fixtures::get_withdrawal_document_fixture; use dpp::withdrawal::Pooling; use drive::config::DEFAULT_QUERY_LIMIT; @@ -243,12 +98,9 @@ mod tests { .set_initial_state_structure(); let mut core_rpc = MockCoreRPCLike::new(); - core_rpc.expect_get_credit_pool_block().returning(move |_| { - Ok(CoreCreditPoolBlock { - credit_pool_balance: pool_duffs, - asset_lock_txids: vec![], - }) - }); + core_rpc + .expect_get_credit_pool_balance() + .returning(move |_| Ok(pool_duffs)); platform.core_rpc = core_rpc; let transaction = platform.drive.grove.start_transaction(); diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs index 61a64eb33bd..db1849a12b8 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs @@ -18,9 +18,9 @@ where /// transitions, and the epoch Core block rewards on an epoch change) as credit inflows /// the net daily withdrawal limit adds to its daily maximum, so money that entered /// Platform within the window may leave again without consuming the withdrawal budget of - /// other users. Asset lock credits are dated by the Core block that mined each asset lock, - /// the way Core's own unlock limit counts them; one Core has not mined at or below the - /// block's chain locked height waits as pending. The other mints are dated by the block. + /// other users. All are dated by the block, except the credits of an asset lock Core mined + /// so long ago that Core's own unlock limit reads it from its window start balance: those + /// add no inflow. /// /// Runs as a system event once per block, so nobody pays fees for the write; a block that /// minted nothing writes nothing. @@ -30,8 +30,8 @@ where /// * `state_transition_mints`: The credits the block's state transitions minted, per asset /// lock. /// * `block_fee_mints`: The credits the block's fee processing minted (epoch Core rewards). - /// * `block_info`: The block being executed; its time dates the other mints, and its Core - /// chain locked height bounds which Core blocks count as mined. + /// * `block_info`: The block being executed; its time dates the mints, and its Core chain + /// locked height decides which asset locks Core mined a window ago. /// * `transaction`: The GroveDB transaction. /// * `platform_version`: The platform version. /// diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs index f3d13adbf90..2c6cce3303c 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs @@ -1,3 +1,4 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::platform::Platform; @@ -7,19 +8,21 @@ use dpp::dashcore::hashes::Hash; use dpp::dashcore::Txid; use dpp::fee::Credits; use dpp::version::PlatformVersion; +use dpp::withdrawal::core_credit_pool_unlock_limit::NetworkCoreCreditPoolWindow; use drive::grovedb::Transaction; impl Platform where C: CoreRPCLike, { - /// Mints that name no asset lock (the epoch Core rewards, and in principle a state - /// transition mint without one) are recorded by the block time through - /// `Drive::record_credit_inflow`, which records nothing for a zero amount. Asset lock - /// mints are dated by the Core block that mined each asset lock: Core is asked, once for the - /// whole block, where it mined them, and only a height at or below the block's chain locked - /// height is taken (every node agrees on those); any other asset lock waits as pending until - /// a scanned Core block holds it (`Drive::record_asset_lock_credit_inflow`). + /// Records the block's mints with one `Drive::record_credit_inflow` call, which records + /// nothing for a zero amount. An asset lock Core mined longer ago than its credit pool + /// window minus `core_credit_pool_unlock_mining_delay_blocks` is left out: Core's own + /// limit already reads it from the window start balance, so it adds only a percent of + /// itself there, and counting it in full here would let a lock published to Platform late + /// raise the limit. Every other mint counts by the block time, the schedule the + /// withdrawal reservations follow, so a deposit and the withdrawal it funds cancel exactly. + /// Core is asked once for the whole block where it mined the asset locks. pub(super) fn record_credit_inflows_for_withdrawals_v0( &self, state_transition_mints: &BlockCreditMints, @@ -28,13 +31,8 @@ where transaction: &Transaction, platform_version: &PlatformVersion, ) -> Result<(), Error> { - self.drive.record_credit_inflow( - block_fee_mints - .saturating_add(state_transition_mints.not_attributed_to_an_asset_lock()), - block_info, - Some(transaction), - platform_version, - )?; + let mut credit_inflows = block_fee_mints + .saturating_add(state_transition_mints.not_attributed_to_an_asset_lock()); let asset_lock_mints: Vec<([u8; 32], Credits)> = state_transition_mints .by_asset_lock() @@ -43,35 +41,53 @@ where .map(|(asset_lock_txid, amount)| (*asset_lock_txid, *amount)) .collect(); - if asset_lock_mints.is_empty() { - return Ok(()); - } + if !asset_lock_mints.is_empty() { + let mining_delay_blocks = platform_version + .system_limits + .core_credit_pool_unlock_mining_delay_blocks + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "record_credit_inflows_for_withdrawals v0 requires system_limits.core_credit_pool_unlock_mining_delay_blocks", + )))?; + // Mined at or below this height, Core already counts the asset lock in the window + // start balance of an unlock pooled now (or soon will). + let stale_at_or_below = block_info.core_height.checked_sub( + self.config + .network + .core_credit_pool_window_blocks() + .saturating_sub(mining_delay_blocks), + ); - let txids: Vec = asset_lock_mints - .iter() - .map(|(asset_lock_txid, _)| Txid::from_byte_array(*asset_lock_txid)) - .collect(); + // Core answers from its active chain and transaction index; one that has not + // reached the chain locked height yet would report a mined asset lock as unknown. + // Fail the block on that node rather than record a different inflow. + self.core_rpc.get_block_hash(block_info.core_height)?; - let mined_heights = self.core_rpc.get_transactions_mined_heights(&txids)?; + let txids: Vec = asset_lock_mints + .iter() + .map(|(asset_lock_txid, _)| Txid::from_byte_array(*asset_lock_txid)) + .collect(); - for ((asset_lock_txid, amount), mined_height) in - asset_lock_mints.into_iter().zip(mined_heights) - { - // A height above the block's chain locked one is not final, and may differ between - // nodes whose Core is further ahead: such an asset lock is not mined yet here. - let mined_at_core_height = - mined_height.filter(|mined_height| *mined_height <= block_info.core_height); + let mined_heights = self.core_rpc.get_transactions_mined_heights(&txids)?; - self.drive.record_asset_lock_credit_inflow( - asset_lock_txid, - amount, - mined_at_core_height, - block_info, - Some(transaction), - platform_version, - )?; + for ((_, amount), mined_height) in asset_lock_mints.into_iter().zip(mined_heights) { + // Only a height at or below the chain locked one is final and the same on + // every node; anything else (above it, in the mempool, unknown) counts. + let stale = mined_height.zip(stale_at_or_below).is_some_and( + |(mined_height, stale_at_or_below)| mined_height <= stale_at_or_below, + ); + if !stale { + credit_inflows = credit_inflows.saturating_add(amount); + } + } } + self.drive.record_credit_inflow( + credit_inflows, + block_info, + Some(transaction), + platform_version, + )?; + Ok(()) } } @@ -86,13 +102,13 @@ mod tests { use dpp::block::epoch::Epoch; use dpp::dash_to_credits; use dpp::dashcore::hashes::Hash; + use dpp::dashcore::BlockHash; use dpp::dashcore::{OutPoint, Txid}; use dpp::fee::Credits; use dpp::platform_value::Bytes36; use dpp::version::PlatformVersion; use drive::drive::identity::withdrawals::paths::{ - get_withdrawal_pending_asset_lock_inflows_path, get_withdrawal_root_path, - WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, + get_withdrawal_root_path, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, }; use drive::util::batch::{DriveOperation, SystemOperationType}; use drive::util::grove_operations::DirectQueryType; @@ -187,12 +203,12 @@ mod tests { assert_eq!(inflows(&transaction), dash_to_credits!(5) as i64); } - /// Asset lock mints are dated by the Core block that mined them: one mined at or below the - /// block's chain locked height counts from that Core block (and adds nothing once a window - /// old), one Core reports above it or does not know waits as pending, and none of them is - /// recorded by the block time. + /// Asset lock mints count by the block time like every other mint, except one Core mined + /// so long ago that Core's own limit already reads it from its window start balance: + /// mined at or below the chain locked height minus Core's window (576 on mainnet) plus + /// `core_credit_pool_unlock_mining_delay_blocks` (48). #[test] - fn should_date_asset_lock_mints_by_the_core_block_that_mined_them() { + fn should_leave_out_asset_locks_core_mined_a_window_ago() { let mut platform = TestPlatformBuilder::new() .with_latest_protocol_version() .build_with_mock_rpc() @@ -200,6 +216,11 @@ mod tests { let platform_version = PlatformVersion::latest(); let mut core_rpc = MockCoreRPCLike::new(); + core_rpc + .expect_get_block_hash() + .withf(|core_height| *core_height == 1000) + .times(1) + .returning(|_| Ok(BlockHash::all_zeros())); core_rpc .expect_get_transactions_mined_heights() .times(1) @@ -207,10 +228,11 @@ mod tests { Ok(tx_ids .iter() .map(|txid| match txid.to_byte_array()[0] { - 1 => Some(995), // mined recently: counts until 995 + 552 - 2 => Some(400), // mined a window ago: adds nothing - 3 => Some(1001), // above the chain locked height: pending - _ => None, // unknown or in the mempool: pending + 1 => Some(995), // mined recently: counts + 2 => Some(472), // mined 528 blocks ago: left out + 3 => Some(473), // mined 527 blocks ago: counts + 4 => Some(1001), // above the chain locked height: counts + _ => None, // unknown or in the mempool: counts }) .collect()) }); @@ -226,7 +248,7 @@ mod tests { platform .record_credit_inflows_for_withdrawals( - &asset_lock_mints(&[(1, 100), (2, 200), (3, 300), (4, 400)]), + &asset_lock_mints(&[(1, 100), (2, 200), (3, 300), (4, 400), (5, 500)]), dash_to_credits!(1), &block_info, &transaction, @@ -234,48 +256,55 @@ mod tests { ) .expect("expected to record"); - let sum_tree = |key: &[u8; 1]| { + assert_eq!( platform .drive .grove_get_sum_tree_total_value( (&get_withdrawal_root_path()).into(), - key, + &WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, DirectQueryType::StatefulDirectQuery, Some(&transaction), &mut vec![], &platform_version.drive, ) - .expect("expected the sum") - }; - // Only the block fee mint is dated by the block time. - assert_eq!( - sum_tree(&WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY), - dash_to_credits!(1) as i64 - ); - // Only the recently mined asset lock is dated by Core. - assert_eq!( - sum_tree(&WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY), - 100 + .expect("expected the sum"), + (dash_to_credits!(1) + 100 + 300 + 400 + 500) as i64 ); + } - let pending = |asset_lock: u8| { - platform - .drive - .grove_get_raw_optional( - (&get_withdrawal_pending_asset_lock_inflows_path()).into(), - &[asset_lock; 32], - DirectQueryType::StatefulDirectQuery, - Some(&transaction), - &mut vec![], - &platform_version.drive, - ) - .expect("expected to read") - .is_some() - }; - assert!(!pending(1)); - assert!(!pending(2)); - assert!(pending(3)); - assert!(pending(4)); + /// A Core that has not reached the chain locked height would report an asset lock mined + /// below it as unknown; the block fails on that node instead of recording another inflow. + #[test] + fn should_fail_while_core_has_not_reached_the_chain_locked_height() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + let platform_version = PlatformVersion::latest(); + + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc.expect_get_block_hash().returning(|_| { + Err(dpp::dashcore_rpc::Error::UnexpectedStructure( + "Block height out of range".to_string(), + )) + }); + core_rpc.expect_get_transactions_mined_heights().times(0); + platform.core_rpc = core_rpc; + + let transaction = platform.drive.grove.start_transaction(); + + assert!(platform + .record_credit_inflows_for_withdrawals( + &asset_lock_mints(&[(1, 100)]), + 0, + &BlockInfo { + core_height: 1000, + ..Default::default() + }, + &transaction, + platform_version, + ) + .is_err()); } /// Before protocol version 14 the version slot is `None` and the event does nothing. diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/mod.rs index 46dd47f9d02..6c9e90aa01e 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/mod.rs @@ -12,12 +12,11 @@ impl Platform where C: CoreRPCLike, { - /// Reads the Core blocks the chain locked height has passed since the last one read: for - /// each, records Core's credit pool balance after it (the Core-anchored withdrawal limit - /// reads these) and dates the asset locks Platform consumed before Core mined them, which - /// that block holds. Reads at most `core_blocks_scanned_per_block_limit` Core blocks per - /// block, oldest first, and never one older than the band the limit reads - /// (`core_credit_pool_window_max_blocks` back); the rest follow in the next blocks. + /// Reads the Core blocks the chain locked height has passed since the last one read and + /// records Core's credit pool balance after each, which the Core-anchored withdrawal limit + /// reads. Reads at most `core_blocks_scanned_per_block_limit` Core blocks per block, oldest + /// first, and never one older than the band the limit reads (Core's credit pool window + /// back); the rest follow in the next blocks. /// /// Only chain locked Core blocks are read, so every node reads the same. Pooling calls it /// every block before it reads the withdrawal limits, so they see the newest Core blocks; @@ -32,8 +31,7 @@ where /// /// # Returns /// - /// * `Ok(())` once the Core blocks are recorded, or at once when the protocol version has - /// no Core-anchored limit (the method version is `None`). + /// * `Ok(())` once the Core blocks are recorded. /// * `Err(Error)` when the method version (or a Drive method it calls) is unknown or not /// active, Core cannot be asked, or a write fails. pub(in crate::execution) fn scan_core_blocks_for_withdrawals( @@ -48,7 +46,6 @@ where .withdrawals .scan_core_blocks_for_withdrawals { - None => Ok(()), Some(0) => { self.scan_core_blocks_for_withdrawals_v0(block_info, transaction, platform_version) } @@ -57,6 +54,10 @@ where known_versions: vec![0], received: version, })), + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "scan_core_blocks_for_withdrawals".to_string(), + known_versions: vec![0], + })), } } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs index 17495c3c013..8fea057d287 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs @@ -1,11 +1,10 @@ -use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; -use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::block::block_info::BlockInfo; -use dpp::dashcore::hashes::Hash; +use dpp::identity::convert_duffs_to_credits; use dpp::version::PlatformVersion; +use dpp::withdrawal::core_credit_pool_unlock_limit::NetworkCoreCreditPoolWindow; use drive::grovedb::TransactionArg; impl Platform @@ -26,19 +25,12 @@ where return Ok(()); } - let window_max_blocks = platform_version - .system_limits - .core_credit_pool_window_max_blocks - .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( - "scan_core_blocks_for_withdrawals v0 requires system_limits.core_credit_pool_window_max_blocks", - )))?; - let chain_locked_height = block_info.core_height; // Nothing older than the band the limit reads is worth reading: its balance is never - // read again, and an asset lock it mined is out of the window anyway (the pending - // entry is dropped by the cleanup). - let oldest_useful_height = chain_locked_height.saturating_sub(window_max_blocks); + // read again. + let oldest_useful_height = chain_locked_height + .saturating_sub(self.config.network.core_credit_pool_window_blocks()); let first_height = match self .drive @@ -59,26 +51,12 @@ where chain_locked_height.min(first_height.saturating_add(u32::from(limit) - 1)); for core_height in first_height..=last_height { - let core_block = self.core_rpc.get_credit_pool_block(core_height)?; - - let credit_pool_balance = core_block - .credit_pool_balance - .checked_mul(CREDITS_PER_DUFF) - .ok_or(Error::Execution(ExecutionError::Overflow( - "core credit pool balance in credits", - )))?; - - let asset_lock_txids: Vec<[u8; 32]> = core_block - .asset_lock_txids - .iter() - .map(|txid| txid.to_byte_array()) - .collect(); + let credit_pool_balance = + convert_duffs_to_credits(self.core_rpc.get_credit_pool_balance(core_height)?)?; self.drive.record_core_credit_pool_block( core_height, credit_pool_balance, - &asset_lock_txids, - block_info, transaction, platform_version, )?; @@ -90,35 +68,24 @@ where #[cfg(test)] mod tests { - use crate::rpc::core::{CoreCreditPoolBlock, MockCoreRPCLike}; + use crate::error::execution::ExecutionError; + use crate::error::Error; + use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TestPlatformBuilder; use dpp::block::block_info::BlockInfo; - use dpp::dashcore::hashes::Hash; - use dpp::dashcore::Txid; use dpp::version::PlatformVersion; - use drive::drive::identity::withdrawals::paths::{ - get_withdrawal_root_path, WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, - }; - use drive::util::grove_operations::DirectQueryType; use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; /// The Core heights read, in order, by a mock that answers every height with a balance of - /// `height * 1000` duffs and, at height 1003, one asset lock. + /// `height * 1000` duffs. fn recording_core(read: Arc>>) -> MockCoreRPCLike { let mut core_rpc = MockCoreRPCLike::new(); core_rpc - .expect_get_credit_pool_block() + .expect_get_credit_pool_balance() .returning(move |core_height| { read.lock().expect("lock").push(core_height); - Ok(CoreCreditPoolBlock { - credit_pool_balance: u64::from(core_height) * 1000, - asset_lock_txids: if core_height == 1003 { - vec![Txid::from_byte_array([3; 32])] - } else { - vec![] - }, - }) + Ok(u64::from(core_height) * 1000) }); core_rpc } @@ -138,18 +105,19 @@ mod tests { ..Default::default() }; - // First run: starts at the far edge of the band (1000 - 600) and reads 32 blocks. + // First run: starts at the far edge of the band (1000 - 576, Core's mainnet window) + // and reads 32 blocks. platform .scan_core_blocks_for_withdrawals(&block(1000), Some(&transaction), platform_version) .expect("expected to scan"); - assert_eq!(*read.lock().expect("lock"), (400..=431).collect::>()); + assert_eq!(*read.lock().expect("lock"), (424..=455).collect::>()); // It goes on from the next unread block. read.lock().expect("lock").clear(); platform .scan_core_blocks_for_withdrawals(&block(1000), Some(&transaction), platform_version) .expect("expected to scan"); - assert_eq!(*read.lock().expect("lock"), (432..=463).collect::>()); + assert_eq!(*read.lock().expect("lock"), (456..=487).collect::>()); // A jump past the band skips what is too old to matter. read.lock().expect("lock").clear(); @@ -158,16 +126,16 @@ mod tests { .expect("expected to scan"); assert_eq!( *read.lock().expect("lock"), - (1400..=1431).collect::>() + (1424..=1455).collect::>() ); // Balances are recorded in credits. assert_eq!( platform .drive - .fetch_core_credit_pool_balances(1400..=1401, Some(&transaction), platform_version) + .fetch_core_credit_pool_balances(1424..=1425, Some(&transaction), platform_version) .expect("expected the balances"), - BTreeMap::from([(1400, 1_400_000_000), (1401, 1_401_000_000)]) + BTreeMap::from([(1424, 1_424_000_000), (1425, 1_425_000_000)]) ); } @@ -205,70 +173,7 @@ mod tests { } #[test] - fn should_date_a_pending_asset_lock_by_the_core_block_that_holds_it() { - let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() - .build_with_mock_rpc() - .set_initial_state_structure(); - let platform_version = PlatformVersion::latest(); - let read = Arc::new(Mutex::new(vec![])); - platform.core_rpc = recording_core(read.clone()); - let transaction = platform.drive.grove.start_transaction(); - let block = |core_height: u32| BlockInfo { - time_ms: 5_000, - core_height, - ..Default::default() - }; - - // Caught up to Core height 1001, then the asset lock is consumed before Core mines it. - platform - .drive - .record_core_credit_pool_block( - 1001, - 0, - &[], - &block(1001), - Some(&transaction), - platform_version, - ) - .expect("expected to record the block"); - platform - .drive - .record_asset_lock_credit_inflow( - [3; 32], - 700, - None, - &block(1001), - Some(&transaction), - platform_version, - ) - .expect("expected to record the pending inflow"); - - let core_dated_inflows = |transaction| { - platform - .drive - .grove_get_sum_tree_total_value( - (&get_withdrawal_root_path()).into(), - &WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, - DirectQueryType::StatefulDirectQuery, - Some(transaction), - &mut vec![], - &platform_version.drive, - ) - .expect("expected the sum") - }; - assert_eq!(core_dated_inflows(&transaction), 0); - - // Core height 1003 mined it. - platform - .scan_core_blocks_for_withdrawals(&block(1003), Some(&transaction), platform_version) - .expect("expected to scan"); - assert_eq!(*read.lock().expect("lock"), vec![1002, 1003]); - assert_eq!(core_dated_inflows(&transaction), 700); - } - - #[test] - fn should_do_nothing_before_the_feature_exists() { + fn should_not_exist_before_protocol_version_14() { let mut platform = TestPlatformBuilder::new() .with_initial_protocol_version(13) .build_with_mock_rpc() @@ -279,16 +184,18 @@ mod tests { platform.core_rpc = recording_core(read.clone()); let transaction = platform.drive.grove.start_transaction(); - platform - .scan_core_blocks_for_withdrawals( - &BlockInfo { - core_height: 1000, - ..Default::default() - }, - Some(&transaction), - platform_version, - ) - .expect("expected the event to be a no-op before v14"); + let result = platform.scan_core_blocks_for_withdrawals( + &BlockInfo { + core_height: 1000, + ..Default::default() + }, + Some(&transaction), + platform_version, + ); + assert!(matches!( + result, + Err(Error::Execution(ExecutionError::VersionNotActive { .. })) + )); assert!(read.lock().expect("lock").is_empty()); } } diff --git a/packages/rs-drive-abci/src/main.rs b/packages/rs-drive-abci/src/main.rs index f04e6019345..70c70954b35 100644 --- a/packages/rs-drive-abci/src/main.rs +++ b/packages/rs-drive-abci/src/main.rs @@ -364,7 +364,7 @@ mod snapshot_bake_main { use dpp::version::PlatformVersion; use drive_abci::config::PlatformConfig; use drive_abci::platform_types::platform::Platform; - use drive_abci::rpc::core::{CoreCreditPoolBlock, CoreRPCLike}; + use drive_abci::rpc::core::CoreRPCLike; use serde_json::Value; /// Stub CoreRPCLike — Platform::open_with_client requires a CoreRPCLike, @@ -451,7 +451,7 @@ mod snapshot_bake_main { fn send_raw_transaction(&self, _: &[u8]) -> Result { unreachable!() } - fn get_credit_pool_block(&self, _: u32) -> Result { + fn get_credit_pool_balance(&self, _: u32) -> Result { unreachable!() } fn get_transactions_mined_heights(&self, _: &[Txid]) -> Result>, Error> { diff --git a/packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs b/packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs index 612e9be9913..069508211c2 100644 --- a/packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs @@ -3,8 +3,8 @@ use drive::util::batch::DriveOperation; use std::collections::BTreeMap; /// The credits a block's applied state transitions minted into Platform, in total and per asset -/// lock transaction they came from. The daily withdrawal limit counts the asset lock mints from -/// the Core block that mined each asset lock, and any other mint from the Platform block. +/// lock transaction they came from. The daily withdrawal limit leaves out the mints of an asset +/// lock Core mined a whole credit pool window ago, and counts every other mint. /// /// Mirrors applied state: the block loop rewinds it with the state a dropped transition rolls /// back, or the block would record an inflow for a transition the proposal omits. diff --git a/packages/rs-drive-abci/src/platform_types/platform/mock.rs b/packages/rs-drive-abci/src/platform_types/platform/mock.rs index 95496ccf505..5055caa5a4f 100644 --- a/packages/rs-drive-abci/src/platform_types/platform/mock.rs +++ b/packages/rs-drive-abci/src/platform_types/platform/mock.rs @@ -2,7 +2,7 @@ use crate::config::PlatformConfig; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; -use crate::rpc::core::{CoreCreditPoolBlock, MockCoreRPCLike}; +use crate::rpc::core::MockCoreRPCLike; use dpp::dashcore::BlockHash; use dpp::serialization::PlatformDeserializableFromVersionedStructureTrusted; use dpp::version::PlatformVersionCurrentVersion; @@ -35,20 +35,17 @@ impl Platform { })) }); - // A credit pool of 10 million Dash at every height, holding no asset locks: the - // Core-anchored withdrawal limit never binds unless a test sets its own answers. - core_rpc_mock.expect_get_credit_pool_block().returning(|_| { - Ok(CoreCreditPoolBlock { - credit_pool_balance: 1_000_000_000_000_000, - asset_lock_txids: vec![], - }) - }); + // A credit pool of 10 million Dash at every height: the Core-anchored withdrawal limit + // never binds unless a test sets its own answers. + core_rpc_mock + .expect_get_credit_pool_balance() + .returning(|_| Ok(1_000_000_000_000_000)); - // Every asset lock mined at Core height 0, so its credits count as an inflow until - // Core height 552, like the inflows of a recently mined asset lock. + // Core knows no asset lock, so every asset lock mint counts as a credit inflow, as one + // Core mined recently does. core_rpc_mock .expect_get_transactions_mined_heights() - .returning(|tx_ids| Ok(vec![Some(0); tx_ids.len()])); + .returning(|tx_ids| Ok(vec![None; tx_ids.len()])); Self::open_with_client(path, config, core_rpc_mock, initial_protocol_version) } diff --git a/packages/rs-drive-abci/src/rpc/core.rs b/packages/rs-drive-abci/src/rpc/core.rs index cee5b29e169..b73021bd817 100644 --- a/packages/rs-drive-abci/src/rpc/core.rs +++ b/packages/rs-drive-abci/src/rpc/core.rs @@ -1,6 +1,5 @@ use crate::rpc::prefetch::CorePrefetcher; use dpp::dashcore::ephemerealdata::chain_lock::ChainLock; -use dpp::dashcore::transaction::special_transaction::TransactionPayload; use dpp::dashcore::{Block, BlockHash, QuorumHash, Transaction, Txid}; use dpp::dashcore::{Header, InstantLock}; use dpp::dashcore_rpc::dashcore_rpc_json::{ @@ -20,48 +19,119 @@ pub type QuorumListExtendedInfo = HashMap; /// The most transaction ids Core's `gettxchainlocks` answers in one call. const MAX_TRANSACTIONS_PER_CHAIN_LOCK_STATUS_REQUEST: usize = 100; -/// What one Core block tells the Core-anchored withdrawal limit. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct CoreCreditPoolBlock { - /// Core's credit pool balance after the block, in duffs, as its coinbase records it; `0` - /// when the block has no credit pool (before the credit pool existed), which is how Core's - /// own unlock limit reads such a block. - pub credit_pool_balance: u64, - /// The ids of the asset lock transactions the block holds. - pub asset_lock_txids: Vec, +/// The special transaction type of a coinbase (`TRANSACTION_COINBASE` in Dash Core). +const COINBASE_TRANSACTION_TYPE: u16 = 5; + +/// Reads Core's credit pool balance after a block, in duffs, from the coinbase of the raw +/// (serialized) block; `0` when the coinbase carries none (a coinbase payload before version 3, +/// before the credit pool existed), which is how Core's own unlock limit reads such a block. +/// +/// Only the header and the coinbase are read, and the coinbase payload only up to the balance +/// within its own length: Core adds fields after it (version 4 appends +/// `merkleRootAssetUnlocks`) and new transaction types to blocks that a full block decoder of +/// an older Platform release cannot read. +pub fn credit_pool_balance_from_raw_block(block: &[u8]) -> Result { + let mut reader = RawReader(block); + reader.skip(80)?; // the header + + if reader.compact_size()? == 0 { + return Err("block has no coinbase".to_string()); + } + + let version_and_type = reader.u32_le()?; + let version = (version_and_type & 0xffff) as u16; + let transaction_type = (version_and_type >> 16) as u16; + + let inputs = reader.compact_size()?; + if inputs == 0 { + return Err("coinbase has no input".to_string()); + } + for _ in 0..inputs { + reader.skip(36)?; // the outpoint + let script_len = reader.compact_size()?; + reader.skip_u64(script_len)?; + reader.skip(4)?; // the sequence + } + for _ in 0..reader.compact_size()? { + reader.skip(8)?; // the value + let script_len = reader.compact_size()?; + reader.skip_u64(script_len)?; + } + reader.skip(4)?; // the lock time + + if version < 3 || transaction_type == 0 { + return Ok(0); + } + if transaction_type != COINBASE_TRANSACTION_TYPE { + return Err(format!( + "coinbase has special transaction type {transaction_type}" + )); + } + + let payload_len = reader.compact_size()?; + let mut payload = RawReader(reader.take_u64(payload_len)?); + if payload.u16_le()? < 3 { + return Ok(0); + } + payload.skip(4)?; // the height + payload.skip(32)?; // merkleRootMNList + payload.skip(32)?; // merkleRootQuorums + payload.compact_size()?; // bestCLHeightDiff + payload.skip(96)?; // bestCLSignature + let balance = payload.i64_le()?; + u64::try_from(balance).map_err(|_| format!("negative credit pool balance {balance}")) } -impl CoreCreditPoolBlock { - /// Reads the credit pool balance from the block's coinbase payload and collects its asset - /// lock transactions. - pub fn from_block(block: &Block) -> Self { - let credit_pool_balance = block - .txdata - .first() - .and_then(|coinbase| match &coinbase.special_transaction_payload { - Some(TransactionPayload::CoinbasePayloadType(payload)) => { - payload.asset_locked_amount - } - _ => None, - }) - .unwrap_or_default(); - - let asset_lock_txids = block - .txdata - .iter() - .filter(|transaction| { - matches!( - transaction.special_transaction_payload, - Some(TransactionPayload::AssetLockPayloadType(_)) - ) - }) - .map(Transaction::txid) - .collect(); - - CoreCreditPoolBlock { - credit_pool_balance, - asset_lock_txids, +/// A cursor over raw consensus-encoded bytes. +struct RawReader<'a>(&'a [u8]); + +impl<'a> RawReader<'a> { + fn take(&mut self, len: usize) -> Result<&'a [u8], String> { + if self.0.len() < len { + return Err("raw block ends early".to_string()); } + let (taken, rest) = self.0.split_at(len); + self.0 = rest; + Ok(taken) + } + + fn take_u64(&mut self, len: u64) -> Result<&'a [u8], String> { + self.take(usize::try_from(len).map_err(|_| "raw block ends early".to_string())?) + } + + fn skip(&mut self, len: usize) -> Result<(), String> { + self.take(len).map(|_| ()) + } + + fn skip_u64(&mut self, len: u64) -> Result<(), String> { + self.take_u64(len).map(|_| ()) + } + + fn array(&mut self) -> Result<[u8; N], String> { + let mut bytes = [0u8; N]; + bytes.copy_from_slice(self.take(N)?); + Ok(bytes) + } + + fn u16_le(&mut self) -> Result { + Ok(u16::from_le_bytes(self.array()?)) + } + + fn u32_le(&mut self) -> Result { + Ok(u32::from_le_bytes(self.array()?)) + } + + fn i64_le(&mut self) -> Result { + Ok(i64::from_le_bytes(self.array()?)) + } + + fn compact_size(&mut self) -> Result { + Ok(match self.take(1)?[0] { + 0xfd => u16::from_le_bytes(self.array()?) as u64, + 0xfe => u32::from_le_bytes(self.array()?) as u64, + 0xff => u64::from_le_bytes(self.array()?), + small => small as u64, + }) } } @@ -176,10 +246,10 @@ pub trait CoreRPCLike { /// Sends raw transaction to the network fn send_raw_transaction(&self, transaction: &[u8]) -> Result; - /// Get Core's credit pool balance after the block at `height` and the asset lock - /// transactions it holds. Only ask for a chain locked height: the answer is then the same - /// on every node. - fn get_credit_pool_block(&self, height: CoreHeight) -> Result; + /// Get Core's credit pool balance after the block at `height`, in duffs, read from the + /// block's coinbase. Only ask for a chain locked height: the answer is then the same on + /// every node. + fn get_credit_pool_balance(&self, height: CoreHeight) -> Result; /// Get the height of the active chain block each transaction was mined in, in the order of /// `tx_ids`; `None` for one Core does not know, or holds in its mempool only. A height is @@ -451,10 +521,13 @@ impl CoreRPCLike for DefaultCoreRPC { .get_asset_unlock_statuses(indices, Some(core_chain_locked_height))) } - fn get_credit_pool_block(&self, height: CoreHeight) -> Result { + fn get_credit_pool_balance(&self, height: CoreHeight) -> Result { let block_hash = self.get_block_hash(height)?; - let block = self.get_block(&block_hash)?; - Ok(CoreCreditPoolBlock::from_block(&block)) + let block_hex = retry!(self.inner.get_block_hex(&block_hash))?; + let block = hex::decode(block_hex).map_err(|e| { + Error::UnexpectedStructure(format!("getblock answered invalid hex: {e}")) + })?; + credit_pool_balance_from_raw_block(&block).map_err(Error::UnexpectedStructure) } fn get_transactions_mined_heights( @@ -481,3 +554,114 @@ impl CoreRPCLike for DefaultCoreRPC { Ok(heights) } } + +#[cfg(test)] +mod tests { + use super::credit_pool_balance_from_raw_block; + use dpp::dashcore::bls_sig_utils::BLSSignature; + use dpp::dashcore::consensus::serialize; + use dpp::dashcore::hash_types::{MerkleRootMasternodeList, MerkleRootQuorums}; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::transaction::special_transaction::coinbase::CoinbasePayload; + use dpp::dashcore::transaction::special_transaction::TransactionPayload; + use dpp::dashcore::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; + + const BALANCE_DUFFS: u64 = 3_700_000_000_000; + + /// A coinbase as the pinned rust-dashcore encodes it, with a version 3 payload. + fn coinbase(payload: Option) -> Vec { + serialize(&Transaction { + version: 3, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::from(vec![0x51, 0x51]), + sequence: u32::MAX, + witness: Default::default(), + }], + output: vec![TxOut { + value: 5_000_000, + script_pubkey: ScriptBuf::from(vec![0x76; 25]), + }], + special_transaction_payload: payload, + }) + } + + fn version_3_payload() -> TransactionPayload { + TransactionPayload::CoinbasePayloadType(CoinbasePayload { + version: 3, + height: 1_000, + merkle_root_masternode_list: MerkleRootMasternodeList::from_byte_array([1; 32]), + merkle_root_quorums: MerkleRootQuorums::from_byte_array([2; 32]), + best_cl_height: Some(30), + best_cl_signature: Some(BLSSignature::from([3; 96])), + asset_locked_amount: Some(BALANCE_DUFFS), + }) + } + + /// A header, then the coinbase, then a transaction no decoder knows. + fn block(coinbase: &[u8]) -> Vec { + let mut block = vec![0u8; 80]; + block.push(2); + block.extend_from_slice(coinbase); + block.extend_from_slice(&[0xff; 40]); + block + } + + #[test] + fn should_read_the_balance_of_a_version_3_coinbase() { + assert_eq!( + credit_pool_balance_from_raw_block(&block(&coinbase(Some(version_3_payload())))), + Ok(BALANCE_DUFFS) + ); + } + + /// Core v24 blocks carry a version 4 payload, which appends `merkleRootAssetUnlocks` + /// after the balance; the pinned decoder cannot read it. + #[test] + fn should_read_the_balance_of_a_version_4_coinbase() { + let version_3 = coinbase(Some(version_3_payload())); + // The payload is last: its 1-byte length (175), then the payload itself. + let payload_start = version_3.len() - 175; + assert_eq!(version_3[payload_start - 1], 175); + let mut version_4 = version_3[..payload_start - 1].to_vec(); + version_4.push(175 + 32); + version_4.extend_from_slice(&4u16.to_le_bytes()); + version_4.extend_from_slice(&version_3[payload_start + 2..]); + version_4.extend_from_slice(&[0xaa; 32]); + + assert_eq!( + credit_pool_balance_from_raw_block(&block(&version_4)), + Ok(BALANCE_DUFFS) + ); + } + + #[test] + fn should_read_no_balance_from_a_coinbase_before_the_credit_pool() { + let version_2 = TransactionPayload::CoinbasePayloadType(CoinbasePayload { + version: 2, + height: 1_000, + merkle_root_masternode_list: MerkleRootMasternodeList::from_byte_array([1; 32]), + merkle_root_quorums: MerkleRootQuorums::from_byte_array([2; 32]), + best_cl_height: None, + best_cl_signature: None, + asset_locked_amount: None, + }); + assert_eq!( + credit_pool_balance_from_raw_block(&block(&coinbase(Some(version_2)))), + Ok(0) + ); + assert_eq!( + credit_pool_balance_from_raw_block(&block(&coinbase(None))), + Ok(0) + ); + } + + #[test] + fn should_fail_on_a_truncated_block() { + let full = block(&coinbase(Some(version_3_payload()))); + // Cut inside the payload, before the balance. + assert!(credit_pool_balance_from_raw_block(&full[..full.len() - 60]).is_err()); + assert!(credit_pool_balance_from_raw_block(&full[..40]).is_err()); + } +} diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs index e048c50ba76..3332292351a 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs @@ -29,6 +29,7 @@ mod tests { use dpp::dash_to_credits; use dpp::dashcore::hashes::Hash; use dpp::dashcore::QuorumHash; + use dpp::dashcore_rpc::dashcore_rpc_json::{AssetUnlockStatus, AssetUnlockStatusResult}; use dpp::data_contract::TokenConfiguration; use dpp::identity::{KeyType, Purpose, SecurityLevel}; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; @@ -1194,6 +1195,19 @@ mod tests { .core_rpc .expect_send_raw_transaction() .returning(move |_| Ok(Txid::all_zeros())); + // Core has mined none of them (pooling asks to subtract what is still in flight). + platform + .core_rpc + .expect_get_asset_unlock_statuses() + .returning(|indices, _| { + Ok(indices + .iter() + .map(|index| AssetUnlockStatusResult { + index: *index, + status: AssetUnlockStatus::Unknown, + }) + .collect()) + }); let outcome = run_chain_for_strategy( &mut platform, @@ -1800,6 +1814,19 @@ mod tests { .core_rpc .expect_send_raw_transaction() .returning(move |_| Ok(Txid::all_zeros())); + // Core has mined none of them (pooling asks to subtract what is still in flight). + platform + .core_rpc + .expect_get_asset_unlock_statuses() + .returning(|indices, _| { + Ok(indices + .iter() + .map(|index| AssetUnlockStatusResult { + index: *index, + status: AssetUnlockStatus::Unknown, + }) + .collect()) + }); let outcome = run_chain_for_strategy( &mut platform, diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs index 72772e6f5b3..b1c65b4d626 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs @@ -11,15 +11,14 @@ mod tests { use dpp::dashcore_rpc::dashcore_rpc_json::{AssetUnlockStatus, AssetUnlockStatusResult}; use dpp::data_contracts::withdrawals_contract; use dpp::identity::{KeyType, Purpose, SecurityLevel}; - use dpp::withdrawal::daily_withdrawal_limit::daily_withdrawal_limit; use dpp::withdrawal::WithdrawalTransactionIndex; use dpp::{dash_to_credits, dash_to_duffs}; use drive::config::DEFAULT_QUERY_LIMIT; use drive::drive::balances::TOTAL_SYSTEM_CREDITS_STORAGE_KEY; use drive::drive::identity::withdrawals::fetch_total_credits_in_platform_a_day_ago::DAY_IN_MS; use drive::drive::identity::withdrawals::paths::{ - get_withdrawal_root_path, WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, - WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, + get_withdrawal_root_path, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, + WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, }; use drive::drive::system::misc_path; use drive::util::grove_operations::DirectQueryType; @@ -27,7 +26,6 @@ mod tests { ChainLockConfig, ExecutionConfig, InstantLockConfig, PlatformConfig, PlatformTestConfig, ValidatorSetConfig, }; - use drive_abci::platform_types::platform_state::PlatformStateV0Methods; use drive_abci::test::helpers::setup::TestPlatformBuilder; use platform_version::version::mocks::v3_test::TEST_PLATFORM_V3; use platform_version::version::PlatformVersion; @@ -2322,6 +2320,8 @@ mod tests { .core_rpc .expect_get_asset_unlock_statuses() .returning(move |indices, _| { + // An index the test has not set a status for is one Core has not mined; + // pooling asks for the broadcast ones to subtract what is still in flight. Ok(indices .iter() .map(|index| { @@ -2331,7 +2331,10 @@ mod tests { .asset_unlock_statuses .get(index) .cloned() - .unwrap() + .unwrap_or(AssetUnlockStatusResult { + index: *index, + status: AssetUnlockStatus::Unknown, + }) }) .collect()) }); @@ -2448,30 +2451,22 @@ mod tests { assert_eq!(total_credits_in_platform, Some(1010000000000000)); // Every credit entered through an asset lock, so the whole 10,100 Dash is - // recorded as credit inflows the daily withdrawal limit would add to its maximum, - // dated by the Core block that mined each asset lock (the mock Core reports height - // 0, so they count until Core height 552) rather than by the block time. - let inflows_of = |key: &[u8; 1]| { - outcome - .abci_app - .platform - .drive - .grove_get_sum_tree_total_value( - (&get_withdrawal_root_path()).into(), - key, - DirectQueryType::StatefulDirectQuery, - None, - &mut vec![], - &platform_version.drive, - ) - .expect("expected to get the credit inflows") - }; + // recorded as credit inflows the daily withdrawal limit would add to its maximum. + let credit_inflows = outcome + .abci_app + .platform + .drive + .grove_get_sum_tree_total_value( + (&get_withdrawal_root_path()).into(), + &WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to get the credit inflows"); - assert_eq!( - inflows_of(&WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY), - 1010000000000000 - ); - assert_eq!(inflows_of(&WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY), 0); + assert_eq!(credit_inflows, 1010000000000000); outcome }; @@ -2591,34 +2586,23 @@ mod tests { ) .await; - // The funding inflows were minted before the day-old base snapshot, so they are - // inside the base and nothing extends the daily maximum any more: it is the - // percentage of the unchanged total alone. (The Core height never moves in this - // test, so the Core-dated entries stay stored until Core height 552; they no - // longer count.) - let platform = &outcome.abci_app.platform; - let last_block_info = platform.state.load().last_block_info().clone(); - let total_credits_in_platform = platform + // The funding inflows are 25 hours old and pruned; nothing extends the daily + // maximum any more. + let credit_inflows = outcome + .abci_app + .platform .drive - .grove_get_raw_value_u64_from_encoded_var_vec( - (&misc_path()).into(), - TOTAL_SYSTEM_CREDITS_STORAGE_KEY, + .grove_get_sum_tree_total_value( + (&get_withdrawal_root_path()).into(), + &WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, DirectQueryType::StatefulDirectQuery, None, &mut vec![], &platform_version.drive, ) - .expect("expected to get total credits in platform") - .expect("expected total credits in platform"); - let limit = platform - .drive - .calculate_current_withdrawal_limit(&last_block_info, None, platform_version) - .expect("expected the withdrawal limit"); - assert_eq!( - limit.daily_maximum, - daily_withdrawal_limit(Some(total_credits_in_platform), platform_version) - .expect("expected the base") - ); + .expect("expected to get the credit inflows"); + + assert_eq!(credit_inflows, 0); outcome }; @@ -3071,6 +3055,255 @@ mod tests { assert_eq!(withdrawal_documents_broadcasted.len(), 80); } + #[tokio::test] + async fn should_hold_back_withdrawals_over_the_core_anchored_limit() { + // Latest protocol version, and a Core whose credit pool holds 120 Dash at every + // height. The chain is so young that every window start Core may measure an unlock + // from lies before it, an empty pool, so Core admits unlocks up to the pool itself. + // Platform's own daily limit (the flat 2,000 Dash while no recorded total is a day old, + // plus the 10,000 Dash of funding inflows) is far above that, so the Core-anchored + // side alone decides what is pooled. + let platform_version = PlatformVersion::latest(); + let start_strategy = NetworkStrategy { + strategy: Strategy { + start_contracts: vec![], + operations: vec![], + start_identities: StartIdentities::default(), + start_addresses: StartAddresses::default(), + identity_inserts: IdentityInsertInfo { + frequency: Frequency { + times_per_block_range: 10..11, + chance_per_block: None, + }, + start_keys: 3, + extra_keys: [( + Purpose::TRANSFER, + [(SecurityLevel::CRITICAL, vec![KeyType::ECDSA_SECP256K1])].into(), + )] + .into(), + start_balance_range: dash_to_duffs!(500)..=dash_to_duffs!(500), + }, + identity_contract_nonce_gaps: None, + signer: None, + }, + total_hpmns: 100, + extra_normal_mns: 0, + validator_quorum_count: 24, + chain_lock_quorum_count: 24, + upgrading_info: None, + + proposer_strategy: Default::default(), + rotate_quorums: false, + failure_testing: None, + query_testing: None, + verify_state_transition_results: true, + ..Default::default() + }; + + let minute_in_ms = 1000 * 60; + let config = PlatformConfig { + validator_set: ValidatorSetConfig::default_100_67(), + chain_lock: ChainLockConfig::default_100_67(), + instant_lock: InstantLockConfig::default_100_67(), + execution: ExecutionConfig { + verify_sum_trees: true, + ..Default::default() + }, + block_spacing_ms: minute_in_ms, + testing_configs: PlatformTestConfig::default_minimal_verifications(), + ..Default::default() + }; + + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .with_config(config.clone()) + .build_with_mock_rpc(); + + // Replace the default Core answers (a credit pool of 10 million Dash) with the small + // pool; Core has mined none of the unlocks. + platform.core_rpc.checkpoint(); + platform + .core_rpc + .expect_get_block_hash() + .returning(|_| Ok(BlockHash::all_zeros())); + platform + .core_rpc + .expect_get_block_json() + .returning(|_| Ok(serde_json::json!({ "tx": [] }))); + platform + .core_rpc + .expect_get_credit_pool_balance() + .returning(|_| Ok(dash_to_duffs!(120))); + platform + .core_rpc + .expect_get_transactions_mined_heights() + .returning(|tx_ids| Ok(vec![None; tx_ids.len()])); + platform + .core_rpc + .expect_send_raw_transaction() + .returning(move |_| Ok(Txid::all_zeros())); + platform + .core_rpc + .expect_get_asset_unlock_statuses() + .returning(|indices, _| { + Ok(indices + .iter() + .map(|index| AssetUnlockStatusResult { + index: *index, + status: AssetUnlockStatus::Unknown, + }) + .collect()) + }); + platform + .core_rpc + .expect_get_best_chain_lock() + .returning(|| { + Ok(ChainLock { + block_height: 1, + block_hash: BlockHash::from_byte_array([1; 32]), + signature: BLSSignature::from([2; 96]), + }) + }); + + // Blocks 1 and 2 create 20 identities of 500 Dash. + let ChainExecutionOutcome { + abci_app, + proposers, + validator_quorums: quorums, + current_validator_quorum_hash: current_quorum_hash, + current_proposer_versions, + end_time_ms, + identity_nonce_counter, + identity_contract_nonce_counter, + instant_lock_quorums, + identities, + addresses_with_balance, + signer, + .. + } = run_chain_for_strategy( + &mut platform, + 2, + start_strategy, + config.clone(), + 1, + &mut None, + &mut None, + ) + .await; + + let continue_strategy_only_withdrawal = NetworkStrategy { + strategy: Strategy { + start_contracts: vec![], + operations: vec![Operation { + op_type: OperationType::IdentityWithdrawal( + dash_to_credits!(50)..=dash_to_credits!(50), + ), + frequency: Frequency { + times_per_block_range: 4..5, // 50 Dash x 4 Withdrawals = 200 Dash + chance_per_block: None, + }, + }], + start_identities: StartIdentities::default(), + start_addresses: StartAddresses::default(), + identity_inserts: IdentityInsertInfo::default(), + identity_contract_nonce_gaps: None, + signer: Some(signer), + }, + total_hpmns: 100, + extra_normal_mns: 0, + validator_quorum_count: 24, + chain_lock_quorum_count: 24, + upgrading_info: None, + + proposer_strategy: Default::default(), + rotate_quorums: false, + failure_testing: None, + query_testing: None, + verify_state_transition_results: true, + ..Default::default() + }; + + // Blocks 3 to 5 withdraw 200 Dash each. + let outcome = continue_chain_for_strategy( + abci_app, + ChainExecutionParameters { + block_start: 3, + core_height_start: 1, + block_count: 3, + proposers, + validator_quorums: quorums, + current_validator_quorum_hash: current_quorum_hash, + current_proposer_versions: Some(current_proposer_versions), + current_identity_nonce_counter: identity_nonce_counter, + current_identity_contract_nonce_counter: identity_contract_nonce_counter, + current_votes: BTreeMap::default(), + start_time_ms: GENESIS_TIME_MS, + current_time_ms: end_time_ms, + instant_lock_quorums, + current_identities: identities, + current_addresses_with_balance: addresses_with_balance, + }, + continue_strategy_only_withdrawal, + config, + StrategyRandomness::SeedEntropy(2), + ) + .await; + + for tx_results_per_block in outcome.state_transition_results_per_block.values() { + assert_eq!(tx_results_per_block.len(), 4); + for (state_transition, result) in tx_results_per_block { + assert_eq!( + result.code, 0, + "state transition got code {} : {:?}", + result.code, state_transition + ); + } + } + + let documents_with_status = |status: withdrawals_contract::WithdrawalStatus| { + outcome + .abci_app + .platform + .drive + .fetch_oldest_withdrawal_documents_by_status( + status.into(), + DEFAULT_QUERY_LIMIT, + None, + platform_version, + ) + .expect("expected to fetch withdrawal documents") + .len() + }; + + // The first withdrawal block pools two (100 Dash) of its four into the 120 Dash Core + // admits; after that what is pooled and not mined leaves under 50 Dash, so the other + // ten wait in the queue although Platform's own limit has thousands left. + assert_eq!( + documents_with_status(withdrawals_contract::WithdrawalStatus::POOLED) + + documents_with_status(withdrawals_contract::WithdrawalStatus::BROADCASTED), + 2 + ); + assert_eq!( + documents_with_status(withdrawals_contract::WithdrawalStatus::QUEUED), + 10 + ); + + let locked_amount = outcome + .abci_app + .platform + .drive + .grove_get_sum_tree_total_value( + (&get_withdrawal_root_path()).into(), + &WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to get locked amount"); + assert_eq!(locked_amount, dash_to_credits!(100) as i64); + } + #[tokio::test] async fn run_chain_withdraw_from_identities_many_small_withdrawals() { // TEST_PLATFORM_V3 is like v4, but without the single quorum can sign withdrawals restriction diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 32043226918..dd21e6272e7 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -3872,86 +3872,6 @@ "children": [] } ] - }, - { - "id": "withdrawals.pending_asset_lock_inflows", - "key": { - "type": "fixed", - "hex": "07", - "label": "PendingAssetLockInflows", - "constant": "WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY" - }, - "kinds": [ - "Tree" - ], - "since": 14, - "presence": "always", - "source": "packages/rs-drive/src/drive/identity/withdrawals/paths.rs", - "description": "Asset locks consumed before Core mined them, waiting to be dated by the Core block that does.", - "children": [ - { - "id": "withdrawals.pending_asset_lock_inflows.asset_lock", - "key": { - "type": "dynamic", - "name": "txid", - "matcher": { - "type": "len", - "len": 32 - }, - "encoding": "hash32", - "description": "The asset lock transaction id" - }, - "kinds": [ - "Item" - ], - "value": "credits u64, block time u64 and Core height u32, big endian", - "since": 14, - "presence": "always", - "source": "packages/rs-drive/src/drive/identity/withdrawals/paths.rs", - "description": "The credits the asset lock minted, and the block that minted them first.", - "children": [] - } - ] - }, - { - "id": "withdrawals.core_dated_credit_inflows", - "key": { - "type": "fixed", - "hex": "08", - "label": "CoreDatedCreditInflows", - "constant": "WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY" - }, - "kinds": [ - "SumTree" - ], - "since": 14, - "presence": "always", - "source": "packages/rs-drive/src/drive/identity/withdrawals/paths.rs", - "description": "Asset lock credit inflows, dated by the Core block that mined them, which raise the relative withdrawal limit.", - "children": [ - { - "id": "withdrawals.core_dated_credit_inflows.inflow", - "key": { - "type": "dynamic", - "name": "expiry_and_time", - "matcher": { - "type": "len", - "len": 12 - }, - "encoding": "composite", - "description": "The Core height the entry stops counting at (u32 big endian), then the block time in milliseconds it was recorded at (u64 big endian)" - }, - "kinds": [ - "SumItem" - ], - "value": "credits", - "since": 14, - "presence": "always", - "source": "packages/rs-drive/src/drive/identity/withdrawals/paths.rs", - "description": "Credits asset locks minted, counting until that Core height.", - "children": [] - } - ] } ] }, @@ -6126,29 +6046,23 @@ "withdrawals": { "origin": "genesis@14", "tree": { - "hex": "04", + "hex": "02", "left": { - "hex": "02", + "hex": "01", "left": { - "hex": "01", - "left": { - "hex": "00" - } - }, - "right": { - "hex": "03" + "hex": "00" } }, "right": { - "hex": "07", + "hex": "04", "left": { - "hex": "06", - "left": { - "hex": "05" - } + "hex": "03" }, "right": { - "hex": "08" + "hex": "05", + "right": { + "hex": "06" + } } } } diff --git a/packages/rs-drive/src/drive/identity/withdrawals/calculate_current_withdrawal_limit/v1/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/calculate_current_withdrawal_limit/v1/mod.rs index 25f51e58286..963ba63f15e 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/calculate_current_withdrawal_limit/v1/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/calculate_current_withdrawal_limit/v1/mod.rs @@ -1,11 +1,8 @@ use crate::drive::identity::withdrawals::calculate_current_withdrawal_limit::WithdrawalLimitInfo; use crate::drive::identity::withdrawals::paths::{ - get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec, get_withdrawal_credit_inflows_sum_tree_path_vec, get_withdrawal_transactions_sum_tree_path_vec, }; -use crate::drive::identity::withdrawals::{ - core_dated_credit_inflow_key, decode_core_dated_credit_inflow_key, DAY_AND_A_HOUR_IN_MS, -}; +use crate::drive::identity::withdrawals::DAY_AND_A_HOUR_IN_MS; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; @@ -52,23 +49,13 @@ impl Drive { /// other users. Only inflows younger than the snapshot count — an older one is already /// inside the base, and adding it again would allow the pool level to drop below the /// guaranteed share of the day-old total — and only unexpired ones, so an entry the - /// bounded cleanup has not deleted yet cannot outlive its 25 hours here. When the - /// protocol version caps the base (`max_daily_withdrawal_amount`; protocol version 14 - /// sets no cap) the inflows ride above the cap: capping the sum would hand the whole - /// capped budget back to a deposit-withdraw cycle whenever the base reaches the cap. - /// Outflow funded by same-window deposits mirrors Core v24's net credit pool rule; + /// bounded cleanup has not deleted yet cannot outlive its 25 hours here. Outflow + /// funded by same-window deposits mirrors Core v24's net credit pool rule; /// * the withdrawal reservations are bounded the same way: one pooled at or before the /// snapshot describes an outflow the base already reflects (the history is recorded /// after state transitions executed), so subtracting it again would deny budget the /// guarantee does not require — a deposit-withdraw cycle would stay debited for the - /// hour its reservation outlives the snapshot instead of cancelling exactly; - /// * asset lock inflows are dated by the Core block that mined them, the way Core counts - /// them, not by the Platform block that consumed them: they sit in their own tree and - /// count while the chain locked height is below the Core height they stop counting at - /// (`core_credit_pool_window_min_blocks` after the mining block) and only when recorded - /// after the snapshot, for the same reason as the other inflows. An asset lock published - /// to Platform long after Core mined it therefore adds nothing. This tree is written from - /// protocol version 14 only, the one version that selects this generation. + /// hour its reservation outlives the snapshot instead of cancelling exactly. /// /// The formula stays `daily_maximum - withdrawals_amount`, floored at zero, with both /// sides counted over the interval after the snapshot. @@ -115,23 +102,11 @@ impl Drive { let total_credits_a_day_ago = recorded_a_day_ago.map(|recorded| recorded.total_credits); - // A cap on the base, when the protocol version sets one, applies inside - // `daily_withdrawal_limit`; the inflows ride on top of the capped base, not under the - // cap. Capping the sum would discard the inflows exactly when the base reaches the - // cap — at that point a deposit-withdraw cycle would consume the whole capped budget - // again (#4471). What Core will mine bounds pooling separately, through the - // Core-anchored limit. - let core_dated_credit_inflows_since_the_snapshot = self - .sum_core_dated_credit_inflows_counting_at( - block_info.core_height, - recorded_a_day_ago.as_ref().map(|recorded| recorded.time_ms), - transaction, - platform_version, - )?; - + // The base has no fixed cap; what Core will mine bounds pooling separately, through + // the Core-anchored limit. Outflow funded by same-window deposits mirrors Core v24's + // net credit pool rule (see #4471). let daily_maximum = daily_withdrawal_limit(total_credits_a_day_ago, platform_version)? - .saturating_add(credit_inflows_since_the_snapshot) - .saturating_add(core_dated_credit_inflows_since_the_snapshot); + .saturating_add(credit_inflows_since_the_snapshot); let withdrawals_since_the_snapshot = self.sum_expiry_keyed_entries_at_or_after( get_withdrawal_transactions_sum_tree_path_vec(), @@ -147,61 +122,6 @@ impl Drive { }) } - /// Sums the Core-dated credit inflows still counting at `core_height` (their key's Core - /// height is above it) that were recorded after the base snapshot taken at - /// `snapshot_time_ms` (all of them while no snapshot exists yet). The tree holds the - /// inflows of one window plus whatever the bounded cleanup has not deleted yet. - fn sum_core_dated_credit_inflows_counting_at( - &self, - core_height: u32, - snapshot_time_ms: Option, - transaction: TransactionArg, - platform_version: &PlatformVersion, - ) -> Result { - let Some(first_counting_height) = core_height.checked_add(1) else { - return Ok(0); - }; - - let path_query = PathQuery::new_unsized( - get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec(), - Query::new_single_query_item(QueryItem::RangeFrom( - core_dated_credit_inflow_key(first_counting_height, 0).., - )), - ); - - let (results, _) = self.grove_get_raw_path_query( - &path_query, - transaction, - QueryResultType::QueryKeyElementPairResultType, - &mut vec![], - &platform_version.drive, - )?; - - let mut total: u64 = 0; - for (key, element) in results.to_key_elements() { - let (_, recorded_at_time_ms) = decode_core_dated_credit_inflow_key(&key)?; - if snapshot_time_ms.is_some_and(|snapshot| recorded_at_time_ms <= snapshot) { - // Minted at or before the snapshot: already inside the base. - continue; - } - let Element::SumItem(amount, _) = element else { - return Err(Error::Drive(DriveError::CorruptedElementType( - "core-dated credit inflow entry is not a sum item", - ))); - }; - let amount: u64 = amount.try_into().map_err(|_| { - Error::Drive(DriveError::CriticalCorruptedState( - "core-dated credit inflow entry is negative", - )) - })?; - total = total.checked_add(amount).ok_or(Error::Drive( - DriveError::CriticalCorruptedState("core-dated credit inflow entries overflow"), - ))?; - } - - Ok(total) - } - /// Sums the sum-item entries of `path` whose expiration key is at or after /// `from_time_ms`. Each tree only ever holds the recording blocks of one 25-hour window /// plus whatever the bounded cleanup has not deleted yet, so walking the range stays @@ -256,8 +176,6 @@ impl Drive { #[cfg(test)] mod tests { use crate::drive::identity::withdrawals::fetch_total_credits_in_platform_a_day_ago::DAY_IN_MS; - - const HOUR_IN_MS: u64 = DAY_IN_MS / 24; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; use dpp::dash_to_credits; @@ -439,103 +357,6 @@ mod tests { assert_eq!(info.available(), dash_to_credits!(1500)); } - /// Inflows extend the daily maximum past a capped base: capping the sum instead would hand - /// the whole capped budget back to a deposit-withdraw cycle whenever the base reaches the - /// cap — #4471. Protocol version 14 sets no cap, but the rule holds for any that does: at a - /// 30,000 Dash total a base capped at 4,000 must leave the full 4,000 available to others - /// after a 4,000 Dash deposit-withdraw cycle. - #[test] - fn a_cycle_at_the_capped_base_should_not_consume_the_budget_of_others() { - let drive = setup_drive_with_initial_state_structure(None); - let mut platform_version = PlatformVersion::latest().clone(); - platform_version - .system_limits - .daily_withdrawal_limit_percent = Some(15); - platform_version.system_limits.max_daily_withdrawal_amount = Some(dash_to_credits!(4000)); - let transaction = drive.grove.start_transaction(); - - let t0 = 10 * DAY_IN_MS; - let block = |time_ms: u64| BlockInfo { - time_ms, - ..Default::default() - }; - let limit = |time_ms: u64| { - drive - .calculate_current_withdrawal_limit( - &block(time_ms), - Some(&transaction), - &platform_version, - ) - .expect("expected the limit") - }; - - // Platform holds 30,000 Dash: 15% would be 4,500, so the base sits at the 4,000 Dash - // cap — the network conditions of #4471. - drive - .add_to_system_credits( - dash_to_credits!(30000), - Some(&transaction), - &platform_version, - ) - .expect("expected to add credits"); - drive - .record_total_credits_history(&block(t0), 64, Some(&transaction), &platform_version) - .expect("expected to record"); - - let day_one = t0 + DAY_IN_MS; - assert_eq!(limit(day_one).daily_maximum, dash_to_credits!(4000)); - - // The attacker deposits the whole capped budget and withdraws it again. - drive - .add_to_system_credits( - dash_to_credits!(4000), - Some(&transaction), - &platform_version, - ) - .expect("expected to add the deposit"); - drive - .record_credit_inflow( - dash_to_credits!(4000), - &block(day_one), - Some(&transaction), - &platform_version, - ) - .expect("expected to record the inflow"); - let mut drive_operations = vec![]; - drive - .add_enqueue_untied_withdrawal_transaction_operations( - vec![(1, vec![0u8; 32])], - dash_to_credits!(4000), - &mut drive_operations, - &platform_version, - ) - .expect("expected to enqueue the withdrawal"); - drive - .apply_drive_operations( - drive_operations, - true, - &block(day_one), - Some(&transaction), - &platform_version, - None, - ) - .expect("expected to apply the pooling operations"); - drive - .remove_from_system_credits( - dash_to_credits!(4000), - Some(&transaction), - &platform_version, - ) - .expect("expected to remove the withdrawn credits"); - - // The inflow rides above the cap, so the cycle nets out: everyone else still has the - // full capped budget available. - let info = limit(day_one); - assert_eq!(info.daily_maximum, dash_to_credits!(8000)); - assert_eq!(info.withdrawals_amount, dash_to_credits!(4000)); - assert_eq!(info.available(), dash_to_credits!(4000)); - } - /// An inflow that is already part of the day-old base must not extend the daily maximum a /// second time: while the deposit block is younger than a day the inflow counts against /// the older snapshot, and the moment the deposit block itself becomes the snapshot the @@ -774,262 +595,4 @@ mod tests { assert_eq!(info.withdrawals_amount, 0); assert_eq!(info.available(), dash_to_credits!(3000)); } - - /// An asset lock counts from the Core block that mined it, for - /// `core_credit_pool_window_min_blocks` (552) Core blocks, the way Core's own limit counts - /// it, not for a day after the Platform block that consumed it. - #[test] - fn an_asset_lock_inflow_should_count_until_a_window_after_the_core_block_that_mined_it() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - let transaction = drive.grove.start_transaction(); - - let t0 = 10 * DAY_IN_MS; - let t1 = t0 + DAY_IN_MS; - let block = |time_ms: u64, core_height: u32| BlockInfo { - time_ms, - core_height, - ..Default::default() - }; - let limit = |time_ms: u64, core_height: u32| { - drive - .calculate_current_withdrawal_limit( - &block(time_ms, core_height), - Some(&transaction), - platform_version, - ) - .expect("expected the limit") - .daily_maximum - }; - - drive - .add_to_system_credits( - dash_to_credits!(20000), - Some(&transaction), - platform_version, - ) - .expect("expected to add credits"); - drive - .record_total_credits_history(&block(t0, 400), 64, Some(&transaction), platform_version) - .expect("expected to record"); - assert_eq!(limit(t1, 1000), dash_to_credits!(3000)); - - // Consumed on Platform at Core height 1000, mined by Core at 990: it counts until Core - // height 990 + 552. - drive - .record_asset_lock_credit_inflow( - [1; 32], - dash_to_credits!(500), - Some(990), - &block(t1, 1000), - Some(&transaction), - platform_version, - ) - .expect("expected to record the inflow"); - assert_eq!(limit(t1, 1000), dash_to_credits!(3500)); - assert_eq!(limit(t1 + HOUR_IN_MS, 1541), dash_to_credits!(3500)); - assert_eq!(limit(t1 + HOUR_IN_MS, 1542), dash_to_credits!(3000)); - } - - /// The edge case the Core dating closes: an asset lock published to Platform a whole - /// window after Core mined it sits in the balance Core's limit starts from, so it must not - /// add budget on Platform's side either. - #[test] - fn an_asset_lock_published_a_window_after_core_mined_it_should_add_nothing() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - let transaction = drive.grove.start_transaction(); - - let t0 = 10 * DAY_IN_MS; - let t1 = t0 + DAY_IN_MS; - let block = |time_ms: u64, core_height: u32| BlockInfo { - time_ms, - core_height, - ..Default::default() - }; - - drive - .add_to_system_credits( - dash_to_credits!(20000), - Some(&transaction), - platform_version, - ) - .expect("expected to add credits"); - drive - .record_total_credits_history(&block(t0, 400), 64, Some(&transaction), platform_version) - .expect("expected to record"); - - // Mined at Core height 400, consumed at 1000: 400 + 552 is already behind. - drive - .record_asset_lock_credit_inflow( - [1; 32], - dash_to_credits!(5000), - Some(400), - &block(t1, 1000), - Some(&transaction), - platform_version, - ) - .expect("expected to record the inflow"); - - let info = drive - .calculate_current_withdrawal_limit( - &block(t1, 1000), - Some(&transaction), - platform_version, - ) - .expect("expected the limit"); - assert_eq!(info.daily_maximum, dash_to_credits!(3000)); - } - - /// An asset lock Platform consumed before Core mined it adds nothing until a Core block - /// holding it is read, and then counts from that block. - #[test] - fn a_pending_asset_lock_inflow_should_count_once_a_core_block_holding_it_is_read() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - let transaction = drive.grove.start_transaction(); - - let t0 = 10 * DAY_IN_MS; - let t1 = t0 + DAY_IN_MS; - let block = |time_ms: u64, core_height: u32| BlockInfo { - time_ms, - core_height, - ..Default::default() - }; - let limit = |time_ms: u64, core_height: u32| { - drive - .calculate_current_withdrawal_limit( - &block(time_ms, core_height), - Some(&transaction), - platform_version, - ) - .expect("expected the limit") - .daily_maximum - }; - - drive - .add_to_system_credits( - dash_to_credits!(20000), - Some(&transaction), - platform_version, - ) - .expect("expected to add credits"); - drive - .record_total_credits_history(&block(t0, 400), 64, Some(&transaction), platform_version) - .expect("expected to record"); - - drive - .record_asset_lock_credit_inflow( - [7; 32], - dash_to_credits!(500), - None, - &block(t1, 1000), - Some(&transaction), - platform_version, - ) - .expect("expected to record the inflow"); - assert_eq!(limit(t1, 1000), dash_to_credits!(3000)); - - // A Core block that does not hold it changes nothing. - drive - .record_core_credit_pool_block( - 1001, - dash_to_credits!(30000), - &[[9; 32]], - &block(t1 + 1, 1001), - Some(&transaction), - platform_version, - ) - .expect("expected to record the block"); - assert_eq!(limit(t1 + 1, 1001), dash_to_credits!(3000)); - - // The block that mined it dates it: it counts until 1002 + 552. - drive - .record_core_credit_pool_block( - 1002, - dash_to_credits!(30500), - &[[7; 32]], - &block(t1 + 2, 1002), - Some(&transaction), - platform_version, - ) - .expect("expected to record the block"); - assert_eq!(limit(t1 + 2, 1002), dash_to_credits!(3500)); - assert_eq!(limit(t1 + HOUR_IN_MS, 1553), dash_to_credits!(3500)); - assert_eq!(limit(t1 + HOUR_IN_MS, 1554), dash_to_credits!(3000)); - - // Reading the same block again finds nothing pending: no double count. - drive - .record_core_credit_pool_block( - 1002, - dash_to_credits!(30500), - &[[7; 32]], - &block(t1 + 3, 1002), - Some(&transaction), - platform_version, - ) - .expect("expected to record the block"); - assert_eq!(limit(t1 + 3, 1002), dash_to_credits!(3500)); - } - - /// Like the other inflows, a Core-dated one minted at or before the day-old snapshot is - /// inside the base and must not count a second time, even while Core still counts it. - #[test] - fn a_core_dated_inflow_inside_the_day_old_base_should_not_count_again() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - let transaction = drive.grove.start_transaction(); - - let t0 = 10 * DAY_IN_MS; - let t1 = t0 + HOUR_IN_MS; - let block = |time_ms: u64, core_height: u32| BlockInfo { - time_ms, - core_height, - ..Default::default() - }; - let limit = |time_ms: u64, core_height: u32| { - drive - .calculate_current_withdrawal_limit( - &block(time_ms, core_height), - Some(&transaction), - platform_version, - ) - .expect("expected the limit") - .daily_maximum - }; - - drive - .add_to_system_credits( - dash_to_credits!(20000), - Some(&transaction), - platform_version, - ) - .expect("expected to add credits"); - drive - .record_total_credits_history(&block(t0, 100), 64, Some(&transaction), platform_version) - .expect("expected to record"); - - drive - .add_to_system_credits(dash_to_credits!(500), Some(&transaction), platform_version) - .expect("expected to add the deposit"); - drive - .record_asset_lock_credit_inflow( - [1; 32], - dash_to_credits!(500), - Some(120), - &block(t1, 120), - Some(&transaction), - platform_version, - ) - .expect("expected to record the inflow"); - drive - .record_total_credits_history(&block(t1, 120), 64, Some(&transaction), platform_version) - .expect("expected to record"); - - // Before the deposit block is the snapshot the inflow counts: 15% of 20,000 + 500. - assert_eq!(limit(t1 + 23 * HOUR_IN_MS, 600), dash_to_credits!(3500)); - // Once it is the snapshot the 500 Dash sit inside the 20,500 base; Core still counts - // it (120 + 552 is ahead), Platform does not count it twice. - assert_eq!(limit(t1 + DAY_IN_MS, 610), dash_to_credits!(3075)); - } } diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs index 9543a9785e1..43a6ce94212 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs @@ -64,7 +64,6 @@ impl Drive { #[cfg(test)] mod tests { use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; - use dpp::block::block_info::BlockInfo; use dpp::version::PlatformVersion; use std::collections::BTreeMap; @@ -86,11 +85,6 @@ mod tests { .record_core_credit_pool_block( core_height, balance, - &[], - &BlockInfo { - core_height, - ..Default::default() - }, Some(&transaction), platform_version, ) diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs index 8c27664c7e0..eb11aeed31b 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs @@ -4,15 +4,28 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use dpp::fee::Credits; +use dpp::withdrawal::WithdrawalTransactionIndex; use grovedb::TransactionArg; use platform_version::version::PlatformVersion; +use std::collections::BTreeMap; + +/// What the pooled withdrawal transactions not completed yet take out of Core's credit pool +/// once mined, in credits. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InFlightWithdrawalAmounts { + /// The sum over the queued transactions, which Core has not seen yet. + pub queued: Credits, + /// Each broadcast transaction's amount by its index: Core may have mined some of them + /// already, which the broadcast tree only learns of a bounded number at a time. + pub broadcast: BTreeMap, +} impl Drive { - /// Sums what the pooled withdrawal transactions not completed yet (the queue and the + /// Reads what the pooled withdrawal transactions not completed yet (the queue and the /// broadcast tree) will take out of Core's credit pool once mined, in credits: each /// transaction's outputs plus its fee, as Core counts an asset unlock. Core's own unlock /// limit only reflects unlocks already mined, so the Core-anchored withdrawal limit - /// subtracts these. + /// subtracts the queued sum and the broadcast transactions Core has not mined. /// /// # Parameters /// @@ -21,14 +34,14 @@ impl Drive { /// /// # Returns /// - /// * `Ok(Credits)`: The sum, in credits. + /// * `Ok(InFlightWithdrawalAmounts)`: The queued sum and the broadcast amounts, in credits. /// * `Err(Error)` when the method version is unknown or not active, a stored transaction /// cannot be decoded, or the sum overflows. pub fn fetch_in_flight_withdrawal_amount( &self, transaction: TransactionArg, platform_version: &PlatformVersion, - ) -> Result { + ) -> Result { match platform_version .drive .methods diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs index 154cf8d9b00..2f23b674943 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs @@ -1,13 +1,15 @@ +use crate::drive::identity::withdrawals::fetch_in_flight_withdrawal_amount::InFlightWithdrawalAmounts; use crate::drive::identity::withdrawals::paths::{ get_withdrawal_transactions_broadcasted_path_vec, get_withdrawal_transactions_queue_path_vec, }; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; -use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::dashcore::consensus::Decodable; use dpp::dashcore::transaction::special_transaction::asset_unlock::unqualified_asset_unlock::AssetUnlockBaseTransactionInfo; use dpp::fee::Credits; +use dpp::identity::convert_duffs_to_credits; +use dpp::withdrawal::WithdrawalTransactionIndex; use grovedb::query_result_type::QueryResultType; use grovedb::{Element, PathQuery, Query, TransactionArg}; use platform_version::version::PlatformVersion; @@ -17,32 +19,60 @@ impl Drive { &self, transaction: TransactionArg, platform_version: &PlatformVersion, - ) -> Result { + ) -> Result { + let mut queued: Credits = 0; + for (_, amount) in self.fetch_withdrawal_transaction_amounts( + get_withdrawal_transactions_queue_path_vec(), + transaction, + platform_version, + )? { + queued = queued.checked_add(amount).ok_or(Error::Drive( + DriveError::CriticalCorruptedState("in-flight withdrawal amount overflow"), + ))?; + } + + let broadcast = self + .fetch_withdrawal_transaction_amounts( + get_withdrawal_transactions_broadcasted_path_vec(), + transaction, + platform_version, + )? + .into_iter() + .collect(); + + Ok(InFlightWithdrawalAmounts { queued, broadcast }) + } + + /// Each untied withdrawal transaction under `path` with what it takes out of Core's credit + /// pool, in credits: its outputs plus its fee. + fn fetch_withdrawal_transaction_amounts( + &self, + path: Vec>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut query = Query::new(); + query.insert_all(); + let path_query = PathQuery::new_unsized(path, query); + + let (results, _) = self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryElementResultType, + &mut vec![], + &platform_version.drive, + )?; + let overflow = || { Error::Drive(DriveError::CriticalCorruptedState( "in-flight withdrawal amount overflow", )) }; - let mut total_duffs: u64 = 0; - - for path in [ - get_withdrawal_transactions_queue_path_vec(), - get_withdrawal_transactions_broadcasted_path_vec(), - ] { - let mut query = Query::new(); - query.insert_all(); - let path_query = PathQuery::new_unsized(path, query); - - let (results, _) = self.grove_get_raw_path_query( - &path_query, - transaction, - QueryResultType::QueryElementResultType, - &mut vec![], - &platform_version.drive, - )?; - - for element in results.to_elements() { + results + .to_elements() + .into_iter() + .map(|element| { let Element::Item(bytes, _) = element else { return Err(Error::Drive(DriveError::CorruptedElementType( "withdrawal transaction is not an item", @@ -59,23 +89,23 @@ impl Drive { })?; // What Core counts against its limit: the outputs plus the fee. - total_duffs = total_duffs - .checked_add(untied.base_payload.fee as u64) - .ok_or_else(overflow)?; + let mut duffs = untied.base_payload.fee as u64; for output in &untied.output { - total_duffs = total_duffs.checked_add(output.value).ok_or_else(overflow)?; + duffs = duffs.checked_add(output.value).ok_or_else(overflow)?; } - } - } - total_duffs - .checked_mul(CREDITS_PER_DUFF) - .ok_or_else(overflow) + Ok(( + untied.base_payload.index, + convert_duffs_to_credits(duffs).map_err(|_| overflow())?, + )) + }) + .collect() } } #[cfg(test)] mod tests { + use crate::drive::identity::withdrawals::fetch_in_flight_withdrawal_amount::InFlightWithdrawalAmounts; use crate::util::batch::DriveOperation; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; @@ -85,6 +115,7 @@ mod tests { }; use dpp::dashcore::{ScriptBuf, TxOut}; use dpp::version::PlatformVersion; + use std::collections::BTreeMap; fn untied_transaction(index: u64, payout_duffs: u64, fee_duffs: u32) -> Vec { let transaction = AssetUnlockBaseTransactionInfo { @@ -108,7 +139,7 @@ mod tests { } #[test] - fn should_sum_the_outputs_and_fees_of_queued_and_broadcast_transactions_in_credits() { + fn should_read_the_outputs_and_fees_of_queued_and_broadcast_transactions_in_credits() { let drive = setup_drive_with_initial_state_structure(None); let platform_version = PlatformVersion::latest(); let transaction = drive.grove.start_transaction(); @@ -117,7 +148,7 @@ mod tests { drive .fetch_in_flight_withdrawal_amount(Some(&transaction), platform_version) .expect("expected the amount"), - 0 + InFlightWithdrawalAmounts::default() ); let mut drive_operations: Vec = vec![]; @@ -164,12 +195,16 @@ mod tests { ) .expect("expected to apply"); - // (100,000 + 2,000 + 50,000 + 1,000) duffs, in credits. + // Queued: 50,000 + 1,000 duffs; broadcast: index 0 with 100,000 + 2,000 duffs; in + // credits. assert_eq!( drive .fetch_in_flight_withdrawal_amount(Some(&transaction), platform_version) .expect("expected the amount"), - 153_000_000 + InFlightWithdrawalAmounts { + queued: 51_000_000, + broadcast: BTreeMap::from([(0, 102_000_000)]), + } ); } } diff --git a/packages/rs-drive/src/drive/identity/withdrawals/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/mod.rs index 3a566dc5be4..02b5d8e2601 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/mod.rs @@ -20,8 +20,6 @@ pub mod fetch_last_recorded_core_credit_pool_height; pub mod fetch_total_credits_in_platform_a_day_ago; /// Functions and constants related to GroveDB paths pub mod paths; -/// Functions related to the credit inflows of asset locks, dated by the Core block that mined them -pub mod record_asset_lock_credit_inflow; /// Functions related to the Core blocks the Core-anchored withdrawal limit reads pub mod record_core_credit_pool_block; /// Functions related to the per-block record of credit inflows the daily withdrawal limit adds @@ -30,92 +28,3 @@ pub mod record_credit_inflow; pub mod record_total_credits_history; /// Functions related to withdrawal transactions pub mod transaction; - -use crate::error::drive::DriveError; -use crate::error::Error; -use dpp::fee::Credits; -use dpp::prelude::TimestampMillis; - -/// An asset lock Platform consumed before Core mined it, stored under its transaction id until -/// a Core block that holds it is read. Its credits count as a credit inflow only from then, -/// dated by that Core block, like those of an asset lock Core had already mined. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PendingAssetLockCreditInflow { - /// The credits the asset lock minted into Platform. - pub amount: Credits, - /// The block time the credits were minted at, in milliseconds. Of several mints of one - /// asset lock, the earliest: the daily withdrawal limit counts an inflow only when it was - /// recorded after its day-old base snapshot, so the earliest is the strictest. - pub recorded_at_time_ms: TimestampMillis, - /// The Core chain locked height of the block the credits were minted in; the entry is - /// dropped once Core is `core_credit_pool_window_max_blocks` past it. - pub recorded_at_core_height: u32, -} - -impl PendingAssetLockCreditInflow { - /// The encoded size: the amount, the time and the Core height, big-endian. - pub const ENCODED_LEN: usize = 8 + 8 + 4; - - /// Encodes the entry as the value of its item. - pub fn to_bytes(&self) -> Vec { - let mut bytes = Vec::with_capacity(Self::ENCODED_LEN); - bytes.extend_from_slice(&self.amount.to_be_bytes()); - bytes.extend_from_slice(&self.recorded_at_time_ms.to_be_bytes()); - bytes.extend_from_slice(&self.recorded_at_core_height.to_be_bytes()); - bytes - } - - /// Decodes the value of an item written by [`Self::to_bytes`]. - pub fn from_bytes(bytes: &[u8]) -> Result { - let corrupted = || { - Error::Drive(DriveError::CorruptedSerialization( - "pending asset lock credit inflow is not 20 bytes".to_string(), - )) - }; - let bytes: &[u8; Self::ENCODED_LEN] = bytes.try_into().map_err(|_| corrupted())?; - let (amount, rest) = bytes.split_at(8); - let (recorded_at_time_ms, recorded_at_core_height) = rest.split_at(8); - Ok(Self { - amount: u64::from_be_bytes(amount.try_into().map_err(|_| corrupted())?), - recorded_at_time_ms: u64::from_be_bytes( - recorded_at_time_ms.try_into().map_err(|_| corrupted())?, - ), - recorded_at_core_height: u32::from_be_bytes( - recorded_at_core_height - .try_into() - .map_err(|_| corrupted())?, - ), - }) - } -} - -/// The key of a Core-dated credit inflow entry: the Core height it stops counting at, then the -/// block time it was recorded at, both big-endian, so a range from a height selects every entry -/// still counting there. -pub fn core_dated_credit_inflow_key( - expires_at_core_height: u32, - recorded_at_time_ms: TimestampMillis, -) -> Vec { - let mut key = Vec::with_capacity(12); - key.extend_from_slice(&expires_at_core_height.to_be_bytes()); - key.extend_from_slice(&recorded_at_time_ms.to_be_bytes()); - key -} - -/// Splits a key written by [`core_dated_credit_inflow_key`] into the Core height the entry -/// stops counting at and the block time it was recorded at. -pub fn decode_core_dated_credit_inflow_key(key: &[u8]) -> Result<(u32, TimestampMillis), Error> { - let corrupted = || { - Error::Drive(DriveError::CorruptedSerialization( - "core-dated credit inflow key is not 12 bytes".to_string(), - )) - }; - if key.len() != 12 { - return Err(corrupted()); - } - let (expires_at_core_height, recorded_at_time_ms) = key.split_at(4); - Ok(( - u32::from_be_bytes(expires_at_core_height.try_into().map_err(|_| corrupted())?), - u64::from_be_bytes(recorded_at_time_ms.try_into().map_err(|_| corrupted())?), - )) -} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/paths.rs b/packages/rs-drive/src/drive/identity/withdrawals/paths.rs index 5a6d286c124..967efd2ce29 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/paths.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/paths.rs @@ -1,7 +1,8 @@ use crate::drive::{Drive, RootTree}; +use crate::error::Error; use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; use crate::util::batch::GroveDbOpBatch; -use grovedb::Element; +use grovedb::{Element, TransactionArg}; use platform_version::version::PlatformVersion; /// constant key for transaction counter @@ -27,19 +28,6 @@ pub const WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY: [u8; 1] = [5]; /// big-endian). The Core-anchored withdrawal limit reads the balance at the chain locked height /// and at the start of Core's unlock window. Exists from protocol version 14. pub const WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY: [u8; 1] = [6]; -/// constant id for the subtree of asset locks Platform consumed before Core mined them (key: -/// the asset lock transaction id; value: a [`PendingAssetLockCreditInflow`]). Their credits -/// count as an inflow only once a Core block that holds them is read, dated by that block. -/// Exists from protocol version 14. -/// -/// [`PendingAssetLockCreditInflow`]: crate::drive::identity::withdrawals::PendingAssetLockCreditInflow -pub const WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY: [u8; 1] = [7]; -/// constant id for the sum tree of credit inflows from asset locks, dated by the Core block that -/// mined them (key: the Core height the entry stops counting at, big-endian, then the block time -/// in milliseconds it was recorded at, big-endian; value: credits, as a sum item). The daily -/// withdrawal limit adds the unexpired entries recorded after its day-old base snapshot to the -/// daily maximum. Exists from protocol version 14. -pub const WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY: [u8; 1] = [8]; impl Drive { /// Add operations for creating initial withdrawal state structure @@ -68,29 +56,40 @@ impl Drive { WITHDRAWAL_TRANSACTIONS_BROADCASTED_KEY.to_vec(), ); } + } - if platform_version.protocol_version >= 14 { - batch.add_insert_empty_tree( - vec![vec![RootTree::WithdrawalTransactions as u8]], - WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY.to_vec(), - ); - batch.add_insert_empty_sum_tree( - vec![vec![RootTree::WithdrawalTransactions as u8]], - WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY.to_vec(), - ); - batch.add_insert_empty_tree( - vec![vec![RootTree::WithdrawalTransactions as u8]], - WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY.to_vec(), - ); - batch.add_insert_empty_tree( - vec![vec![RootTree::WithdrawalTransactions as u8]], - WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY.to_vec(), - ); - batch.add_insert_empty_sum_tree( - vec![vec![RootTree::WithdrawalTransactions as u8]], - WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY.to_vec(), - ); + /// Inserts the withdrawal limit trees of protocol version 14 under the withdrawals tree, + /// one after the other: the total credits history, the credit inflows sum tree and the + /// Core credit pool balances. Genesis (`create_initial_state_structure` 4, after its batch) + /// and the upgrade (`Platform::transition_to_version_14`) both call it, so the withdrawals + /// Merk is built by the same sequence of inserts on both node populations: adding the + /// trees to the genesis batch would root it at another key than the upgrade does. + pub fn insert_withdrawal_limit_trees( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + for (key, tree) in [ + (WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY, Element::empty_tree()), + ( + WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, + Element::empty_sum_tree(), + ), + ( + WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, + Element::empty_tree(), + ), + ] { + self.grove_insert_if_not_exists( + (&get_withdrawal_root_path()).into(), + &key, + tree, + transaction, + None, + &platform_version.drive, + )?; } + Ok(()) } } @@ -199,35 +198,3 @@ pub fn get_withdrawal_core_credit_pool_balances_path() -> [&'static [u8]; 2] { &WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, ] } - -/// Helper function to get the pending asset lock inflows path as Vec -pub fn get_withdrawal_pending_asset_lock_inflows_path_vec() -> Vec> { - vec![ - vec![RootTree::WithdrawalTransactions as u8], - WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY.to_vec(), - ] -} - -/// Helper function to get the pending asset lock inflows path as [u8] -pub fn get_withdrawal_pending_asset_lock_inflows_path() -> [&'static [u8]; 2] { - [ - Into::<&[u8; 1]>::into(RootTree::WithdrawalTransactions), - &WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, - ] -} - -/// Helper function to get the Core-dated credit inflows sum tree path as Vec -pub fn get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec() -> Vec> { - vec![ - vec![RootTree::WithdrawalTransactions as u8], - WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY.to_vec(), - ] -} - -/// Helper function to get the Core-dated credit inflows sum tree path as [u8] -pub fn get_withdrawal_core_dated_credit_inflows_sum_tree_path() -> [&'static [u8]; 2] { - [ - Into::<&[u8; 1]>::into(RootTree::WithdrawalTransactions), - &WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, - ] -} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/mod.rs deleted file mode 100644 index ae972ee375c..00000000000 --- a/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/mod.rs +++ /dev/null @@ -1,71 +0,0 @@ -mod v0; - -use crate::drive::Drive; -use crate::error::drive::DriveError; -use crate::error::Error; -use dpp::block::block_info::BlockInfo; -use dpp::fee::Credits; -use grovedb::TransactionArg; -use platform_version::version::PlatformVersion; - -impl Drive { - /// Records the credits an asset lock minted into Platform as a credit inflow dated by the - /// Core block that mined it, the way Core counts it, rather than by the Platform block that - /// consumed it. An asset lock Core mined so long ago that it left the window - /// (`core_credit_pool_window_min_blocks`) records nothing: a lock published late adds no - /// budget Core does not grant. One Core has not mined at or below the block's chain locked - /// height is recorded as pending, and counts once a Core block holding it is read - /// (`record_core_credit_pool_block`). - /// - /// # Parameters - /// - /// * `asset_lock_txid`: The id of the asset lock transaction. - /// * `amount`: The credits it minted in this block. - /// * `mined_at_core_height`: The height of the Core block that mined it, when that is at or - /// below the block's chain locked height; `None` otherwise. - /// * `block_info`: The Platform block being executed. - /// * `transaction`: The GroveDB transaction. - /// * `platform_version`: The platform version. - /// - /// # Returns - /// - /// * `Ok(())` once the inflow is recorded (dated or pending), or at once when `amount` is - /// zero or the inflow is already out of the window. - /// * `Err(Error)` when the method version is unknown or not active, a stored entry is - /// corrupted, or the write fails. - pub fn record_asset_lock_credit_inflow( - &self, - asset_lock_txid: [u8; 32], - amount: Credits, - mined_at_core_height: Option, - block_info: &BlockInfo, - transaction: TransactionArg, - platform_version: &PlatformVersion, - ) -> Result<(), Error> { - match platform_version - .drive - .methods - .identity - .withdrawals - .record_asset_lock_credit_inflow - { - Some(0) => self.record_asset_lock_credit_inflow_v0( - asset_lock_txid, - amount, - mined_at_core_height, - block_info, - transaction, - platform_version, - ), - Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { - method: "record_asset_lock_credit_inflow".to_string(), - known_versions: vec![0], - received: version, - })), - None => Err(Error::Drive(DriveError::VersionNotActive { - method: "record_asset_lock_credit_inflow".to_string(), - known_versions: vec![0], - })), - } - } -} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/v0/mod.rs deleted file mode 100644 index 74804afd5f5..00000000000 --- a/packages/rs-drive/src/drive/identity/withdrawals/record_asset_lock_credit_inflow/v0/mod.rs +++ /dev/null @@ -1,199 +0,0 @@ -use crate::drive::identity::withdrawals::paths::{ - get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec, - get_withdrawal_pending_asset_lock_inflows_path, - get_withdrawal_pending_asset_lock_inflows_path_vec, -}; -use crate::drive::identity::withdrawals::{ - core_dated_credit_inflow_key, PendingAssetLockCreditInflow, -}; -use crate::drive::Drive; -use crate::error::drive::DriveError; -use crate::error::Error; -use crate::util::grove_operations::{BatchInsertApplyType, DirectQueryType}; -use crate::util::object_size_info::PathKeyElementInfo; -use dpp::block::block_info::BlockInfo; -use dpp::fee::{Credits, SignedCredits}; -use grovedb::{Element, TransactionArg}; -use platform_version::version::PlatformVersion; - -impl Drive { - pub(super) fn record_asset_lock_credit_inflow_v0( - &self, - asset_lock_txid: [u8; 32], - amount: Credits, - mined_at_core_height: Option, - block_info: &BlockInfo, - transaction: TransactionArg, - platform_version: &PlatformVersion, - ) -> Result<(), Error> { - if amount == 0 { - return Ok(()); - } - - let mut drive_operations = vec![]; - - match mined_at_core_height { - Some(mined_at_core_height) => { - let window_min_blocks = platform_version - .system_limits - .core_credit_pool_window_min_blocks - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "record_asset_lock_credit_inflow v0 requires system_limits.core_credit_pool_window_min_blocks", - )))?; - - let expires_at_core_height = mined_at_core_height.saturating_add(window_min_blocks); - if expires_at_core_height <= block_info.core_height { - // Core mined it a whole window ago: its credits sit in the balance Core's - // limit starts from, and adding them again would grant budget Core does not. - return Ok(()); - } - - let amount = SignedCredits::try_from(amount).map_err(|_| { - Error::Drive(DriveError::CriticalCorruptedState( - "core-dated credit inflow does not fit a sum item", - )) - })?; - - self.batch_insert_sum_item_or_add_to_if_already_exists( - PathKeyElementInfo::PathKeyElement::<0>(( - get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec(), - core_dated_credit_inflow_key(expires_at_core_height, block_info.time_ms), - Element::SumItem(amount, None), - )), - BatchInsertApplyType::StatefulBatchInsert, - transaction, - &mut drive_operations, - &platform_version.drive, - )?; - } - None => { - let pending_path = get_withdrawal_pending_asset_lock_inflows_path(); - - // Another mint of the same asset lock may already wait (a partly used asset - // lock): add to it, keeping when it was first recorded. - let pending = match self.grove_get_raw_optional( - (&pending_path).into(), - &asset_lock_txid, - DirectQueryType::StatefulDirectQuery, - transaction, - &mut vec![], - &platform_version.drive, - )? { - Some(Element::Item(value, _)) => { - let mut pending = PendingAssetLockCreditInflow::from_bytes(&value)?; - pending.amount = pending.amount.checked_add(amount).ok_or(Error::Drive( - DriveError::CriticalCorruptedState( - "pending asset lock credit inflow overflow", - ), - ))?; - pending - } - Some(_) => { - return Err(Error::Drive(DriveError::CorruptedElementType( - "pending asset lock credit inflow is not an item", - ))) - } - None => PendingAssetLockCreditInflow { - amount, - recorded_at_time_ms: block_info.time_ms, - recorded_at_core_height: block_info.core_height, - }, - }; - - self.batch_insert( - PathKeyElementInfo::PathKeyElement::<0>(( - get_withdrawal_pending_asset_lock_inflows_path_vec(), - asset_lock_txid.to_vec(), - Element::new_item(pending.to_bytes()), - )), - &mut drive_operations, - &platform_version.drive, - )?; - } - } - - self.apply_batch_low_level_drive_operations( - None, - transaction, - drive_operations, - &mut vec![], - &platform_version.drive, - )?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use crate::drive::identity::withdrawals::paths::get_withdrawal_pending_asset_lock_inflows_path; - use crate::drive::identity::withdrawals::PendingAssetLockCreditInflow; - use crate::util::grove_operations::DirectQueryType; - use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; - use dpp::block::block_info::BlockInfo; - use dpp::version::PlatformVersion; - use grovedb::Element; - - #[test] - fn should_add_a_second_mint_to_a_pending_asset_lock_and_keep_when_it_was_first_recorded() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - let transaction = drive.grove.start_transaction(); - - for (time_ms, core_height, amount) in - [(1_000u64, 50u32, 300u64), (2_000, 51, 200), (3_000, 52, 0)] - { - drive - .record_asset_lock_credit_inflow( - [5; 32], - amount, - None, - &BlockInfo { - time_ms, - core_height, - ..Default::default() - }, - Some(&transaction), - platform_version, - ) - .expect("expected to record the inflow"); - } - - let element = drive - .grove_get_raw_optional( - (&get_withdrawal_pending_asset_lock_inflows_path()).into(), - &[5; 32], - DirectQueryType::StatefulDirectQuery, - Some(&transaction), - &mut vec![], - &platform_version.drive, - ) - .expect("expected to read") - .expect("expected the pending entry"); - let Element::Item(value, _) = element else { - panic!("expected an item"); - }; - assert_eq!( - PendingAssetLockCreditInflow::from_bytes(&value).expect("expected to decode"), - PendingAssetLockCreditInflow { - amount: 500, - recorded_at_time_ms: 1_000, - recorded_at_core_height: 50, - } - ); - } - - #[test] - fn should_round_trip_a_pending_entry_and_refuse_other_lengths() { - let pending = PendingAssetLockCreditInflow { - amount: u64::MAX, - recorded_at_time_ms: 7, - recorded_at_core_height: u32::MAX, - }; - assert_eq!( - PendingAssetLockCreditInflow::from_bytes(&pending.to_bytes()).expect("decodes"), - pending - ); - assert!(PendingAssetLockCreditInflow::from_bytes(&[0; 19]).is_err()); - } -} diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/mod.rs index 2963c8f327e..b1f81350a86 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/mod.rs @@ -3,41 +3,29 @@ mod v0; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; -use dpp::block::block_info::BlockInfo; use dpp::fee::Credits; use grovedb::TransactionArg; use platform_version::version::PlatformVersion; impl Drive { - /// Records what a Core block tells the Core-anchored withdrawal limit: Core's credit pool - /// balance after it, and which of the asset locks Platform consumed before Core mined them - /// it holds. The balance is stored under the block's height; each such asset lock leaves the - /// pending tree and, while the block is still inside the window - /// (`core_credit_pool_window_min_blocks` past it), its credits are recorded as a credit - /// inflow dated by this block. + /// Records Core's credit pool balance after a Core block, under the block's height, for the + /// Core-anchored withdrawal limit. /// /// # Parameters /// /// * `core_height`: The height of the Core block. /// * `credit_pool_balance`: Core's credit pool balance after the block, in credits. - /// * `asset_lock_txids`: The ids of the asset lock transactions the block holds. - /// * `block_info`: The Platform block being executed; its Core height decides whether a - /// dated inflow still counts, and its time is not used (a resolved entry keeps the time - /// its credits were minted at). /// * `transaction`: The GroveDB transaction. /// * `platform_version`: The platform version. /// /// # Returns /// - /// * `Ok(())` once the balance is stored and the pending asset locks of the block resolved. - /// * `Err(Error)` when the method version is unknown or not active, a stored entry is - /// corrupted, or the write fails. + /// * `Ok(())` once the balance is stored. + /// * `Err(Error)` when the method version is unknown or not active, or the write fails. pub fn record_core_credit_pool_block( &self, core_height: u32, credit_pool_balance: Credits, - asset_lock_txids: &[[u8; 32]], - block_info: &BlockInfo, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<(), Error> { @@ -51,8 +39,6 @@ impl Drive { Some(0) => self.record_core_credit_pool_block_v0( core_height, credit_pool_balance, - asset_lock_txids, - block_info, transaction, platform_version, ), diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/v0/mod.rs index 98132c73f74..e7df2c90ffd 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/v0/mod.rs @@ -1,39 +1,19 @@ -use crate::drive::identity::withdrawals::paths::{ - get_withdrawal_core_credit_pool_balances_path_vec, - get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec, - get_withdrawal_pending_asset_lock_inflows_path, -}; -use crate::drive::identity::withdrawals::{ - core_dated_credit_inflow_key, PendingAssetLockCreditInflow, -}; +use crate::drive::identity::withdrawals::paths::get_withdrawal_core_credit_pool_balances_path_vec; use crate::drive::Drive; -use crate::error::drive::DriveError; use crate::error::Error; -use crate::util::grove_operations::{BatchDeleteApplyType, BatchInsertApplyType, DirectQueryType}; use crate::util::object_size_info::PathKeyElementInfo; -use dpp::block::block_info::BlockInfo; -use dpp::fee::{Credits, SignedCredits}; -use grovedb::{Element, MaybeTree, TransactionArg}; +use dpp::fee::Credits; +use grovedb::{Element, TransactionArg}; use platform_version::version::PlatformVersion; -use std::collections::BTreeMap; impl Drive { pub(super) fn record_core_credit_pool_block_v0( &self, core_height: u32, credit_pool_balance: Credits, - asset_lock_txids: &[[u8; 32]], - block_info: &BlockInfo, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<(), Error> { - let window_min_blocks = platform_version - .system_limits - .core_credit_pool_window_min_blocks - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "record_core_credit_pool_block v0 requires system_limits.core_credit_pool_window_min_blocks", - )))?; - let mut drive_operations = vec![]; self.batch_insert( @@ -46,85 +26,6 @@ impl Drive { &platform_version.drive, )?; - // Core counts an asset lock in full for its window after the block that mined it, so - // the credits of one Platform consumed early count from this block, like those of one - // Core had mined already; past the window they no longer count at all. - let expires_at_core_height = core_height.saturating_add(window_min_blocks); - let still_counts = expires_at_core_height > block_info.core_height; - - let pending_path = get_withdrawal_pending_asset_lock_inflows_path(); - - // Summed per key first: two asset locks minted in one Platform block and mined in one - // Core block share a key, and the add-or-insert below reads the stored value, not the - // operations queued in this batch. - let mut dated_inflows: BTreeMap, Credits> = BTreeMap::new(); - - for txid in asset_lock_txids { - let Some(element) = self.grove_get_raw_optional( - (&pending_path).into(), - txid, - DirectQueryType::StatefulDirectQuery, - transaction, - &mut vec![], - &platform_version.drive, - )? - else { - // Not consumed by Platform yet, or consumed after Core mined it (then it was - // dated when it was consumed). - continue; - }; - - let Element::Item(value, _) = element else { - return Err(Error::Drive(DriveError::CorruptedElementType( - "pending asset lock credit inflow is not an item", - ))); - }; - let pending = PendingAssetLockCreditInflow::from_bytes(&value)?; - - self.batch_delete( - (&pending_path).into(), - txid, - BatchDeleteApplyType::StatefulBatchDelete { - is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), - }, - transaction, - &mut drive_operations, - &platform_version.drive, - )?; - - if still_counts { - let total = dated_inflows - .entry(core_dated_credit_inflow_key( - expires_at_core_height, - pending.recorded_at_time_ms, - )) - .or_default(); - *total = total.checked_add(pending.amount).ok_or(Error::Drive( - DriveError::CriticalCorruptedState("core-dated credit inflows overflow"), - ))?; - } - } - - let dated_path = get_withdrawal_core_dated_credit_inflows_sum_tree_path_vec(); - for (key, amount) in dated_inflows { - let amount = SignedCredits::try_from(amount).map_err(|_| { - Error::Drive(DriveError::CriticalCorruptedState( - "core-dated credit inflow does not fit a sum item", - )) - })?; - self.batch_insert_sum_item_or_add_to_if_already_exists( - PathKeyElementInfo::PathKeyElement::<0>(( - dated_path.clone(), - key, - Element::SumItem(amount, None), - )), - BatchInsertApplyType::StatefulBatchInsert, - transaction, - &mut drive_operations, - &platform_version.drive, - )?; - } - self.apply_batch_low_level_drive_operations( None, transaction, diff --git a/packages/rs-drive/src/drive/identity/withdrawals/structure.rs b/packages/rs-drive/src/drive/identity/withdrawals/structure.rs index 856e2fae424..6e74d294c70 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/structure.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/structure.rs @@ -1,6 +1,5 @@ use crate::drive::identity::withdrawals::paths::{ - WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, - WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, + WITHDRAWAL_CORE_CREDIT_POOL_BALANCES_KEY, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, WITHDRAWAL_TOTAL_CREDITS_HISTORY_KEY, WITHDRAWAL_TRANSACTIONS_BROADCASTED_KEY, WITHDRAWAL_TRANSACTIONS_NEXT_INDEX_KEY, WITHDRAWAL_TRANSACTIONS_QUEUE_KEY, WITHDRAWAL_TRANSACTIONS_SUM_AMOUNT_TREE_KEY, @@ -185,56 +184,5 @@ pub(crate) fn structure() -> StructureNode { .value("credits, u64 big endian") .describe("The credit pool balance after that Core block."), ), - StructureNode::fixed( - "pending_asset_lock_inflows", - &WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY, - "PendingAssetLockInflows", - "WITHDRAWAL_PENDING_ASSET_LOCK_INFLOWS_KEY", - ) - .kind(ElementKind::Tree) - .since(14) - .source("packages/rs-drive/src/drive/identity/withdrawals/paths.rs") - .describe( - "Asset locks consumed before Core mined them, \ - waiting to be dated by the Core block that does.", - ) - .child( - StructureNode::dynamic( - "asset_lock", - "txid", - KeyMatcher::Len(32), - KeyEncoding::Hash32, - "The asset lock transaction id", - ) - .kind(ElementKind::Item) - .value("credits u64, block time u64 and Core height u32, big endian") - .describe("The credits the asset lock minted, and the block that minted them first."), - ), - StructureNode::fixed( - "core_dated_credit_inflows", - &WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY, - "CoreDatedCreditInflows", - "WITHDRAWAL_CORE_DATED_CREDIT_INFLOWS_SUM_TREE_KEY", - ) - .kind(ElementKind::SumTree) - .since(14) - .source("packages/rs-drive/src/drive/identity/withdrawals/paths.rs") - .describe( - "Asset lock credit inflows, dated by the Core block \ - that mined them, which raise the relative withdrawal limit.", - ) - .child( - StructureNode::dynamic( - "inflow", - "expiry_and_time", - KeyMatcher::Len(12), - KeyEncoding::Composite, - "The Core height the entry stops counting at (u32 big endian), then the block \ - time in milliseconds it was recorded at (u64 big endian)", - ) - .kind(ElementKind::SumItem) - .value("credits") - .describe("Credits asset locks minted, counting until that Core height."), - ), ]) } diff --git a/packages/rs-drive/src/drive/initialization/v4/mod.rs b/packages/rs-drive/src/drive/initialization/v4/mod.rs index 7e3475fb916..765d8a39b09 100644 --- a/packages/rs-drive/src/drive/initialization/v4/mod.rs +++ b/packages/rs-drive/src/drive/initialization/v4/mod.rs @@ -112,6 +112,13 @@ impl Drive { // upgrade path (`Platform::transition_to_version_14`), in the same position: last. self.insert_document_ttl_trees(transaction, platform_version)?; + // Withdrawal limit trees (protocol version 14): the total credits history, the credit + // inflows and the Core credit pool balances under the withdrawals tree, which the batch + // apply creates. Inserted one after the other through the same helper as the upgrade + // path (`Platform::transition_to_version_14`), so the withdrawals Merk is built by the + // same sequence of inserts on both node populations. + self.insert_withdrawal_limit_trees(transaction, platform_version)?; + Ok(()) } } diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 6eef2ab91aa..b330633a9ef 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -1223,39 +1223,13 @@ mod fixtures { conformance_of(&drive, "address_balances", run); } - /// The Core-anchored withdrawal accounting: a recorded Core credit pool balance, an asset - /// lock waiting for Core to mine it, and one dated by the Core block that mined it. + /// The Core-anchored withdrawal accounting: a recorded Core credit pool balance. fn core_anchored_withdrawal_accounting(run: &mut FixtureRun) { let platform_version = PlatformVersion::latest(); let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let block_info = BlockInfo { - time_ms: 1_000, - core_height: 100, - ..Default::default() - }; drive - .record_core_credit_pool_block(100, 5_000_000, &[], &block_info, None, platform_version) + .record_core_credit_pool_block(100, 5_000_000, None, platform_version) .expect("expected to record a Core block"); - drive - .record_asset_lock_credit_inflow( - [21; 32], - 300_000, - None, - &block_info, - None, - platform_version, - ) - .expect("expected to record a pending asset lock"); - drive - .record_asset_lock_credit_inflow( - [22; 32], - 400_000, - Some(99), - &block_info, - None, - platform_version, - ) - .expect("expected to record a dated asset lock"); conformance_of(&drive, "core_anchored_withdrawal_accounting", run); } diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index dea47f7bddc..e29e79ce454 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -22,7 +22,7 @@ use crate::version::drive_abci_versions::drive_abci_method_versions::{ /// `pool_withdrawals_into_transactions_queue` 2 pools only what also fits the Core-anchored /// withdrawal limit (`calculate_core_anchored_withdrawal_limit`, `Some(0)`), fed by /// `scan_core_blocks_for_withdrawals` (`Some(0)`), which records Core's credit pool balance per -/// Core block and dates consumed asset locks by the Core block that mined them. +/// Core block. /// `decode_raw_state_transitions` 1 refuses bytes left over after a raw state transition. /// `add_distribute_storage_fee_to_epochs_operations` 1 claws each pending storage refund back /// from the epochs it was priced for. @@ -106,7 +106,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet cleanup_expired_locks_of_withdrawal_amounts: 1, // changed in v14: also prunes expired entries of the credit inflows sum tree record_credit_inflows_for_withdrawals: Some(0), // new in v14: the block's credit mints recorded as an inflow for the net daily withdrawal limit record_total_credits_history_for_withdrawals: Some(0), // changed in v14: per-block total credits history for the day-lagged daily withdrawal limit - scan_core_blocks_for_withdrawals: Some(0), // new in v14: Core credit pool balances and Core-dated asset lock inflows + scan_core_blocks_for_withdrawals: Some(0), // new in v14: Core credit pool balances for the Core-anchored withdrawal limit calculate_core_anchored_withdrawal_limit: Some(0), // new in v14: withdrawals also fit a stricter copy of Core's unlock limit }, voting: DriveAbciVotingMethodVersions { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs index 7d27e03965b..ad0abcd0e11 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs @@ -27,12 +27,9 @@ pub struct DriveIdentityWithdrawalMethodVersions { /// tree the net daily withdrawal limit reads back. The subtree exists from protocol /// version 14. pub record_credit_inflows: OptionalFeatureVersion, - /// Record a Core block's credit pool balance and date the consumed asset locks it mined, - /// for the Core-anchored withdrawal limit. The subtrees exist from protocol version 14. + /// Record a Core block's credit pool balance for the Core-anchored withdrawal limit. The + /// subtree exists from protocol version 14. pub record_core_credit_pool_block: OptionalFeatureVersion, - /// Record the credits an asset lock minted as a credit inflow dated by the Core block that - /// mined it, or as pending until Core mines it. Exists from protocol version 14. - pub record_asset_lock_credit_inflow: OptionalFeatureVersion, /// Read the recorded Core credit pool balances. Exists from protocol version 14. pub fetch_core_credit_pool_balances: OptionalFeatureVersion, /// Sum what the queued and broadcast withdrawal transactions take out of Core's credit diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs index bb33e9a4a24..e0f60851cb9 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs @@ -172,7 +172,6 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V1: DriveIdentityMethodVersions = fetch_total_credits_in_platform_a_day_ago: None, record_credit_inflows: None, record_core_credit_pool_block: None, - record_asset_lock_credit_inflow: None, fetch_core_credit_pool_balances: None, fetch_in_flight_withdrawal_amount: None, }, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs index 0282801941f..808d859d7ed 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs @@ -51,9 +51,8 @@ use crate::version::drive_versions::drive_identity_method_versions::{ /// * `withdrawals.calculate_current_withdrawal_limit` 0 -> 1: the daily /// maximum derives from the total credits Platform held a day ago (the /// relative daily withdrawal limit) instead of the current total. The -/// optional `max_daily_withdrawal_amount` cap (unset in v14) applies to that -/// day-old base; credit inflows from the active window are added after it so -/// matching deposit-withdraw cycles do not consume the budget of others. +/// credit inflows from the active window are added on top so matching +/// deposit-withdraw cycles do not consume the budget of others. /// * `withdrawals.record_total_credits_history` and /// `withdrawals.fetch_total_credits_in_platform_a_day_ago` `None -> Some(0)`: /// the per-block total credits history under the withdrawals tree that the @@ -65,15 +64,12 @@ use crate::version::drive_versions::drive_identity_method_versions::{ /// a deposit -> withdraw cycle no longer consumes the budget of other users. /// The subtree does not exist before v14, so V1 keeps the slot `None`. /// * `withdrawals.record_core_credit_pool_block`, -/// `withdrawals.record_asset_lock_credit_inflow`, /// `withdrawals.fetch_core_credit_pool_balances` and /// `withdrawals.fetch_in_flight_withdrawal_amount` `None -> Some(0)`: the -/// Core-anchored withdrawal limit. Core's credit pool balance is recorded per -/// Core block, and an asset lock's credits count as an inflow from the Core -/// block that mined it rather than the Platform block that consumed it; one -/// consumed before Core mined it waits in a pending tree until a scanned Core -/// block holds it. The subtrees do not exist before v14, so V1 keeps the -/// slots `None`. +/// Core-anchored withdrawal limit, which reads Core's credit pool balance as +/// recorded per Core block and what pooled withdrawals take out of the pool +/// once mined. The subtree does not exist before v14, so V1 keeps the slots +/// `None`. pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = DriveIdentityMethodVersions { fetch: DriveIdentityFetchMethodVersions { @@ -232,8 +228,7 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = record_total_credits_history: Some(0), // new in v14: total credits history for the day-lagged daily withdrawal limit fetch_total_credits_in_platform_a_day_ago: Some(0), // new in v14 record_credit_inflows: Some(0), // new in v14: credit inflows sum tree for the net daily withdrawal limit - record_core_credit_pool_block: Some(0), // new in v14: Core credit pool balances and Core-dated asset lock inflows - record_asset_lock_credit_inflow: Some(0), // new in v14: asset lock inflows dated by the Core block that mined them + record_core_credit_pool_block: Some(0), // new in v14: Core credit pool balances for the Core-anchored withdrawal limit fetch_core_credit_pool_balances: Some(0), // new in v14 fetch_in_flight_withdrawal_amount: Some(0), // new in v14 }, 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 c1b3b70e5fc..58803060b53 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -598,11 +598,9 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { retry_signing_expired_withdrawal_documents_per_block_limit: 1, max_withdrawal_amount: 50_000_000_000_000, daily_withdrawal_limit_percent: None, - max_daily_withdrawal_amount: None, core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, - core_credit_pool_window_min_blocks: None, - core_credit_pool_window_max_blocks: None, + core_credit_pool_unlock_mining_delay_blocks: None, min_withdrawal_amount: 190_000, core_dust_relay_fee_per_kb: None, max_core_fee_per_byte: None, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 0aaac38a334..ef5e944a8d4 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -127,41 +127,30 @@ pub struct SystemLimits { /// version 1 applied a flat 2000 Dash. Versioned: see `daily_withdrawal_limit_percent` in /// each `SYSTEM_LIMITS_V*`. pub daily_withdrawal_limit_percent: Option, - /// Optional upper bound (in credits) of the relative daily withdrawal limit - /// (`daily_withdrawal_limit` method version 2). `None` leaves the relative limit uncapped, - /// which is what protocol version 14 does: what Core will mine is bounded instead by the - /// Core-anchored limit (`core_credit_pool_unlock_limit_percent` and the fields after it), - /// read from Core's own credit pool balances. When set it must be at least - /// `max_withdrawal_amount`. `None` too for the protocol versions that predate the relative - /// rule, which never read it. - pub max_daily_withdrawal_amount: Option, /// Allowed drop of Core's credit pool per window, as a percentage of its balance at the - /// window start, in the Core-anchored withdrawal limit of protocol version 14 - /// (`core_credit_pool_unlock_limit` method version 0). Platform pools a withdrawal only - /// while it also fits this limit, a stricter copy of Core v24's own unlock rule (20%, at - /// least 2000 Dash), so it never pools more than Core will mine. `None` for the protocol + /// window start, in the Core-anchored withdrawal limit of protocol version 14, read by + /// `core_credit_pool_unlock_limit` method version 0. Platform pools a withdrawal only while + /// it also fits this limit, a stricter copy of Core v24's own unlock rule (20%, at least + /// 2000 Dash), so it does not pool more than Core will mine. `None` for the protocol /// versions that predate the Core-anchored limit. pub core_credit_pool_unlock_limit_percent: Option, /// Smallest allowed drop (in credits) of Core's credit pool per window in the Core-anchored /// withdrawal limit, applied when `core_credit_pool_unlock_limit_percent` of the window start - /// balance is less. Below Core v24's own 2000 Dash floor, so small pools keep a margin too, - /// and at least `max_withdrawal_amount` so a queued withdrawal always fits eventually. - /// `None` for the protocol versions that predate the Core-anchored limit. + /// balance is less; read by `core_credit_pool_unlock_limit` method version 0. Below Core + /// v24's own 2000 Dash floor, so small pools keep a margin too, and at least + /// `max_withdrawal_amount` so a queued withdrawal always fits eventually. `None` for the + /// protocol versions that predate the Core-anchored limit. pub core_credit_pool_unlock_limit_floor: Option, - /// The nearest window start, in Core blocks before the chain locked height, the - /// Core-anchored withdrawal limit considers; with `core_credit_pool_window_max_blocks` it - /// bounds a band around Core's own 576-block window, and the limit takes the highest pool - /// balance among those window starts. The nearer edge covers the blocks an unlock may take - /// to be mined after it is pooled. An asset lock counts as a credit inflow for this many - /// Core blocks after the block that mined it. `None` for the protocol versions that predate - /// the Core-anchored limit. - pub core_credit_pool_window_min_blocks: Option, - /// The farthest window start, in Core blocks before the chain locked height, the - /// Core-anchored withdrawal limit considers (see `core_credit_pool_window_min_blocks`). - /// Also how many Core blocks an asset lock consumed before Core mined it waits to be dated - /// before it is dropped and never counts as a credit inflow. `None` for the protocol + /// How many Core blocks past the chain locked height a withdrawal pooled at it may still be + /// mined: Core accepts an asset unlock until 48 blocks past the height it is signed at, and + /// it is signed at or after the height it is pooled at. The Core-anchored limit takes the + /// highest credit pool balance among the window starts Core may use for it (Core's window + /// length back from the chain locked height, up to this many blocks later), and an asset lock + /// counts as a credit inflow only while it is younger than Core's window minus this many + /// blocks. Read by `calculate_core_anchored_withdrawal_limit` method version 0 and + /// `record_credit_inflows_for_withdrawals` method version 0. `None` for the protocol /// versions that predate the Core-anchored limit. - pub core_credit_pool_window_max_blocks: Option, + pub core_credit_pool_unlock_mining_delay_blocks: Option, /// Minimum net amount (in credits) a withdrawal may send to Core, shared by the /// transparent (identity + address) and shielded withdrawal paths. The dust floor that /// keeps Core from rejecting the resulting `TxOut`. Versioned: see `min_withdrawal_amount` diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index c77d741cd03..b5a1ed4450d 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -42,11 +42,9 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { retry_signing_expired_withdrawal_documents_per_block_limit: 1, max_withdrawal_amount: 50_000_000_000_000, //500 Dash daily_withdrawal_limit_percent: None, // relative daily withdrawal limit arrives in v14 - max_daily_withdrawal_amount: None, core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, - core_credit_pool_window_min_blocks: None, - core_credit_pool_window_max_blocks: None, + core_credit_pool_unlock_mining_delay_blocks: None, // = dpp MIN_WITHDRAWAL_AMOUNT: ASSET_UNLOCK_TX_SIZE(190) * MIN_CORE_FEE_PER_BYTE(1) // * CREDITS_PER_DUFF(1000) = 190_000 credits = 190 duffs. min_withdrawal_amount: 190_000, diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 25e001584f8..4c7095cd75f 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -25,11 +25,9 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { retry_signing_expired_withdrawal_documents_per_block_limit: 1, max_withdrawal_amount: 50_000_000_000_000, //500 Dash daily_withdrawal_limit_percent: None, // relative daily withdrawal limit arrives in v14 - max_daily_withdrawal_amount: None, core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, - core_credit_pool_window_min_blocks: None, - core_credit_pool_window_max_blocks: None, + core_credit_pool_unlock_mining_delay_blocks: None, min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) core_dust_relay_fee_per_kb: None, // expired dust withdrawals fail from v14 max_core_fee_per_byte: None, diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 279bbcda183..72962a5e51d 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -27,11 +27,9 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { retry_signing_expired_withdrawal_documents_per_block_limit: 1, max_withdrawal_amount: 50_000_000_000_000, //500 Dash daily_withdrawal_limit_percent: None, // relative daily withdrawal limit arrives in v14 - max_daily_withdrawal_amount: None, core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, - core_credit_pool_window_min_blocks: None, - core_credit_pool_window_max_blocks: None, + core_credit_pool_unlock_mining_delay_blocks: None, min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) core_dust_relay_fee_per_kb: None, // expired dust withdrawals fail from v14 max_core_fee_per_byte: None, diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 223753fc949..faa51fb2976 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -23,16 +23,15 @@ use crate::version::system_limits::SystemLimits; /// so Platform pools at most 15% of the total credits it held a day ago into asset unlock /// transactions per 24 hours, never below one maximal withdrawal, instead of the flat 2000 /// Dash that applied from v8 (matching Core v22's `LimitAmountV22`). v13 is already live on -/// networks with the flat limit, so the change gates here. `max_daily_withdrawal_amount` -/// stays `None`: the limit has no fixed cap. +/// networks with the flat limit, so the change gates here. The limit has no fixed cap. /// * Withdrawals also fit a Core-anchored limit, a stricter copy of Core v24's relative net /// unlock rule read from Core's own credit pool balances: the pool may drop by at most /// `core_credit_pool_unlock_limit_percent` (15, Core allows 20) of its highest balance at a -/// window start 552 to 600 Core blocks back (`core_credit_pool_window_min_blocks`, -/// `core_credit_pool_window_max_blocks`; Core's window is 576), at least +/// window start Core may use for the unlock (Core's window back from the chain locked height, +/// up to `core_credit_pool_unlock_mining_delay_blocks`, 48, later), at least /// `core_credit_pool_unlock_limit_floor` (1500 Dash, Core's floor is 2000), less what is -/// pooled and not yet mined. An asset lock counts as a credit inflow for 552 Core blocks -/// after the block that mined it. +/// pooled and not yet mined. An asset lock Core mined longer ago than its window minus those +/// 48 blocks adds no credit inflow. /// * `max_time_range_overlap_factor` is set: a `timeRange` index transform may declare at most /// 24 overlapping windows per timestamp (a day-long window sliding hourly). The rule cannot /// exist before v14 because the `timeRange` keyword itself is only admitted by the v14 @@ -121,12 +120,10 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { retry_signing_expired_withdrawal_documents_per_block_limit: 1, max_withdrawal_amount: 50_000_000_000_000, //500 Dash daily_withdrawal_limit_percent: Some(15), // 15% of the total credits a day ago (replaces the flat 2000 Dash in v14) - max_daily_withdrawal_amount: None, // uncapped: the Core-anchored limit below bounds what Core will mine core_credit_pool_unlock_limit_percent: Some(15), // Core v24 allows 20% of the pool a window ago core_credit_pool_unlock_limit_floor: Some(150_000_000_000_000), // 1500 Dash; Core v24's floor is 2000 Dash - core_credit_pool_window_min_blocks: Some(552), // Core's 576-block window minus 24 blocks for mining delay - core_credit_pool_window_max_blocks: Some(600), // Core's 576-block window plus 24 blocks - min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) + core_credit_pool_unlock_mining_delay_blocks: Some(48), // Core's asset unlock validity past its signing height + min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) core_dust_relay_fee_per_kb: Some(3000), // Core's default dust relay fee: 546-duff P2PKH threshold; expired withdrawals below it fail instead of re-signing max_core_fee_per_byte: Some(6_765), max_group_member_count: 256, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index ac34ab76625..ba8ac30c3a1 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -78,16 +78,15 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `daily_withdrawal_limit` v2 through `DPP_METHOD_VERSIONS_V3`), never below /// one maximal withdrawal (`max_withdrawal_amount`) so every accepted /// withdrawal eventually fits and cannot block the pooling queue. The base has -/// no fixed cap (`max_daily_withdrawal_amount` is `None`): what Core will mine -/// bounds pooling through the Core-anchored limit of note 70 instead. The -/// credit inflows of the active window — every credit mint, recorded per -/// block by `record_credit_inflows_for_withdrawals` — are added to the base, -/// so the limit counts net outflow and a matching deposit -> withdraw cycle -/// does not consume the budget of other users (#4471), mirroring Core v24's -/// net credit-pool rule. Asset lock credits count from the Core block that -/// mined each asset lock, for `core_credit_pool_window_min_blocks` (552) -/// Core blocks, in their own sum tree; the other mints (the epoch Core -/// rewards) count from the block, in the credit inflows sum tree. Both the +/// no fixed cap: what Core will mine bounds pooling through the Core-anchored +/// limit of note 70 instead. The credit inflows of the active window — every +/// credit mint, recorded per block by `record_credit_inflows_for_withdrawals` +/// in the credit inflows sum tree — are added to the base, so the limit +/// counts net outflow and a matching deposit -> withdraw cycle does not +/// consume the budget of other users (#4471), mirroring Core v24's net +/// credit-pool rule. An asset lock Core mined longer ago than its window +/// minus `core_credit_pool_unlock_mining_delay_blocks` adds no inflow: Core +/// no longer counts it in full either. Both the /// inflows and the pooled reservations count over the /// interval after the base snapshot only — an entry the snapshot already /// reflects is neither added nor subtracted again. The base is @@ -1866,32 +1865,30 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// documents in state share one of its entries. Inert for every contract /// without the keyword, which every earlier grammar refuses. /// 70. **Withdrawals also fit a Core-anchored limit**: pooling -/// (`pool_withdrawals_into_transactions_queue` 2) admits withdrawals up to -/// the smaller of the daily withdrawal limit (note 4) and +/// (`pool_withdrawals_into_transactions_queue` 2, which reuses version 1's +/// pooling through a shared helper) admits withdrawals up to the smaller of +/// the daily withdrawal limit (note 4) and /// `calculate_core_anchored_withdrawal_limit`, a stricter copy of Core v24's /// relative net unlock rule (dash#7712) read from Core's own credit pool /// balances at chain locked heights: the pool may drop by at most /// `core_credit_pool_unlock_limit_percent` (15; Core allows 20) of its -/// highest balance at a window start 552 to 600 Core blocks back -/// (`core_credit_pool_window_min_blocks`, `core_credit_pool_window_max_blocks`; -/// Core's window is 576), at least `core_credit_pool_unlock_limit_floor` -/// (1500 Dash; Core's floor is 2000), less what is queued or broadcast and -/// not mined yet (`fetch_in_flight_withdrawal_amount`). The formula is +/// highest balance at a window start Core may use for the unlock (Core's +/// window, 576 blocks or 100 on regtest, back from the chain locked height, +/// up to `core_credit_pool_unlock_mining_delay_blocks`, 48, later), at least +/// `core_credit_pool_unlock_limit_floor` (1500 Dash; Core's floor is 2000), +/// less what is queued or broadcast and not mined yet. The formula is /// `core_credit_pool_unlock_limit` 0 in `DPP_METHOD_VERSIONS_V3`. Before -/// pooling, `scan_core_blocks_for_withdrawals` reads the Core blocks the chain -/// locked height passed (at most `core_blocks_scanned_per_block_limit`, 32, -/// per block) through `CoreRPCLike::get_credit_pool_block`: it records each -/// one's credit pool balance under the withdrawals tree and dates the asset -/// locks Platform consumed before Core mined them, which wait in a pending -/// tree until then (asked of Core once per block through -/// `get_transactions_mined_heights`, `gettxchainlocks`). The Platform-side +/// pooling, `scan_core_blocks_for_withdrawals` reads the Core blocks the +/// chain locked height passed (at most `core_blocks_scanned_per_block_limit`, +/// 32, per block) and records each one's credit pool balance, read from the +/// coinbase of the raw block, under the withdrawals tree. The Platform-side /// accounting can grant more than Core will mine (an asset lock published to -/// Platform long after Core mined it, a whole epoch of Core rewards minted in -/// one block); over Core's limit an unlock waits unmined and is re-signed, -/// and while Core's mempool holds more than the limit Core InstantSend-locks -/// no withdrawal at all. The trees are created at genesis and by +/// Platform after Core mined it, a whole epoch of Core rewards minted in one +/// block); over Core's limit an unlock waits unmined and is re-signed, and +/// while Core's mempool holds more than the limit Core InstantSend-locks no +/// withdrawal at all. The balance tree is created at genesis and by /// `transition_to_version_14`, and `cleanup_expired_locks_of_withdrawal_amounts` -/// 1 prunes them by Core height. +/// 1 prunes it by Core height. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by From 0a0adb12c20f1b08ae21d15cd4033ec048f0b800 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 1 Oct 2026 12:19:58 +0700 Subject: [PATCH 3/7] test(dashmate): expect the config format version this build produces The Tenderdash image migration test hardcoded 4.2.0, the newest migration while it was ahead of the package version. Since the 5.0.0-beta.1 bump the package version is the target, so the test failed on v5.0-dev. Co-Authored-By: Claude Opus 5.5 --- .../unit/config/configFile/tenderdashImageMigration.spec.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/dashmate/test/unit/config/configFile/tenderdashImageMigration.spec.js b/packages/dashmate/test/unit/config/configFile/tenderdashImageMigration.spec.js index 300e666ca85..c93aa55fa9e 100644 --- a/packages/dashmate/test/unit/config/configFile/tenderdashImageMigration.spec.js +++ b/packages/dashmate/test/unit/config/configFile/tenderdashImageMigration.spec.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import getBaseConfigFactory from '../../../../configs/defaults/getBaseConfigFactory.js'; import getConfigFileMigrationsFactory from '../../../../configs/getConfigFileMigrationsFactory.js'; +import getConfigFormatVersion from '../../../../src/config/configFile/getConfigFormatVersion.js'; import migrateConfigFileFactory from '../../../../src/config/configFile/migrateConfigFileFactory.js'; import { PACKAGE_ROOT_DIR } from '../../../../src/constants.js'; @@ -38,7 +39,10 @@ describe('Tenderdash image migration', () => { expect(migrated.configs.withoutDocker).to.deep.equal({ platform: { drive: { tenderdash: {} } }, }); - expect(migrated.configFormatVersion).to.equal('4.2.0'); + // Stamped with the format this build produces: the newest migration while it is ahead + // of the package version, the package version once a release has passed it. + expect(migrated.configFormatVersion) + .to.equal(getConfigFormatVersion(getMigrations(), version)); expect(migrateConfigFile(migrated, migrated.configFormatVersion, version)).to.equal(migrated); }); } From 75ca5cd6f2775118283209ed82ec3af3b282b4e7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 1 Oct 2026 16:34:07 +0700 Subject: [PATCH 4/7] fix(platform)!: pin Core's credit pool window in the PV14 system limits - `core_credit_pool_window_blocks` (576) and `regtest_core_credit_pool_window_blocks` (100) join SYSTEM_LIMITS_V4 (None before); dpp's `core_credit_pool_window_blocks(network, platform_version)` reads them, so admission, the scan, inflow recording and cleanup take the window from the active protocol version instead of an unversioned per-network constant. - process_raw_state_transitions v0 collects each transition's mints apart and merges them into the block's unless a rollback drops the transition, instead of copying the block's accumulator before every transition (in place, inert: same saturating total). - The raw coinbase reader is crate-private. Co-Authored-By: Claude Opus 5.5 --- book/src/versioning/feature-versions.md | 2 + .../core_credit_pool_unlock_limit/mod.rs | 58 +++++++++++++------ .../process_raw_state_transitions/v0/mod.rs | 24 ++++---- .../v0/mod.rs | 4 +- .../v1/mod.rs | 12 ++-- .../v0/mod.rs | 6 +- .../v0/mod.rs | 7 ++- packages/rs-drive-abci/src/rpc/core.rs | 2 +- .../src/version/mocks/v2_test.rs | 2 + .../src/version/system_limits/mod.rs | 12 ++++ .../src/version/system_limits/v1.rs | 2 + .../src/version/system_limits/v2.rs | 2 + .../src/version/system_limits/v3.rs | 2 + .../src/version/system_limits/v4.rs | 7 ++- .../rs-platform-version/src/version/v14.rs | 5 +- 15 files changed, 98 insertions(+), 49 deletions(-) diff --git a/book/src/versioning/feature-versions.md b/book/src/versioning/feature-versions.md index 843e9208676..20da656f953 100644 --- a/book/src/versioning/feature-versions.md +++ b/book/src/versioning/feature-versions.md @@ -335,6 +335,8 @@ pub struct SystemLimits { pub core_credit_pool_unlock_limit_percent: Option, pub core_credit_pool_unlock_limit_floor: Option, pub core_credit_pool_unlock_mining_delay_blocks: Option, + pub core_credit_pool_window_blocks: Option, + pub regtest_core_credit_pool_window_blocks: Option, pub min_withdrawal_amount: u64, pub max_contract_group_size: u16, pub max_token_redemption_cycles: u32, diff --git a/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs b/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs index 205dd58b7d6..e5b40f96c55 100644 --- a/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs +++ b/packages/rs-dpp/src/withdrawal/core_credit_pool_unlock_limit/mod.rs @@ -5,22 +5,31 @@ use platform_version::version::PlatformVersion; mod v0; -/// Core's credit pool window: how many Core blocks before an asset unlock's block lies the -/// balance Core v24 measures the unlock limit from (`CreditPoolPeriodBlocks` in Dash Core's -/// chain parameters). -pub trait NetworkCoreCreditPoolWindow { - fn core_credit_pool_window_blocks(&self) -> u32; -} - -impl NetworkCoreCreditPoolWindow for Network { - fn core_credit_pool_window_blocks(&self) -> u32 { - match self { - Network::Mainnet => 576, - Network::Testnet => 576, - Network::Devnet => 576, - Network::Regtest => 100, +/// Core's credit pool window on `network`: how many Core blocks before an asset unlock's block +/// lies the balance Core v24 measures the unlock limit from (`CreditPoolPeriodBlocks` in Dash +/// Core's chain parameters), as the protocol version's system limits pin it +/// (`core_credit_pool_window_blocks`, `regtest_core_credit_pool_window_blocks` on regtest). +/// +/// # Errors +/// +/// `ProtocolError::CorruptedCodeExecution` when the protocol version predates the +/// Core-anchored withdrawal limit and sets no window. +pub fn core_credit_pool_window_blocks( + network: Network, + platform_version: &PlatformVersion, +) -> Result { + let system_limits = &platform_version.system_limits; + let window_blocks = match network { + Network::Mainnet | Network::Testnet | Network::Devnet => { + system_limits.core_credit_pool_window_blocks } - } + Network::Regtest => system_limits.regtest_core_credit_pool_window_blocks, + }; + window_blocks.ok_or_else(|| { + ProtocolError::CorruptedCodeExecution( + "the protocol version sets no Core credit pool window".to_string(), + ) + }) } /// Returns how much Core's credit pool may still give up to asset unlocks, given its balance @@ -72,10 +81,21 @@ mod tests { #[test] fn should_use_cores_window_of_each_network() { - assert_eq!(Network::Mainnet.core_credit_pool_window_blocks(), 576); - assert_eq!(Network::Testnet.core_credit_pool_window_blocks(), 576); - assert_eq!(Network::Devnet.core_credit_pool_window_blocks(), 576); - assert_eq!(Network::Regtest.core_credit_pool_window_blocks(), 100); + let v14 = PlatformVersion::get(14).expect("expected protocol version 14"); + for (network, window_blocks) in [ + (Network::Mainnet, 576), + (Network::Testnet, 576), + (Network::Devnet, 576), + (Network::Regtest, 100), + ] { + assert_eq!( + core_credit_pool_window_blocks(network, v14).expect("expected the window"), + window_blocks + ); + } + + let v13 = PlatformVersion::get(13).expect("expected protocol version 13"); + assert!(core_credit_pool_window_blocks(Network::Mainnet, v13).is_err()); } #[test] diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs index 7d6c2889fe7..693350dfadd 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs @@ -184,15 +184,17 @@ where } // Mark the state we can return to if this transition's result strips - // it from the block (see `rollback_dropped_transitions` above). The - // mint accumulator mirrors applied state, so it rewinds with it. + // it from the block (see `rollback_dropped_transitions` above). if rollback_dropped_transitions { transaction.set_savepoint(); } - // In place and inert: the savepoint is only restored when - // `rollback_dropped_transitions` holds, so it is only taken then. - let credit_mints_at_savepoint = - rollback_dropped_transitions.then(|| block_credit_mints.clone()); + // This transition's mints, merged into the block's below unless a + // rollback drops its writes: the mint accumulator mirrors applied + // state. Changed in place from a snapshot of the block's total, inert + // for protocol versions 1 to 14: merging one transition's saturating + // sum adds up to the same saturating total, and a dropped transition + // contributes nothing either way. + let mut transition_credit_mints = BlockCreditMints::default(); // Validate state transition and produce an execution event let execution_result = process_state_transition( @@ -211,7 +213,7 @@ where validation_result, block_info, transaction, - &mut block_credit_mints, + &mut transition_credit_mints, platform_version, platform_ref.state.previous_fee_versions(), ) @@ -244,11 +246,7 @@ where // its mints with them, or the block would record a // credit inflow for a transition the proposal omits and // validators re-executing it would compute other state. - if let Some(credit_mints_at_savepoint) = - credit_mints_at_savepoint - { - block_credit_mints = credit_mints_at_savepoint; - } + transition_credit_mints = BlockCreditMints::default(); // Any contract the transition rewrote was re-seeded into // the block cache as it was applied, and the rollback // just reverted it in state. Drop those copies so the @@ -282,6 +280,8 @@ where } } + block_credit_mints.add(transition_credit_mints); + // Store metrics let elapsed_time = start_time.elapsed() + decoding_elapsed_time; diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs index e14acd71342..0e57855236d 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs @@ -8,7 +8,7 @@ use dpp::fee::Credits; use dpp::identity::convert_duffs_to_credits; use dpp::version::PlatformVersion; use dpp::withdrawal::core_credit_pool_unlock_limit::{ - core_credit_pool_unlock_limit, NetworkCoreCreditPoolWindow, + core_credit_pool_unlock_limit, core_credit_pool_window_blocks, }; use dpp::withdrawal::WithdrawalTransactionIndex; use drive::grovedb::TransactionArg; @@ -32,7 +32,7 @@ where .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( "calculate_core_anchored_withdrawal_limit v0 requires system_limits.core_credit_pool_unlock_mining_delay_blocks", )))?; - let window_blocks = self.config.network.core_credit_pool_window_blocks(); + let window_blocks = core_credit_pool_window_blocks(self.config.network, platform_version)?; let chain_locked_height = block_info.core_height; diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs index e77b928abae..6d5fe70ccce 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs @@ -5,7 +5,7 @@ use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; use dpp::version::PlatformVersion; -use dpp::withdrawal::core_credit_pool_unlock_limit::NetworkCoreCreditPoolWindow; +use dpp::withdrawal::core_credit_pool_unlock_limit::core_credit_pool_window_blocks; use drive::drive::identity::withdrawals::paths::{ get_withdrawal_core_credit_pool_balances_path_vec, get_withdrawal_credit_inflows_sum_tree_path_vec, get_withdrawal_transactions_sum_tree_path_vec, @@ -67,9 +67,13 @@ where } // The Core-anchored limit never reads a balance older than its farthest window start. - if let Some(oldest_read_height) = block_info - .core_height - .checked_sub(self.config.network.core_credit_pool_window_blocks()) + if let Some(oldest_read_height) = + block_info + .core_height + .checked_sub(core_credit_pool_window_blocks( + self.config.network, + platform_version, + )?) { let mut path_query = PathQuery::new_single_query_item( get_withdrawal_core_credit_pool_balances_path_vec(), diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs index 2c6cce3303c..2e00acca058 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs @@ -8,7 +8,7 @@ use dpp::dashcore::hashes::Hash; use dpp::dashcore::Txid; use dpp::fee::Credits; use dpp::version::PlatformVersion; -use dpp::withdrawal::core_credit_pool_unlock_limit::NetworkCoreCreditPoolWindow; +use dpp::withdrawal::core_credit_pool_unlock_limit::core_credit_pool_window_blocks; use drive::grovedb::Transaction; impl Platform @@ -51,9 +51,7 @@ where // Mined at or below this height, Core already counts the asset lock in the window // start balance of an unlock pooled now (or soon will). let stale_at_or_below = block_info.core_height.checked_sub( - self.config - .network - .core_credit_pool_window_blocks() + core_credit_pool_window_blocks(self.config.network, platform_version)? .saturating_sub(mining_delay_blocks), ); diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs index 8fea057d287..c770b66b405 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs @@ -4,7 +4,7 @@ use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; use dpp::identity::convert_duffs_to_credits; use dpp::version::PlatformVersion; -use dpp::withdrawal::core_credit_pool_unlock_limit::NetworkCoreCreditPoolWindow; +use dpp::withdrawal::core_credit_pool_unlock_limit::core_credit_pool_window_blocks; use drive::grovedb::TransactionArg; impl Platform @@ -29,8 +29,9 @@ where // Nothing older than the band the limit reads is worth reading: its balance is never // read again. - let oldest_useful_height = chain_locked_height - .saturating_sub(self.config.network.core_credit_pool_window_blocks()); + let oldest_useful_height = chain_locked_height.saturating_sub( + core_credit_pool_window_blocks(self.config.network, platform_version)?, + ); let first_height = match self .drive diff --git a/packages/rs-drive-abci/src/rpc/core.rs b/packages/rs-drive-abci/src/rpc/core.rs index b73021bd817..8f70d87d47d 100644 --- a/packages/rs-drive-abci/src/rpc/core.rs +++ b/packages/rs-drive-abci/src/rpc/core.rs @@ -30,7 +30,7 @@ const COINBASE_TRANSACTION_TYPE: u16 = 5; /// within its own length: Core adds fields after it (version 4 appends /// `merkleRootAssetUnlocks`) and new transaction types to blocks that a full block decoder of /// an older Platform release cannot read. -pub fn credit_pool_balance_from_raw_block(block: &[u8]) -> Result { +pub(crate) fn credit_pool_balance_from_raw_block(block: &[u8]) -> Result { let mut reader = RawReader(block); reader.skip(80)?; // the header 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 58803060b53..20792af0295 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -601,6 +601,8 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, core_credit_pool_unlock_mining_delay_blocks: None, + core_credit_pool_window_blocks: None, + regtest_core_credit_pool_window_blocks: None, min_withdrawal_amount: 190_000, core_dust_relay_fee_per_kb: None, max_core_fee_per_byte: None, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index ef5e944a8d4..f8e3b5c2d36 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -151,6 +151,18 @@ pub struct SystemLimits { /// `record_credit_inflows_for_withdrawals` method version 0. `None` for the protocol /// versions that predate the Core-anchored limit. pub core_credit_pool_unlock_mining_delay_blocks: Option, + /// Core's credit pool window on mainnet, testnet and devnets (`CreditPoolPeriodBlocks` in + /// Dash Core's chain parameters): how many Core blocks before an asset unlock's block lies + /// the balance Core v24 measures the unlock limit from. The Core-anchored withdrawal limit + /// reads its window starts this far back, an asset lock Core mined this far back (less + /// `core_credit_pool_unlock_mining_delay_blocks`) adds no credit inflow, and recorded + /// balances older than it are pruned. Read through `core_credit_pool_window_blocks` in dpp. + /// `None` for the protocol versions that predate the Core-anchored limit. + pub core_credit_pool_window_blocks: Option, + /// Core's credit pool window on regtest, which Dash Core shortens; see + /// `core_credit_pool_window_blocks`. `None` for the protocol versions that predate the + /// Core-anchored limit. + pub regtest_core_credit_pool_window_blocks: Option, /// Minimum net amount (in credits) a withdrawal may send to Core, shared by the /// transparent (identity + address) and shielded withdrawal paths. The dust floor that /// keeps Core from rejecting the resulting `TxOut`. Versioned: see `min_withdrawal_amount` diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index b5a1ed4450d..92df84153f2 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -45,6 +45,8 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, core_credit_pool_unlock_mining_delay_blocks: None, + core_credit_pool_window_blocks: None, + regtest_core_credit_pool_window_blocks: None, // = dpp MIN_WITHDRAWAL_AMOUNT: ASSET_UNLOCK_TX_SIZE(190) * MIN_CORE_FEE_PER_BYTE(1) // * CREDITS_PER_DUFF(1000) = 190_000 credits = 190 duffs. min_withdrawal_amount: 190_000, diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 4c7095cd75f..31150341a5a 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -28,6 +28,8 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, core_credit_pool_unlock_mining_delay_blocks: None, + core_credit_pool_window_blocks: None, + regtest_core_credit_pool_window_blocks: None, min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) core_dust_relay_fee_per_kb: None, // expired dust withdrawals fail from v14 max_core_fee_per_byte: None, diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 72962a5e51d..551f26b0715 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -30,6 +30,8 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, core_credit_pool_unlock_mining_delay_blocks: None, + core_credit_pool_window_blocks: None, + regtest_core_credit_pool_window_blocks: None, min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) core_dust_relay_fee_per_kb: None, // expired dust withdrawals fail from v14 max_core_fee_per_byte: None, diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index faa51fb2976..955e171192a 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -27,7 +27,8 @@ use crate::version::system_limits::SystemLimits; /// * Withdrawals also fit a Core-anchored limit, a stricter copy of Core v24's relative net /// unlock rule read from Core's own credit pool balances: the pool may drop by at most /// `core_credit_pool_unlock_limit_percent` (15, Core allows 20) of its highest balance at a -/// window start Core may use for the unlock (Core's window back from the chain locked height, +/// window start Core may use for the unlock (Core's window, `core_credit_pool_window_blocks` +/// 576 or `regtest_core_credit_pool_window_blocks` 100, back from the chain locked height, /// up to `core_credit_pool_unlock_mining_delay_blocks`, 48, later), at least /// `core_credit_pool_unlock_limit_floor` (1500 Dash, Core's floor is 2000), less what is /// pooled and not yet mined. An asset lock Core mined longer ago than its window minus those @@ -123,7 +124,9 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { core_credit_pool_unlock_limit_percent: Some(15), // Core v24 allows 20% of the pool a window ago core_credit_pool_unlock_limit_floor: Some(150_000_000_000_000), // 1500 Dash; Core v24's floor is 2000 Dash core_credit_pool_unlock_mining_delay_blocks: Some(48), // Core's asset unlock validity past its signing height - min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) + core_credit_pool_window_blocks: Some(576), // Core's credit pool window (CreditPoolPeriodBlocks), mainnet, testnet and devnets + regtest_core_credit_pool_window_blocks: Some(100), // Core's credit pool window on regtest + min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) core_dust_relay_fee_per_kb: Some(3000), // Core's default dust relay fee: 546-duff P2PKH threshold; expired withdrawals below it fail instead of re-signing max_core_fee_per_byte: Some(6_765), max_group_member_count: 256, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index ba8ac30c3a1..06cf67ebc29 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1873,8 +1873,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// balances at chain locked heights: the pool may drop by at most /// `core_credit_pool_unlock_limit_percent` (15; Core allows 20) of its /// highest balance at a window start Core may use for the unlock (Core's -/// window, 576 blocks or 100 on regtest, back from the chain locked height, -/// up to `core_credit_pool_unlock_mining_delay_blocks`, 48, later), at least +/// window, `core_credit_pool_window_blocks` 576 or +/// `regtest_core_credit_pool_window_blocks` 100, back from the chain locked +/// height, up to `core_credit_pool_unlock_mining_delay_blocks`, 48, later), at least /// `core_credit_pool_unlock_limit_floor` (1500 Dash; Core's floor is 2000), /// less what is queued or broadcast and not mined yet. The formula is /// `core_credit_pool_unlock_limit` 0 in `DPP_METHOD_VERSIONS_V3`. Before From ef251681f5fa19cc1e864b71f69c815e944acfe7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 1 Oct 2026 16:43:36 +0700 Subject: [PATCH 5/7] fix(platform): fail the block when Core answers fewer mined heights than asked record_credit_inflows_for_withdrawals v0 paired the block's asset locks with Core's answers through zip, which stops at the shorter list: a short reply would have dropped the remaining mints on that node only. Co-Authored-By: Claude Opus 5.5 --- .../v0/mod.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs index 2e00acca058..8e38a147a56 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs @@ -66,6 +66,16 @@ where .collect(); let mined_heights = self.core_rpc.get_transactions_mined_heights(&txids)?; + // One height per asset lock, or the pairing below would drop the rest of the mints. + if mined_heights.len() != txids.len() { + return Err(Error::Execution(ExecutionError::DashCoreBadResponseError( + format!( + "expected {} mined heights, Core returned {}", + txids.len(), + mined_heights.len() + ), + ))); + } for ((_, amount), mined_height) in asset_lock_mints.into_iter().zip(mined_heights) { // Only a height at or below the chain locked one is final and the same on @@ -305,6 +315,41 @@ mod tests { .is_err()); } + /// Core answering for fewer asset locks than asked fails the block instead of dropping the + /// mints it left out. + #[test] + fn should_fail_when_core_answers_fewer_mined_heights_than_asked() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + let platform_version = PlatformVersion::latest(); + + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc + .expect_get_block_hash() + .returning(|_| Ok(BlockHash::all_zeros())); + core_rpc + .expect_get_transactions_mined_heights() + .returning(|_| Ok(vec![None])); + platform.core_rpc = core_rpc; + + let transaction = platform.drive.grove.start_transaction(); + + assert!(platform + .record_credit_inflows_for_withdrawals( + &asset_lock_mints(&[(1, 100), (2, 200)]), + 0, + &BlockInfo { + core_height: 1000, + ..Default::default() + }, + &transaction, + platform_version, + ) + .is_err()); + } + /// Before protocol version 14 the version slot is `None` and the event does nothing. #[test] fn should_do_nothing_before_the_feature_exists() { From 0f3eea25e90aeb6b4279cff9cf4bc5018b6d935f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 1 Oct 2026 19:34:48 +0700 Subject: [PATCH 6/7] fix(platform)!: let dashmate's consensus user read the coinbase, and review fixes (PV14) - dashmate: the `drive_consensus` Core RPC user may call `getspecialtxes` and `gettxchainlocks`, which block execution now uses from protocol version 14; a 5.0.0-beta.2 migration re-syncs the whitelist. Core answered 403 and every dashmate node failed the first PV14 block. - Read Core's credit pool balance from the coinbase alone (`getspecialtxes 5 1 0 1`) instead of the whole raw block, with dashcore's decoders for its parts; the payload is still taken by its own length so version 4 (Core v24) reads too. - Core's asset unlock validity comes from `withdrawal_constants.core_expiration_blocks`; the duplicate SystemLimits field is removed. Band comments state the actual reason for the 48-block margin. - Pooling 2 skips the Core-anchored side while the oldest queued withdrawal does not fit Platform's own limit. - Tests: pooling through the dispatcher at protocol versions 13 and 14, version 1's tests pinned to 13, the withdrawals Merk shape compared by proof in the born-at-14 equivalence test, and the Core credit pool floor kept at least one maximal withdrawal for every protocol version. Co-Authored-By: Claude Opus 5.5 --- book/src/versioning/feature-versions.md | 1 - .../configs/defaults/getBaseConfigFactory.js | 1 + .../configs/getConfigFileMigrationsFactory.js | 14 ++ .../v0/mod.rs | 61 +++++- .../v0/mod.rs | 31 +-- .../v0/mod.rs | 4 +- .../v1/mod.rs | 37 +++- .../v2/mod.rs | 79 ++++++-- .../v0/mod.rs | 17 +- packages/rs-drive-abci/src/rpc/core.rs | 181 +++++------------- .../src/version/mocks/v2_test.rs | 1 - .../src/version/system_limits/mod.rs | 48 +++-- .../src/version/system_limits/v1.rs | 1 - .../src/version/system_limits/v2.rs | 1 - .../src/version/system_limits/v3.rs | 1 - .../src/version/system_limits/v4.rs | 4 +- .../rs-platform-version/src/version/v14.rs | 10 +- 17 files changed, 288 insertions(+), 204 deletions(-) diff --git a/book/src/versioning/feature-versions.md b/book/src/versioning/feature-versions.md index 20da656f953..7f1d65ac42b 100644 --- a/book/src/versioning/feature-versions.md +++ b/book/src/versioning/feature-versions.md @@ -334,7 +334,6 @@ pub struct SystemLimits { pub daily_withdrawal_limit_percent: Option, pub core_credit_pool_unlock_limit_percent: Option, pub core_credit_pool_unlock_limit_floor: Option, - pub core_credit_pool_unlock_mining_delay_blocks: Option, pub core_credit_pool_window_blocks: Option, pub regtest_core_credit_pool_window_blocks: Option, pub min_withdrawal_amount: u64, diff --git a/packages/dashmate/configs/defaults/getBaseConfigFactory.js b/packages/dashmate/configs/defaults/getBaseConfigFactory.js index 54ac9eade1e..cb942444d0d 100644 --- a/packages/dashmate/configs/defaults/getBaseConfigFactory.js +++ b/packages/dashmate/configs/defaults/getBaseConfigFactory.js @@ -94,6 +94,7 @@ export default function getBaseConfigFactory() { 'getbestchainlock', 'getblockchaininfo', 'getrawtransaction', 'submitchainlock', 'verifychainlock', 'protxlistdiff', 'quorumlistextended', 'quoruminfo', 'getassetunlockstatuses', 'sendrawtransaction', 'mnsyncstatus', 'getblockheader', 'getblockhash', + 'getspecialtxes', 'gettxchainlocks', ], lowPriority: false, }, diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index e1d07a74f27..6b9ec4ca66c 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -1777,6 +1777,20 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) return configFile; }, + '5.0.0-beta.2': (configFile) => { + Object.entries(configFile.configs) + .forEach(([, options]) => { + // Drive's withdrawal limit (protocol version 14) reads Core's credit pool + // balance from each block's coinbase (getspecialtxes) and asks Core where + // asset locks were mined (gettxchainlocks). Core refuses both to the + // consensus user until its whitelist names them. + if (options.core?.rpc?.users?.drive_consensus) { + options.core.rpc.users.drive_consensus.whitelist = base.getStored('core.rpc.users.drive_consensus.whitelist'); + } + }); + + return configFile; + }, }; } diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 45c1cc6c562..c4c3f8dca9f 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -851,6 +851,8 @@ mod tests { shielded_credit_pool_path, MAIN_SHIELDED_CREDIT_POOL_KEY_U8, SHIELDED_ANCHORS_IN_POOL_KEY, SHIELDED_NOTES_KEY, SHIELDED_NULLIFIERS_KEY, }; + use drive::grovedb::operations::proof::{GroveDBProof, ProofBytes}; + use drive::grovedb::{PathQuery, Query}; use drive::util::grove_operations::DirectQueryType; /// Recursively compares the GroveDB subtree rooted at `root_path` between @@ -2284,8 +2286,8 @@ mod tests { /// Genesis creates the withdrawal trees of version 14 in one batch, the upgrade adds the /// new ones one insert at a time, and Merk's shape depends on insertion order: the - /// withdrawals tree element (its root key) and every element below it are the same on a - /// chain born at 14 and one upgraded to it. + /// withdrawals tree element (its root key), every element below it and the shape of its + /// Merk are the same on a chain born at 14 and one upgraded to it. #[test] fn should_build_the_withdrawal_trees_as_a_chain_born_at_14_does() { let platform_version = PlatformVersion::latest(); @@ -2310,8 +2312,7 @@ mod tests { .expect("expected version 14 transition to succeed"); let withdrawals_element = - |platform: &crate::platform_types::platform::Platform, - transaction: Option<&Transaction>| { + |platform: &Platform, transaction: Option<&Transaction>| { platform .drive .grove_get_raw( @@ -2343,6 +2344,58 @@ mod tests { to it:\n{}", diffs.join("\n"), ); + + // Equal elements and an equal root key can still sit in differently shaped Merks (the + // same keys inserted in another order). A proof of the whole withdrawals tree encodes + // its Merk node by node, so it differs whenever the shape does. + upgraded + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the upgrade"); + let withdrawals_merk_proof = |platform: &Platform| { + let withdrawals_key = vec![RootTree::WithdrawalTransactions as u8]; + let mut query = Query::new(); + query.insert_all(); + let proof = platform + .drive + .grove + .prove_query_non_serialized( + &PathQuery::new_unsized(vec![withdrawals_key.clone()], query), + None, + &platform_version.drive.grove_version, + ) + .unwrap() + .expect("expected to prove the withdrawals tree"); + match proof { + GroveDBProof::V0(proof) => proof + .root_layer + .lower_layers + .get(&withdrawals_key) + .expect("expected the withdrawals layer") + .merk_proof + .clone(), + GroveDBProof::V1(proof) => { + match &proof + .root_layer + .lower_layers + .get(&withdrawals_key) + .expect("expected the withdrawals layer") + .merk_proof + { + ProofBytes::Merk(merk_proof) => merk_proof.clone(), + _ => panic!("expected a Merk proof of the withdrawals tree"), + } + } + } + }; + assert_eq!( + withdrawals_merk_proof(&born_at_14), + withdrawals_merk_proof(&upgraded), + "the withdrawals Merk is shaped differently on a chain born at version 14 and one \ + upgraded to it" + ); } #[test] diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs index 0e57855236d..fa4a65a0c39 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs @@ -1,4 +1,3 @@ -use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; @@ -26,22 +25,25 @@ where transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { - let mining_delay_blocks = platform_version - .system_limits - .core_credit_pool_unlock_mining_delay_blocks - .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( - "calculate_core_anchored_withdrawal_limit v0 requires system_limits.core_credit_pool_unlock_mining_delay_blocks", - )))?; + // Core's asset unlock validity, which `update_broadcasted_withdrawal_statuses` also + // expires withdrawals by. + let unlock_validity_blocks = platform_version + .drive_abci + .withdrawal_constants + .core_expiration_blocks; let window_blocks = core_credit_pool_window_blocks(self.config.network, platform_version)?; let chain_locked_height = block_info.core_height; - // Core measures an unlock mined in block M from the balance after M - 1 - window; M is - // past the chain locked height and at most `mining_delay_blocks` past it (the unlock - // is signed at it or later). A window start before the chain's start has no credit - // pool, which Core reads as a balance of 0: it never raises the highest. + // An unlock signed at request height r is mined in a block M with r < M <= r + 48 (Core + // checks the previous block's height against r + 48) and measured from the balance + // after M - 1 - window: a window start from r - window to r + 47 - window. Pooling at + // chain locked height h signs at h, or at h + 1 when the chain locked height moves before + // the later Platform block that signs it, so the window starts read run from h - window + // to h + 48 - window. A window start before the chain's start has no credit pool, which + // Core reads as a balance of 0: it never raises the highest. let window_start_balance = match chain_locked_height - .saturating_add(mining_delay_blocks) + .saturating_add(unlock_validity_blocks) .checked_sub(window_blocks) { None => 0, @@ -235,8 +237,9 @@ mod tests { // At 10,000 the window starts are 9,424..=9,472: the deposit counts in full. assert_eq!(limit(10_000), dash_to_credits!(10550)); assert_eq!(limit(10_027), dash_to_credits!(10550)); - // At 10,028 the nearest window start reaches 9,500: an unlock pooled now may be mined - // 48 blocks later, when Core's window no longer holds the deposit. + // At 10,028 the nearest window start reaches 9,500: an unlock pooled now and signed one + // Core block later may be mined at 10,077, measured from the balance after 9,500, which + // already holds the deposit. assert_eq!(limit(10_028), dash_to_credits!(6300)); } diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v0/mod.rs index a3c1f187ac5..2bdb96557cb 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v0/mod.rs @@ -229,8 +229,10 @@ mod tests { /// attempting to build any transactions. #[test] fn v1_returns_ok_when_no_queued_documents() { - let platform_version = PlatformVersion::latest(); + // Version 1 is frozen: the last protocol version that selects it. + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) .build_with_mock_rpc() .set_initial_state_structure(); diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs index 90dc3f1cf56..35beeec6826 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs @@ -37,7 +37,7 @@ where block_info, transaction, platform_version, - |available, daily_maximum| { + |available, daily_maximum, _oldest_queued_amount| { let current_withdrawal_limit = available; // Store prometheus metrics @@ -51,17 +51,20 @@ where } /// Version 1's pooling, given the amount to pool up to once the daily withdrawal limit is - /// known (`current_withdrawal_limit`, called with its available amount and daily maximum - /// only when withdrawals are queued). Extracted in place, unchanged, so version 2 can - /// reuse it: for version 1 the closure returns the available daily limit and sets the - /// gauges exactly where they were set before, so every protocol version that selects - /// version 1 (8 to 13) pools the same documents and writes the same state. + /// known (`current_withdrawal_limit`, called with its available amount, its daily maximum + /// and the amount of the oldest queued withdrawal, only when withdrawals are queued). + /// Extracted in place so version 2 can reuse it, inert for protocol versions 1 to 13 (those + /// selecting version 1, and through version 0, which delegates to it, those selecting + /// version 0): the closure of version 1 returns the available daily limit and sets the + /// gauges exactly where they were set before, and reading the oldest withdrawal's amount + /// first can fail only where the loop below fails on that same withdrawal, so every such + /// block pools the same documents and writes the same state. pub(super) fn pool_withdrawals_up_to_limit_v1( &self, block_info: &BlockInfo, transaction: TransactionArg, platform_version: &PlatformVersion, - current_withdrawal_limit: impl FnOnce(Credits, Credits) -> Result, + current_withdrawal_limit: impl FnOnce(Credits, Credits, Credits) -> Result, ) -> Result<(), Error> { let documents = self.drive.fetch_oldest_withdrawal_documents_by_status( withdrawals_contract::WithdrawalStatus::QUEUED.into(), @@ -96,8 +99,20 @@ where "Calculated withdrawal limit info" ); - let current_withdrawal_limit = - current_withdrawal_limit(withdrawals_info.available(), withdrawals_info.daily_maximum)?; + // Pooling stops at the first queued withdrawal over the limit, so nothing pools while + // the oldest one does not fit. + let oldest_queued_amount: u64 = match documents.first() { + Some(document) => document + .properties() + .get_integer(withdrawal::properties::AMOUNT)?, + None => 0, + }; + + let current_withdrawal_limit = current_withdrawal_limit( + withdrawals_info.available(), + withdrawals_info.daily_maximum, + oldest_queued_amount, + )?; // Only process documents up to the current withdrawal limit. let mut total_withdrawal_amount = 0u64; @@ -238,8 +253,10 @@ mod tests { #[test] fn test_pooling() { - let platform_version = PlatformVersion::latest(); + // Version 1 is frozen: the last protocol version that selects it. + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) .build_with_mock_rpc() .set_initial_state_structure(); diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs index 28dffe440f8..e4b26c89c20 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs @@ -40,7 +40,15 @@ where block_info, transaction, platform_version, - |available, daily_maximum| { + |available, daily_maximum, oldest_queued_amount| { + // Nothing fits Platform's own limit, and the Core side can only lower it: skip + // its reads and Core calls. + if available < oldest_queued_amount { + gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_AVAILABLE).set(available as f64); + gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_TOTAL).set(daily_maximum as f64); + return Ok(available); + } + let core_anchored_withdrawal_limit = self .calculate_core_anchored_withdrawal_limit( block_info, @@ -75,6 +83,7 @@ mod tests { use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TestPlatformBuilder; use dpp::block::epoch::Epoch; + use dpp::dash_to_credits; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contracts::SystemDataContract; use dpp::identifier::Identifier; @@ -87,20 +96,27 @@ mod tests { use dpp::withdrawal::Pooling; use drive::config::DEFAULT_QUERY_LIMIT; use drive::util::test_helpers::setup::{setup_document, setup_system_data_contract}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; - /// Pools two queued withdrawals of 1,000 credits each against a Core whose credit pool - /// holds `pool_duffs` at every height, and returns how many were pooled. - fn pooled_with_a_core_pool_of(pool_duffs: u64) -> usize { - let platform_version = PlatformVersion::latest(); + /// Pools two queued withdrawals of `amount` credits each through the dispatcher, at + /// `platform_version`, against a Core whose credit pool holds `pool_duffs` at every height. + /// Returns how many were pooled and how many credit pool balances were asked of Core. + fn pool(platform_version: &PlatformVersion, amount: u64, pool_duffs: u64) -> (usize, usize) { let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() + .with_initial_protocol_version(platform_version.protocol_version) .build_with_mock_rpc() .set_initial_state_structure(); + let balance_reads = Arc::new(AtomicUsize::new(0)); let mut core_rpc = MockCoreRPCLike::new(); + let reads = balance_reads.clone(); core_rpc .expect_get_credit_pool_balance() - .returning(move |_| Ok(pool_duffs)); + .returning(move |_| { + reads.fetch_add(1, Ordering::SeqCst); + Ok(pool_duffs) + }); platform.core_rpc = core_rpc; let transaction = platform.drive.grove.start_transaction(); @@ -125,7 +141,7 @@ mod tests { &data_contract, Identifier::new([1u8; 32]), platform_value!({ - "amount": 1000u64, + "amount": amount, "coreFeePerByte": 1u32, "pooling": Pooling::Never as u8, "outputScript": CoreScript::from_bytes((0..23).collect::>()), @@ -145,15 +161,17 @@ mod tests { ); } + let platform_state = platform.state.load(); platform - .pool_withdrawals_into_transactions_queue_v2( + .pool_withdrawals_into_transactions_queue( &block_info, + &platform_state, Some(&transaction), platform_version, ) .expect("to pool withdrawal documents into transactions"); - platform + let pooled = platform .drive .fetch_oldest_withdrawal_documents_by_status( withdrawals_contract::WithdrawalStatus::POOLED.into(), @@ -162,20 +180,53 @@ mod tests { platform_version, ) .expect("to fetch withdrawal documents") - .len() + .len(); + (pooled, balance_reads.load(Ordering::SeqCst)) } #[test] fn should_pool_what_fits_both_the_daily_limit_and_cores_credit_pool() { // Plenty in Core's pool: both withdrawals pool. - assert_eq!(pooled_with_a_core_pool_of(1_000_000_000_000), 2); + assert_eq!( + pool(PlatformVersion::latest(), 1000, 1_000_000_000_000).0, + 2 + ); } #[test] fn should_leave_queued_what_cores_credit_pool_could_not_give_up() { // A pool of 1 duff, 1,000 credits: it fits one 1,000 credit withdrawal, not two, // although the daily limit (2,000 Dash before any history) would allow both. - assert_eq!(pooled_with_a_core_pool_of(1), 1); - assert_eq!(pooled_with_a_core_pool_of(0), 0); + assert_eq!(pool(PlatformVersion::latest(), 1000, 1).0, 1); + assert_eq!(pool(PlatformVersion::latest(), 1000, 0).0, 0); + } + + /// Through the dispatcher on both sides of the gate: version 1 (protocol version 13) pools + /// on the daily limit alone, version 2 (14) also on Core's credit pool. + #[test] + fn should_hold_back_on_cores_credit_pool_only_from_protocol_version_14() { + let version_13 = PlatformVersion::get(13).expect("expected protocol version 13"); + assert_eq!(pool(version_13, 1000, 1), (2, 0)); + assert_eq!(pool(PlatformVersion::latest(), 1000, 1).0, 1); + } + + /// While the oldest queued withdrawal does not fit Platform's own limit nothing pools, so + /// the Core side is not read: only the scan asks Core (32 Core blocks from 10,000 - 576), + /// not the 18 window starts and chain locked height it has not recorded yet. + #[test] + fn should_not_read_the_core_side_when_nothing_fits_the_daily_limit() { + // 3,000 Dash each, over the flat 2,000 Dash before any history. + assert_eq!( + pool( + PlatformVersion::latest(), + dash_to_credits!(3000), + 1_000_000_000_000 + ), + (0, 32) + ); + assert_eq!( + pool(PlatformVersion::latest(), 1000, 1_000_000_000_000), + (2, 50) + ); } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs index 8e38a147a56..e45e7163155 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs @@ -42,17 +42,16 @@ where .collect(); if !asset_lock_mints.is_empty() { - let mining_delay_blocks = platform_version - .system_limits - .core_credit_pool_unlock_mining_delay_blocks - .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( - "record_credit_inflows_for_withdrawals v0 requires system_limits.core_credit_pool_unlock_mining_delay_blocks", - )))?; - // Mined at or below this height, Core already counts the asset lock in the window - // start balance of an unlock pooled now (or soon will). + let unlock_validity_blocks = platform_version + .drive_abci + .withdrawal_constants + .core_expiration_blocks; + // Mined at or below the nearest window start the Core-anchored limit reads, the + // asset lock is inside the window start balance Core may measure an unlock pooled + // now from. let stale_at_or_below = block_info.core_height.checked_sub( core_credit_pool_window_blocks(self.config.network, platform_version)? - .saturating_sub(mining_delay_blocks), + .saturating_sub(unlock_validity_blocks), ); // Core answers from its active chain and transaction index; one that has not diff --git a/packages/rs-drive-abci/src/rpc/core.rs b/packages/rs-drive-abci/src/rpc/core.rs index 8f70d87d47d..139011c6a08 100644 --- a/packages/rs-drive-abci/src/rpc/core.rs +++ b/packages/rs-drive-abci/src/rpc/core.rs @@ -1,6 +1,9 @@ use crate::rpc::prefetch::CorePrefetcher; +use dpp::dashcore::consensus::encode::{self, deserialize_partial}; +use dpp::dashcore::consensus::Decodable; use dpp::dashcore::ephemerealdata::chain_lock::ChainLock; -use dpp::dashcore::{Block, BlockHash, QuorumHash, Transaction, Txid}; +use dpp::dashcore::transaction::special_transaction::coinbase::CoinbasePayload; +use dpp::dashcore::{Block, BlockHash, QuorumHash, Transaction, TxIn, TxOut, Txid}; use dpp::dashcore::{Header, InstantLock}; use dpp::dashcore_rpc::dashcore_rpc_json::{ AssetUnlockStatusResult, ExtendedQuorumDetails, ExtendedQuorumListResult, GetChainTipsResult, @@ -22,43 +25,27 @@ const MAX_TRANSACTIONS_PER_CHAIN_LOCK_STATUS_REQUEST: usize = 100; /// The special transaction type of a coinbase (`TRANSACTION_COINBASE` in Dash Core). const COINBASE_TRANSACTION_TYPE: u16 = 5; -/// Reads Core's credit pool balance after a block, in duffs, from the coinbase of the raw -/// (serialized) block; `0` when the coinbase carries none (a coinbase payload before version 3, -/// before the credit pool existed), which is how Core's own unlock limit reads such a block. +/// Reads Core's credit pool balance after a block, in duffs, from the block's serialized +/// coinbase transaction; `0` when its payload predates version 3, before the credit pool +/// existed, which is how Core's own unlock limit reads such a block. /// -/// Only the header and the coinbase are read, and the coinbase payload only up to the balance -/// within its own length: Core adds fields after it (version 4 appends -/// `merkleRootAssetUnlocks`) and new transaction types to blocks that a full block decoder of -/// an older Platform release cannot read. -pub(crate) fn credit_pool_balance_from_raw_block(block: &[u8]) -> Result { - let mut reader = RawReader(block); - reader.skip(80)?; // the header - - if reader.compact_size()? == 0 { - return Err("block has no coinbase".to_string()); - } +/// The parts are read with dashcore's decoders, but not as one `Transaction`: the pinned decoder +/// ignores the payload's length prefix and cannot read the version 4 payload Core v24 requires, +/// which appends `merkleRootAssetUnlocks` after the balance. So the payload is taken by its own +/// length and read only up to the balance. Core has only ever appended fields to it, and its +/// consensus rules (`CheckCbTx`) refuse versions it does not know, so the balance stays where +/// version 3 put it unless a Core release moves it, which Platform would have to follow anyway. +pub(crate) fn credit_pool_balance_from_coinbase(coinbase: &[u8]) -> Result { + let decode_error = |e: encode::Error| format!("coinbase cannot be decoded: {e}"); + let mut reader = coinbase; + + let version_and_type = u32::consensus_decode(&mut reader).map_err(decode_error)?; + Vec::::consensus_decode(&mut reader).map_err(decode_error)?; + Vec::::consensus_decode(&mut reader).map_err(decode_error)?; + u32::consensus_decode(&mut reader).map_err(decode_error)?; // the lock time - let version_and_type = reader.u32_le()?; let version = (version_and_type & 0xffff) as u16; let transaction_type = (version_and_type >> 16) as u16; - - let inputs = reader.compact_size()?; - if inputs == 0 { - return Err("coinbase has no input".to_string()); - } - for _ in 0..inputs { - reader.skip(36)?; // the outpoint - let script_len = reader.compact_size()?; - reader.skip_u64(script_len)?; - reader.skip(4)?; // the sequence - } - for _ in 0..reader.compact_size()? { - reader.skip(8)?; // the value - let script_len = reader.compact_size()?; - reader.skip_u64(script_len)?; - } - reader.skip(4)?; // the lock time - if version < 3 || transaction_type == 0 { return Ok(0); } @@ -68,71 +55,9 @@ pub(crate) fn credit_pool_balance_from_raw_block(block: &[u8]) -> Result(&'a [u8]); - -impl<'a> RawReader<'a> { - fn take(&mut self, len: usize) -> Result<&'a [u8], String> { - if self.0.len() < len { - return Err("raw block ends early".to_string()); - } - let (taken, rest) = self.0.split_at(len); - self.0 = rest; - Ok(taken) - } - - fn take_u64(&mut self, len: u64) -> Result<&'a [u8], String> { - self.take(usize::try_from(len).map_err(|_| "raw block ends early".to_string())?) - } - - fn skip(&mut self, len: usize) -> Result<(), String> { - self.take(len).map(|_| ()) - } - - fn skip_u64(&mut self, len: u64) -> Result<(), String> { - self.take_u64(len).map(|_| ()) - } - - fn array(&mut self) -> Result<[u8; N], String> { - let mut bytes = [0u8; N]; - bytes.copy_from_slice(self.take(N)?); - Ok(bytes) - } - - fn u16_le(&mut self) -> Result { - Ok(u16::from_le_bytes(self.array()?)) - } - - fn u32_le(&mut self) -> Result { - Ok(u32::from_le_bytes(self.array()?)) - } - - fn i64_le(&mut self) -> Result { - Ok(i64::from_le_bytes(self.array()?)) - } - - fn compact_size(&mut self) -> Result { - Ok(match self.take(1)?[0] { - 0xfd => u16::from_le_bytes(self.array()?) as u64, - 0xfe => u32::from_le_bytes(self.array()?) as u64, - 0xff => u64::from_le_bytes(self.array()?), - small => small as u64, - }) - } + let payload = Vec::::consensus_decode(&mut reader).map_err(decode_error)?; + let (payload, _) = deserialize_partial::(&payload).map_err(decode_error)?; + Ok(payload.asset_locked_amount.unwrap_or_default()) } /// Core height must be of type u32 (Platform heights are u64) @@ -247,8 +172,8 @@ pub trait CoreRPCLike { fn send_raw_transaction(&self, transaction: &[u8]) -> Result; /// Get Core's credit pool balance after the block at `height`, in duffs, read from the - /// block's coinbase. Only ask for a chain locked height: the answer is then the same on - /// every node. + /// block's coinbase (only the coinbase is transferred). Only ask for a chain locked height: + /// the answer is then the same on every node. fn get_credit_pool_balance(&self, height: CoreHeight) -> Result; /// Get the height of the active chain block each transaction was mined in, in the order of @@ -523,11 +448,23 @@ impl CoreRPCLike for DefaultCoreRPC { fn get_credit_pool_balance(&self, height: CoreHeight) -> Result { let block_hash = self.get_block_hash(height)?; - let block_hex = retry!(self.inner.get_block_hex(&block_hash))?; - let block = hex::decode(block_hex).map_err(|e| { - Error::UnexpectedStructure(format!("getblock answered invalid hex: {e}")) + // Only the coinbase, as raw hex: the special transactions of type 5, the first one, + // verbosity 1. An empty answer is a coinbase without a payload, before DIP3. + let args = [ + Value::String(block_hash.to_string()), + Value::from(COINBASE_TRANSACTION_TYPE), + Value::from(1), + Value::from(0), + Value::from(1), + ]; + let coinbases = retry!(self.inner.call::>("getspecialtxes", &args))?; + let Some(coinbase) = coinbases.first() else { + return Ok(0); + }; + let coinbase = hex::decode(coinbase).map_err(|e| { + Error::UnexpectedStructure(format!("getspecialtxes answered invalid hex: {e}")) })?; - credit_pool_balance_from_raw_block(&block).map_err(Error::UnexpectedStructure) + credit_pool_balance_from_coinbase(&coinbase).map_err(Error::UnexpectedStructure) } fn get_transactions_mined_heights( @@ -557,7 +494,7 @@ impl CoreRPCLike for DefaultCoreRPC { #[cfg(test)] mod tests { - use super::credit_pool_balance_from_raw_block; + use super::credit_pool_balance_from_coinbase; use dpp::dashcore::bls_sig_utils::BLSSignature; use dpp::dashcore::consensus::serialize; use dpp::dashcore::hash_types::{MerkleRootMasternodeList, MerkleRootQuorums}; @@ -568,7 +505,7 @@ mod tests { const BALANCE_DUFFS: u64 = 3_700_000_000_000; - /// A coinbase as the pinned rust-dashcore encodes it, with a version 3 payload. + /// A coinbase as the pinned rust-dashcore encodes it. fn coinbase(payload: Option) -> Vec { serialize(&Transaction { version: 3, @@ -599,25 +536,16 @@ mod tests { }) } - /// A header, then the coinbase, then a transaction no decoder knows. - fn block(coinbase: &[u8]) -> Vec { - let mut block = vec![0u8; 80]; - block.push(2); - block.extend_from_slice(coinbase); - block.extend_from_slice(&[0xff; 40]); - block - } - #[test] fn should_read_the_balance_of_a_version_3_coinbase() { assert_eq!( - credit_pool_balance_from_raw_block(&block(&coinbase(Some(version_3_payload())))), + credit_pool_balance_from_coinbase(&coinbase(Some(version_3_payload()))), Ok(BALANCE_DUFFS) ); } /// Core v24 blocks carry a version 4 payload, which appends `merkleRootAssetUnlocks` - /// after the balance; the pinned decoder cannot read it. + /// after the balance; the pinned transaction decoder cannot read it. #[test] fn should_read_the_balance_of_a_version_4_coinbase() { let version_3 = coinbase(Some(version_3_payload())); @@ -631,7 +559,7 @@ mod tests { version_4.extend_from_slice(&[0xaa; 32]); assert_eq!( - credit_pool_balance_from_raw_block(&block(&version_4)), + credit_pool_balance_from_coinbase(&version_4), Ok(BALANCE_DUFFS) ); } @@ -648,20 +576,17 @@ mod tests { asset_locked_amount: None, }); assert_eq!( - credit_pool_balance_from_raw_block(&block(&coinbase(Some(version_2)))), - Ok(0) - ); - assert_eq!( - credit_pool_balance_from_raw_block(&block(&coinbase(None))), + credit_pool_balance_from_coinbase(&coinbase(Some(version_2))), Ok(0) ); + assert_eq!(credit_pool_balance_from_coinbase(&coinbase(None)), Ok(0)); } #[test] - fn should_fail_on_a_truncated_block() { - let full = block(&coinbase(Some(version_3_payload()))); + fn should_fail_on_a_truncated_coinbase() { + let full = coinbase(Some(version_3_payload())); // Cut inside the payload, before the balance. - assert!(credit_pool_balance_from_raw_block(&full[..full.len() - 60]).is_err()); - assert!(credit_pool_balance_from_raw_block(&full[..40]).is_err()); + assert!(credit_pool_balance_from_coinbase(&full[..full.len() - 60]).is_err()); + assert!(credit_pool_balance_from_coinbase(&full[..40]).is_err()); } } 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 20792af0295..5b86692b4e3 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -600,7 +600,6 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { daily_withdrawal_limit_percent: None, core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, - core_credit_pool_unlock_mining_delay_blocks: None, core_credit_pool_window_blocks: None, regtest_core_credit_pool_window_blocks: None, min_withdrawal_amount: 190_000, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index f8e3b5c2d36..cda3cea1658 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -141,23 +141,14 @@ pub struct SystemLimits { /// `max_withdrawal_amount` so a queued withdrawal always fits eventually. `None` for the /// protocol versions that predate the Core-anchored limit. pub core_credit_pool_unlock_limit_floor: Option, - /// How many Core blocks past the chain locked height a withdrawal pooled at it may still be - /// mined: Core accepts an asset unlock until 48 blocks past the height it is signed at, and - /// it is signed at or after the height it is pooled at. The Core-anchored limit takes the - /// highest credit pool balance among the window starts Core may use for it (Core's window - /// length back from the chain locked height, up to this many blocks later), and an asset lock - /// counts as a credit inflow only while it is younger than Core's window minus this many - /// blocks. Read by `calculate_core_anchored_withdrawal_limit` method version 0 and - /// `record_credit_inflows_for_withdrawals` method version 0. `None` for the protocol - /// versions that predate the Core-anchored limit. - pub core_credit_pool_unlock_mining_delay_blocks: Option, /// Core's credit pool window on mainnet, testnet and devnets (`CreditPoolPeriodBlocks` in /// Dash Core's chain parameters): how many Core blocks before an asset unlock's block lies /// the balance Core v24 measures the unlock limit from. The Core-anchored withdrawal limit /// reads its window starts this far back, an asset lock Core mined this far back (less - /// `core_credit_pool_unlock_mining_delay_blocks`) adds no credit inflow, and recorded - /// balances older than it are pruned. Read through `core_credit_pool_window_blocks` in dpp. - /// `None` for the protocol versions that predate the Core-anchored limit. + /// Core's asset unlock validity, `withdrawal_constants.core_expiration_blocks`) adds no + /// credit inflow, and recorded balances older than it are pruned. Read through + /// `core_credit_pool_window_blocks` in dpp. `None` for the protocol versions that predate + /// the Core-anchored limit. pub core_credit_pool_window_blocks: Option, /// Core's credit pool window on regtest, which Dash Core shortens; see /// `core_credit_pool_window_blocks`. `None` for the protocol versions that predate the @@ -449,6 +440,37 @@ mod tests { ); } + /// The Core-anchored withdrawal limit never drops below its floor, and pooling stops at the + /// first queued withdrawal that does not fit: a floor below one maximal withdrawal would let + /// a maximal withdrawal wait forever on a small credit pool, with everything queued behind + /// it. + #[test] + fn should_keep_the_core_credit_pool_floor_at_least_one_maximal_withdrawal() { + let with_a_floor: Vec<_> = PLATFORM_VERSIONS + .iter() + .filter_map(|platform_version| { + platform_version + .system_limits + .core_credit_pool_unlock_limit_floor + .map(|floor| (platform_version, floor)) + }) + .collect(); + assert!( + !with_a_floor.is_empty(), + "no protocol version sets a Core credit pool floor; this test would assert nothing" + ); + for (platform_version, floor) in with_a_floor { + assert!( + floor >= platform_version.system_limits.max_withdrawal_amount, + "protocol version {} sets a Core credit pool floor of {} credits, below one \ + maximal withdrawal ({} credits)", + platform_version.protocol_version, + floor, + platform_version.system_limits.max_withdrawal_amount + ); + } + } + /// The withdrawal structure generations selected from protocol version 14 read the cap /// through `dpp::withdrawal::validate_core_fee_per_byte_cap`, which treats `None` as "no /// cap" per the field's contract. A table that selected one of those generations without a diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 92df84153f2..5332a0b3070 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -44,7 +44,6 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { daily_withdrawal_limit_percent: None, // relative daily withdrawal limit arrives in v14 core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, - core_credit_pool_unlock_mining_delay_blocks: None, core_credit_pool_window_blocks: None, regtest_core_credit_pool_window_blocks: None, // = dpp MIN_WITHDRAWAL_AMOUNT: ASSET_UNLOCK_TX_SIZE(190) * MIN_CORE_FEE_PER_BYTE(1) diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 31150341a5a..6231a90ded7 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -27,7 +27,6 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { daily_withdrawal_limit_percent: None, // relative daily withdrawal limit arrives in v14 core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, - core_credit_pool_unlock_mining_delay_blocks: None, core_credit_pool_window_blocks: None, regtest_core_credit_pool_window_blocks: None, min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 551f26b0715..ab3a3d881f7 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -29,7 +29,6 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { daily_withdrawal_limit_percent: None, // relative daily withdrawal limit arrives in v14 core_credit_pool_unlock_limit_percent: None, core_credit_pool_unlock_limit_floor: None, - core_credit_pool_unlock_mining_delay_blocks: None, core_credit_pool_window_blocks: None, regtest_core_credit_pool_window_blocks: None, min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 955e171192a..4e216b49d7a 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -29,7 +29,8 @@ use crate::version::system_limits::SystemLimits; /// `core_credit_pool_unlock_limit_percent` (15, Core allows 20) of its highest balance at a /// window start Core may use for the unlock (Core's window, `core_credit_pool_window_blocks` /// 576 or `regtest_core_credit_pool_window_blocks` 100, back from the chain locked height, -/// up to `core_credit_pool_unlock_mining_delay_blocks`, 48, later), at least +/// up to Core's asset unlock validity, `withdrawal_constants.core_expiration_blocks` 48, +/// later), at least /// `core_credit_pool_unlock_limit_floor` (1500 Dash, Core's floor is 2000), less what is /// pooled and not yet mined. An asset lock Core mined longer ago than its window minus those /// 48 blocks adds no credit inflow. @@ -123,7 +124,6 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { daily_withdrawal_limit_percent: Some(15), // 15% of the total credits a day ago (replaces the flat 2000 Dash in v14) core_credit_pool_unlock_limit_percent: Some(15), // Core v24 allows 20% of the pool a window ago core_credit_pool_unlock_limit_floor: Some(150_000_000_000_000), // 1500 Dash; Core v24's floor is 2000 Dash - core_credit_pool_unlock_mining_delay_blocks: Some(48), // Core's asset unlock validity past its signing height core_credit_pool_window_blocks: Some(576), // Core's credit pool window (CreditPoolPeriodBlocks), mainnet, testnet and devnets regtest_core_credit_pool_window_blocks: Some(100), // Core's credit pool window on regtest min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 06cf67ebc29..0e66805f8a7 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -85,8 +85,8 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// counts net outflow and a matching deposit -> withdraw cycle does not /// consume the budget of other users (#4471), mirroring Core v24's net /// credit-pool rule. An asset lock Core mined longer ago than its window -/// minus `core_credit_pool_unlock_mining_delay_blocks` adds no inflow: Core -/// no longer counts it in full either. Both the +/// minus its asset unlock validity (`core_expiration_blocks`) adds no +/// inflow: Core no longer counts it in full either. Both the /// inflows and the pooled reservations count over the /// interval after the base snapshot only — an entry the snapshot already /// reflects is neither added nor subtracted again. The base is @@ -1875,14 +1875,16 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// highest balance at a window start Core may use for the unlock (Core's /// window, `core_credit_pool_window_blocks` 576 or /// `regtest_core_credit_pool_window_blocks` 100, back from the chain locked -/// height, up to `core_credit_pool_unlock_mining_delay_blocks`, 48, later), at least +/// height, up to Core's asset unlock validity, `core_expiration_blocks` 48, +/// later), at least /// `core_credit_pool_unlock_limit_floor` (1500 Dash; Core's floor is 2000), /// less what is queued or broadcast and not mined yet. The formula is /// `core_credit_pool_unlock_limit` 0 in `DPP_METHOD_VERSIONS_V3`. Before /// pooling, `scan_core_blocks_for_withdrawals` reads the Core blocks the /// chain locked height passed (at most `core_blocks_scanned_per_block_limit`, /// 32, per block) and records each one's credit pool balance, read from the -/// coinbase of the raw block, under the withdrawals tree. The Platform-side +/// block's coinbase alone (`getspecialtxes`), under the withdrawals tree. The +/// Platform-side /// accounting can grant more than Core will mine (an asset lock published to /// Platform after Core mined it, a whole epoch of Core rewards minted in one /// block); over Core's limit an unlock waits unmined and is re-signed, and From e267d2af06b75c8449fb4bbace230714340a8782 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 4 Oct 2026 12:15:01 +0200 Subject: [PATCH 7/7] fix(platform)!: review fixes for the Core-anchored withdrawal limit (PV14) - Drop the late-lock gate: an asset lock Core mined a window ago counts as a credit inflow by block time again. Pooling already takes the smaller of the daily and the Core-anchored limit, so the gate only held back unlocks Core would mine, while its gettxchainlocks answer depended on Core's txindex: a node whose index was still building recorded a different inflow. Removes BlockCreditMints, DriveOperation::asset_lock_credit_mints, CoreRPCLike::get_transactions_mined_heights and the gettxchainlocks whitelist entry, and restores execute_event v0/v1, process_validation_result v0/v1, process_raw_state_transitions v0, run_block_proposal v0 and record_credit_inflows_for_withdrawals v0 to the base. - Subtract every queued and broadcast unlock from state alone instead of asking getassetunlockstatuses every pooling block; only a backlog beyond update_broadcasted_withdrawal_statuses' batch is then subtracted after Core mined it, which only lowers the limit. - Pooling 2 reads the Core side whenever withdrawals are queued, so both gauges stay current; the pooling helper takes its closure before platform_version, and version 1's extraction no longer reads the oldest amount first. - The scan records its Core blocks in one batch (Drive::record_core_credit_pool_blocks). - Coinbase balance read with deserialize_partial::. - drive-abci refuses to start while Core will not answer getspecialtxes for the consensus user (a Core not restarted since its whitelist changed). - Genesis and transition_to_version_14 both insert the withdrawal limit trees last; book and comments updated. - Protocol version 14 note renumbered to 74; stale docs fixed. - Tests: scan, limit and cleanup over 600 Core blocks; every Core credit pool window at least the unlock validity. Co-Authored-By: Claude Opus 5.5 --- book/src/versioning/versioned-dispatch.md | 7 +- .../configs/defaults/getBaseConfigFactory.js | 2 +- .../configs/getConfigFileMigrationsFactory.js | 6 +- .../src/core/wait_for_core_to_sync/v0/mod.rs | 21 +- .../src/execution/check_tx/v0/mod.rs | 6 +- .../engine/run_block_proposal/v0/mod.rs | 9 +- .../v0/mod.rs | 30 +- .../execute_event/mod.rs | 4 +- .../execute_event/v0/mod.rs | 60 ++-- .../execute_event/v1/mod.rs | 9 +- .../process_raw_state_transitions/v0/mod.rs | 26 +- .../process_validation_result/mod.rs | 8 +- .../process_validation_result/v0/mod.rs | 8 +- .../process_validation_result/v1/mod.rs | 8 +- .../mod.rs | 16 +- .../v0/mod.rs | 221 ++++++++------ .../v1/mod.rs | 77 +++-- .../v1/mod.rs | 38 +-- .../v2/mod.rs | 27 +- .../mod.rs | 26 +- .../v0/mod.rs | 278 +----------------- .../v0/mod.rs | 23 +- .../tests.rs | 5 +- .../state_transitions/identity_top_up/mod.rs | 10 +- packages/rs-drive-abci/src/main.rs | 3 - .../platform_types/block_credit_mints/mod.rs | 115 -------- .../rs-drive-abci/src/platform_types/mod.rs | 2 - .../src/platform_types/platform/mock.rs | 13 +- .../mod.rs | 15 +- packages/rs-drive-abci/src/rpc/core.rs | 92 ++---- .../test_cases/address_tests.rs | 27 -- .../test_cases/withdrawal_tests.rs | 11 +- .../fetch_core_credit_pool_balances/v0/mod.rs | 17 +- .../fetch_in_flight_withdrawal_amount/mod.rs | 19 +- .../v0/mod.rs | 57 ++-- .../src/drive/identity/withdrawals/mod.rs | 2 +- .../mod.rs | 30 +- .../v0/mod.rs | 29 +- .../src/drive/initialization/v4/mod.rs | 7 +- packages/rs-drive/src/structure/tests.rs | 2 +- .../src/util/batch/drive_op_batch/mod.rs | 65 ---- .../drive_abci_method_versions/mod.rs | 11 +- .../drive_abci_method_versions/v10.rs | 5 +- .../drive_identity_method_versions/mod.rs | 4 +- .../drive_identity_method_versions/v1.rs | 2 +- .../drive_identity_method_versions/v2.rs | 4 +- .../src/version/system_limits/mod.rs | 45 ++- .../src/version/system_limits/v4.rs | 3 +- .../rs-platform-version/src/version/v14.rs | 37 +-- 49 files changed, 514 insertions(+), 1028 deletions(-) delete mode 100644 packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs rename packages/rs-drive/src/drive/identity/withdrawals/{record_core_credit_pool_block => record_core_credit_pool_blocks}/mod.rs (56%) rename packages/rs-drive/src/drive/identity/withdrawals/{record_core_credit_pool_block => record_core_credit_pool_blocks}/v0/mod.rs (52%) diff --git a/book/src/versioning/versioned-dispatch.md b/book/src/versioning/versioned-dispatch.md index 7a59a994be1..3031863e99e 100644 --- a/book/src/versioning/versioned-dispatch.md +++ b/book/src/versioning/versioned-dispatch.md @@ -705,8 +705,11 @@ The Drive helpers that build the initial state structure follow the same rule for the same reason: they run once, at chain creation, under the chain's initial protocol version, and a chain that already exists gets the same trees from its upgrade rung. `add_initial_withdrawal_state_structure_operations` -adds the withdrawal sum trees behind `>= 4` and the credit history trees behind -`>= 14`; replaying mainnet's genesis at protocol version 1 takes neither branch. +adds the withdrawal sum trees behind `>= 4`, which replaying mainnet's genesis at +protocol version 1 does not take. The withdrawal limit trees of protocol version +14 are not in that batch: genesis and `transition_to_version_14` both add them +one insert at a time through `Drive::insert_withdrawal_limit_trees`, so both +build the withdrawals Merk in the same shape. ## Rules diff --git a/packages/dashmate/configs/defaults/getBaseConfigFactory.js b/packages/dashmate/configs/defaults/getBaseConfigFactory.js index cb942444d0d..3722703b6fc 100644 --- a/packages/dashmate/configs/defaults/getBaseConfigFactory.js +++ b/packages/dashmate/configs/defaults/getBaseConfigFactory.js @@ -94,7 +94,7 @@ export default function getBaseConfigFactory() { 'getbestchainlock', 'getblockchaininfo', 'getrawtransaction', 'submitchainlock', 'verifychainlock', 'protxlistdiff', 'quorumlistextended', 'quoruminfo', 'getassetunlockstatuses', 'sendrawtransaction', 'mnsyncstatus', 'getblockheader', 'getblockhash', - 'getspecialtxes', 'gettxchainlocks', + 'getspecialtxes', ], lowPriority: false, }, diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index 6b9ec4ca66c..bba15f5f96c 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -1781,9 +1781,9 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) Object.entries(configFile.configs) .forEach(([, options]) => { // Drive's withdrawal limit (protocol version 14) reads Core's credit pool - // balance from each block's coinbase (getspecialtxes) and asks Core where - // asset locks were mined (gettxchainlocks). Core refuses both to the - // consensus user until its whitelist names them. + // balance from each block's coinbase (getspecialtxes). Core refuses it to the + // consensus user until its whitelist names it, and loads the whitelist only + // when Core itself restarts. if (options.core?.rpc?.users?.drive_consensus) { options.core.rpc.users.drive_consensus.whitelist = base.getStored('core.rpc.users.drive_consensus.whitelist'); } diff --git a/packages/rs-drive-abci/src/core/wait_for_core_to_sync/v0/mod.rs b/packages/rs-drive-abci/src/core/wait_for_core_to_sync/v0/mod.rs index d1f79b1e55d..d9b75631965 100644 --- a/packages/rs-drive-abci/src/core/wait_for_core_to_sync/v0/mod.rs +++ b/packages/rs-drive-abci/src/core/wait_for_core_to_sync/v0/mod.rs @@ -7,7 +7,8 @@ use std::time::Duration; const CORE_SYNC_STATUS_CHECK_TIMEOUT: Duration = Duration::from_secs(5); -/// Blocks execution until Core is synced +/// Blocks execution until Core is synced, then checks that Core answers the credit pool balance +/// read block execution needs from protocol version 14. /// This isn't in consensus, however we still version it just in case we will upgrade it on a /// version pub fn wait_for_core_to_sync_v0( @@ -42,5 +43,23 @@ pub fn wait_for_core_to_sync_v0( } } + if cancel.is_cancelled() { + return Ok(()); + } + + // From protocol version 14 every block reads Core's credit pool balance from a chain locked + // block's coinbase (`getspecialtxes`). Core loads the consensus user's `rpcwhitelist` only + // when Core itself starts, so a Core not restarted since its whitelist gained the method + // answers every other call until that version activates and refuses every block after. + // Ask once here, so such a node fails to start instead. + let chain_lock = core_rpc.get_best_chain_lock()?; + if let Err(error) = core_rpc.get_credit_pool_balance(chain_lock.block_height) { + tracing::error!( + ?error, + "core cannot read the credit pool balance (getspecialtxes); restart core so it loads the rpc whitelist of drive's consensus user" + ); + return Err(error.into()); + } + Ok(()) } diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index c65791c6ddf..d6ea3b552b8 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -4,8 +4,6 @@ use crate::execution::validation::state_transition::check_tx_verification::state use crate::execution::validation::state_transition::processor::traits::shielded_proof::{ StateTransitionHasShieldedProofValidationV0, StateTransitionShieldedProofValidationV0, }; -#[cfg(test)] -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::check_tx_proof_verifier::IdentityProofVerification; #[cfg(test)] @@ -74,8 +72,8 @@ where errors, state_read_guard.last_block_info(), transaction, - None, // address_balances_in_update not needed for check_tx - &mut BlockCreditMints::default(), // check_tx's transaction is discarded, its mints are never recorded + None, // address_balances_in_update not needed for check_tx + &mut 0, // check_tx's transaction is discarded, its mints are never recorded platform_ref.state.current_platform_version()?, platform_ref.state.previous_fee_versions(), ) diff --git a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs index da1587fe3fa..15d8242f2f3 100644 --- a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs @@ -485,12 +485,11 @@ where // Record the credits this block minted into Platform (asset locks funding state // transitions, epoch Core rewards) as a credit inflow: the daily withdrawal limit adds // inflows younger than its day-old base to the daily maximum, so it limits net outflow. - // A system event, so nobody pays fees for the write. Changed in place to pass the two - // mint sources apart instead of their sum, inert for protocol versions 1 to 13: the - // event is `None` there and reads neither. + // A system event, so nobody pays fees for the write. self.record_credit_inflows_for_withdrawals( - state_transitions_result.credit_mints(), - processed_block_fees.credit_mints, + state_transitions_result + .credit_mints() + .saturating_add(processed_block_fees.credit_mints), &block_info, transaction, platform_version, diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index c4c3f8dca9f..5dff975c6be 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -768,15 +768,6 @@ impl Platform { platform_version, )?; - // Withdrawal limit trees under the withdrawals tree: the total credits history (the - // daily withdrawal limit becomes a share of the total credits Platform held a day ago, - // recorded every block), the credit inflows sum tree (every credit mint, so the daily - // limit counts net outflow instead of gross) and Core's credit pool balance per Core - // block read (the Core-anchored withdrawal limit). Through the same helper as genesis, - // so both build the withdrawals Merk by the same sequence of inserts. - self.drive - .insert_withdrawal_limit_trees(Some(transaction), platform_version)?; - // Contract version items: from this version the storage writer stores every // contract's version as a four-byte item beside it, and // `getDataContractsLatestVersions` reads and proves that item instead of the @@ -824,10 +815,21 @@ impl Platform { // indexes every document of a type declaring a `ttl` (a keyword protocol version 14 // introduces) by when it expires, and the lifetime storage fee pools sum tree under // `Pools`, which holds their storage fees until an epoch change spreads them. Fresh - // chains call the same helper last in `create_initial_state_structure` v4. + // chains call the same helper in `create_initial_state_structure` v4, in the same + // position: just before the withdrawal limit trees. self.drive .insert_document_ttl_trees(Some(transaction), platform_version)?; + // Withdrawal limit trees under the withdrawals tree: the total credits history (the + // daily withdrawal limit becomes a share of the total credits Platform held a day ago, + // recorded every block), the credit inflows sum tree (every credit mint, so the daily + // limit counts net outflow instead of gross) and Core's credit pool balance per Core + // block read (the Core-anchored withdrawal limit). Through the same helper as genesis, + // which also calls it last, so both build the withdrawals Merk by the same sequence of + // inserts. + self.drive + .insert_withdrawal_limit_trees(Some(transaction), platform_version)?; + Ok(()) } } @@ -2284,10 +2286,12 @@ mod tests { } } - /// Genesis creates the withdrawal trees of version 14 in one batch, the upgrade adds the - /// new ones one insert at a time, and Merk's shape depends on insertion order: the + /// Merk's shape depends on insertion order, and genesis builds the withdrawals tree's first + /// keys in its batch while the upgrade adds the trees of version 14 to an existing one: + /// both insert those trees one at a time through `insert_withdrawal_limit_trees`, so the /// withdrawals tree element (its root key), every element below it and the shape of its - /// Merk are the same on a chain born at 14 and one upgraded to it. + /// Merk are the same on a chain born at 14 and one upgraded to it. Batching them into + /// genesis again would root the Merk at another key. #[test] fn should_build_the_withdrawal_trees_as_a_chain_born_at_14_does() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/mod.rs index 8ef44781b26..6ef82d537fd 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/mod.rs @@ -4,7 +4,6 @@ mod v1; use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::execution_event::ExecutionEvent; -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EventExecutionResult; use crate::platform_types::platform::Platform; use std::collections::BTreeMap; @@ -15,6 +14,7 @@ use dpp::balances::credits::CreditOperation; use dpp::block::block_info::BlockInfo; use dpp::consensus::ConsensusError; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::fee::Credits; use dpp::version::PlatformVersion; use drive::grovedb::Transaction; @@ -54,7 +54,7 @@ where block_info: &BlockInfo, transaction: &Transaction, address_balances_in_update: Option<&mut BTreeMap>, - block_credit_mints: &mut BlockCreditMints, + block_credit_mints: &mut Credits, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index 0e9672c56da..6bab00c6722 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -3,7 +3,6 @@ use crate::error::Error; use crate::execution::platform_events::state_transition_processing::record_added_balance_outputs::AddedBalanceOutputsOrigin; use crate::execution::types::execution_event::ExecutionEvent; use crate::execution::types::execution_operation::ValidationOperation; -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EventExecutionResult; use crate::platform_types::event_execution_result::EventExecutionResult::{ SuccessfulFreeExecution, SuccessfulPaidExecution, UnpaidConsensusExecutionError, @@ -44,17 +43,12 @@ where block_info: &BlockInfo, mut consensus_errors: Vec, transaction: &Transaction, - // Changed in place from `&mut Credits`, inert for protocol versions 1 to 13 (all that - // select this generation): the total is the same saturating sum of `credit_mints`, and - // the per-asset-lock part is read only by `record_credit_inflows_for_withdrawals`, - // which is `None` before 14. - block_credit_mints: &mut BlockCreditMints, + block_credit_mints: &mut Credits, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { if fee_validation_result.is_valid_with_data() { - // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. - let credit_mints = BlockCreditMints::of_operations(&operations); + let credit_mints = DriveOperation::credit_mints(&operations); //todo: make this into an atomic event with partial batches let mut individual_fee_result = self .drive @@ -68,7 +62,7 @@ where ) .map_err(Error::Drive)?; - block_credit_mints.add(credit_mints); + *block_credit_mints = block_credit_mints.saturating_add(credit_mints); ValidationOperation::add_many_to_fee_result( &execution_operations, @@ -126,17 +120,12 @@ where mut consensus_errors: Vec, transaction: &Transaction, mut address_balances_in_update: Option<&mut BTreeMap>, - // Changed in place from `&mut Credits`, inert for protocol versions 1 to 13 (all that - // select this generation): the total is the same saturating sum of `credit_mints`, and - // the per-asset-lock part is read only by `record_credit_inflows_for_withdrawals`, - // which is `None` before 14. - block_credit_mints: &mut BlockCreditMints, + block_credit_mints: &mut Credits, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { if fee_validation_result.is_valid_with_data() { - // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. - let credit_mints = BlockCreditMints::of_operations(&operations); + let credit_mints = DriveOperation::credit_mints(&operations); // Apply the drive operations first to calculate the fee let mut individual_fee_result = self .drive @@ -150,7 +139,7 @@ where ) .map_err(Error::Drive)?; - block_credit_mints.add(credit_mints); + *block_credit_mints = block_credit_mints.saturating_add(credit_mints); ValidationOperation::add_many_to_fee_result( &execution_operations, @@ -389,11 +378,7 @@ where block_info: &BlockInfo, transaction: &Transaction, address_balances_in_update: Option<&mut BTreeMap>, - // Changed in place from `&mut Credits`, inert for protocol versions 1 to 13 (all that - // select this generation): the total is the same saturating sum of `credit_mints`, and - // the per-asset-lock part is read only by `record_credit_inflows_for_withdrawals`, - // which is `None` before 14. - block_credit_mints: &mut BlockCreditMints, + block_credit_mints: &mut Credits, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { @@ -515,8 +500,7 @@ where processing_fees, operations, } => { - // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. - let credit_mints = BlockCreditMints::of_operations(&operations); + let credit_mints = DriveOperation::credit_mints(&operations); self.drive .apply_drive_operations( operations, @@ -528,7 +512,7 @@ where ) .map_err(Error::Drive)?; - block_credit_mints.add(credit_mints); + *block_credit_mints = block_credit_mints.saturating_add(credit_mints); if consensus_errors.is_empty() { Ok(SuccessfulPaidExecution( @@ -548,8 +532,7 @@ where fees_to_add_to_pool, } => { if consensus_errors.is_empty() { - // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. - let credit_mints = BlockCreditMints::of_operations(&operations); + let credit_mints = DriveOperation::credit_mints(&operations); self.drive .apply_drive_operations( operations, @@ -561,7 +544,7 @@ where ) .map_err(Error::Drive)?; - block_credit_mints.add(credit_mints); + *block_credit_mints = block_credit_mints.saturating_add(credit_mints); Ok(SuccessfulPaidExecution( None, @@ -602,8 +585,7 @@ where return Ok(UnpaidConsensusExecutionError(consensus_errors)); } - // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. - let credit_mints = BlockCreditMints::of_operations(&operations); + let credit_mints = DriveOperation::credit_mints(&operations); let applied_fees = self .drive .apply_drive_operations( @@ -616,7 +598,7 @@ where ) .map_err(Error::Drive)?; - block_credit_mints.add(credit_mints); + *block_credit_mints = block_credit_mints.saturating_add(credit_mints); // The ops just applied credited any transparent output address (an Unshield's // recipient, including the chargeable-failure fallback address). Record that credit @@ -665,8 +647,7 @@ where all_errors.extend(consensus_errors); if all_errors.is_empty() { - // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. - let credit_mints = BlockCreditMints::of_operations(&operations); + let credit_mints = DriveOperation::credit_mints(&operations); let applied_fees = self .drive .apply_drive_operations( @@ -679,7 +660,7 @@ where ) .map_err(Error::Drive)?; - block_credit_mints.add(credit_mints); + *block_credit_mints = block_credit_mints.saturating_add(credit_mints); // The ops just applied credited the shield's transparent surplus-output address // (when set). Record that credit so incremental client sync sees it. ShieldedSpend @@ -746,8 +727,7 @@ where ) } ExecutionEvent::Free { operations } => { - // In place, inert for protocol versions 1 to 13: see `block_credit_mints`. - let credit_mints = BlockCreditMints::of_operations(&operations); + let credit_mints = DriveOperation::credit_mints(&operations); self.drive .apply_drive_operations( operations, @@ -758,7 +738,7 @@ where Some(previous_fee_versions), ) .map_err(Error::Drive)?; - block_credit_mints.add(credit_mints); + *block_credit_mints = block_credit_mints.saturating_add(credit_mints); Ok(SuccessfulFreeExecution) } } @@ -805,7 +785,7 @@ mod tests { &BlockInfo::default(), &transaction, Some(&mut address_balances), - &mut BlockCreditMints::default(), + &mut 0, platform_version, &fee_versions, ) @@ -851,7 +831,7 @@ mod tests { &BlockInfo::default(), &transaction, Some(&mut address_balances), - &mut BlockCreditMints::default(), + &mut 0, platform_version, &fee_versions, ) @@ -894,7 +874,7 @@ mod tests { &BlockInfo::default(), &transaction, Some(&mut address_balances), - &mut BlockCreditMints::default(), + &mut 0, platform_version, &fee_versions, ) diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs index a3bd4818849..9c32fec557e 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs @@ -5,7 +5,6 @@ use crate::execution::platform_events::state_transition_processing::validate_fee use crate::execution::types::execution_event::ExecutionEvent; use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::signing_key_limits::SigningKeyLimits; -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EventExecutionResult; use crate::platform_types::event_execution_result::EventExecutionResult::{ SuccessfulPaidExecution, UnpaidConsensusExecutionError, UnsuccessfulPaidExecution, @@ -18,10 +17,12 @@ use dpp::block::block_info::BlockInfo; use dpp::consensus::ConsensusError; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::fee::fee_result::BalanceChange; +use dpp::fee::Credits; use dpp::version::PlatformVersion; use drive::drive::identity::update::apply_balance_change_outcome::ApplyBalanceChangeOutcomeV0Methods; use drive::grovedb::Transaction; use drive::state_transition_action::batch::{action_fee_operations, action_fees_total}; +use drive::util::batch::DriveOperation; use std::collections::BTreeMap; impl Platform @@ -61,7 +62,7 @@ where block_info: &BlockInfo, transaction: &Transaction, address_balances_in_update: Option<&mut BTreeMap>, - block_credit_mints: &mut BlockCreditMints, + block_credit_mints: &mut Credits, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result { @@ -161,7 +162,7 @@ where action_fees_total(&identity.id, &action_fees)? }; - let credit_mints = BlockCreditMints::of_operations(&operations); + let credit_mints = DriveOperation::credit_mints(&operations); let mut individual_fee_result = self .drive .apply_drive_operations( @@ -174,7 +175,7 @@ where ) .map_err(Error::Drive)?; - block_credit_mints.add(credit_mints); + *block_credit_mints = block_credit_mints.saturating_add(credit_mints); ValidationOperation::add_many_to_fee_result( &execution_operations, diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs index 693350dfadd..41ee5c9a904 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs @@ -1,10 +1,10 @@ use crate::error::Error; -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::platform::{Platform, PlatformRef}; use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; use dpp::consensus::codes::ErrorWithCode; +use dpp::fee::Credits; use crate::execution::types::state_transition_container::v0::{ DecodedStateTransition, InvalidStateTransition, InvalidWithProtocolErrorStateTransition, @@ -140,11 +140,8 @@ where // Credits the block's applied operations mint into Platform (asset locks), summed // across state transitions and recorded once per block as a credit inflow the net - // daily withdrawal limit adds to its daily maximum. Changed in place from `Credits`, - // inert for protocol versions 1 to 14 (all select this generation): its total is the - // same sum, and only `record_credit_inflows_for_withdrawals`, `None` before 14, reads - // it (from 14 also per asset lock, which is new there). - let mut block_credit_mints = BlockCreditMints::default(); + // daily withdrawal limit adds to its daily maximum. + let mut block_credit_mints: Credits = 0; for decoded_state_transition in state_transition_container.into_iter() { // If we propose state transitions, we need to check if we have a time limit for processing @@ -184,17 +181,12 @@ where } // Mark the state we can return to if this transition's result strips - // it from the block (see `rollback_dropped_transitions` above). + // it from the block (see `rollback_dropped_transitions` above). The + // mint accumulator mirrors applied state, so it rewinds with it. if rollback_dropped_transitions { transaction.set_savepoint(); } - // This transition's mints, merged into the block's below unless a - // rollback drops its writes: the mint accumulator mirrors applied - // state. Changed in place from a snapshot of the block's total, inert - // for protocol versions 1 to 14: merging one transition's saturating - // sum adds up to the same saturating total, and a dropped transition - // contributes nothing either way. - let mut transition_credit_mints = BlockCreditMints::default(); + let credit_mints_at_savepoint = block_credit_mints; // Validate state transition and produce an execution event let execution_result = process_state_transition( @@ -213,7 +205,7 @@ where validation_result, block_info, transaction, - &mut transition_credit_mints, + &mut block_credit_mints, platform_version, platform_ref.state.previous_fee_versions(), ) @@ -246,7 +238,7 @@ where // its mints with them, or the block would record a // credit inflow for a transition the proposal omits and // validators re-executing it would compute other state. - transition_credit_mints = BlockCreditMints::default(); + block_credit_mints = credit_mints_at_savepoint; // Any contract the transition rewrote was re-seeded into // the block cache as it was applied, and the rollback // just reverted it in state. Drop those copies so the @@ -280,8 +272,6 @@ where } } - block_credit_mints.add(transition_credit_mints); - // Store metrics let elapsed_time = start_time.elapsed() + decoding_elapsed_time; diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs index 99e9de6b3ee..f1cb18611ed 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs @@ -5,12 +5,12 @@ use super::StateTransitionAwareError; use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::execution_event::ExecutionEvent; -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::platform::Platform; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::fee::Credits; use dpp::validation::ConsensusValidationResult; use dpp::version::PlatformVersion; use drive::grovedb::Transaction; @@ -57,7 +57,7 @@ where validation_result: ConsensusValidationResult, block_info: &BlockInfo, transaction: &Transaction, - block_credit_mints: &mut BlockCreditMints, + block_credit_mints: &mut Credits, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result> { @@ -150,7 +150,7 @@ mod tests { validation_result, &BlockInfo::default(), &transaction, - &mut BlockCreditMints::default(), + &mut 0, platform_version, &fee_versions, ) @@ -161,7 +161,7 @@ mod tests { validation_result, &BlockInfo::default(), &transaction, - &mut BlockCreditMints::default(), + &mut 0, platform_version, &fee_versions, ) diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs index ebf281e81c4..c5ff68078dc 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v0/mod.rs @@ -1,6 +1,5 @@ use super::super::StateTransitionAwareError; use crate::execution::types::execution_event::ExecutionEvent; -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EventExecutionResult; use crate::platform_types::platform::Platform; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; @@ -8,6 +7,7 @@ use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::fee::fee_result::FeeResult; +use dpp::fee::Credits; use dpp::util::hash::hash_single; use dpp::validation::ConsensusValidationResult; use dpp::version::PlatformVersion; @@ -33,11 +33,7 @@ where mut validation_result: ConsensusValidationResult, block_info: &BlockInfo, transaction: &Transaction, - // Changed in place from `&mut Credits`, inert for protocol versions 1 to 12 (all that - // select this generation): it is only passed on to `execute_event`, whose total is - // the same sum, and the per-asset-lock part is read only by - // `record_credit_inflows_for_withdrawals` (`None` before 14, reading it is new in 14). - block_credit_mints: &mut BlockCreditMints, + block_credit_mints: &mut Credits, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result> { diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs index 816173b8782..d0ddd84cb6b 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/v1/mod.rs @@ -1,6 +1,5 @@ use super::super::StateTransitionAwareError; use crate::execution::types::execution_event::ExecutionEvent; -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EventExecutionResult; use crate::platform_types::platform::Platform; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; @@ -8,6 +7,7 @@ use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::fee::fee_result::FeeResult; +use dpp::fee::Credits; use dpp::util::hash::hash_single; use dpp::validation::ConsensusValidationResult; use dpp::version::PlatformVersion; @@ -32,11 +32,7 @@ where mut validation_result: ConsensusValidationResult, block_info: &BlockInfo, transaction: &Transaction, - // Changed in place from `&mut Credits`, inert for protocol versions 13 and 14 (all that - // select this generation): it is only passed on to `execute_event`, whose total is - // the same sum, and the per-asset-lock part is read only by - // `record_credit_inflows_for_withdrawals` (`None` before 14, reading it is new in 14). - block_credit_mints: &mut BlockCreditMints, + block_credit_mints: &mut Credits, platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result> { diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs index 7bb781fe5b0..2c4077c1d1a 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/mod.rs @@ -15,14 +15,14 @@ where { /// How many more credits withdrawals pooled now may take out of Core's credit pool: a /// stricter copy of Core's own asset unlock limit (`core_credit_pool_unlock_limit`), less - /// what is pooled and not mined yet. It reads Core's credit pool balance at the block's - /// chain locked height, and the highest balance among the window starts Core may measure - /// an unlock pooled now from: Core's credit pool window (576 blocks, 100 on regtest) back - /// from the chain locked height, up to `core_credit_pool_unlock_mining_delay_blocks` - /// later, as Core mines an unlock until that many blocks past the height it is signed at - /// while its window moves on and older deposits leave it. A balance the scan has not - /// recorded yet is read from Core, which every node answers alike for a chain locked - /// height. + /// what is queued or broadcast and not completed yet. It reads Core's credit pool balance + /// at the block's chain locked height, and the highest balance among the window starts + /// Core may measure an unlock pooled now from: Core's credit pool window (576 blocks, 100 + /// on regtest) back from the chain locked height, up to Core's asset unlock validity + /// (`withdrawal_constants.core_expiration_blocks`, 48) later, as Core mines an unlock + /// until that many blocks past the height it is signed at while its window moves on and + /// older deposits leave it. A balance the scan has not recorded yet is read from Core, + /// which every node answers alike for a chain locked height. /// /// # Parameters /// diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs index fa4a65a0c39..44e410b56af 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/calculate_core_anchored_withdrawal_limit/v0/mod.rs @@ -2,18 +2,14 @@ use crate::error::Error; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; -use dpp::dashcore_rpc::dashcore_rpc_json::AssetUnlockStatus; use dpp::fee::Credits; use dpp::identity::convert_duffs_to_credits; use dpp::version::PlatformVersion; use dpp::withdrawal::core_credit_pool_unlock_limit::{ core_credit_pool_unlock_limit, core_credit_pool_window_blocks, }; -use dpp::withdrawal::WithdrawalTransactionIndex; use drive::grovedb::TransactionArg; - -/// The most asset unlock indexes Core's `getassetunlockstatuses` answers in one call. -const MAX_ASSET_UNLOCK_STATUSES_PER_REQUEST: usize = 100; +use std::ops::RangeInclusive; impl Platform where @@ -47,69 +43,64 @@ where .checked_sub(window_blocks) { None => 0, - Some(nearest_window_start) => { - let farthest_window_start = chain_locked_height.saturating_sub(window_blocks); - let recorded = self.drive.fetch_core_credit_pool_balances( - farthest_window_start..=nearest_window_start, - transaction, - platform_version, - )?; - let mut highest: Credits = 0; - for core_height in farthest_window_start..=nearest_window_start { - let balance = match recorded.get(&core_height) { - Some(balance) => *balance, - None => self.core_credit_pool_balance_from_core(core_height)?, - }; - highest = highest.max(balance); - } - highest - } - }; - - let balance = match self - .drive - .fetch_core_credit_pool_balances( - chain_locked_height..=chain_locked_height, + Some(nearest_window_start) => self.highest_core_credit_pool_balance( + chain_locked_height.saturating_sub(window_blocks)..=nearest_window_start, transaction, platform_version, - )? - .get(&chain_locked_height) - { - Some(balance) => *balance, - None => self.core_credit_pool_balance_from_core(chain_locked_height)?, + )?, }; + let balance = self.highest_core_credit_pool_balance( + chain_locked_height..=chain_locked_height, + transaction, + platform_version, + )?; + let limit = core_credit_pool_unlock_limit(balance, window_start_balance, platform_version)?; - // Core's own limit only reflects unlocks already mined: subtract the queued ones and - // the broadcast ones Core has not mined by the chain locked height. The broadcast tree - // keeps mined ones until their documents are updated, a bounded number per Core block. + // Core's own limit only reflects unlocks already mined: subtract every queued and + // broadcast one. `update_broadcasted_withdrawal_statuses` removes the ones Core mined by + // the chain locked height from the broadcast tree in the first block at that height, + // up to its batch of withdrawal documents, and one signed since cannot be mined by it, + // so only a broadcast backlog beyond that batch is subtracted again after Core mined + // it: the limit is then lower than Core's, never higher, until the statuses catch up. let in_flight = self .drive .fetch_in_flight_withdrawal_amount(transaction, platform_version)?; - let broadcast_indices: Vec = - in_flight.broadcast.keys().copied().collect(); - let mut not_mined = in_flight.queued; - for indices in broadcast_indices.chunks(MAX_ASSET_UNLOCK_STATUSES_PER_REQUEST) { - let statuses = self.fetch_transactions_block_inclusion_status( - chain_locked_height, - indices, - platform_version, - )?; - for index in indices { - if statuses.get(index) != Some(&AssetUnlockStatus::Chainlocked) { - let amount = in_flight.broadcast.get(index).copied().unwrap_or_default(); - not_mined = not_mined.saturating_add(amount); - } - } - } - Ok(limit.saturating_sub(not_mined)) + Ok(limit.saturating_sub(in_flight)) + } + + /// The highest of Core's credit pool balances after the chain locked Core blocks at + /// `core_heights`, in credits: recorded by the scan where it has, read from Core otherwise. + fn highest_core_credit_pool_balance( + &self, + core_heights: RangeInclusive, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let recorded = self.drive.fetch_core_credit_pool_balances( + core_heights.clone(), + transaction, + platform_version, + )?; + let mut highest: Credits = 0; + for core_height in core_heights { + let balance = match recorded.get(&core_height) { + Some(balance) => *balance, + None => self.core_credit_pool_balance_from_core(core_height)?, + }; + highest = highest.max(balance); + } + Ok(highest) } /// Core's credit pool balance after the chain locked Core block at `core_height`, in - /// credits, read from Core because the scan has not recorded it (yet). - fn core_credit_pool_balance_from_core(&self, core_height: u32) -> Result { + /// credits, as Core answers it. + pub(in crate::execution::platform_events::withdrawals) fn core_credit_pool_balance_from_core( + &self, + core_height: u32, + ) -> Result { Ok(convert_duffs_to_credits( self.core_rpc.get_credit_pool_balance(core_height)?, )?) @@ -127,10 +118,11 @@ mod tests { AssetUnlockBasePayload, AssetUnlockBaseTransactionInfo, }; use dpp::dashcore::{ScriptBuf, TxOut}; - use dpp::dashcore_rpc::dashcore_rpc_json::{AssetUnlockStatus, AssetUnlockStatusResult}; + use dpp::fee::Credits; use dpp::version::PlatformVersion; use drive::grovedb::Transaction; use drive::util::batch::DriveOperation; + use std::sync::{Arc, Mutex}; const DUFFS_PER_DASH: u64 = 100_000_000; @@ -347,36 +339,19 @@ mod tests { ); } - /// Core's balance at the chain locked height already reflects a broadcast unlock it mined - /// by then, even while the broadcast tree still holds it. + /// Queued and broadcast unlocks are both subtracted, from state alone: Core is not asked + /// whether it mined a broadcast one (the mock has no answer for that and would panic). #[test] - fn should_not_subtract_a_broadcast_unlock_core_already_mined() { + fn should_subtract_queued_and_broadcast_unlocks_without_asking_core() { let mut platform = TestPlatformBuilder::new() .with_latest_protocol_version() .build_with_mock_rpc() .set_initial_state_structure(); - let mut core_rpc = core_with_balances(|_| 37_000); - core_rpc - .expect_get_asset_unlock_statuses() - .withf(|_, core_height| *core_height == 10_000) - .returning(|indices, _| { - Ok(indices - .iter() - .map(|index| AssetUnlockStatusResult { - index: *index, - status: if *index == 0 { - AssetUnlockStatus::Chainlocked - } else { - AssetUnlockStatus::Mempooled - }, - }) - .collect()) - }); - platform.core_rpc = core_rpc; + platform.core_rpc = core_with_balances(|_| 37_000); let platform_version = PlatformVersion::latest(); let transaction = platform.drive.grove.start_transaction(); - // Index 0 is mined, index 1 broadcast and not mined, index 2 still queued. + // Indices 0 and 1 broadcast, index 2 still queued. pool_withdrawals(&platform, &[0, 1, 2], 2, &transaction, platform_version); assert_eq!( @@ -387,10 +362,93 @@ mod tests { platform_version ) .expect("expected the limit"), - dash_to_credits!(3550) - 2_000_000 + dash_to_credits!(2550) - 3_000_000 ); } + /// The scan, the limit and the cleanup over a chain locked height that advances one Core + /// block per Platform block, as in run_block_proposal: once the scan has caught up, the + /// only balance asked of Core is the new chain locked height's, the limit follows a deposit + /// out of the band, and the cleanup keeps exactly the heights the band can still read. + #[test] + fn should_read_only_the_new_core_block_once_the_scan_has_caught_up() { + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + let platform_version = PlatformVersion::latest(); + // 37,000 Dash, plus a 5,000 Dash asset lock mined at Core height 10,000. + let reads = Arc::new(Mutex::new(vec![])); + let read = reads.clone(); + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc + .expect_get_credit_pool_balance() + .returning(move |core_height| { + read.lock().expect("lock").push(core_height); + let dash = if core_height >= 10_000 { + 42_000 + } else { + 37_000 + }; + Ok(dash * DUFFS_PER_DASH) + }); + platform.core_rpc = core_rpc; + let transaction = platform.drive.grove.start_transaction(); + + let run_block = |core_height: u32| -> Credits { + let block_info = BlockInfo { + time_ms: 1_000_000, + core_height, + ..Default::default() + }; + platform + .scan_core_blocks_for_withdrawals(&block_info, Some(&transaction), platform_version) + .expect("expected to scan"); + let limit = platform + .calculate_core_anchored_withdrawal_limit( + &block_info, + Some(&transaction), + platform_version, + ) + .expect("expected the limit"); + platform + .clean_up_expired_locks_of_withdrawal_amounts( + &block_info, + &transaction, + platform_version, + ) + .expect("expected the cleanup"); + limit + }; + + // The scan reads 32 Core blocks per Platform block: 577 from 9,424 to 10,000 take 19. + for _ in 0..19 { + run_block(10_000); + } + + for core_height in 10_001..=10_600 { + reads.lock().expect("lock").clear(); + let limit = run_block(core_height); + assert_eq!(*reads.lock().expect("lock"), vec![core_height]); + + // The deposit counts in full until the nearest window start reaches it. + let expected = if core_height < 10_528 { + dash_to_credits!(10550) + } else { + dash_to_credits!(6300) + }; + assert_eq!(limit, expected, "at Core height {core_height}"); + } + + let recorded: Vec = platform + .drive + .fetch_core_credit_pool_balances(0..=u32::MAX, Some(&transaction), platform_version) + .expect("expected the balances") + .into_keys() + .collect(); + assert_eq!(recorded, (10_600 - 576..=10_600).collect::>()); + } + /// Recorded balances are read from state; Core is asked only for what is missing. #[test] fn should_prefer_recorded_balances_to_asking_core() { @@ -406,9 +464,8 @@ mod tests { // The scan recorded 40,000 Dash at one window start of the band. platform .drive - .record_core_credit_pool_block( - 9_430, - dash_to_credits!(40000), + .record_core_credit_pool_blocks( + &[(9_430, dash_to_credits!(40000))], Some(&transaction), platform_version, ) diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs index 6d5fe70ccce..995d3e5d7b5 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/cleanup_expired_locks_of_withdrawal_amounts/v1/mod.rs @@ -40,50 +40,47 @@ where return Ok(()); } - let mut batch_operations = vec![]; - - for path in [ - get_withdrawal_transactions_sum_tree_path_vec(), - get_withdrawal_credit_inflows_sum_tree_path_vec(), - ] { - let mut path_query = PathQuery::new_single_query_item( - path, - QueryItem::RangeTo(..block_info.time_ms.to_be_bytes().to_vec()), - ); - - path_query.query.limit = Some(limit); - - self.drive.batch_delete_items_in_path_query( - &path_query, - true, - // we know that we are not deleting a subtree - BatchDeleteApplyType::StatefulBatchDelete { - is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), - }, - Some(transaction), - &mut batch_operations, - &platform_version.drive, - )?; - } - // The Core-anchored limit never reads a balance older than its farthest window start. - if let Some(oldest_read_height) = + let oldest_read_core_height = block_info .core_height .checked_sub(core_credit_pool_window_blocks( self.config.network, platform_version, - )?) - { - let mut path_query = PathQuery::new_single_query_item( - get_withdrawal_core_credit_pool_balances_path_vec(), - QueryItem::RangeTo(..oldest_read_height.to_be_bytes().to_vec()), - ); + )?); + + // Each tree with the key its expired entries sort below: the reservations and the + // credit inflows by the block time they stop counting, the recorded Core credit pool + // balances by Core height. + let expired_below = [ + Some(( + get_withdrawal_transactions_sum_tree_path_vec(), + block_info.time_ms.to_be_bytes().to_vec(), + )), + Some(( + get_withdrawal_credit_inflows_sum_tree_path_vec(), + block_info.time_ms.to_be_bytes().to_vec(), + )), + oldest_read_core_height.map(|core_height| { + ( + get_withdrawal_core_credit_pool_balances_path_vec(), + core_height.to_be_bytes().to_vec(), + ) + }), + ]; + + let mut batch_operations = vec![]; + + for (path, before_key) in expired_below.into_iter().flatten() { + let mut path_query = + PathQuery::new_single_query_item(path, QueryItem::RangeTo(..before_key)); + path_query.query.limit = Some(limit); self.drive.batch_delete_items_in_path_query( &path_query, true, + // we know that we are not deleting a subtree BatchDeleteApplyType::StatefulBatchDelete { is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), }, @@ -217,12 +214,14 @@ mod tests { .set_initial_state_structure(); let transaction = platform.drive.grove.start_transaction(); - for core_height in [423, 424, 425] { - platform - .drive - .record_core_credit_pool_block(core_height, 1, Some(&transaction), platform_version) - .expect("expected to record the block"); - } + platform + .drive + .record_core_credit_pool_blocks( + &[(423, 1), (424, 1), (425, 1)], + Some(&transaction), + platform_version, + ) + .expect("expected to record the blocks"); platform .cleanup_expired_locks_of_withdrawal_amounts_v1( diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs index 35beeec6826..2c079903994 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v1/mod.rs @@ -35,9 +35,7 @@ where ) -> Result<(), Error> { self.pool_withdrawals_up_to_limit_v1( block_info, - transaction, - platform_version, - |available, daily_maximum, _oldest_queued_amount| { + |available, daily_maximum| { let current_withdrawal_limit = available; // Store prometheus metrics @@ -47,24 +45,24 @@ where Ok(current_withdrawal_limit) }, + transaction, + platform_version, ) } /// Version 1's pooling, given the amount to pool up to once the daily withdrawal limit is - /// known (`current_withdrawal_limit`, called with its available amount, its daily maximum - /// and the amount of the oldest queued withdrawal, only when withdrawals are queued). - /// Extracted in place so version 2 can reuse it, inert for protocol versions 1 to 13 (those - /// selecting version 1, and through version 0, which delegates to it, those selecting - /// version 0): the closure of version 1 returns the available daily limit and sets the - /// gauges exactly where they were set before, and reading the oldest withdrawal's amount - /// first can fail only where the loop below fails on that same withdrawal, so every such - /// block pools the same documents and writes the same state. + /// known (`current_withdrawal_limit`, called with its available amount and its daily + /// maximum, only when withdrawals are queued). Extracted in place so version 2 can reuse + /// it, inert for protocol versions 1 to 13 (those selecting version 1, and through version + /// 0, which delegates to it, those selecting version 0): the closure of version 1 returns + /// the available daily limit and sets the gauges exactly where they were set before, so + /// every such block pools the same documents and writes the same state. pub(super) fn pool_withdrawals_up_to_limit_v1( &self, block_info: &BlockInfo, + current_withdrawal_limit: impl FnOnce(Credits, Credits) -> Result, transaction: TransactionArg, platform_version: &PlatformVersion, - current_withdrawal_limit: impl FnOnce(Credits, Credits, Credits) -> Result, ) -> Result<(), Error> { let documents = self.drive.fetch_oldest_withdrawal_documents_by_status( withdrawals_contract::WithdrawalStatus::QUEUED.into(), @@ -99,20 +97,8 @@ where "Calculated withdrawal limit info" ); - // Pooling stops at the first queued withdrawal over the limit, so nothing pools while - // the oldest one does not fit. - let oldest_queued_amount: u64 = match documents.first() { - Some(document) => document - .properties() - .get_integer(withdrawal::properties::AMOUNT)?, - None => 0, - }; - - let current_withdrawal_limit = current_withdrawal_limit( - withdrawals_info.available(), - withdrawals_info.daily_maximum, - oldest_queued_amount, - )?; + let current_withdrawal_limit = + current_withdrawal_limit(withdrawals_info.available(), withdrawals_info.daily_maximum)?; // Only process documents up to the current withdrawal limit. let mut total_withdrawal_amount = 0u64; diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs index e4b26c89c20..8bd5a66bad4 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/pool_withdrawals_into_transactions_queue/v2/mod.rs @@ -38,17 +38,9 @@ where self.pool_withdrawals_up_to_limit_v1( block_info, - transaction, - platform_version, - |available, daily_maximum, oldest_queued_amount| { - // Nothing fits Platform's own limit, and the Core side can only lower it: skip - // its reads and Core calls. - if available < oldest_queued_amount { - gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_AVAILABLE).set(available as f64); - gauge!(GAUGE_CREDIT_WITHDRAWAL_LIMIT_TOTAL).set(daily_maximum as f64); - return Ok(available); - } - + |available, daily_maximum| { + // Computed whenever withdrawals are queued, so its gauge stays current. Once the + // scan has recorded the band and the chain locked height, it reads state only. let core_anchored_withdrawal_limit = self .calculate_core_anchored_withdrawal_limit( block_info, @@ -73,6 +65,8 @@ where Ok(current_withdrawal_limit) }, + transaction, + platform_version, ) } } @@ -210,11 +204,12 @@ mod tests { assert_eq!(pool(PlatformVersion::latest(), 1000, 1).0, 1); } - /// While the oldest queued withdrawal does not fit Platform's own limit nothing pools, so - /// the Core side is not read: only the scan asks Core (32 Core blocks from 10,000 - 576), - /// not the 18 window starts and chain locked height it has not recorded yet. + /// While the oldest queued withdrawal does not fit Platform's own limit nothing pools, + /// however much Core's pool admits, and the Core side is still read so its gauge stays + /// current: the scan asks Core for 32 Core blocks from 10,000 - 576, the limit for the 17 + /// window starts and the chain locked height the scan has not recorded yet. #[test] - fn should_not_read_the_core_side_when_nothing_fits_the_daily_limit() { + fn should_pool_nothing_while_the_oldest_does_not_fit_the_daily_limit() { // 3,000 Dash each, over the flat 2,000 Dash before any history. assert_eq!( pool( @@ -222,7 +217,7 @@ mod tests { dash_to_credits!(3000), 1_000_000_000_000 ), - (0, 32) + (0, 50) ); assert_eq!( pool(PlatformVersion::latest(), 1000, 1_000_000_000_000), diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs index db1849a12b8..8e27a077d9b 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs @@ -2,7 +2,6 @@ mod v0; use crate::error::execution::ExecutionError; use crate::error::Error; -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; @@ -15,36 +14,30 @@ where C: CoreRPCLike, { /// Records the credits this block minted into Platform (asset locks funding state - /// transitions, and the epoch Core block rewards on an epoch change) as credit inflows + /// transitions, and the epoch Core block rewards on an epoch change) as a credit inflow /// the net daily withdrawal limit adds to its daily maximum, so money that entered /// Platform within the window may leave again without consuming the withdrawal budget of - /// other users. All are dated by the block, except the credits of an asset lock Core mined - /// so long ago that Core's own unlock limit reads it from its window start balance: those - /// add no inflow. + /// other users. /// /// Runs as a system event once per block, so nobody pays fees for the write; a block that /// minted nothing writes nothing. /// /// # Parameters /// - /// * `state_transition_mints`: The credits the block's state transitions minted, per asset - /// lock. - /// * `block_fee_mints`: The credits the block's fee processing minted (epoch Core rewards). - /// * `block_info`: The block being executed; its time dates the mints, and its Core chain - /// locked height decides which asset locks Core mined a window ago. + /// * `credit_mints`: The credits the block minted into Platform. + /// * `block_info`: The block being executed; its time sets when the inflow expires. /// * `transaction`: The GroveDB transaction. /// * `platform_version`: The platform version. /// /// # Returns /// - /// * `Ok(())` once the inflows are recorded, or at once when nothing was minted or the + /// * `Ok(())` once the inflow is recorded, or at once when `credit_mints` is zero or the /// protocol version has no credit inflows (the method version is `None`). - /// * `Err(Error)` when the method version (or a Drive method it calls) is unknown or not - /// active, Core cannot be asked, or a write fails. + /// * `Err(Error)` when the method version (or the Drive method it calls) is unknown or not + /// active, or the write fails. pub(in crate::execution) fn record_credit_inflows_for_withdrawals( &self, - state_transition_mints: &BlockCreditMints, - block_fee_mints: Credits, + credit_mints: Credits, block_info: &BlockInfo, transaction: &Transaction, platform_version: &PlatformVersion, @@ -57,8 +50,7 @@ where { None => Ok(()), Some(0) => self.record_credit_inflows_for_withdrawals_v0( - state_transition_mints, - block_fee_mints, + credit_mints, block_info, transaction, platform_version, diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs index e45e7163155..789f8cf5542 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/v0/mod.rs @@ -1,152 +1,48 @@ -use crate::error::execution::ExecutionError; use crate::error::Error; -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; -use dpp::dashcore::hashes::Hash; -use dpp::dashcore::Txid; use dpp::fee::Credits; use dpp::version::PlatformVersion; -use dpp::withdrawal::core_credit_pool_unlock_limit::core_credit_pool_window_blocks; use drive::grovedb::Transaction; impl Platform where C: CoreRPCLike, { - /// Records the block's mints with one `Drive::record_credit_inflow` call, which records - /// nothing for a zero amount. An asset lock Core mined longer ago than its credit pool - /// window minus `core_credit_pool_unlock_mining_delay_blocks` is left out: Core's own - /// limit already reads it from the window start balance, so it adds only a percent of - /// itself there, and counting it in full here would let a lock published to Platform late - /// raise the limit. Every other mint counts by the block time, the schedule the - /// withdrawal reservations follow, so a deposit and the withdrawal it funds cancel exactly. - /// Core is asked once for the whole block where it mined the asset locks. + /// Delegates to `Drive::record_credit_inflow`, which records nothing for a zero amount. pub(super) fn record_credit_inflows_for_withdrawals_v0( &self, - state_transition_mints: &BlockCreditMints, - block_fee_mints: Credits, + credit_mints: Credits, block_info: &BlockInfo, transaction: &Transaction, platform_version: &PlatformVersion, ) -> Result<(), Error> { - let mut credit_inflows = block_fee_mints - .saturating_add(state_transition_mints.not_attributed_to_an_asset_lock()); - - let asset_lock_mints: Vec<([u8; 32], Credits)> = state_transition_mints - .by_asset_lock() - .iter() - .filter(|(_, amount)| **amount > 0) - .map(|(asset_lock_txid, amount)| (*asset_lock_txid, *amount)) - .collect(); - - if !asset_lock_mints.is_empty() { - let unlock_validity_blocks = platform_version - .drive_abci - .withdrawal_constants - .core_expiration_blocks; - // Mined at or below the nearest window start the Core-anchored limit reads, the - // asset lock is inside the window start balance Core may measure an unlock pooled - // now from. - let stale_at_or_below = block_info.core_height.checked_sub( - core_credit_pool_window_blocks(self.config.network, platform_version)? - .saturating_sub(unlock_validity_blocks), - ); - - // Core answers from its active chain and transaction index; one that has not - // reached the chain locked height yet would report a mined asset lock as unknown. - // Fail the block on that node rather than record a different inflow. - self.core_rpc.get_block_hash(block_info.core_height)?; - - let txids: Vec = asset_lock_mints - .iter() - .map(|(asset_lock_txid, _)| Txid::from_byte_array(*asset_lock_txid)) - .collect(); - - let mined_heights = self.core_rpc.get_transactions_mined_heights(&txids)?; - // One height per asset lock, or the pairing below would drop the rest of the mints. - if mined_heights.len() != txids.len() { - return Err(Error::Execution(ExecutionError::DashCoreBadResponseError( - format!( - "expected {} mined heights, Core returned {}", - txids.len(), - mined_heights.len() - ), - ))); - } - - for ((_, amount), mined_height) in asset_lock_mints.into_iter().zip(mined_heights) { - // Only a height at or below the chain locked one is final and the same on - // every node; anything else (above it, in the mempool, unknown) counts. - let stale = mined_height.zip(stale_at_or_below).is_some_and( - |(mined_height, stale_at_or_below)| mined_height <= stale_at_or_below, - ); - if !stale { - credit_inflows = credit_inflows.saturating_add(amount); - } - } - } - - self.drive.record_credit_inflow( - credit_inflows, - block_info, - Some(transaction), - platform_version, - )?; - - Ok(()) + self.drive + .record_credit_inflow( + credit_mints, + block_info, + Some(transaction), + platform_version, + ) + .map_err(Error::Drive) } } #[cfg(test)] mod tests { - use crate::platform_types::block_credit_mints::BlockCreditMints; - use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TestPlatformBuilder; - use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; use dpp::block::block_info::BlockInfo; use dpp::block::epoch::Epoch; use dpp::dash_to_credits; - use dpp::dashcore::hashes::Hash; - use dpp::dashcore::BlockHash; - use dpp::dashcore::{OutPoint, Txid}; - use dpp::fee::Credits; - use dpp::platform_value::Bytes36; use dpp::version::PlatformVersion; use drive::drive::identity::withdrawals::paths::{ get_withdrawal_root_path, WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, }; - use drive::util::batch::{DriveOperation, SystemOperationType}; use drive::util::grove_operations::DirectQueryType; - fn asset_lock_mints(spends: &[(u8, Credits)]) -> BlockCreditMints { - let mut mints = BlockCreditMints::default(); - for (asset_lock, amount) in spends { - mints.add(BlockCreditMints::of_operations(&[ - DriveOperation::SystemOperation(SystemOperationType::AddToSystemCredits { - amount: *amount, - }), - DriveOperation::SystemOperation(SystemOperationType::AddUsedAssetLock { - asset_lock_outpoint: Bytes36::new( - OutPoint::new(Txid::from_byte_array([*asset_lock; 32]), 0).into(), - ), - asset_lock_value: AssetLockValue::new( - *amount, - vec![], - 0, - vec![], - PlatformVersion::latest(), - ) - .expect("expected an asset lock value"), - }), - ])); - } - mints - } - - /// The event records the block's other mints in the credit inflows sum tree, - /// accumulating within a block time, and records nothing for a block that minted nothing. + /// The event records the block's mints in the credit inflows sum tree, accumulating + /// within a block time, and records nothing for a block that minted nothing. #[test] fn should_record_the_blocks_mints_and_skip_zero() { let platform = TestPlatformBuilder::new() @@ -178,19 +74,12 @@ mod tests { }; platform - .record_credit_inflows_for_withdrawals( - &BlockCreditMints::default(), - 0, - &block_info, - &transaction, - platform_version, - ) + .record_credit_inflows_for_withdrawals(0, &block_info, &transaction, platform_version) .expect("expected to record nothing"); assert_eq!(inflows(&transaction), 0); platform .record_credit_inflows_for_withdrawals( - &BlockCreditMints::default(), dash_to_credits!(3), &block_info, &transaction, @@ -199,7 +88,6 @@ mod tests { .expect("expected to record"); platform .record_credit_inflows_for_withdrawals( - &BlockCreditMints::default(), dash_to_credits!(2), &block_info, &transaction, @@ -210,145 +98,6 @@ mod tests { assert_eq!(inflows(&transaction), dash_to_credits!(5) as i64); } - /// Asset lock mints count by the block time like every other mint, except one Core mined - /// so long ago that Core's own limit already reads it from its window start balance: - /// mined at or below the chain locked height minus Core's window (576 on mainnet) plus - /// `core_credit_pool_unlock_mining_delay_blocks` (48). - #[test] - fn should_leave_out_asset_locks_core_mined_a_window_ago() { - let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() - .build_with_mock_rpc() - .set_initial_state_structure(); - let platform_version = PlatformVersion::latest(); - - let mut core_rpc = MockCoreRPCLike::new(); - core_rpc - .expect_get_block_hash() - .withf(|core_height| *core_height == 1000) - .times(1) - .returning(|_| Ok(BlockHash::all_zeros())); - core_rpc - .expect_get_transactions_mined_heights() - .times(1) - .returning(|tx_ids| { - Ok(tx_ids - .iter() - .map(|txid| match txid.to_byte_array()[0] { - 1 => Some(995), // mined recently: counts - 2 => Some(472), // mined 528 blocks ago: left out - 3 => Some(473), // mined 527 blocks ago: counts - 4 => Some(1001), // above the chain locked height: counts - _ => None, // unknown or in the mempool: counts - }) - .collect()) - }); - platform.core_rpc = core_rpc; - - let transaction = platform.drive.grove.start_transaction(); - let block_info = BlockInfo { - time_ms: 1_000_000, - height: 100, - core_height: 1000, - epoch: Epoch::default(), - }; - - platform - .record_credit_inflows_for_withdrawals( - &asset_lock_mints(&[(1, 100), (2, 200), (3, 300), (4, 400), (5, 500)]), - dash_to_credits!(1), - &block_info, - &transaction, - platform_version, - ) - .expect("expected to record"); - - assert_eq!( - platform - .drive - .grove_get_sum_tree_total_value( - (&get_withdrawal_root_path()).into(), - &WITHDRAWAL_CREDIT_INFLOWS_SUM_TREE_KEY, - DirectQueryType::StatefulDirectQuery, - Some(&transaction), - &mut vec![], - &platform_version.drive, - ) - .expect("expected the sum"), - (dash_to_credits!(1) + 100 + 300 + 400 + 500) as i64 - ); - } - - /// A Core that has not reached the chain locked height would report an asset lock mined - /// below it as unknown; the block fails on that node instead of recording another inflow. - #[test] - fn should_fail_while_core_has_not_reached_the_chain_locked_height() { - let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() - .build_with_mock_rpc() - .set_initial_state_structure(); - let platform_version = PlatformVersion::latest(); - - let mut core_rpc = MockCoreRPCLike::new(); - core_rpc.expect_get_block_hash().returning(|_| { - Err(dpp::dashcore_rpc::Error::UnexpectedStructure( - "Block height out of range".to_string(), - )) - }); - core_rpc.expect_get_transactions_mined_heights().times(0); - platform.core_rpc = core_rpc; - - let transaction = platform.drive.grove.start_transaction(); - - assert!(platform - .record_credit_inflows_for_withdrawals( - &asset_lock_mints(&[(1, 100)]), - 0, - &BlockInfo { - core_height: 1000, - ..Default::default() - }, - &transaction, - platform_version, - ) - .is_err()); - } - - /// Core answering for fewer asset locks than asked fails the block instead of dropping the - /// mints it left out. - #[test] - fn should_fail_when_core_answers_fewer_mined_heights_than_asked() { - let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() - .build_with_mock_rpc() - .set_initial_state_structure(); - let platform_version = PlatformVersion::latest(); - - let mut core_rpc = MockCoreRPCLike::new(); - core_rpc - .expect_get_block_hash() - .returning(|_| Ok(BlockHash::all_zeros())); - core_rpc - .expect_get_transactions_mined_heights() - .returning(|_| Ok(vec![None])); - platform.core_rpc = core_rpc; - - let transaction = platform.drive.grove.start_transaction(); - - assert!(platform - .record_credit_inflows_for_withdrawals( - &asset_lock_mints(&[(1, 100), (2, 200)]), - 0, - &BlockInfo { - core_height: 1000, - ..Default::default() - }, - &transaction, - platform_version, - ) - .is_err()); - } - /// Before protocol version 14 the version slot is `None` and the event does nothing. #[test] fn should_do_nothing_before_the_feature_exists() { @@ -362,7 +111,6 @@ mod tests { platform .record_credit_inflows_for_withdrawals( - &asset_lock_mints(&[(1, 100)]), dash_to_credits!(3), &BlockInfo::default(), &transaction, diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs index c770b66b405..dc0698c11b0 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/scan_core_blocks_for_withdrawals/v0/mod.rs @@ -2,7 +2,6 @@ use crate::error::Error; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; -use dpp::identity::convert_duffs_to_credits; use dpp::version::PlatformVersion; use dpp::withdrawal::core_credit_pool_unlock_limit::core_credit_pool_window_blocks; use drive::grovedb::TransactionArg; @@ -51,17 +50,17 @@ where let last_height = chain_locked_height.min(first_height.saturating_add(u32::from(limit) - 1)); - for core_height in first_height..=last_height { - let credit_pool_balance = - convert_duffs_to_credits(self.core_rpc.get_credit_pool_balance(core_height)?)?; - - self.drive.record_core_credit_pool_block( - core_height, - credit_pool_balance, - transaction, - platform_version, - )?; - } + let balances = (first_height..=last_height) + .map(|core_height| { + Ok(( + core_height, + self.core_credit_pool_balance_from_core(core_height)?, + )) + }) + .collect::, Error>>()?; + + self.drive + .record_core_credit_pool_blocks(&balances, transaction, platform_version)?; Ok(()) } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index d3f86183e80..92e876c80e4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -24,7 +24,6 @@ use super::state::v0::IdentityCreateFromShieldedPoolStateTransitionStateValidationV0; use super::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::execution::validation::state_transition::state_transitions::test_helpers::{ insert_anchor_into_state, insert_dummy_encrypted_notes, set_pool_total_balance, setup_platform, }; @@ -578,7 +577,7 @@ fn failure_path_charge_executes_through_execute_event() { &block_info, &transaction, None, - &mut BlockCreditMints::default(), + &mut 0, platform_version, &fee_versions, ) @@ -1048,7 +1047,7 @@ fn executed_transition_result_proof_roundtrips() { &block_info, &transaction, None, - &mut BlockCreditMints::default(), + &mut 0, platform_version, &fee_versions, ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs index 6e2d12e1b12..d6a54ae5397 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs @@ -106,7 +106,6 @@ impl StateTransitionBasicStructureValidationV0 for IdentityTopUpTransition { #[cfg(test)] mod tests { use crate::config::{PlatformConfig, PlatformTestConfig}; - use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::test::helpers::setup::TestPlatformBuilder; use dpp::block::block_info::BlockInfo; use dpp::dashcore::{Network, PrivateKey}; @@ -533,14 +532,9 @@ mod tests { assert_eq!(control_result.valid_count(), 1); let expected_mint = control_result.credit_mints(); assert!( - expected_mint.total() > 0, + expected_mint > 0, "sanity: a successful asset-lock top up must report its mint" ); - assert_eq!( - expected_mint.by_asset_lock().values().sum::(), - expected_mint.total(), - "sanity: the top up's mint is attributed to the asset lock it spent" - ); platform .drive .grove @@ -579,7 +573,7 @@ mod tests { ); assert_eq!( processing_result.credit_mints(), - &BlockCreditMints::default(), + 0, "PHANTOM MINT: a transition dropped from the proposal left its mint in the \ block's credit-mint accumulator" ); diff --git a/packages/rs-drive-abci/src/main.rs b/packages/rs-drive-abci/src/main.rs index 70c70954b35..b5d964684d5 100644 --- a/packages/rs-drive-abci/src/main.rs +++ b/packages/rs-drive-abci/src/main.rs @@ -454,9 +454,6 @@ mod snapshot_bake_main { fn get_credit_pool_balance(&self, _: u32) -> Result { unreachable!() } - fn get_transactions_mined_heights(&self, _: &[Txid]) -> Result>, Error> { - unreachable!() - } } /// Produce a shielded-pool snapshot at `out_path` from a fresh temporary diff --git a/packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs b/packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs deleted file mode 100644 index 069508211c2..00000000000 --- a/packages/rs-drive-abci/src/platform_types/block_credit_mints/mod.rs +++ /dev/null @@ -1,115 +0,0 @@ -use dpp::fee::Credits; -use drive::util::batch::DriveOperation; -use std::collections::BTreeMap; - -/// The credits a block's applied state transitions minted into Platform, in total and per asset -/// lock transaction they came from. The daily withdrawal limit leaves out the mints of an asset -/// lock Core mined a whole credit pool window ago, and counts every other mint. -/// -/// Mirrors applied state: the block loop rewinds it with the state a dropped transition rolls -/// back, or the block would record an inflow for a transition the proposal omits. -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct BlockCreditMints { - total: Credits, - by_asset_lock: BTreeMap<[u8; 32], Credits>, -} - -impl BlockCreditMints { - /// The mints of one batch of operations (one executed state transition): their - /// `AddToSystemCredits` total, attributed to the asset lock they spent when they name one. - pub fn of_operations(operations: &[DriveOperation]) -> Self { - let mut mints = BlockCreditMints { - total: DriveOperation::credit_mints(operations), - by_asset_lock: BTreeMap::new(), - }; - if let Some((asset_lock_txid, amount)) = DriveOperation::asset_lock_credit_mints(operations) - { - mints.by_asset_lock.insert(asset_lock_txid, amount); - } - mints - } - - /// Adds the mints of another batch, saturating like the total always has. - pub fn add(&mut self, other: BlockCreditMints) { - self.total = self.total.saturating_add(other.total); - for (asset_lock_txid, amount) in other.by_asset_lock { - let minted = self.by_asset_lock.entry(asset_lock_txid).or_default(); - *minted = minted.saturating_add(amount); - } - } - - /// Every credit the block's state transitions minted. - pub fn total(&self) -> Credits { - self.total - } - - /// The credits minted per asset lock transaction id (in the byte order `Txid` holds it). - pub fn by_asset_lock(&self) -> &BTreeMap<[u8; 32], Credits> { - &self.by_asset_lock - } - - /// The credits minted without naming one asset lock: none in practice, as every state - /// transition that mints spends one. - pub fn not_attributed_to_an_asset_lock(&self) -> Credits { - let attributed = self - .by_asset_lock - .values() - .fold(0u64, |sum, amount| sum.saturating_add(*amount)); - self.total.saturating_sub(attributed) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; - use dpp::platform_value::Bytes36; - use dpp::version::PlatformVersion; - use drive::util::batch::SystemOperationType; - - fn spend(asset_lock: u8, amount: Credits) -> Vec> { - let mut outpoint = [asset_lock; 36]; - outpoint[32..].copy_from_slice(&0u32.to_le_bytes()); - vec![ - DriveOperation::SystemOperation(SystemOperationType::AddToSystemCredits { amount }), - DriveOperation::SystemOperation(SystemOperationType::AddUsedAssetLock { - asset_lock_outpoint: Bytes36::new(outpoint), - asset_lock_value: AssetLockValue::new( - amount, - vec![], - 0, - vec![], - PlatformVersion::latest(), - ) - .expect("expected an asset lock value"), - }), - ] - } - - #[test] - fn should_sum_mints_per_asset_lock_and_in_total() { - let mut mints = BlockCreditMints::default(); - mints.add(BlockCreditMints::of_operations(&spend(1, 300))); - mints.add(BlockCreditMints::of_operations(&spend(2, 500))); - mints.add(BlockCreditMints::of_operations(&spend(1, 200))); - - assert_eq!(mints.total(), 1_000); - assert_eq!( - mints.by_asset_lock(), - &BTreeMap::from([([1; 32], 500), ([2; 32], 500)]) - ); - assert_eq!(mints.not_attributed_to_an_asset_lock(), 0); - } - - #[test] - fn should_count_a_mint_naming_no_asset_lock_in_the_total_only() { - let mut mints = BlockCreditMints::default(); - mints.add(BlockCreditMints::of_operations(&[ - DriveOperation::SystemOperation(SystemOperationType::AddToSystemCredits { amount: 70 }), - ])); - mints.add(BlockCreditMints::of_operations(&spend(3, 30))); - - assert_eq!(mints.total(), 100); - assert_eq!(mints.not_attributed_to_an_asset_lock(), 70); - } -} diff --git a/packages/rs-drive-abci/src/platform_types/mod.rs b/packages/rs-drive-abci/src/platform_types/mod.rs index 89253354d30..0f3b33981de 100644 --- a/packages/rs-drive-abci/src/platform_types/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/mod.rs @@ -1,5 +1,3 @@ -/// The credits a block's state transitions minted into Platform, per asset lock -pub mod block_credit_mints; /// The outcome of a block execution pub mod block_execution_outcome; /// The block proposal diff --git a/packages/rs-drive-abci/src/platform_types/platform/mock.rs b/packages/rs-drive-abci/src/platform_types/platform/mock.rs index 5055caa5a4f..8b5fb08caaf 100644 --- a/packages/rs-drive-abci/src/platform_types/platform/mock.rs +++ b/packages/rs-drive-abci/src/platform_types/platform/mock.rs @@ -35,18 +35,15 @@ impl Platform { })) }); - // A credit pool of 10 million Dash at every height: the Core-anchored withdrawal limit - // never binds unless a test sets its own answers. + // A credit pool of 10 million Dash at every height, so the Core-anchored withdrawal limit + // never binds. Registered first: mockall uses the first matching expectation, so an + // answer a test adds later on this platform is never reached. To bind the Core side, + // call `core_rpc.checkpoint()` (which also drops the answers above) and set every + // answer the test needs, or replace `core_rpc` with a new `MockCoreRPCLike`. core_rpc_mock .expect_get_credit_pool_balance() .returning(|_| Ok(1_000_000_000_000_000)); - // Core knows no asset lock, so every asset lock mint counts as a credit inflow, as one - // Core mined recently does. - core_rpc_mock - .expect_get_transactions_mined_heights() - .returning(|tx_ids| Ok(vec![None; tx_ids.len()])); - Self::open_with_client(path, config, core_rpc_mock, initial_protocol_version) } diff --git a/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs b/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs index 9509859efb0..1c26adde5f2 100644 --- a/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs @@ -1,10 +1,10 @@ use dpp::address_funds::PlatformAddress; use dpp::balances::credits::CreditOperation; use dpp::consensus::ConsensusError; +use dpp::fee::Credits; use std::collections::BTreeMap; use crate::error::Error; -use crate::platform_types::block_credit_mints::BlockCreditMints; use crate::platform_types::event_execution_result::EstimatedFeeResult; use dpp::fee::fee_result::FeeResult; @@ -69,7 +69,7 @@ pub struct StateTransitionsProcessingResult { valid_count: usize, failed_count: usize, fees: FeeResult, - credit_mints: BlockCreditMints, + credit_mints: Credits, } impl StateTransitionsProcessingResult { @@ -177,16 +177,15 @@ impl StateTransitionsProcessingResult { } /// Sets the credits the block's applied state transitions minted into Platform - pub fn set_credit_mints(&mut self, credit_mints: BlockCreditMints) { + pub fn set_credit_mints(&mut self, credit_mints: Credits) { self.credit_mints = credit_mints; } /// Returns the credits the block's applied state transitions minted into Platform - /// (their `AddToSystemCredits` operations), in total and per asset lock: the - /// state-transition share of the block's credit inflow, recorded for the net daily - /// withdrawal limit. - pub fn credit_mints(&self) -> &BlockCreditMints { - &self.credit_mints + /// (their `AddToSystemCredits` operations): the state-transition share of the block's + /// credit inflow, recorded for the net daily withdrawal limit. + pub fn credit_mints(&self) -> Credits { + self.credit_mints } /// Returns the aggregated fees diff --git a/packages/rs-drive-abci/src/rpc/core.rs b/packages/rs-drive-abci/src/rpc/core.rs index 139011c6a08..749936920c7 100644 --- a/packages/rs-drive-abci/src/rpc/core.rs +++ b/packages/rs-drive-abci/src/rpc/core.rs @@ -1,9 +1,8 @@ use crate::rpc::prefetch::CorePrefetcher; -use dpp::dashcore::consensus::encode::{self, deserialize_partial}; -use dpp::dashcore::consensus::Decodable; +use dpp::dashcore::consensus::encode::deserialize_partial; use dpp::dashcore::ephemerealdata::chain_lock::ChainLock; -use dpp::dashcore::transaction::special_transaction::coinbase::CoinbasePayload; -use dpp::dashcore::{Block, BlockHash, QuorumHash, Transaction, TxIn, TxOut, Txid}; +use dpp::dashcore::transaction::special_transaction::TransactionPayload; +use dpp::dashcore::{Block, BlockHash, QuorumHash, Transaction, Txid}; use dpp::dashcore::{Header, InstantLock}; use dpp::dashcore_rpc::dashcore_rpc_json::{ AssetUnlockStatusResult, ExtendedQuorumDetails, ExtendedQuorumListResult, GetChainTipsResult, @@ -19,45 +18,33 @@ use std::time::Duration; /// Information returned by QuorumListExtended pub type QuorumListExtendedInfo = HashMap; -/// The most transaction ids Core's `gettxchainlocks` answers in one call. -const MAX_TRANSACTIONS_PER_CHAIN_LOCK_STATUS_REQUEST: usize = 100; - /// The special transaction type of a coinbase (`TRANSACTION_COINBASE` in Dash Core). const COINBASE_TRANSACTION_TYPE: u16 = 5; /// Reads Core's credit pool balance after a block, in duffs, from the block's serialized -/// coinbase transaction; `0` when its payload predates version 3, before the credit pool -/// existed, which is how Core's own unlock limit reads such a block. +/// coinbase transaction; `0` when it carries no payload or its payload predates version 3, +/// before the credit pool existed, which is how Core's own unlock limit reads such a block. /// -/// The parts are read with dashcore's decoders, but not as one `Transaction`: the pinned decoder -/// ignores the payload's length prefix and cannot read the version 4 payload Core v24 requires, -/// which appends `merkleRootAssetUnlocks` after the balance. So the payload is taken by its own -/// length and read only up to the balance. Core has only ever appended fields to it, and its -/// consensus rules (`CheckCbTx`) refuse versions it does not know, so the balance stays where -/// version 3 put it unless a Core release moves it, which Platform would have to follow anyway. +/// Decoded with `deserialize_partial`: the pinned payload decoder reads the fields of version 3 +/// for every later version and stops after the balance, while the version 4 payload Core v24 +/// requires appends `merkleRootAssetUnlocks` after it. A strict `deserialize`, and so a whole +/// `Block` decode, refuses those unread bytes. Core has only ever appended fields to the +/// payload, and its consensus rules (`CheckCbTx`) refuse versions it does not know, so the +/// balance stays where version 3 put it unless a Core release moves it, which Platform would +/// have to follow anyway. pub(crate) fn credit_pool_balance_from_coinbase(coinbase: &[u8]) -> Result { - let decode_error = |e: encode::Error| format!("coinbase cannot be decoded: {e}"); - let mut reader = coinbase; - - let version_and_type = u32::consensus_decode(&mut reader).map_err(decode_error)?; - Vec::::consensus_decode(&mut reader).map_err(decode_error)?; - Vec::::consensus_decode(&mut reader).map_err(decode_error)?; - u32::consensus_decode(&mut reader).map_err(decode_error)?; // the lock time - - let version = (version_and_type & 0xffff) as u16; - let transaction_type = (version_and_type >> 16) as u16; - if version < 3 || transaction_type == 0 { - return Ok(0); - } - if transaction_type != COINBASE_TRANSACTION_TYPE { - return Err(format!( - "coinbase has special transaction type {transaction_type}" - )); + let (transaction, _) = deserialize_partial::(coinbase) + .map_err(|e| format!("coinbase cannot be decoded: {e}"))?; + match transaction.special_transaction_payload { + None => Ok(0), + Some(TransactionPayload::CoinbasePayloadType(payload)) => { + Ok(payload.asset_locked_amount.unwrap_or_default()) + } + Some(payload) => Err(format!( + "coinbase carries a {:?} payload", + payload.get_type() + )), } - - let payload = Vec::::consensus_decode(&mut reader).map_err(decode_error)?; - let (payload, _) = deserialize_partial::(&payload).map_err(decode_error)?; - Ok(payload.asset_locked_amount.unwrap_or_default()) } /// Core height must be of type u32 (Platform heights are u64) @@ -175,15 +162,6 @@ pub trait CoreRPCLike { /// block's coinbase (only the coinbase is transferred). Only ask for a chain locked height: /// the answer is then the same on every node. fn get_credit_pool_balance(&self, height: CoreHeight) -> Result; - - /// Get the height of the active chain block each transaction was mined in, in the order of - /// `tx_ids`; `None` for one Core does not know, or holds in its mempool only. A height is - /// the same on every node only when it is at or below a chain locked height, so callers - /// treat anything above theirs as not mined yet. - fn get_transactions_mined_heights( - &self, - tx_ids: &[Txid], - ) -> Result>, Error>; } #[derive(Debug)] @@ -466,30 +444,6 @@ impl CoreRPCLike for DefaultCoreRPC { })?; credit_pool_balance_from_coinbase(&coinbase).map_err(Error::UnexpectedStructure) } - - fn get_transactions_mined_heights( - &self, - tx_ids: &[Txid], - ) -> Result>, Error> { - let mut heights = Vec::with_capacity(tx_ids.len()); - for chunk in tx_ids.chunks(MAX_TRANSACTIONS_PER_CHAIN_LOCK_STATUS_REQUEST) { - let statuses: Vec<_> = retry!(self.inner.get_transaction_are_locked(chunk))?; - if statuses.len() != chunk.len() { - return Err(Error::UnexpectedStructure(format!( - "gettxchainlocks answered {} of {} transactions", - statuses.len(), - chunk.len() - ))); - } - // Core reports -1 for a transaction it does not know or holds in its mempool only. - heights.extend( - statuses.into_iter().map(|status| { - status.and_then(|status| CoreHeight::try_from(status.height).ok()) - }), - ); - } - Ok(heights) - } } #[cfg(test)] diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs index 3332292351a..e048c50ba76 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs @@ -29,7 +29,6 @@ mod tests { use dpp::dash_to_credits; use dpp::dashcore::hashes::Hash; use dpp::dashcore::QuorumHash; - use dpp::dashcore_rpc::dashcore_rpc_json::{AssetUnlockStatus, AssetUnlockStatusResult}; use dpp::data_contract::TokenConfiguration; use dpp::identity::{KeyType, Purpose, SecurityLevel}; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; @@ -1195,19 +1194,6 @@ mod tests { .core_rpc .expect_send_raw_transaction() .returning(move |_| Ok(Txid::all_zeros())); - // Core has mined none of them (pooling asks to subtract what is still in flight). - platform - .core_rpc - .expect_get_asset_unlock_statuses() - .returning(|indices, _| { - Ok(indices - .iter() - .map(|index| AssetUnlockStatusResult { - index: *index, - status: AssetUnlockStatus::Unknown, - }) - .collect()) - }); let outcome = run_chain_for_strategy( &mut platform, @@ -1814,19 +1800,6 @@ mod tests { .core_rpc .expect_send_raw_transaction() .returning(move |_| Ok(Txid::all_zeros())); - // Core has mined none of them (pooling asks to subtract what is still in flight). - platform - .core_rpc - .expect_get_asset_unlock_statuses() - .returning(|indices, _| { - Ok(indices - .iter() - .map(|index| AssetUnlockStatusResult { - index: *index, - status: AssetUnlockStatus::Unknown, - }) - .collect()) - }); let outcome = run_chain_for_strategy( &mut platform, diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs index b1c65b4d626..85c8007e9dc 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/withdrawal_tests.rs @@ -2320,8 +2320,6 @@ mod tests { .core_rpc .expect_get_asset_unlock_statuses() .returning(move |indices, _| { - // An index the test has not set a status for is one Core has not mined; - // pooling asks for the broadcast ones to subtract what is still in flight. Ok(indices .iter() .map(|index| { @@ -2331,10 +2329,7 @@ mod tests { .asset_unlock_statuses .get(index) .cloned() - .unwrap_or(AssetUnlockStatusResult { - index: *index, - status: AssetUnlockStatus::Unknown, - }) + .unwrap() }) .collect()) }); @@ -3134,10 +3129,6 @@ mod tests { .core_rpc .expect_get_credit_pool_balance() .returning(|_| Ok(dash_to_duffs!(120))); - platform - .core_rpc - .expect_get_transactions_mined_heights() - .returning(|tx_ids| Ok(vec![None; tx_ids.len()])); platform .core_rpc .expect_send_raw_transaction() diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs index 43a6ce94212..b675374c0e9 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_core_credit_pool_balances/v0/mod.rs @@ -80,16 +80,13 @@ mod tests { None ); - for (core_height, balance) in [(10u32, 1_000u64), (11, 1_100), (12, 1_200)] { - drive - .record_core_credit_pool_block( - core_height, - balance, - Some(&transaction), - platform_version, - ) - .expect("expected to record the block"); - } + drive + .record_core_credit_pool_blocks( + &[(10, 1_000), (11, 1_100), (12, 1_200)], + Some(&transaction), + platform_version, + ) + .expect("expected to record the blocks"); assert_eq!( drive diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs index eb11aeed31b..cefc9b09381 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/mod.rs @@ -4,28 +4,15 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use dpp::fee::Credits; -use dpp::withdrawal::WithdrawalTransactionIndex; use grovedb::TransactionArg; use platform_version::version::PlatformVersion; -use std::collections::BTreeMap; - -/// What the pooled withdrawal transactions not completed yet take out of Core's credit pool -/// once mined, in credits. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct InFlightWithdrawalAmounts { - /// The sum over the queued transactions, which Core has not seen yet. - pub queued: Credits, - /// Each broadcast transaction's amount by its index: Core may have mined some of them - /// already, which the broadcast tree only learns of a bounded number at a time. - pub broadcast: BTreeMap, -} impl Drive { /// Reads what the pooled withdrawal transactions not completed yet (the queue and the /// broadcast tree) will take out of Core's credit pool once mined, in credits: each /// transaction's outputs plus its fee, as Core counts an asset unlock. Core's own unlock /// limit only reflects unlocks already mined, so the Core-anchored withdrawal limit - /// subtracts the queued sum and the broadcast transactions Core has not mined. + /// subtracts this sum. /// /// # Parameters /// @@ -34,14 +21,14 @@ impl Drive { /// /// # Returns /// - /// * `Ok(InFlightWithdrawalAmounts)`: The queued sum and the broadcast amounts, in credits. + /// * `Ok(Credits)`: The sum over the queued and broadcast transactions, in credits. /// * `Err(Error)` when the method version is unknown or not active, a stored transaction /// cannot be decoded, or the sum overflows. pub fn fetch_in_flight_withdrawal_amount( &self, transaction: TransactionArg, platform_version: &PlatformVersion, - ) -> Result { + ) -> Result { match platform_version .drive .methods diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs index 2f23b674943..9cabc3fa0d6 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_in_flight_withdrawal_amount/v0/mod.rs @@ -1,4 +1,3 @@ -use crate::drive::identity::withdrawals::fetch_in_flight_withdrawal_amount::InFlightWithdrawalAmounts; use crate::drive::identity::withdrawals::paths::{ get_withdrawal_transactions_broadcasted_path_vec, get_withdrawal_transactions_queue_path_vec, }; @@ -9,7 +8,6 @@ use dpp::dashcore::consensus::Decodable; use dpp::dashcore::transaction::special_transaction::asset_unlock::unqualified_asset_unlock::AssetUnlockBaseTransactionInfo; use dpp::fee::Credits; use dpp::identity::convert_duffs_to_credits; -use dpp::withdrawal::WithdrawalTransactionIndex; use grovedb::query_result_type::QueryResultType; use grovedb::{Element, PathQuery, Query, TransactionArg}; use platform_version::version::PlatformVersion; @@ -19,38 +17,32 @@ impl Drive { &self, transaction: TransactionArg, platform_version: &PlatformVersion, - ) -> Result { - let mut queued: Credits = 0; - for (_, amount) in self.fetch_withdrawal_transaction_amounts( + ) -> Result { + let mut in_flight: Credits = 0; + for path in [ get_withdrawal_transactions_queue_path_vec(), - transaction, - platform_version, - )? { - queued = queued.checked_add(amount).ok_or(Error::Drive( - DriveError::CriticalCorruptedState("in-flight withdrawal amount overflow"), - ))?; + get_withdrawal_transactions_broadcasted_path_vec(), + ] { + for amount in + self.fetch_withdrawal_transaction_amounts(path, transaction, platform_version)? + { + in_flight = in_flight.checked_add(amount).ok_or(Error::Drive( + DriveError::CriticalCorruptedState("in-flight withdrawal amount overflow"), + ))?; + } } - let broadcast = self - .fetch_withdrawal_transaction_amounts( - get_withdrawal_transactions_broadcasted_path_vec(), - transaction, - platform_version, - )? - .into_iter() - .collect(); - - Ok(InFlightWithdrawalAmounts { queued, broadcast }) + Ok(in_flight) } - /// Each untied withdrawal transaction under `path` with what it takes out of Core's credit - /// pool, in credits: its outputs plus its fee. + /// What each untied withdrawal transaction under `path` takes out of Core's credit pool, in + /// credits: its outputs plus its fee. fn fetch_withdrawal_transaction_amounts( &self, path: Vec>, transaction: TransactionArg, platform_version: &PlatformVersion, - ) -> Result, Error> { + ) -> Result, Error> { let mut query = Query::new(); query.insert_all(); let path_query = PathQuery::new_unsized(path, query); @@ -94,10 +86,7 @@ impl Drive { duffs = duffs.checked_add(output.value).ok_or_else(overflow)?; } - Ok(( - untied.base_payload.index, - convert_duffs_to_credits(duffs).map_err(|_| overflow())?, - )) + convert_duffs_to_credits(duffs).map_err(|_| overflow()) }) .collect() } @@ -105,7 +94,6 @@ impl Drive { #[cfg(test)] mod tests { - use crate::drive::identity::withdrawals::fetch_in_flight_withdrawal_amount::InFlightWithdrawalAmounts; use crate::util::batch::DriveOperation; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; @@ -115,7 +103,6 @@ mod tests { }; use dpp::dashcore::{ScriptBuf, TxOut}; use dpp::version::PlatformVersion; - use std::collections::BTreeMap; fn untied_transaction(index: u64, payout_duffs: u64, fee_duffs: u32) -> Vec { let transaction = AssetUnlockBaseTransactionInfo { @@ -148,7 +135,7 @@ mod tests { drive .fetch_in_flight_withdrawal_amount(Some(&transaction), platform_version) .expect("expected the amount"), - InFlightWithdrawalAmounts::default() + 0 ); let mut drive_operations: Vec = vec![]; @@ -195,16 +182,12 @@ mod tests { ) .expect("expected to apply"); - // Queued: 50,000 + 1,000 duffs; broadcast: index 0 with 100,000 + 2,000 duffs; in - // credits. + // Queued: 50,000 + 1,000 duffs; broadcast: 100,000 + 2,000 duffs; in credits. assert_eq!( drive .fetch_in_flight_withdrawal_amount(Some(&transaction), platform_version) .expect("expected the amount"), - InFlightWithdrawalAmounts { - queued: 51_000_000, - broadcast: BTreeMap::from([(0, 102_000_000)]), - } + 153_000_000 ); } } diff --git a/packages/rs-drive/src/drive/identity/withdrawals/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/mod.rs index 02b5d8e2601..480e0980a5b 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/mod.rs @@ -21,7 +21,7 @@ pub mod fetch_total_credits_in_platform_a_day_ago; /// Functions and constants related to GroveDB paths pub mod paths; /// Functions related to the Core blocks the Core-anchored withdrawal limit reads -pub mod record_core_credit_pool_block; +pub mod record_core_credit_pool_blocks; /// Functions related to the per-block record of credit inflows the daily withdrawal limit adds pub mod record_credit_inflow; /// Functions related to the per-block record of total credits the daily withdrawal limit reads diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_blocks/mod.rs similarity index 56% rename from packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/mod.rs rename to packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_blocks/mod.rs index b1f81350a86..a16dcef747f 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_blocks/mod.rs @@ -8,24 +8,23 @@ use grovedb::TransactionArg; use platform_version::version::PlatformVersion; impl Drive { - /// Records Core's credit pool balance after a Core block, under the block's height, for the - /// Core-anchored withdrawal limit. + /// Records Core's credit pool balance after each of the given Core blocks, under the block's + /// height, for the Core-anchored withdrawal limit, in one batch. /// /// # Parameters /// - /// * `core_height`: The height of the Core block. - /// * `credit_pool_balance`: Core's credit pool balance after the block, in credits. + /// * `balances`: Each Core block's height with Core's credit pool balance after it, in + /// credits. /// * `transaction`: The GroveDB transaction. /// * `platform_version`: The platform version. /// /// # Returns /// - /// * `Ok(())` once the balance is stored. + /// * `Ok(())` once the balances are stored, or at once when there are none. /// * `Err(Error)` when the method version is unknown or not active, or the write fails. - pub fn record_core_credit_pool_block( + pub fn record_core_credit_pool_blocks( &self, - core_height: u32, - credit_pool_balance: Credits, + balances: &[(u32, Credits)], transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<(), Error> { @@ -34,21 +33,18 @@ impl Drive { .methods .identity .withdrawals - .record_core_credit_pool_block + .record_core_credit_pool_blocks { - Some(0) => self.record_core_credit_pool_block_v0( - core_height, - credit_pool_balance, - transaction, - platform_version, - ), + Some(0) => { + self.record_core_credit_pool_blocks_v0(balances, transaction, platform_version) + } Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { - method: "record_core_credit_pool_block".to_string(), + method: "record_core_credit_pool_blocks".to_string(), known_versions: vec![0], received: version, })), None => Err(Error::Drive(DriveError::VersionNotActive { - method: "record_core_credit_pool_block".to_string(), + method: "record_core_credit_pool_blocks".to_string(), known_versions: vec![0], })), } diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_blocks/v0/mod.rs similarity index 52% rename from packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/v0/mod.rs rename to packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_blocks/v0/mod.rs index e7df2c90ffd..9804cd91c2d 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_block/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/record_core_credit_pool_blocks/v0/mod.rs @@ -7,24 +7,29 @@ use grovedb::{Element, TransactionArg}; use platform_version::version::PlatformVersion; impl Drive { - pub(super) fn record_core_credit_pool_block_v0( + pub(super) fn record_core_credit_pool_blocks_v0( &self, - core_height: u32, - credit_pool_balance: Credits, + balances: &[(u32, Credits)], transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<(), Error> { + if balances.is_empty() { + return Ok(()); + } + let mut drive_operations = vec![]; - self.batch_insert( - PathKeyElementInfo::PathKeyElement::<0>(( - get_withdrawal_core_credit_pool_balances_path_vec(), - core_height.to_be_bytes().to_vec(), - Element::new_item(credit_pool_balance.to_be_bytes().to_vec()), - )), - &mut drive_operations, - &platform_version.drive, - )?; + for (core_height, credit_pool_balance) in balances { + self.batch_insert( + PathKeyElementInfo::PathKeyElement::<0>(( + get_withdrawal_core_credit_pool_balances_path_vec(), + core_height.to_be_bytes().to_vec(), + Element::new_item(credit_pool_balance.to_be_bytes().to_vec()), + )), + &mut drive_operations, + &platform_version.drive, + )?; + } self.apply_batch_low_level_drive_operations( None, diff --git a/packages/rs-drive/src/drive/initialization/v4/mod.rs b/packages/rs-drive/src/drive/initialization/v4/mod.rs index 765d8a39b09..04ddbffe124 100644 --- a/packages/rs-drive/src/drive/initialization/v4/mod.rs +++ b/packages/rs-drive/src/drive/initialization/v4/mod.rs @@ -109,14 +109,15 @@ impl Drive { // it expires, and the lifetime storage fee pools sum tree under `Pools`, which holds // their storage fees until an epoch change spreads them. After the batch apply, which // creates `Misc` and the fee pools under `Pools`, and through the same helper as the - // upgrade path (`Platform::transition_to_version_14`), in the same position: last. + // upgrade path (`Platform::transition_to_version_14`), in the same position: just + // before the withdrawal limit trees. self.insert_document_ttl_trees(transaction, platform_version)?; // Withdrawal limit trees (protocol version 14): the total credits history, the credit // inflows and the Core credit pool balances under the withdrawals tree, which the batch // apply creates. Inserted one after the other through the same helper as the upgrade - // path (`Platform::transition_to_version_14`), so the withdrawals Merk is built by the - // same sequence of inserts on both node populations. + // path (`Platform::transition_to_version_14`), in the same position: last, so the + // withdrawals Merk is built by the same sequence of inserts on both node populations. self.insert_withdrawal_limit_trees(transaction, platform_version)?; Ok(()) diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index b330633a9ef..91c1643703e 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -1228,7 +1228,7 @@ mod fixtures { let platform_version = PlatformVersion::latest(); let drive = setup_drive_with_initial_state_structure(Some(platform_version)); drive - .record_core_credit_pool_block(100, 5_000_000, None, platform_version) + .record_core_credit_pool_blocks(&[(100, 5_000_000)], None, platform_version) .expect("expected to record a Core block"); conformance_of(&drive, "core_anchored_withdrawal_accounting", run); } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index b72549c97da..78a107ae3f5 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -391,37 +391,6 @@ impl DriveOperation<'_> { .fold(0u64, |total, amount| total.saturating_add(amount)) } - /// The asset lock transaction a batch's mints came from, with those mints: every state - /// transition that mints credits spends exactly one asset lock and records it as used - /// (`AddUsedAssetLock`) in the same batch. `None` when the batch mints nothing, or does not - /// name exactly one asset lock (no such batch exists today), so the caller treats its mints - /// as it treats any other. - pub fn asset_lock_credit_mints(operations: &[DriveOperation]) -> Option<([u8; 32], Credits)> { - let minted = Self::credit_mints(operations); - if minted == 0 { - return None; - } - - let mut used_asset_locks = operations.iter().filter_map(|operation| match operation { - DriveOperation::SystemOperation(SystemOperationType::AddUsedAssetLock { - asset_lock_outpoint, - .. - }) => Some(asset_lock_outpoint), - _ => None, - }); - - let outpoint = used_asset_locks.next()?; - if used_asset_locks.next().is_some() { - return None; - } - - // An outpoint is the transaction id (32 bytes, in the order `Txid` holds it) followed - // by the output index. - let mut txid = [0u8; 32]; - txid.copy_from_slice(&outpoint.as_slice()[..32]); - Some((txid, minted)) - } - /// Merges every write of one identity balance, of one contract fee pot, and of one /// prefunded specialized balance, into a single net operation. /// @@ -1749,38 +1718,4 @@ mod tests { ]; assert_eq!(merged(operations.clone()), format!("{operations:?}")); } - - #[test] - fn should_attribute_a_batch_mint_to_the_one_asset_lock_it_spends() { - use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; - use dpp::dashcore::hashes::Hash; - use dpp::dashcore::{OutPoint, Txid}; - use dpp::platform_value::Bytes36; - - let platform_version = PlatformVersion::latest(); - let txid = Txid::from_byte_array([3; 32]); - let used = |vout: u32| { - DriveOperation::SystemOperation(SystemOperationType::AddUsedAssetLock { - asset_lock_outpoint: Bytes36::new(OutPoint::new(txid, vout).into()), - asset_lock_value: AssetLockValue::new(10, vec![], 0, vec![], platform_version) - .expect("expected an asset lock value"), - }) - }; - let mint = |amount: u64| { - DriveOperation::SystemOperation(SystemOperationType::AddToSystemCredits { amount }) - }; - - // The txid comes back in the byte order `Txid` holds, whatever the output index. - assert_eq!( - DriveOperation::asset_lock_credit_mints(&[mint(500), used(1)]), - Some((txid.to_byte_array(), 500)) - ); - // Nothing minted, or no single asset lock named: no attribution. - assert_eq!(DriveOperation::asset_lock_credit_mints(&[used(0)]), None); - assert_eq!(DriveOperation::asset_lock_credit_mints(&[mint(500)]), None); - assert_eq!( - DriveOperation::asset_lock_credit_mints(&[mint(500), used(0), used(1)]), - None - ); - } } diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs index 28d82cf0b12..3d19137c0b2 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs @@ -171,14 +171,13 @@ pub struct DriveAbciIdentityCreditWithdrawalMethodVersions { /// limit's daily maximum; exists from protocol version 14. pub record_credit_inflows_for_withdrawals: OptionalFeatureVersion, pub record_total_credits_history_for_withdrawals: OptionalFeatureVersion, - /// Reads the Core blocks the chain lock height advanced over: records each one's credit - /// pool balance and dates the consumed asset locks it mined. Feeds the Core-anchored - /// withdrawal limit; called by `pool_withdrawals_into_transactions_queue` 2; exists from - /// protocol version 14. + /// Reads the Core blocks the chain lock height advanced over and records each one's credit + /// pool balance. Feeds the Core-anchored withdrawal limit; called by + /// `pool_withdrawals_into_transactions_queue` 2; exists from protocol version 14. pub scan_core_blocks_for_withdrawals: OptionalFeatureVersion, /// How much more Core's credit pool may give up to withdrawals pooled now: a stricter copy - /// of Core's own unlock limit, less what is pooled and not mined yet. Exists from protocol - /// version 14. + /// of Core's own unlock limit, less what is queued or broadcast and not completed yet. + /// Exists from protocol version 14. pub calculate_core_anchored_withdrawal_limit: OptionalFeatureVersion, /// Whether the next block has withdrawal work waiting (queued transactions to sign or expired /// documents to re-queue); drives the `propose_next_block_immediately` hint to Tenderdash. diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index e29e79ce454..009ac276b45 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -16,7 +16,8 @@ use crate::version::drive_abci_versions::drive_abci_method_versions::{ /// `record_total_credits_history_for_withdrawals` (`Some(0)`), the per-block record of the total /// credits in Platform that the day-lagged daily withdrawal limit reads, and bumps /// `cleanup_expired_locks_of_withdrawal_amounts` to 1 so the per-block cleanup also prunes the -/// expired entries of the credit inflows sum tree the net daily withdrawal limit reads, and +/// expired entries of the credit inflows sum tree the net daily withdrawal limit reads and the +/// Core credit pool balances older than the Core-anchored limit's window, and /// bumps `rebroadcast_expired_withdrawal_documents` to 2 so an expired withdrawal whose /// payout is below Core's dust threshold is marked FAILED instead of re-signed forever. /// `pool_withdrawals_into_transactions_queue` 2 pools only what also fits the Core-anchored @@ -103,7 +104,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet rebroadcast_expired_withdrawal_documents: 2, // changed in v14: an expired withdrawal whose payout is Core dust is marked FAILED instead of re-signed append_signatures_and_broadcast_withdrawal_transactions: 0, has_pending_withdrawal_work: 0, - cleanup_expired_locks_of_withdrawal_amounts: 1, // changed in v14: also prunes expired entries of the credit inflows sum tree + cleanup_expired_locks_of_withdrawal_amounts: 1, // changed in v14: also prunes expired entries of the credit inflows sum tree and old Core credit pool balances record_credit_inflows_for_withdrawals: Some(0), // new in v14: the block's credit mints recorded as an inflow for the net daily withdrawal limit record_total_credits_history_for_withdrawals: Some(0), // changed in v14: per-block total credits history for the day-lagged daily withdrawal limit scan_core_blocks_for_withdrawals: Some(0), // new in v14: Core credit pool balances for the Core-anchored withdrawal limit diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs index ad0abcd0e11..ae534ebf94f 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs @@ -27,9 +27,9 @@ pub struct DriveIdentityWithdrawalMethodVersions { /// tree the net daily withdrawal limit reads back. The subtree exists from protocol /// version 14. pub record_credit_inflows: OptionalFeatureVersion, - /// Record a Core block's credit pool balance for the Core-anchored withdrawal limit. The + /// Record Core blocks' credit pool balances for the Core-anchored withdrawal limit. The /// subtree exists from protocol version 14. - pub record_core_credit_pool_block: OptionalFeatureVersion, + pub record_core_credit_pool_blocks: OptionalFeatureVersion, /// Read the recorded Core credit pool balances. Exists from protocol version 14. pub fetch_core_credit_pool_balances: OptionalFeatureVersion, /// Sum what the queued and broadcast withdrawal transactions take out of Core's credit diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs index e0f60851cb9..f2db37e35d9 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs @@ -171,7 +171,7 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V1: DriveIdentityMethodVersions = record_total_credits_history: None, fetch_total_credits_in_platform_a_day_ago: None, record_credit_inflows: None, - record_core_credit_pool_block: None, + record_core_credit_pool_blocks: None, fetch_core_credit_pool_balances: None, fetch_in_flight_withdrawal_amount: None, }, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs index 808d859d7ed..d189ab87895 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs @@ -63,7 +63,7 @@ use crate::version::drive_versions::drive_identity_method_versions::{ /// tree so the daily withdrawal limit counts net outflow instead of gross — /// a deposit -> withdraw cycle no longer consumes the budget of other users. /// The subtree does not exist before v14, so V1 keeps the slot `None`. -/// * `withdrawals.record_core_credit_pool_block`, +/// * `withdrawals.record_core_credit_pool_blocks`, /// `withdrawals.fetch_core_credit_pool_balances` and /// `withdrawals.fetch_in_flight_withdrawal_amount` `None -> Some(0)`: the /// Core-anchored withdrawal limit, which reads Core's credit pool balance as @@ -228,7 +228,7 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = record_total_credits_history: Some(0), // new in v14: total credits history for the day-lagged daily withdrawal limit fetch_total_credits_in_platform_a_day_ago: Some(0), // new in v14 record_credit_inflows: Some(0), // new in v14: credit inflows sum tree for the net daily withdrawal limit - record_core_credit_pool_block: Some(0), // new in v14: Core credit pool balances for the Core-anchored withdrawal limit + record_core_credit_pool_blocks: Some(0), // new in v14: Core credit pool balances for the Core-anchored withdrawal limit fetch_core_credit_pool_balances: Some(0), // new in v14 fetch_in_flight_withdrawal_amount: Some(0), // new in v14 }, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 45e93c994e1..db04e10a3d2 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -144,9 +144,9 @@ pub struct SystemLimits { /// Core's credit pool window on mainnet, testnet and devnets (`CreditPoolPeriodBlocks` in /// Dash Core's chain parameters): how many Core blocks before an asset unlock's block lies /// the balance Core v24 measures the unlock limit from. The Core-anchored withdrawal limit - /// reads its window starts this far back, an asset lock Core mined this far back (less - /// Core's asset unlock validity, `withdrawal_constants.core_expiration_blocks`) adds no - /// credit inflow, and recorded balances older than it are pruned. Read through + /// reads its window starts this far back, up to Core's asset unlock validity + /// (`withdrawal_constants.core_expiration_blocks`) later, so the window must be at least + /// that long, and recorded balances older than it are pruned. Read through /// `core_credit_pool_window_blocks` in dpp. `None` for the protocol versions that predate /// the Core-anchored limit. pub core_credit_pool_window_blocks: Option, @@ -482,6 +482,45 @@ mod tests { } } + /// The Core-anchored withdrawal limit reads window starts from the chain locked height back + /// by Core's credit pool window up to Core's asset unlock validity later. A window shorter + /// than that validity would put the nearest window start above the chain locked height, + /// whose balance is not final and so not the same on every node. + #[test] + fn should_keep_every_core_credit_pool_window_at_least_the_unlock_validity() { + let with_a_window: Vec<_> = PLATFORM_VERSIONS + .iter() + .flat_map(|platform_version| { + let system_limits = &platform_version.system_limits; + [ + system_limits.core_credit_pool_window_blocks, + system_limits.regtest_core_credit_pool_window_blocks, + ] + .into_iter() + .flatten() + .map(move |window_blocks| (platform_version, window_blocks)) + }) + .collect(); + assert!( + !with_a_window.is_empty(), + "no protocol version sets a Core credit pool window; this test would assert nothing" + ); + for (platform_version, window_blocks) in with_a_window { + let unlock_validity_blocks = platform_version + .drive_abci + .withdrawal_constants + .core_expiration_blocks; + assert!( + window_blocks >= unlock_validity_blocks, + "protocol version {} sets a Core credit pool window of {} blocks, shorter than \ + Core's asset unlock validity ({} blocks)", + platform_version.protocol_version, + window_blocks, + unlock_validity_blocks + ); + } + } + /// The withdrawal structure generations selected from protocol version 14 read the cap /// through `dpp::withdrawal::validate_core_fee_per_byte_cap`, which treats `None` as "no /// cap" per the field's contract. A table that selected one of those generations without a diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index a5d7f1ec7d3..3adfbb2dfc1 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -32,8 +32,7 @@ use crate::version::system_limits::SystemLimits; /// up to Core's asset unlock validity, `withdrawal_constants.core_expiration_blocks` 48, /// later), at least /// `core_credit_pool_unlock_limit_floor` (1500 Dash, Core's floor is 2000), less what is -/// pooled and not yet mined. An asset lock Core mined longer ago than its window minus those -/// 48 blocks adds no credit inflow. +/// queued or broadcast and not completed yet. /// * `max_time_range_overlap_factor` is set: a `timeRange` index transform may declare at most /// 24 overlapping windows per timestamp (a day-long window sliding hourly). The rule cannot /// exist before v14 because the `timeRange` keyword itself is only admitted by the v14 diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 38f2f6efc7d..17e83fa681c 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -79,15 +79,12 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// one maximal withdrawal (`max_withdrawal_amount`) so every accepted /// withdrawal eventually fits and cannot block the pooling queue. The base has /// no fixed cap: what Core will mine bounds pooling through the Core-anchored -/// limit of note 70 instead. The credit inflows of the active window — every +/// limit of note 74 instead. The credit inflows of the active window — every /// credit mint, recorded per block by `record_credit_inflows_for_withdrawals` /// in the credit inflows sum tree — are added to the base, so the limit /// counts net outflow and a matching deposit -> withdraw cycle does not /// consume the budget of other users (#4471), mirroring Core v24's net -/// credit-pool rule. An asset lock Core mined longer ago than its window -/// minus its asset unlock validity (`core_expiration_blocks`) adds no -/// inflow: Core no longer counts it in full either. Both the -/// inflows and the pooled reservations count over the +/// credit-pool rule. Both the inflows and the pooled reservations count over the /// interval after the base snapshot only — an entry the snapshot already /// reflects is neither added nor subtracted again. The base is /// the total credits recorded at the latest block at least 24 hours before @@ -106,7 +103,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// exactly as before. Pre-V24 Core caps unlocks at `LimitAmountV22` (2000 /// Dash) per *block*, with the amount checked only at block level, so any /// daily total is still minable across blocks; V24 limits the net drop of -/// its credit pool per 576-block window, which note 70 follows. +/// its credit pool per 576-block window, which note 74 follows. /// 5. **Time-range indexes**: an index can declare a `timeRange` transform /// that buckets a required system timestamp (`$createdAt` / /// `$updatedAt` / `$transferredAt`) into fixed-length, regularly-spaced, @@ -1864,7 +1861,17 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// the key of an index a delete clears that skips nothing, so no two /// documents in state share one of its entries. Inert for every contract /// without the keyword, which every earlier grammar refuses. -/// 70. **Withdrawals also fit a Core-anchored limit**: pooling +/// 70. **A contested type sums only small values**: parser generation 3, in +/// place, refuses under full validation a document type with a contested +/// index and a summed property (`summable`, `averageable`, +/// `documentsSummable` or `documentsAverageable`) unless the property's +/// schema declares a `minimum` of at least -2^27 and a `maximum` of at most +/// 2^27 (`SYSTEM_LIMITS_V4.max_contested_summed_value_magnitude`, `None` in +/// the earlier tables). The end of a contest writes the winner's document +/// into the type's sums with no transition to refuse, so the values must be +/// small enough that the sums stay in `i64`, which they do short of 2^36 +/// documents. A stored contract still parses. +/// 74. **Withdrawals also fit a Core-anchored limit**: pooling /// (`pool_withdrawals_into_transactions_queue` 2, which reuses version 1's /// pooling through a shared helper) admits withdrawals up to the smaller of /// the daily withdrawal limit (note 4) and @@ -1878,14 +1885,13 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// height, up to Core's asset unlock validity, `core_expiration_blocks` 48, /// later), at least /// `core_credit_pool_unlock_limit_floor` (1500 Dash; Core's floor is 2000), -/// less what is queued or broadcast and not mined yet. The formula is +/// less what is queued or broadcast and not completed yet. The formula is /// `core_credit_pool_unlock_limit` 0 in `DPP_METHOD_VERSIONS_V3`. Before /// pooling, `scan_core_blocks_for_withdrawals` reads the Core blocks the /// chain locked height passed (at most `core_blocks_scanned_per_block_limit`, /// 32, per block) and records each one's credit pool balance, read from the /// block's coinbase alone (`getspecialtxes`), under the withdrawals tree. The -/// Platform-side -/// accounting can grant more than Core will mine (an asset lock published to +/// Platform-side accounting can grant more than Core will mine (an asset lock published to /// Platform after Core mined it, a whole epoch of Core rewards minted in one /// block); over Core's limit an unlock waits unmined and is re-signed, and /// while Core's mempool holds more than the limit Core InstantSend-locks no @@ -1893,17 +1899,6 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `transition_to_version_14`, and `cleanup_expired_locks_of_withdrawal_amounts` /// 1 prunes it by Core height. /// -/// 70. **A contested type sums only small values**: parser generation 3, in -/// place, refuses under full validation a document type with a contested -/// index and a summed property (`summable`, `averageable`, -/// `documentsSummable` or `documentsAverageable`) unless the property's -/// schema declares a `minimum` of at least -2^27 and a `maximum` of at most -/// 2^27 (`SYSTEM_LIMITS_V4.max_contested_summed_value_magnitude`, `None` in -/// the earlier tables). The end of a contest writes the winner's document -/// into the type's sums with no transition to refuse, so the values must be -/// small enough that the sums stay in `i64`, which they do short of 2^36 -/// documents. A stored contract still parses. -/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's