From 053d47996320282245217a4829d659ab729efb48 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:50:28 -0500 Subject: [PATCH 01/12] feat(platform): add smart-contract computation limits to the system limits Introduce `SystemLimits::smart_contract_computation`, an optional group holding the per-invocation and per-block computation limits in one contract-only unit (`ComputationUnits`), and `SYSTEM_LIMITS_V5` carrying the provisional numbers from the DashVM allocation register (25 million units per outer invocation, 250 million per block). Every shipped table and the hand-written mock carry `None`, so no shipped protocol version changes behaviour. A compile-time assertion pins the one invariant that must survive measurement: both limits are non-zero and one maximal invocation fits in a block. `system_limits` becomes a public module so the unit alias and the limits struct are nameable from dpp, drive-abci and the future runtime crate. Co-Authored-By: Claude Fable 5.1 --- .../src/version/mocks/v2_test.rs | 1 + .../rs-platform-version/src/version/mod.rs | 2 +- .../src/version/system_limits/mod.rs | 18 ++++- .../version/system_limits/smart_contract.rs | 74 +++++++++++++++++++ .../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 | 1 + .../src/version/system_limits/v5.rs | 32 ++++++++ 9 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 packages/rs-platform-version/src/version/system_limits/smart_contract.rs create mode 100644 packages/rs-platform-version/src/version/system_limits/v5.rs 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 0ce27215cc1..88955147d6b 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -615,6 +615,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_document_expirations_per_block: 0, max_document_expiration_weight_per_block: 0, minimum_grovedb_proof_envelope_version: 0, + smart_contract_computation: None, }, consensus: ConsensusVersions { tenderdash_consensus_version: 0, diff --git a/packages/rs-platform-version/src/version/mod.rs b/packages/rs-platform-version/src/version/mod.rs index 1b1635efb42..8e79499e196 100644 --- a/packages/rs-platform-version/src/version/mod.rs +++ b/packages/rs-platform-version/src/version/mod.rs @@ -13,7 +13,7 @@ pub mod fee; #[cfg(feature = "mock-versions")] pub mod mocks; pub mod system_data_contract_versions; -mod system_limits; +pub mod system_limits; pub mod v1; pub mod v10; pub mod v11; 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 0567a7c2f6f..029761c4c18 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -1,9 +1,13 @@ +pub mod smart_contract; pub mod v1; pub mod v2; pub mod v3; pub mod v4; +pub mod v5; -#[derive(Clone, Debug, Default)] +use crate::version::system_limits::smart_contract::SmartContractComputationLimits; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct SystemLimits { pub estimated_contract_max_serialized_size: u16, pub max_field_value_size: u32, @@ -315,6 +319,18 @@ pub struct SystemLimits { /// version 3 (protocol version 13), so every live network already serves /// V1 by the time the floor applies. pub minimum_grovedb_proof_envelope_version: u32, + /// The consensus limits on smart-contract computation, counted in computation units by + /// one contract-only counter: how much one outer invocation may consume and how much all + /// invocations in a block may consume together. Read by the per-block computation ledger + /// in `drive-abci` (`BlockComputationBudget`) and handed to the runtime as the budget of + /// each invocation; the fee schedule's `dashvm` group prices the units. Independent of + /// every native budget (proposer timer, withdrawal and shielded per-block caps, Tenderdash + /// block gas), none of which changes. + /// + /// `None` for the protocol versions that predate smart contracts: those versions meter, + /// price and budget nothing, and a code path that reads `None` skips the contract path + /// entirely. See `SmartContractComputationLimits`. + pub smart_contract_computation: Option, } #[cfg(test)] diff --git a/packages/rs-platform-version/src/version/system_limits/smart_contract.rs b/packages/rs-platform-version/src/version/system_limits/smart_contract.rs new file mode 100644 index 00000000000..bd9e5c5288a --- /dev/null +++ b/packages/rs-platform-version/src/version/system_limits/smart_contract.rs @@ -0,0 +1,74 @@ +/// A deterministic count of smart-contract work. +/// +/// One unit is the weight the active metering generation assigns to one admitted guest +/// operation or one slice of host work performed on the guest's behalf (host entry, byte +/// copying, memory growth, native operation cost). The weights themselves live with the +/// metering generation, not here. A unit is never wall-clock time: two nodes that run the same +/// invocation under the same protocol version count exactly the same number of units, whatever +/// their hardware, cache state or compiler backend. +/// +/// Consumption is reported in this unit by the runtime and priced in credits by the fee +/// schedule (`FeeVersion::dashvm`), see `dpp::fee::smart_contract_computation`. +pub type ComputationUnits = u64; + +/// The consensus limits on smart-contract computation. +/// +/// Both limits are counted in [`ComputationUnits`] by one contract-only counter. That counter is +/// separate from every native budget: the proposer's wall-clock timer, the per-block withdrawal +/// and shielded caps and the Tenderdash block gas limit all keep their existing meaning and none +/// of them is derived from these numbers. +/// +/// # Per invocation +/// +/// `max_computation_units_per_invocation` bounds one outer contract invocation: a direct call, +/// a predicate evaluated for a native transition, or one scheduled attempt. Everything the +/// invocation causes is charged to that one counter, including module initialisation, nested +/// contract calls, predicates those calls trigger and the host work they request. Nothing is +/// counted twice: a nested call shares its caller's counter rather than opening a second one. +/// The runtime receives this value (or a smaller bound the caller declares and can afford) as +/// the budget of the invocation, meters guest and host work against it, and reports the units +/// actually consumed. A failed invocation still consumed its units and is charged for them. +/// +/// # Per block +/// +/// `max_computation_units_per_block` bounds the sum of all contract invocations in one block, +/// ordinary and scheduled together. The block loop reserves an invocation's admitted bound +/// against the block before it runs and settles the actual consumption afterwards, so an +/// invocation that would not fit is never started; it is delayed by the proposer and rejected by +/// a validator, never failed part-way through. The ledger that does this bookkeeping lives in +/// `drive-abci` (`BlockComputationBudget`). +/// +/// # Credits and gas +/// +/// Units become credits through the protocol-versioned price in the fee schedule +/// (`FeeVersion::dashvm.credits_per_computation_unit`, checked multiplication). The resulting +/// charge enters the processing fee of the invocation's `FeeResult`, which is what +/// Tenderdash's `gas_used` and `gas_wanted` already report. Gas therefore stays denominated in +/// credits; no unit equivalence between computation units and Tenderdash gas is introduced. +/// +/// # Versioning +/// +/// `None` on every protocol version that predates smart contracts: no code path meters, prices +/// or budgets contract computation there. The numbers on the first version that carries +/// `Some` are provisional until measured; see the `SYSTEM_LIMITS_V*` that sets them. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SmartContractComputationLimits { + /// Most computation units one outer contract invocation may consume, counting every nested + /// call, predicate, module initialisation and host entry it causes. + pub max_computation_units_per_invocation: ComputationUnits, + /// Most computation units all contract invocations in one block may consume together, + /// ordinary and scheduled. + pub max_computation_units_per_block: ComputationUnits, +} + +impl SmartContractComputationLimits { + /// Whether the limits can be enforced at all: both are non-zero (a zero limit would reject + /// every invocation) and one invocation fits in a block (otherwise the per-invocation limit + /// could never be reached and the per-block reservation would refuse a maximal invocation). + /// This is the only invariant that must hold whatever the measured numbers turn out to be; + /// the table that sets the numbers asserts it at compile time. + pub const fn is_well_formed(&self) -> bool { + self.max_computation_units_per_invocation > 0 + && self.max_computation_units_per_block >= self.max_computation_units_per_invocation + } +} 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 a9b5e7fdbb6..0bba6fa95e7 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -83,4 +83,5 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_document_expirations_per_block: 0, max_document_expiration_weight_per_block: 0, minimum_grovedb_proof_envelope_version: 0, // V0 envelopes stay accepted until v14 + smart_contract_computation: None, // smart contracts arrive with the 5.0 protocol version }; 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 bb5d64573be..2511f5feb59 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -64,4 +64,5 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_document_expirations_per_block: 0, max_document_expiration_weight_per_block: 0, minimum_grovedb_proof_envelope_version: 0, // V0 envelopes stay accepted until v14 + smart_contract_computation: None, // smart contracts arrive with the 5.0 protocol version }; 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 b54132c50e2..dba5f957a06 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -66,4 +66,5 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_document_expirations_per_block: 0, max_document_expiration_weight_per_block: 0, minimum_grovedb_proof_envelope_version: 0, // V0 envelopes stay accepted until v14 + smart_contract_computation: None, // smart contracts arrive with the 5.0 protocol version }; 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 a7b23123841..07e1edd680f 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -146,4 +146,5 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_document_expirations_per_block: 128, // document ttl (new in v14): expired documents deleted per block max_document_expiration_weight_per_block: 1_024, // document ttl (new in v14): deleted documents plus their index levels per block minimum_grovedb_proof_envelope_version: 1, // clients reject legacy V0 GroveDB proof envelopes from v14 + smart_contract_computation: None, // smart contracts arrive with the 5.0 protocol version }; diff --git a/packages/rs-platform-version/src/version/system_limits/v5.rs b/packages/rs-platform-version/src/version/system_limits/v5.rs new file mode 100644 index 00000000000..6509570f85b --- /dev/null +++ b/packages/rs-platform-version/src/version/system_limits/v5.rs @@ -0,0 +1,32 @@ +use crate::version::system_limits::smart_contract::SmartContractComputationLimits; +use crate::version::system_limits::v4::SYSTEM_LIMITS_V4; +use crate::version::system_limits::SystemLimits; + +/// System limits for protocol version 17 (5.0) and above. +/// +/// Identical to [`SYSTEM_LIMITS_V4`] except that smart-contract computation is bounded: one +/// outer invocation may consume at most 25 million computation units and all invocations in a +/// block at most 250 million. The numbers are the provisional starting values of the DashVM +/// shared allocation register (the "Compute" row); they are measured and revised before any +/// network is asked to run this protocol version. See `SmartContractComputationLimits` for what +/// a unit is and how the two limits are enforced. +pub const SYSTEM_LIMITS_V5: SystemLimits = SystemLimits { + smart_contract_computation: Some(SmartContractComputationLimits { + // provisional: allocation register "Compute" row, 25 million units per outer invocation + max_computation_units_per_invocation: 25_000_000, + // provisional: allocation register "Compute" row, 250 million contract units per block + max_computation_units_per_block: 250_000_000, + }), + ..SYSTEM_LIMITS_V4 +}; + +// Whatever the measured revision of the numbers above is, both limits stay non-zero and one +// maximal invocation still fits in a block; otherwise the per-block reservation could never admit +// an invocation that uses its full per-invocation budget. +const _: () = assert!( + match SYSTEM_LIMITS_V5.smart_contract_computation { + Some(ref limits) => limits.is_well_formed(), + None => false, + }, + "SYSTEM_LIMITS_V5 must carry well-formed smart-contract computation limits" +); From 7974f8191cf4d8ca026d12a107d321517ad20279 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:51:19 -0500 Subject: [PATCH 02/12] feat(platform): price smart-contract computation in the fee schedule Add the optional `dashvm` group to `FeeVersion` with one row for now, the credits charged per computation unit, and `FEE_VERSION4` carrying the provisional register price (1 credit per unit) on top of `FEE_VERSION3`, the protocol version 14 schedule that prices document expiry and the moderation election fund. `FEE_VERSION1` to `FEE_VERSION3` and the pre-1.4 saved-state conversion carry `None`, so a schedule without contract pricing can never price computation at zero by accident. `fee_version_number` stays 1 because no storage, processing, hashing or signature rate changes, and for the same reason the new schedule is not appended to `FEE_VERSIONS`: the registry holds one entry per number and the persisted fee history keys nothing new. Co-Authored-By: Claude Fable 5.1 --- .../src/version/fee/dashvm/mod.rs | 42 +++++++++++++++++++ .../src/version/fee/dashvm/v1.rs | 8 ++++ .../src/version/fee/mod.rs | 9 ++++ .../rs-platform-version/src/version/fee/v1.rs | 1 + .../rs-platform-version/src/version/fee/v2.rs | 1 + .../rs-platform-version/src/version/fee/v4.rs | 16 +++++++ 6 files changed, 77 insertions(+) create mode 100644 packages/rs-platform-version/src/version/fee/dashvm/mod.rs create mode 100644 packages/rs-platform-version/src/version/fee/dashvm/v1.rs create mode 100644 packages/rs-platform-version/src/version/fee/v4.rs diff --git a/packages/rs-platform-version/src/version/fee/dashvm/mod.rs b/packages/rs-platform-version/src/version/fee/dashvm/mod.rs new file mode 100644 index 00000000000..85c60995e1f --- /dev/null +++ b/packages/rs-platform-version/src/version/fee/dashvm/mod.rs @@ -0,0 +1,42 @@ +use bincode::{Decode, Encode}; + +pub mod v1; + +/// The prices of smart-contract work. +/// +/// Absent (`FeeVersion::dashvm == None`) on every schedule that predates smart contracts, so a +/// schedule without contract pricing cannot price computation at zero by accident: pricing a +/// unit at zero is never intended, unlike the genuinely free fees elsewhere in the tables. +/// +/// One row today, the price of a computation unit. The remaining register rows (deployment +/// validation, readiness verification, host entry, per-byte copy) are added to this group and +/// its `FEE_DASHVM_VERSION*` constant in place while the protocol version that introduces it is +/// unreleased. +#[derive(Clone, Debug, Encode, Decode, Default, PartialEq, Eq)] +pub struct FeeDashVmVersion { + /// Processing credits charged per computation unit consumed by a contract invocation. + /// The charge is `units * credits_per_computation_unit` with checked arithmetic + /// (`dpp::fee::smart_contract_computation::computation_units_to_credits`) and enters the + /// processing fee of the invocation, which is what Tenderdash's gas fields report. + pub credits_per_computation_unit: u64, +} + +#[cfg(test)] +mod tests { + use super::FeeDashVmVersion; + + #[test] + // If this test failed, then a new field was added in FeeDashVmVersion. And the corresponding eq needs to be updated as well + fn test_fee_dashvm_version_equality() { + let version1 = FeeDashVmVersion { + credits_per_computation_unit: 1, + }; + + let version2 = FeeDashVmVersion { + credits_per_computation_unit: 1, + }; + + // This assertion will check if all fields are considered in the equality comparison + assert_eq!(version1, version2, "FeeDashVmVersion equality test failed. If a field was added or removed, update the Eq implementation."); + } +} diff --git a/packages/rs-platform-version/src/version/fee/dashvm/v1.rs b/packages/rs-platform-version/src/version/fee/dashvm/v1.rs new file mode 100644 index 00000000000..7d5302c4fe4 --- /dev/null +++ b/packages/rs-platform-version/src/version/fee/dashvm/v1.rs @@ -0,0 +1,8 @@ +use crate::version::fee::dashvm::FeeDashVmVersion; + +/// Introduced with protocol version 17 (5.0). +pub const FEE_DASHVM_VERSION1: FeeDashVmVersion = FeeDashVmVersion { + // provisional: allocation register "Provisional price", 1 processing credit per computation + // unit; measured and revised before activation + credits_per_computation_unit: 1, +}; diff --git a/packages/rs-platform-version/src/version/fee/mod.rs b/packages/rs-platform-version/src/version/fee/mod.rs index abe1f78a003..6adfa811d59 100644 --- a/packages/rs-platform-version/src/version/fee/mod.rs +++ b/packages/rs-platform-version/src/version/fee/mod.rs @@ -1,4 +1,5 @@ use crate::error::PlatformVersionError; +use crate::version::fee::dashvm::FeeDashVmVersion; use crate::version::fee::data_contract_registration::v1::FEE_DATA_CONTRACT_REGISTRATION_VERSION1; use crate::version::fee::data_contract_registration::FeeDataContractRegistrationVersion; use crate::version::fee::data_contract_validation::FeeDataContractValidationVersion; @@ -20,6 +21,7 @@ use crate::version::fee::vote_resolution_fund_fees::{ }; use bincode::{Decode, Encode}; +pub mod dashvm; pub mod data_contract_registration; mod data_contract_validation; pub mod document_ttl; @@ -31,6 +33,7 @@ pub mod storage; pub mod v1; pub mod v2; pub mod v3; +pub mod v4; pub mod vote_resolution_fund_fees; pub type FeeVersionNumber = u32; @@ -54,6 +57,10 @@ pub struct FeeVersion { /// version 14). Platform states store only `fee_version_number`, so this field does /// not touch any stored format; `FeeVersionFieldsBeforeVersion4` must not gain it. pub document_ttl: FeeDocumentTtlVersion, + /// Prices of smart-contract work; `None` on every schedule that predates smart contracts. + /// Like `document_ttl`, never part of a stored format: `FeeVersionFieldsBeforeVersion4` + /// must not gain it. + pub dashvm: Option, } impl FeeVersion { @@ -155,6 +162,8 @@ impl From for FeeVersion { vote_resolution_fund_fees: value.vote_resolution_fund_fees.into(), // Pre-4.2 tables predate the document `ttl` keyword; the group is unread there. document_ttl: FEE_DOCUMENT_TTL_VERSION1, + // Pre-4.2 tables predate smart contracts; no schedule before 5.0 prices them. + dashvm: None, } } } diff --git a/packages/rs-platform-version/src/version/fee/v1.rs b/packages/rs-platform-version/src/version/fee/v1.rs index 005c3a10c86..a25ab0e7cb8 100644 --- a/packages/rs-platform-version/src/version/fee/v1.rs +++ b/packages/rs-platform-version/src/version/fee/v1.rs @@ -22,4 +22,5 @@ pub const FEE_VERSION1: FeeVersion = FeeVersion { vote_resolution_fund_fees: VOTE_RESOLUTION_FUND_FEES_VERSION1, // Unread before protocol version 14: the document `ttl` keyword does not parse there. document_ttl: FEE_DOCUMENT_TTL_VERSION1, + dashvm: None, // smart-contract pricing arrives with the 5.0 protocol version }; diff --git a/packages/rs-platform-version/src/version/fee/v2.rs b/packages/rs-platform-version/src/version/fee/v2.rs index 8f3f79fe7b7..4376930cc75 100644 --- a/packages/rs-platform-version/src/version/fee/v2.rs +++ b/packages/rs-platform-version/src/version/fee/v2.rs @@ -23,4 +23,5 @@ pub const FEE_VERSION2: FeeVersion = FeeVersion { vote_resolution_fund_fees: VOTE_RESOLUTION_FUND_FEES_VERSION1, // Unread before protocol version 14: the document `ttl` keyword does not parse there. document_ttl: FEE_DOCUMENT_TTL_VERSION1, + dashvm: None, // smart-contract pricing arrives with the 5.0 protocol version }; diff --git a/packages/rs-platform-version/src/version/fee/v4.rs b/packages/rs-platform-version/src/version/fee/v4.rs new file mode 100644 index 00000000000..8ae6a2e91c1 --- /dev/null +++ b/packages/rs-platform-version/src/version/fee/v4.rs @@ -0,0 +1,16 @@ +use crate::version::fee::dashvm::v1::FEE_DASHVM_VERSION1; +use crate::version::fee::v3::FEE_VERSION3; +use crate::version::fee::FeeVersion; + +/// Introduced in protocol version 17 (5.0). +/// +/// Identical to [`FEE_VERSION3`] except that it prices smart-contract computation +/// (`dashvm: Some(FEE_DASHVM_VERSION1)`). Storage, processing, hashing and signature rates are +/// unchanged, so `fee_version_number` stays 1: the number keys the persisted fee history and +/// the storage refund rates, and a schedule that only changes a group the history never serves +/// keeps the number of the generation it agrees with. For the same reason this schedule is not +/// appended to `FEE_VERSIONS`, which holds one entry per number. +pub const FEE_VERSION4: FeeVersion = FeeVersion { + dashvm: Some(FEE_DASHVM_VERSION1), + ..FEE_VERSION3 +}; From 948a0d3b11914e40370a48806cf9592f96c225ba Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:52:36 -0500 Subject: [PATCH 03/12] feat(platform): introduce protocol versions 15 to 17 with 17 as the 5.0 version Register protocol versions 15 (4.3 placeholder), 16 (4.4 placeholder) and 17 (5.0) following the DashVM allocation register. The two placeholders are struct updates over their predecessor so that a forward merge of the real 4.3 or 4.4 version file is resolved by taking the incoming file and every table it changes flows into 17 without a second edit. Version 17 overrides only `system_limits` (`SYSTEM_LIMITS_V5`, the computation limits) and `fee_version` (`FEE_VERSION4`, the computation price); nothing dispatches on either yet, so a node at 17 behaves exactly like one at 16. `LATEST_VERSION` and `LATEST_PLATFORM_VERSION` move to 17. Three tests guard the registry: the existing length assertion, a cross-table invariant that limits and price activate together and only from 17, and an inheritance test pinning that 17 differs from 16 in exactly those two tables. The committed GroveDB structure description is keyed by the latest protocol version, so it is regenerated here with every origin at 17 and the contract layer test pins the new origin. Co-Authored-By: Claude Fable 5.1 --- packages/rs-drive/grovedb-structure.json | 70 +++++----- packages/rs-drive/src/structure/tests.rs | 2 +- .../rs-platform-version/src/version/mod.rs | 7 +- .../src/version/protocol_version.rs | 8 +- .../src/version/system_limits/mod.rs | 131 ++++++++++++++++++ .../rs-platform-version/src/version/v15.rs | 21 +++ .../rs-platform-version/src/version/v16.rs | 16 +++ .../rs-platform-version/src/version/v17.rs | 30 ++++ 8 files changed, 246 insertions(+), 39 deletions(-) create mode 100644 packages/rs-platform-version/src/version/v15.rs create mode 100644 packages/rs-platform-version/src/version/v16.rs create mode 100644 packages/rs-platform-version/src/version/v17.rs diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 4e4c8f0a377..ef91a14ff7e 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "latest_protocol_version": 14, + "latest_protocol_version": 17, "element_kinds": [ { "name": "Item", @@ -5206,7 +5206,7 @@ }, "layer_shapes": { "contract_groups": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "00", "right": { @@ -5215,7 +5215,7 @@ } }, "contract_groups.groups.group": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@17", "tree": { "hex": "02", "left": { @@ -5230,7 +5230,7 @@ } }, "contract_groups.members.contract": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@17", "tree": { "hex": "01", "left": { @@ -5242,7 +5242,7 @@ } }, "contracts.contract": { - "origin": "fixture contracts_with_documents@14", + "origin": "fixture contracts_with_documents@17", "tree": { "hex": "01", "left": { @@ -5254,7 +5254,7 @@ } }, "contracts.contract.other": { - "origin": "fixture moderated_contract@14", + "origin": "fixture moderated_contract@17", "tree": { "hex": "80", "left": { @@ -5272,7 +5272,7 @@ } }, "group_actions.contract.group": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@17", "tree": { "hex": "4d", "left": { @@ -5284,7 +5284,7 @@ } }, "group_actions.contract.group.active.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@17", "tree": { "hex": "53", "left": { @@ -5293,7 +5293,7 @@ } }, "group_actions.contract.group.closed.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@17", "tree": { "hex": "53", "left": { @@ -5302,7 +5302,7 @@ } }, "identities.identity": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@17", "tree": { "hex": "80", "left": { @@ -5327,7 +5327,7 @@ "states": [ { "state": "created", - "origin": "fixture identities@14", + "origin": "fixture identities@17", "tree": { "hex": "80", "left": { @@ -5346,7 +5346,7 @@ }, { "state": "used_with_a_contract", - "origin": "fixture identities@14", + "origin": "fixture identities@17", "tree": { "hex": "80", "left": { @@ -5368,7 +5368,7 @@ }, { "state": "budgeted_key_and_contract", - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@17", "tree": { "hex": "80", "left": { @@ -5394,7 +5394,7 @@ ] }, "identities.identity.contract_info.bound": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@17", "tree": { "hex": "01", "left": { @@ -5403,7 +5403,7 @@ } }, "identities.identity.key_references": { - "origin": "fixture identities@14", + "origin": "fixture identities@17", "tree": { "hex": "03", "left": { @@ -5415,7 +5415,7 @@ } }, "identities.identity.key_references.authentication": { - "origin": "fixture identities@14", + "origin": "fixture identities@17", "tree": { "hex": "02", "left": { @@ -5430,7 +5430,7 @@ } }, "misc": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "45", "left": { @@ -5442,7 +5442,7 @@ } }, "pools.epoch": { - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@17", "tree": { "hex": "6d", "left": { @@ -5470,14 +5470,14 @@ "states": [ { "state": "future", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@17", "tree": { "hex": "73" } }, { "state": "running", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@17", "tree": { "hex": "6d", "left": { @@ -5505,7 +5505,7 @@ }, { "state": "paid", - "origin": "fixture paid_epoch@14", + "origin": "fixture paid_epoch@17", "tree": { "hex": "74", "left": { @@ -5528,7 +5528,7 @@ ] }, "prefunded_balances": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "80", "left": { @@ -5540,7 +5540,7 @@ } }, "root": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "40", "left": { @@ -5597,7 +5597,7 @@ } }, "saved_block_transactions": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "65", "left": { @@ -5609,7 +5609,7 @@ } }, "shielded_balances.main_pool": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "80", "left": { @@ -5627,7 +5627,7 @@ } }, "tokens": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "80", "left": { @@ -5648,7 +5648,7 @@ } }, "tokens.distributions": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "80", "left": { @@ -5663,7 +5663,7 @@ } }, "tokens.distributions.perpetual.token": { - "origin": "fixture token_distributions_unclaimed@14", + "origin": "fixture token_distributions_unclaimed@17", "tree": { "hex": "c0", "left": { @@ -5672,7 +5672,7 @@ } }, "tokens.distributions.timed": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "80", "left": { @@ -5684,7 +5684,7 @@ } }, "versions": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "01", "left": { @@ -5693,7 +5693,7 @@ } }, "votes": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "64", "left": { @@ -5705,7 +5705,7 @@ } }, "votes.contested_resource": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "70", "left": { @@ -5714,7 +5714,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@17", "tree": { "hex": "01", "left": { @@ -5723,7 +5723,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type.indexes.value.contender": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@17", "tree": { "hex": "01", "left": { @@ -5732,7 +5732,7 @@ } }, "withdrawals": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "03", "left": { diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 5102d8c3e41..fe9c574f626 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -119,7 +119,7 @@ fn should_record_a_contract_layer_with_its_documents_on_top() { // fixture. Documents are read most and sit at the root of the layer; the // contract itself and everything else hang below. let contract = &json["layer_shapes"]["contracts.contract"]; - assert_eq!(contract["origin"], "fixture contracts_with_documents@14"); + assert_eq!(contract["origin"], "fixture contracts_with_documents@17"); assert_eq!(contract["tree"]["hex"], "01"); assert_eq!(contract["tree"]["left"]["hex"], "00"); assert_eq!(contract["tree"]["right"]["hex"], "02"); diff --git a/packages/rs-platform-version/src/version/mod.rs b/packages/rs-platform-version/src/version/mod.rs index 8e79499e196..021fcb0f97d 100644 --- a/packages/rs-platform-version/src/version/mod.rs +++ b/packages/rs-platform-version/src/version/mod.rs @@ -1,6 +1,6 @@ mod protocol_version; -use crate::version::v14::PROTOCOL_VERSION_14; +use crate::version::v17::PROTOCOL_VERSION_17; pub use protocol_version::*; use std::ops::RangeInclusive; @@ -20,6 +20,9 @@ pub mod v11; pub mod v12; pub mod v13; pub mod v14; +pub mod v15; +pub mod v16; +pub mod v17; pub mod v2; pub mod v3; pub mod v4; @@ -33,5 +36,5 @@ pub type ProtocolVersion = u32; pub const ALL_VERSIONS: RangeInclusive = 1..=LATEST_VERSION; -pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_14; +pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_17; pub const INITIAL_PROTOCOL_VERSION: ProtocolVersion = 1; diff --git a/packages/rs-platform-version/src/version/protocol_version.rs b/packages/rs-platform-version/src/version/protocol_version.rs index 00cc470bbc7..96236d1b0a5 100644 --- a/packages/rs-platform-version/src/version/protocol_version.rs +++ b/packages/rs-platform-version/src/version/protocol_version.rs @@ -22,6 +22,9 @@ use crate::version::v11::PLATFORM_V11; use crate::version::v12::PLATFORM_V12; use crate::version::v13::PLATFORM_V13; use crate::version::v14::PLATFORM_V14; +use crate::version::v15::PLATFORM_V15; +use crate::version::v16::PLATFORM_V16; +use crate::version::v17::PLATFORM_V17; use crate::version::v2::PLATFORM_V2; use crate::version::v3::PLATFORM_V3; use crate::version::v4::PLATFORM_V4; @@ -61,6 +64,9 @@ pub const PLATFORM_VERSIONS: &[PlatformVersion] = &[ PLATFORM_V12, PLATFORM_V13, PLATFORM_V14, + PLATFORM_V15, + PLATFORM_V16, + PLATFORM_V17, ]; #[cfg(feature = "mock-versions")] @@ -69,7 +75,7 @@ pub static PLATFORM_TEST_VERSIONS: OnceLock> = OnceLock::ne #[cfg(feature = "mock-versions")] const DEFAULT_PLATFORM_TEST_VERSIONS: &[PlatformVersion] = &[TEST_PLATFORM_V2, TEST_PLATFORM_V3]; -pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V14; +pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V17; pub const DESIRED_PLATFORM_VERSION: &PlatformVersion = LATEST_PLATFORM_VERSION; 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 029761c4c18..efd35847272 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -335,7 +335,11 @@ pub struct SystemLimits { #[cfg(test)] mod tests { + use crate::version::fee::FeeVersion; use crate::version::protocol_version::PLATFORM_VERSIONS; + use crate::version::system_limits::SystemLimits; + use crate::version::v16::PLATFORM_V16; + use crate::version::v17::{PLATFORM_V17, PROTOCOL_VERSION_17}; use crate::version::{PlatformVersion, LATEST_VERSION}; /// The cap is what keeps two document operations out of a shared GroveDB batch, and with @@ -408,6 +412,133 @@ mod tests { } } + /// The computation limits and the computation price are two tables that only make sense + /// together: limits without a price would let an activated version meter contract work for + /// free, a price without limits would leave the per-block ledger nothing to reserve against + /// and the runtime no budget, so every invocation would be refused. This pins the + /// cross-table invariant on every registered version rather than restating either literal: + /// both `Some` or both `None`, the limits enforceable, the price non-zero, and nothing + /// before the 5.0 protocol version carrying either. + #[test] + fn smart_contract_computation_limits_and_pricing_activate_together() { + assert_eq!( + PLATFORM_VERSIONS.len(), + LATEST_VERSION as usize, + "the protocol version registry does not hold every declared version" + ); + for platform_version in PLATFORM_VERSIONS { + let limits = platform_version + .system_limits + .smart_contract_computation + .as_ref(); + let price = platform_version.fee_version.dashvm.as_ref(); + assert_eq!( + limits.is_some(), + price.is_some(), + "protocol version {} carries smart-contract computation limits without a price \ + or a price without limits", + platform_version.protocol_version + ); + if platform_version.protocol_version < PROTOCOL_VERSION_17 { + assert!( + limits.is_none(), + "protocol version {} predates smart contracts and must not bound their \ + computation", + platform_version.protocol_version + ); + } + if let Some(limits) = limits { + assert!( + limits.is_well_formed(), + "protocol version {} has smart-contract computation limits that cannot be \ + enforced: {limits:?}", + platform_version.protocol_version + ); + } + if let Some(price) = price { + assert_ne!( + price.credits_per_computation_unit, 0, + "protocol version {} prices smart-contract computation at zero", + platform_version.protocol_version + ); + } + } + } + + /// The 5.0 protocol version is a struct update over its predecessor that overrides exactly + /// two tables, and the placeholders for 4.3 and 4.4 are struct updates too, so a forward + /// merge that brings a real v15 or v16 must flow into v17 without a second edit. This pins + /// the delta v17 adds rather than the tables themselves: it fails when v16 gains a limit or + /// fee group that v17 does not inherit, or when a merge keeps this branch's generation over + /// an incoming one. A later 5.0 change that widens the delta extends the expected delta here + /// as part of its own change. + #[test] + fn the_5_0_protocol_version_changes_only_the_smart_contract_computation_tables() { + assert!( + PLATFORM_V17 + .system_limits + .smart_contract_computation + .is_some(), + "the 5.0 protocol version must bound smart-contract computation" + ); + assert!( + PLATFORM_V17.fee_version.dashvm.is_some(), + "the 5.0 protocol version must price smart-contract computation" + ); + assert_eq!( + SystemLimits { + smart_contract_computation: None, + ..PLATFORM_V17.system_limits.clone() + }, + PLATFORM_V16.system_limits, + "the 5.0 protocol version changes a system limit other than the smart-contract \ + computation limits without inheriting it from v16" + ); + assert_eq!( + FeeVersion { + dashvm: None, + ..PLATFORM_V17.fee_version.clone() + }, + PLATFORM_V16.fee_version, + "the 5.0 protocol version changes a fee group other than the smart-contract \ + pricing without inheriting it from v16" + ); + } + + /// The mock versions execute state transitions in drive-abci's protocol-upgrade suite and + /// one of them hand-writes its `SystemLimits`, so it is the one place the registry loop + /// above cannot reach. A mock that bounded contract computation would carry limits into a + /// suite that has no runtime to enforce them. + #[cfg(feature = "mock-versions")] + #[test] + fn mock_platform_versions_have_no_smart_contract_computation_limits() { + use crate::version::mocks::v2_test::TEST_PLATFORM_V2; + use crate::version::mocks::v3_test::TEST_PLATFORM_V3; + use crate::version::protocol_version::PLATFORM_TEST_VERSIONS; + + let versions = + PLATFORM_TEST_VERSIONS.get_or_init(|| vec![TEST_PLATFORM_V2, TEST_PLATFORM_V3]); + assert!( + !versions.is_empty(), + "the mock version registry is empty; this test would assert nothing" + ); + for platform_version in versions { + assert!( + platform_version + .system_limits + .smart_contract_computation + .is_none(), + "mock platform version {} bounds smart-contract computation", + platform_version.protocol_version + ); + assert!( + platform_version.fee_version.dashvm.is_none(), + "mock platform version {} prices smart-contract computation", + platform_version.protocol_version + ); + } + } + #[test] fn document_value_depth_limit_starts_at_protocol_version_13() { // v12 is already active on live networks, so the limit must not apply there. diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs new file mode 100644 index 00000000000..d091a5f1aee --- /dev/null +++ b/packages/rs-platform-version/src/version/v15.rs @@ -0,0 +1,21 @@ +use crate::version::protocol_version::PlatformVersion; +use crate::version::v14::PLATFORM_V14; +use crate::version::ProtocolVersion; + +pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; + +/// Placeholder for the 4.3 protocol version. +/// +/// The DashVM allocation register reserves protocol version 15 for the 4.3 release, 16 for 4.4 +/// and 17 for 5.0, and the registry is indexed by number, so the 5.0 version cannot exist on +/// this branch without 15 and 16. Until the 4.3 branch merges its real `v15.rs` forward this +/// version is identical to v14. +/// +/// It is written as a struct update rather than a copy of `v14.rs` on purpose: when the real +/// file arrives the add/add conflict is resolved by taking the incoming file, and because v16 +/// and v17 are struct updates over their predecessor every table the incoming version changes +/// flows into them without a second edit. +pub const PLATFORM_V15: PlatformVersion = PlatformVersion { + protocol_version: PROTOCOL_VERSION_15, + ..PLATFORM_V14 +}; diff --git a/packages/rs-platform-version/src/version/v16.rs b/packages/rs-platform-version/src/version/v16.rs new file mode 100644 index 00000000000..e3d80c4b16e --- /dev/null +++ b/packages/rs-platform-version/src/version/v16.rs @@ -0,0 +1,16 @@ +use crate::version::protocol_version::PlatformVersion; +use crate::version::v15::PLATFORM_V15; +use crate::version::ProtocolVersion; + +pub const PROTOCOL_VERSION_16: ProtocolVersion = 16; + +/// Placeholder for the 4.4 protocol version. +/// +/// Reserved by the DashVM allocation register (15 for 4.3, 16 for 4.4, 17 for 5.0). Identical to +/// v15 until the 4.4 branch merges its real `v16.rs` forward; see `v15.rs` for why it is a +/// struct update and how the forward merge is resolved. Should 4.4 ship no consensus change, the +/// register drops this activation and the 5.0 version becomes 16. +pub const PLATFORM_V16: PlatformVersion = PlatformVersion { + protocol_version: PROTOCOL_VERSION_16, + ..PLATFORM_V15 +}; diff --git a/packages/rs-platform-version/src/version/v17.rs b/packages/rs-platform-version/src/version/v17.rs new file mode 100644 index 00000000000..a741fee6bb0 --- /dev/null +++ b/packages/rs-platform-version/src/version/v17.rs @@ -0,0 +1,30 @@ +use crate::version::fee::v4::FEE_VERSION4; +use crate::version::protocol_version::PlatformVersion; +use crate::version::system_limits::v5::SYSTEM_LIMITS_V5; +use crate::version::v16::PLATFORM_V16; +use crate::version::ProtocolVersion; + +pub const PROTOCOL_VERSION_17: ProtocolVersion = 17; + +/// The 5.0 protocol version, the one that introduces smart contracts. Provisional number from +/// the DashVM allocation register (15 for 4.3, 16 for 4.4, 17 for 5.0). +/// +/// Two changes over v16 so far, both tables and both still unread by any dispatching code path: +/// +/// * `SYSTEM_LIMITS_V5` sets `smart_contract_computation`: at most 25 million computation +/// units per outer contract invocation and 250 million per block, counted by one +/// contract-only counter separate from every native budget. +/// * `FEE_VERSION4` prices those units at 1 credit each (`dashvm`); its number stays 1 because +/// no storage rate changes. +/// +/// Both numbers are provisional register values, measured and revised before any network is +/// asked to propose this version. Enforcement (the per-block ledger on the block execution +/// context, the not-executed classification for a full block, CheckTx affordability) arrives +/// with the tasks that wire the runtime in; until then a node at v17 behaves exactly like one +/// at v16. +pub const PLATFORM_V17: PlatformVersion = PlatformVersion { + protocol_version: PROTOCOL_VERSION_17, + fee_version: FEE_VERSION4, // changed: prices smart-contract computation (dashvm group) + system_limits: SYSTEM_LIMITS_V5, // changed: per-invocation and per-block smart-contract computation limits + ..PLATFORM_V16 +}; From 6f8a15d77fa32cc1ff21f6de234cdd9e7cc7b0c5 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:54:04 -0500 Subject: [PATCH 04/12] feat(dpp): price smart-contract computation units in credits Add `dpp::fee::smart_contract_computation` with `computation_units_to_credits`: checked multiplication of the units an invocation consumed by the fee schedule's `dashvm` price, a corrupted code execution error when the schedule has no contract pricing and an overflow error when the charge does not fit in credits. The module documents the gas mapping: the charge enters the invocation's processing fee, which is what Tenderdash's gas fields already report in credits, so no new gas unit and no unit equivalence with Tenderdash gas is introduced. The unit alias and the limits struct are re-exported from platform-version for dpp consumers. Co-Authored-By: Claude Fable 5.1 --- packages/rs-dpp/src/fee/mod.rs | 1 + .../src/fee/smart_contract_computation.rs | 124 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 packages/rs-dpp/src/fee/smart_contract_computation.rs diff --git a/packages/rs-dpp/src/fee/mod.rs b/packages/rs-dpp/src/fee/mod.rs index f89ba6be42a..7ef1f926b4e 100644 --- a/packages/rs-dpp/src/fee/mod.rs +++ b/packages/rs-dpp/src/fee/mod.rs @@ -2,5 +2,6 @@ pub mod default_costs; pub mod epoch; #[cfg(feature = "fee-distribution")] pub mod fee_result; +pub mod smart_contract_computation; pub use crate::balances::credits::{Credits, SignedCredits}; diff --git a/packages/rs-dpp/src/fee/smart_contract_computation.rs b/packages/rs-dpp/src/fee/smart_contract_computation.rs new file mode 100644 index 00000000000..7c41932854b --- /dev/null +++ b/packages/rs-dpp/src/fee/smart_contract_computation.rs @@ -0,0 +1,124 @@ +//! Pricing of smart-contract computation. +//! +//! Contract work is metered by the runtime in [`ComputationUnits`], a deterministic count under +//! the active metering generation, and bounded per invocation and per block by +//! [`SmartContractComputationLimits`] in the protocol version's system limits. This module turns +//! the units an invocation consumed into credits at the protocol-versioned price of the fee +//! schedule (`FeeVersion::dashvm`). +//! +//! The charge enters the processing fee of the invocation's `FeeResult`, exactly like every other +//! processing charge, and therefore reaches Tenderdash through the existing `gas_used` and +//! `gas_wanted` fields, which report `FeeResult::total_base_fee()` in credits. Gas stays +//! denominated in credits; computation units are never reported to Tenderdash and no unit +//! equivalence between them and Tenderdash gas exists. + +use crate::fee::Credits; +use crate::ProtocolError; +use platform_version::version::fee::FeeVersion; +pub use platform_version::version::system_limits::smart_contract::{ + ComputationUnits, SmartContractComputationLimits, +}; + +/// Prices `units` of smart-contract computation in credits at the schedule's rate. +/// +/// The table is the versioned part: a schedule that prices computation differently is a new +/// `FEE_VERSION*` with a different `dashvm` group, not a new generation of this function. +/// +/// # Errors +/// +/// * `ProtocolError::CorruptedCodeExecution` when `fee_version` has no smart-contract pricing. +/// A caller only reaches this function after the protocol version admitted contract execution, +/// and the tables guarantee that such a version prices computation, so a missing price is a +/// broken build rather than a user mistake. +/// * `ProtocolError::Overflow` when the charge does not fit in `Credits`. With the provisional +/// rate of one credit per unit and limits far below `u64::MAX` this is unreachable, but the +/// arithmetic is checked so that no revision of either table can wrap a fee. +pub fn computation_units_to_credits( + units: ComputationUnits, + fee_version: &FeeVersion, +) -> Result { + let price = fee_version.dashvm.as_ref().ok_or_else(|| { + ProtocolError::CorruptedCodeExecution( + "computation_units_to_credits requires fee_version.dashvm".to_string(), + ) + })?; + + units + .checked_mul(price.credits_per_computation_unit) + .ok_or(ProtocolError::Overflow( + "smart-contract computation charge overflowed credits", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use platform_version::version::fee::dashvm::FeeDashVmVersion; + use platform_version::version::PlatformVersion; + + #[test] + fn should_price_computation_units_at_the_schedule_rate() { + let platform_version = PlatformVersion::latest(); + let limits = platform_version + .system_limits + .smart_contract_computation + .as_ref() + .expect("the latest protocol version bounds smart-contract computation"); + let price = platform_version + .fee_version + .dashvm + .as_ref() + .expect("the latest protocol version prices smart-contract computation"); + + let units = limits.max_computation_units_per_invocation; + + let credits = computation_units_to_credits(units, &platform_version.fee_version) + .expect("a maximal invocation must be priceable"); + + assert_eq!(credits, units * price.credits_per_computation_unit); + } + + #[test] + fn should_price_zero_units_as_zero_credits() { + let platform_version = PlatformVersion::latest(); + + let credits = computation_units_to_credits(0, &platform_version.fee_version) + .expect("zero units must be priceable"); + + assert_eq!(credits, 0); + } + + #[test] + fn should_fail_with_overflow_when_the_charge_does_not_fit_in_credits() { + let platform_version = PlatformVersion::latest(); + let fee_version = FeeVersion { + dashvm: Some(FeeDashVmVersion { + credits_per_computation_unit: 2, + }), + ..platform_version.fee_version.clone() + }; + + let result = computation_units_to_credits(u64::MAX, &fee_version); + + assert!( + matches!(result, Err(ProtocolError::Overflow(_))), + "expected an overflow error, got {result:?}" + ); + } + + #[test] + fn should_report_corrupted_code_execution_when_the_schedule_has_no_smart_contract_pricing() { + let platform_version = PlatformVersion::get(14).expect("protocol version 14 exists"); + assert!( + platform_version.fee_version.dashvm.is_none(), + "protocol version 14 predates smart-contract pricing" + ); + + let result = computation_units_to_credits(1, &platform_version.fee_version); + + assert!( + matches!(result, Err(ProtocolError::CorruptedCodeExecution(_))), + "expected a corrupted code execution error, got {result:?}" + ); + } +} From dc9195840448982fc65ec865ca80868f20953632 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:54:04 -0500 Subject: [PATCH 05/12] feat(drive-abci): add the per-block smart-contract computation ledger Add `execution::types::block_computation_budget` with `BlockComputationBudget`, the deterministic per-block accounting proposal creation and proposal validation will share: reserve an invocation's admitted bound against the block before it runs, settle the actual consumption afterwards and release the unused part. Exhaustion is an admission outcome that leaves the ledger unchanged, settling more than the reserved bound is a corrupted code execution error, and every operation uses checked arithmetic. `ComputationReservation` is `#[must_use]`, not `Clone`, and consumed by settlement, so a reservation cannot be spent twice. `for_platform_version` returns `None` before the 5.0 protocol version so callers skip the contract path entirely. Nothing wires the ledger into the block loop yet. Co-Authored-By: Claude Fable 5.1 --- .../types/block_computation_budget.rs | 365 ++++++++++++++++++ .../rs-drive-abci/src/execution/types/mod.rs | 2 + 2 files changed, 367 insertions(+) create mode 100644 packages/rs-drive-abci/src/execution/types/block_computation_budget.rs diff --git a/packages/rs-drive-abci/src/execution/types/block_computation_budget.rs b/packages/rs-drive-abci/src/execution/types/block_computation_budget.rs new file mode 100644 index 00000000000..c3d9e7a55e9 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/types/block_computation_budget.rs @@ -0,0 +1,365 @@ +//! The per-block ledger of smart-contract computation. +//! +//! `SystemLimits::smart_contract_computation` bounds how many computation units all contract +//! invocations in one block may consume together, ordinary and scheduled. This ledger is the +//! deterministic accounting both proposal creation and proposal validation run over the same +//! sequence of invocations: reserve an invocation's admitted bound before it runs, settle what +//! it actually consumed afterwards, release the rest. Reserving the bound rather than the actual +//! consumption means an invocation that would not fit is never started, so per-block exhaustion +//! is an admission outcome (the proposer delays the transition, a validator rejects the block) +//! and never a paid failure part-way through execution, and an invocation's own outcome does not +//! depend on its position in the block. +//! +//! The ledger is in-memory block state and is never serialised; it has no version wrapper. + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use dpp::fee::smart_contract_computation::ComputationUnits; +use dpp::version::PlatformVersion; + +/// The admitted computation bound of one contract invocation, held against the block's budget +/// until it is settled. +/// +/// Not `Clone` and consumed by [`BlockComputationBudget::settle`], so a reservation can only be +/// spent once. Dropping it without settling keeps its bound held for the rest of the block; a +/// caller that abandons an invocation before it runs settles with zero consumption instead. +#[derive(Debug, PartialEq, Eq)] +#[must_use = "an unsettled reservation keeps its bound held for the rest of the block"] +pub struct ComputationReservation { + bound: ComputationUnits, +} + +impl ComputationReservation { + /// The computation units held by this reservation. + pub fn bound(&self) -> ComputationUnits { + self.bound + } +} + +/// Why a reservation was refused: the block does not have `requested` units left, only +/// `remaining`. An admission outcome, not an error: the ledger is unchanged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlockComputationBudgetExceeded { + /// The bound the invocation asked to reserve. + pub requested: ComputationUnits, + /// The units the block still had available. + pub remaining: ComputationUnits, +} + +/// The computation units one block may still hand to contract invocations, and the units it has +/// already handed out. +/// +/// Invariant after every operation: `consumed + held + remaining == limit`, where `held` is the +/// sum of the outstanding reservations. Every operation uses checked arithmetic; the invariant +/// keeps each intermediate value within `limit`, but the checks make that proof local to each +/// method rather than something a reader has to carry across the file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockComputationBudget { + limit: ComputationUnits, + /// Units settled as actually consumed. + consumed: ComputationUnits, + /// Units still available to reserve: the limit minus consumed and held units. + remaining: ComputationUnits, +} + +impl BlockComputationBudget { + /// The ledger for a block executed under `platform_version`, or `None` when the version + /// predates smart contracts so that callers skip the contract path entirely. + pub fn for_platform_version(platform_version: &PlatformVersion) -> Option { + platform_version + .system_limits + .smart_contract_computation + .as_ref() + .map(|limits| Self::with_limit(limits.max_computation_units_per_block)) + } + + /// A ledger over an explicit limit. + pub fn with_limit(limit: ComputationUnits) -> Self { + Self { + limit, + consumed: 0, + remaining: limit, + } + } + + /// The per-block limit this ledger enforces. + pub fn limit(&self) -> ComputationUnits { + self.limit + } + + /// Units settled as actually consumed so far. + pub fn consumed(&self) -> ComputationUnits { + self.consumed + } + + /// Units still available to reserve. Outstanding reservations are not available. + pub fn remaining(&self) -> ComputationUnits { + self.remaining + } + + /// Holds `bound` units for one invocation. + /// + /// Refused, with the ledger unchanged, when fewer than `bound` units remain. Reserving zero + /// units is allowed and holds nothing. + pub fn reserve( + &mut self, + bound: ComputationUnits, + ) -> Result { + match self.remaining.checked_sub(bound) { + Some(remaining) => { + self.remaining = remaining; + Ok(ComputationReservation { bound }) + } + None => Err(BlockComputationBudgetExceeded { + requested: bound, + remaining: self.remaining, + }), + } + } + + /// Settles a reservation with the units the invocation actually consumed: the consumed + /// units are recorded and the unused part of the bound is returned to the block. Returns + /// the released units. + /// + /// `actual` above the reserved bound is `ExecutionError::CorruptedCodeExecution`: the + /// runtime is handed the bound as its budget and cannot legally exceed it, so this is a + /// broken runtime, not an admission outcome. The ledger is unchanged in that case. + pub fn settle( + &mut self, + reservation: ComputationReservation, + actual: ComputationUnits, + ) -> Result { + let released = reservation + .bound + .checked_sub(actual) + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a contract invocation consumed more computation than its reserved bound", + )))?; + + let consumed = + self.consumed + .checked_add(actual) + .ok_or(Error::Execution(ExecutionError::Overflow( + "consumed smart-contract computation overflowed the block ledger", + )))?; + let remaining = self + .remaining + .checked_add(released) + .ok_or(Error::Execution(ExecutionError::Overflow( + "released smart-contract computation overflowed the block ledger", + )))?; + + self.consumed = consumed; + self.remaining = remaining; + + Ok(released) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::version::PlatformVersion; + + fn ledger(limit: ComputationUnits) -> BlockComputationBudget { + BlockComputationBudget::with_limit(limit) + } + + #[test] + fn should_not_exist_before_the_5_0_protocol_version() { + let platform_version_14 = PlatformVersion::get(14).expect("protocol version 14 exists"); + assert_eq!( + BlockComputationBudget::for_platform_version(platform_version_14), + None, + "protocol version 14 predates smart contracts and must have no computation ledger" + ); + + let latest = PlatformVersion::latest(); + let limit = latest + .system_limits + .smart_contract_computation + .as_ref() + .expect("the latest protocol version bounds smart-contract computation") + .max_computation_units_per_block; + let budget = BlockComputationBudget::for_platform_version(latest) + .expect("the latest protocol version must have a computation ledger"); + + assert_eq!(budget.limit(), limit); + assert_eq!(budget.remaining(), limit); + assert_eq!(budget.consumed(), 0); + } + + #[test] + fn should_reserve_up_to_the_exact_remaining_budget() { + let mut budget = ledger(1_000); + + let exact = budget + .reserve(1_000) + .expect("a bound equal to the remaining budget must fit"); + assert_eq!(exact.bound(), 1_000); + assert_eq!(budget.remaining(), 0); + + let refused = budget + .reserve(1) + .expect_err("one unit more than the remaining budget must be refused"); + assert_eq!( + refused, + BlockComputationBudgetExceeded { + requested: 1, + remaining: 0, + } + ); + assert_eq!( + budget, + BlockComputationBudget { + limit: 1_000, + consumed: 0, + remaining: 0, + }, + "a refused reservation must leave the ledger unchanged" + ); + + let released = budget + .settle(exact, 1_000) + .expect("consuming the whole bound is legal"); + assert_eq!(released, 0); + assert_eq!(budget.consumed(), 1_000); + assert_eq!(budget.remaining(), 0); + } + + #[test] + fn should_settle_actual_consumption_and_release_the_unused_bound() { + let mut budget = ledger(10_000); + + let reservation = budget.reserve(1_000).expect("1,000 units fit in 10,000"); + assert_eq!(budget.remaining(), 9_000); + + let released = budget + .settle(reservation, 600) + .expect("consuming less than the bound is legal"); + + assert_eq!(released, 400); + assert_eq!(budget.consumed(), 600); + assert_eq!(budget.remaining(), 9_400); + } + + #[test] + fn should_reject_settling_more_than_the_reserved_bound() { + let mut budget = ledger(10_000); + let reservation = budget.reserve(1_000).expect("1,000 units fit in 10,000"); + + let result = budget.settle(reservation, 1_001); + + assert!( + matches!( + result, + Err(Error::Execution(ExecutionError::CorruptedCodeExecution(_))) + ), + "expected a corrupted code execution error, got {result:?}" + ); + assert_eq!( + budget, + BlockComputationBudget { + limit: 10_000, + consumed: 0, + remaining: 9_000, + }, + "a rejected settlement must leave the ledger unchanged, with the bound still held" + ); + } + + #[test] + fn should_account_many_reservations_without_double_spending() { + let limit = 1_000; + let mut budget = ledger(limit); + let mut expected_consumed = 0; + + for (bound, actual) in [(300, 300), (300, 100), (200, 0), (250, 250), (150, 50)] { + let held_before = limit - budget.consumed() - budget.remaining(); + assert_eq!( + held_before, 0, + "nothing is held between settled invocations" + ); + + let reservation = budget.reserve(bound).expect("each bound fits"); + assert_eq!( + budget.consumed() + reservation.bound() + budget.remaining(), + limit, + "consumed + held + remaining must equal the limit while a reservation is out" + ); + + let released = budget.settle(reservation, actual).expect("actual <= bound"); + expected_consumed += actual; + + assert_eq!(released, bound - actual); + assert_eq!(budget.consumed(), expected_consumed); + assert_eq!( + budget.consumed() + budget.remaining(), + limit, + "consumed + remaining must equal the limit after settlement" + ); + } + + // 300 + 100 + 0 + 250 + 50 = 700 consumed; 300 units remain, so a 301-unit bound is + // refused even though the sum of the bounds reserved so far (1,200) exceeded the limit: + // released units are available again, spent units are not. + assert_eq!(budget.consumed(), 700); + assert_eq!(budget.remaining(), 300); + let refused = budget + .reserve(301) + .expect_err("301 units exceed the 300 remaining"); + assert_eq!(refused.remaining, 300); + // A reservation moved into `settle` cannot be settled again: the type system enforces + // it, which is why `ComputationReservation` is neither `Clone` nor `Copy`. + } + + #[test] + fn should_keep_the_budget_of_an_unsettled_reservation_held() { + let mut budget = ledger(1_000); + + let outstanding = budget.reserve(600).expect("600 units fit in 1,000"); + assert_eq!(budget.remaining(), 400); + + let refused = budget + .reserve(401) + .expect_err("an outstanding reservation keeps its bound unavailable"); + assert_eq!(refused.remaining, 400); + + // Abandoning an invocation before it runs is settled with zero consumption, which + // returns the whole bound; dropping the reservation instead would keep the 600 held. + let released = budget + .settle(outstanding, 0) + .expect("zero consumption is legal"); + assert_eq!(released, 600); + assert_eq!(budget.consumed(), 0); + assert_eq!(budget.remaining(), 1_000); + } + + #[test] + fn should_use_checked_arithmetic_at_the_top_of_the_range() { + let mut budget = ledger(ComputationUnits::MAX); + + let whole = budget + .reserve(ComputationUnits::MAX) + .expect("the whole budget can be reserved at once"); + assert_eq!(budget.remaining(), 0); + let refused = budget + .reserve(1) + .expect_err("nothing remains once the whole budget is held"); + assert_eq!(refused.remaining, 0); + + let released = budget + .settle(whole, ComputationUnits::MAX) + .expect("consuming the whole budget is legal"); + assert_eq!(released, 0); + assert_eq!(budget.consumed(), ComputationUnits::MAX); + assert_eq!(budget.remaining(), 0); + + // Reserving zero still succeeds with nothing left, and settling it changes nothing. + let empty = budget.reserve(0).expect("a zero bound always fits"); + let released = budget.settle(empty, 0).expect("zero consumption is legal"); + assert_eq!(released, 0); + assert_eq!(budget.consumed(), ComputationUnits::MAX); + assert_eq!(budget.remaining(), 0); + } +} diff --git a/packages/rs-drive-abci/src/execution/types/mod.rs b/packages/rs-drive-abci/src/execution/types/mod.rs index 7bc018955de..2bb3fe1b990 100644 --- a/packages/rs-drive-abci/src/execution/types/mod.rs +++ b/packages/rs-drive-abci/src/execution/types/mod.rs @@ -1,3 +1,5 @@ +/// The per-block ledger of smart-contract computation +pub mod block_computation_budget; /// The block execution context pub mod block_execution_context; /// A structure representing block fees From e2deac7519ff97549c47e22b6d5938c38403c8ae Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:54:42 -0500 Subject: [PATCH 06/12] docs: describe smart-contract computation limits, pricing and gas mapping Document in the platform book what a computation unit is, the per-invocation and per-block limits in the system limits, the price in the fee schedule, why the fee version number stays 1, how the charge reaches Tenderdash gas through the processing fee, and why the 5.0 branch carries placeholder protocol versions 15 and 16 as struct updates. Co-Authored-By: Claude Fable 5.1 --- book/src/fees/overview.md | 43 +++++++++++++++++++++++++ book/src/versioning/feature-versions.md | 10 +++++- book/src/versioning/platform-version.md | 16 +++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 94af5b6e964..0ba86ddd6d6 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -534,6 +534,7 @@ pub struct FeeVersion { pub data_contract_registration: FeeDataContractRegistrationVersion, pub state_transition_min_fees: StateTransitionMinFees, pub vote_resolution_fund_fees: VoteResolutionFundFees, + pub dashvm: Option, } ``` @@ -541,6 +542,48 @@ Fee versions are stored in the `FEE_VERSIONS` array and looked up by number. The `uses_version_fee_multiplier_permille` field allows a global scaling factor (permille = divide by 1000; a value of 1000 means no change). +`fee_version_number` keys the persisted fee history and the storage refund +rates, so it only changes when a storage rate changes. A schedule that changes +only a group the history never serves keeps the number of the generation it +agrees with and is not appended to `FEE_VERSIONS`: `FEE_VERSION2` (protocol +version 9), `FEE_VERSION3` (protocol version 14) and `FEE_VERSION4` (protocol +version 17) all carry number 1. + +### Smart-contract computation (protocol version 17, 5.0) + +Smart-contract work is metered by the runtime in *computation units* +(`ComputationUnits` in `rs-platform-version`): a deterministic count of the +admitted guest operations and host work an invocation performs, weighted by the +active metering generation. A unit is never wall-clock time, so every node +counts the same number of units for the same invocation, whatever its hardware +or cache state. + +Two consensus limits bound the units, both in +`SystemLimits::smart_contract_computation` and both counted by one +contract-only counter that is separate from every native budget (the proposer +timer, the withdrawal and shielded per-block caps, the Tenderdash block gas +limit): + +| Limit | Scope | +|---|---| +| `max_computation_units_per_invocation` | One outer invocation: a direct call, a predicate, or one scheduled attempt, including every nested call, predicate, module initialisation and host entry it causes. The runtime receives it as the budget of the invocation. | +| `max_computation_units_per_block` | All invocations in one block, ordinary and scheduled. The block loop reserves an invocation's admitted bound before it runs and settles the actual consumption afterwards (`BlockComputationBudget` in `rs-drive-abci`), so an invocation that would not fit is delayed or rejected, never failed part-way through. | + +Units become credits at the schedule's price, +`FeeVersion::dashvm.credits_per_computation_unit`, through +`dpp::fee::smart_contract_computation::computation_units_to_credits` (checked +multiplication). The charge enters the processing fee of the invocation's +`FeeResult`, which is what Tenderdash's `gas_used` and `gas_wanted` already +report, so gas stays denominated in credits and no unit equivalence between +computation units and Tenderdash gas exists. A failed invocation still consumed +its units and is charged for them. + +Protocol versions before 17 carry `None` for both the limits and the price: +nothing meters, prices or budgets contract computation there. The numbers on +protocol version 17 (25 million units per invocation, 250 million per block, +1 credit per unit) are the provisional starting values of the DashVM allocation +register and are measured and revised before any network runs that version. + ## Key Source Files | File | Contents | diff --git a/book/src/versioning/feature-versions.md b/book/src/versioning/feature-versions.md index f2395089285..13a21bcb039 100644 --- a/book/src/versioning/feature-versions.md +++ b/book/src/versioning/feature-versions.md @@ -338,15 +338,23 @@ pub struct SystemLimits { pub max_token_redemption_cycles: u32, pub max_shielded_transition_actions: u16, pub max_time_range_overlap_factor: Option, + // ... + pub smart_contract_computation: Option, } ``` -There are four `SYSTEM_LIMITS_V*` constants, one for each protocol version at +There are five `SYSTEM_LIMITS_V*` constants, one for each protocol version at which a limit changed. The `Option` fields show the idiom for a parameter that did not exist before some version: `None` in the tables of the versions that predate the rule, `Some(value)` from the version that introduced it. It is the parameter-shaped twin of `OptionalFeatureVersion`. +Nested optional groups follow the same rule as optional method versions: +`smart_contract_computation` is `None` on every protocol version that predates +smart contracts and carries the per-invocation and per-block computation +limits from protocol version 17. Consumers pass the group as one value, and +later limits of the same family extend the group rather than the flat table. + The same shape recurs wherever a subsystem owns tunables: ```rust diff --git a/book/src/versioning/platform-version.md b/book/src/versioning/platform-version.md index 32e7db207f5..80003287265 100644 --- a/book/src/versioning/platform-version.md +++ b/book/src/versioning/platform-version.md @@ -108,6 +108,22 @@ record, and the next consensus change creates `v15.rs`. There is never a `v14.rs` that means one thing on a node built last month and another on a node built today. +Because the array is indexed by number, a version cannot be registered without +every number below it. The 5.0 development branch therefore carries protocol +version 17 (its own) together with 15 and 16, which the allocation register +reserves for the 4.3 and 4.4 releases. Until those branches merge their real +`v15.rs` and `v16.rs` forward, the two files are placeholders written as +struct updates over their predecessor +(`PlatformVersion { protocol_version: PROTOCOL_VERSION_15, ..PLATFORM_V14 }`). +A forward merge that brings the real file is resolved by taking the incoming +file; because 16 and 17 are struct updates too, every table the incoming +version changes flows into them without a second edit. + +`system_limits` is a public module, so `SystemLimits` and the nested limit +groups it holds (such as `SmartContractComputationLimits` and the +`ComputationUnits` alias) can be named from `dpp`, `drive-abci` and the +runtime crates. + ## What a Version Snapshot Looks Like Here is the very first version, `PLATFORM_V1`, slightly abbreviated: From c622e173d36340fad1b5d11525bfea4bdf3c4927 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:29:28 -0500 Subject: [PATCH 07/12] fix(dpp): read the smart-contract computation price from the active protocol version The persisted epoch fee history is keyed by `fee_version_number`, records a schedule only when that number changes, and is restored from saved state through `FeeVersion::get(number)`. It serves the storage, processing, hashing and signature groups and the storage refund rates, and nothing else, so it never carries the `dashvm` group, exactly as it never carried the contract registration, minimum fee or vote resolution groups. `computation_units_to_credits` now takes `&PlatformVersion` and reads `fee_version.dashvm` from the active protocol version, so a history entry cannot be passed to it by mistake; the field, group and schedule docs state the rule. Two tests pin it: `FEE_VERSION4` agrees with the registered generation its number resolves to on every group the history serves, and an upgrade from protocol version 16 to 17 on an epoch change through the real hook records no new history entry, survives a save and reload with the history unchanged, and prices computation from the active version on both sides of the restart. Co-Authored-By: Claude Fable 5.1 --- book/src/fees/overview.md | 18 ++- .../src/fee/smart_contract_computation.rs | 71 +++++++--- .../upgrade_protocol_version/v0/mod.rs | 134 ++++++++++++++++++ .../src/version/fee/dashvm/mod.rs | 7 + .../src/version/fee/mod.rs | 6 + .../rs-platform-version/src/version/fee/v4.rs | 37 +++++ .../version/system_limits/smart_contract.rs | 10 +- 7 files changed, 253 insertions(+), 30 deletions(-) diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 0ba86ddd6d6..745684b9740 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -549,6 +549,17 @@ agrees with and is not appended to `FEE_VERSIONS`: `FEE_VERSION2` (protocol version 9), `FEE_VERSION3` (protocol version 14) and `FEE_VERSION4` (protocol version 17) all carry number 1. +The epoch fee history (`previous_fee_versions` in platform state) records a +schedule only when its number changes, is saved as numbers and restored through +`FeeVersion::get(number)`, and serves exactly the groups `KnownCostItem` reads: +storage, processing, hashing and signature. Every other group +(`data_contract_registration`, `state_transition_min_fees`, +`vote_resolution_fund_fees`, `dashvm`) is read from the active protocol +version's schedule, `platform_version.fee_version`, and never from the history. +Upgrading from protocol version 16 to 17 therefore records nothing new in the +history and a restart resolves the existing entry to `FEE_VERSION1`; contract +pricing is unaffected because nothing reads it from there. + ### Smart-contract computation (protocol version 17, 5.0) Smart-contract work is metered by the runtime in *computation units* @@ -569,10 +580,11 @@ limit): | `max_computation_units_per_invocation` | One outer invocation: a direct call, a predicate, or one scheduled attempt, including every nested call, predicate, module initialisation and host entry it causes. The runtime receives it as the budget of the invocation. | | `max_computation_units_per_block` | All invocations in one block, ordinary and scheduled. The block loop reserves an invocation's admitted bound before it runs and settles the actual consumption afterwards (`BlockComputationBudget` in `rs-drive-abci`), so an invocation that would not fit is delayed or rejected, never failed part-way through. | -Units become credits at the schedule's price, -`FeeVersion::dashvm.credits_per_computation_unit`, through +Units become credits at the active protocol version's price, +`platform_version.fee_version.dashvm.credits_per_computation_unit`, through `dpp::fee::smart_contract_computation::computation_units_to_credits` (checked -multiplication). The charge enters the processing fee of the invocation's +multiplication; the function takes `&PlatformVersion`, so a schedule taken from +the epoch fee history cannot be passed to it). The charge enters the processing fee of the invocation's `FeeResult`, which is what Tenderdash's `gas_used` and `gas_wanted` already report, so gas stays denominated in credits and no unit equivalence between computation units and Tenderdash gas exists. A failed invocation still consumed diff --git a/packages/rs-dpp/src/fee/smart_contract_computation.rs b/packages/rs-dpp/src/fee/smart_contract_computation.rs index 7c41932854b..51aea5b5db5 100644 --- a/packages/rs-dpp/src/fee/smart_contract_computation.rs +++ b/packages/rs-dpp/src/fee/smart_contract_computation.rs @@ -6,6 +6,17 @@ //! the units an invocation consumed into credits at the protocol-versioned price of the fee //! schedule (`FeeVersion::dashvm`). //! +//! The price is read from the fee schedule of the **active protocol version** +//! (`platform_version.fee_version.dashvm`), never from the persisted epoch fee history. That +//! history is keyed by `fee_version_number`, records a schedule only when that number changes, +//! and is restored from saved state through `FeeVersion::get(number)`; it serves the storage, +//! processing, hashing and signature groups (`KnownCostItem`) and the storage refund rates, and +//! nothing else. A schedule that adds contract pricing does not change the number, so the history +//! never carries the `dashvm` group, exactly as it never carried `data_contract_registration`, +//! `state_transition_min_fees` or `vote_resolution_fund_fees`, all of which are likewise read from +//! the active protocol version. The function below therefore takes `&PlatformVersion`, so a +//! history entry cannot be passed to it by mistake. +//! //! The charge enters the processing fee of the invocation's `FeeResult`, exactly like every other //! processing charge, and therefore reaches Tenderdash through the existing `gas_used` and //! `gas_wanted` fields, which report `FeeResult::total_base_fee()` in credits. Gas stays @@ -14,34 +25,43 @@ use crate::fee::Credits; use crate::ProtocolError; -use platform_version::version::fee::FeeVersion; pub use platform_version::version::system_limits::smart_contract::{ ComputationUnits, SmartContractComputationLimits, }; +use platform_version::version::PlatformVersion; -/// Prices `units` of smart-contract computation in credits at the schedule's rate. +/// Prices `units` of smart-contract computation in credits at the active protocol version's +/// rate (`platform_version.fee_version.dashvm.credits_per_computation_unit`). /// /// The table is the versioned part: a schedule that prices computation differently is a new /// `FEE_VERSION*` with a different `dashvm` group, not a new generation of this function. +/// Callers on a block path pass the version from platform state +/// (`platform_state.current_platform_version()`), never `PlatformVersion::latest()` and never a +/// schedule taken from the epoch fee history (see the module documentation). /// /// # Errors /// -/// * `ProtocolError::CorruptedCodeExecution` when `fee_version` has no smart-contract pricing. -/// A caller only reaches this function after the protocol version admitted contract execution, -/// and the tables guarantee that such a version prices computation, so a missing price is a -/// broken build rather than a user mistake. +/// * `ProtocolError::CorruptedCodeExecution` when the protocol version has no smart-contract +/// pricing. A caller only reaches this function after the protocol version admitted contract +/// execution, and the tables guarantee that such a version prices computation, so a missing +/// price is a broken build rather than a user mistake. /// * `ProtocolError::Overflow` when the charge does not fit in `Credits`. With the provisional /// rate of one credit per unit and limits far below `u64::MAX` this is unreachable, but the /// arithmetic is checked so that no revision of either table can wrap a fee. pub fn computation_units_to_credits( units: ComputationUnits, - fee_version: &FeeVersion, + platform_version: &PlatformVersion, ) -> Result { - let price = fee_version.dashvm.as_ref().ok_or_else(|| { - ProtocolError::CorruptedCodeExecution( - "computation_units_to_credits requires fee_version.dashvm".to_string(), - ) - })?; + let price = platform_version + .fee_version + .dashvm + .as_ref() + .ok_or_else(|| { + ProtocolError::CorruptedCodeExecution(format!( + "computation_units_to_credits requires fee_version.dashvm, which protocol version {} does not carry", + platform_version.protocol_version + )) + })?; units .checked_mul(price.credits_per_computation_unit) @@ -54,6 +74,7 @@ pub fn computation_units_to_credits( mod tests { use super::*; use platform_version::version::fee::dashvm::FeeDashVmVersion; + use platform_version::version::fee::FeeVersion; use platform_version::version::PlatformVersion; #[test] @@ -72,7 +93,7 @@ mod tests { let units = limits.max_computation_units_per_invocation; - let credits = computation_units_to_credits(units, &platform_version.fee_version) + let credits = computation_units_to_credits(units, platform_version) .expect("a maximal invocation must be priceable"); assert_eq!(credits, units * price.credits_per_computation_unit); @@ -82,7 +103,7 @@ mod tests { fn should_price_zero_units_as_zero_credits() { let platform_version = PlatformVersion::latest(); - let credits = computation_units_to_credits(0, &platform_version.fee_version) + let credits = computation_units_to_credits(0, platform_version) .expect("zero units must be priceable"); assert_eq!(credits, 0); @@ -90,15 +111,18 @@ mod tests { #[test] fn should_fail_with_overflow_when_the_charge_does_not_fit_in_credits() { - let platform_version = PlatformVersion::latest(); - let fee_version = FeeVersion { - dashvm: Some(FeeDashVmVersion { - credits_per_computation_unit: 2, - }), - ..platform_version.fee_version.clone() + let latest = PlatformVersion::latest(); + let platform_version = PlatformVersion { + fee_version: FeeVersion { + dashvm: Some(FeeDashVmVersion { + credits_per_computation_unit: 2, + }), + ..latest.fee_version.clone() + }, + ..latest.clone() }; - let result = computation_units_to_credits(u64::MAX, &fee_version); + let result = computation_units_to_credits(u64::MAX, &platform_version); assert!( matches!(result, Err(ProtocolError::Overflow(_))), @@ -107,14 +131,15 @@ mod tests { } #[test] - fn should_report_corrupted_code_execution_when_the_schedule_has_no_smart_contract_pricing() { + fn should_report_corrupted_code_execution_when_the_protocol_version_has_no_smart_contract_pricing( + ) { let platform_version = PlatformVersion::get(14).expect("protocol version 14 exists"); assert!( platform_version.fee_version.dashvm.is_none(), "protocol version 14 predates smart-contract pricing" ); - let result = computation_units_to_credits(1, &platform_version.fee_version); + let result = computation_units_to_credits(1, platform_version); assert!( matches!(result, Err(ProtocolError::CorruptedCodeExecution(_))), diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs index f87f67705b5..06c9395490a 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs @@ -177,6 +177,11 @@ mod tests { use crate::test::helpers::setup::TestPlatformBuilder; use dpp::block::block_info::BlockInfo; use dpp::block::epoch::Epoch; + use dpp::fee::default_costs::EpochCosts; + use dpp::fee::smart_contract_computation::computation_units_to_credits; + use dpp::serialization::PlatformDeserializableFromVersionedStructureTrusted; + use dpp::version::v16::PROTOCOL_VERSION_16; + use dpp::version::v17::PROTOCOL_VERSION_17; use dpp::version::PlatformVersion; #[test] @@ -464,4 +469,133 @@ mod tests { assert!(counter.get(&platform_version.protocol_version).is_ok()); } } + + /// The 5.0 protocol version prices smart-contract computation through a fee schedule that + /// keeps `fee_version_number` 1, because no rate the epoch fee history serves changes. This + /// pins what that means end to end: upgrading 16 to 17 on an epoch change records nothing new + /// in the history, the history keeps resolving to the registered generation without contract + /// pricing, saving and reloading the upgraded state keeps it that way, and the price is + /// nevertheless available on every one of those paths through the active protocol version. + /// A schedule that reached the history would mean a storage rate changed, which is a + /// different change with its own number. + #[test] + fn test_upgrade_to_the_5_0_protocol_version_keeps_the_fee_history_and_prices_computation_from_the_active_version( + ) { + let previous_version = + PlatformVersion::get(PROTOCOL_VERSION_16).expect("protocol version 16 exists"); + let upgraded_version = + PlatformVersion::get(PROTOCOL_VERSION_17).expect("protocol version 17 exists"); + assert_eq!( + previous_version.fee_version.fee_version_number, + upgraded_version.fee_version.fee_version_number, + "the 5.0 schedule must keep the number of the generation it agrees with" + ); + + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(PROTOCOL_VERSION_16) + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = platform.drive.grove.start_transaction(); + + let epoch_info = EpochInfo::V0(EpochInfoV0 { + current_epoch_index: 3, + previous_epoch_index: Some(2), + is_epoch_change: true, + }); + + let block_info = BlockInfo { + time_ms: 1_000_000, + height: 300, + core_height: 100, + epoch: Epoch::new(3).expect("expected epoch"), + }; + + // A history as a network that ran protocol version 16 would have it: one entry, the + // registered generation, recorded at its first epoch change. + let last_committed_state = platform.state.load(); + let mut block_platform_state = last_committed_state.as_ref().clone(); + block_platform_state.previous_fee_versions_mut().clear(); + block_platform_state + .previous_fee_versions_mut() + .insert(1, previous_version.fee_version.as_static()); + + // The upgrade vote carried: this block runs at 17 while the last committed state is 16. + block_platform_state.set_current_protocol_version_in_consensus(PROTOCOL_VERSION_17); + + platform + .upgrade_protocol_version_on_epoch_change_v0( + &block_info, + &epoch_info, + &last_committed_state, + &mut block_platform_state, + &transaction, + upgraded_version, + ) + .expect("the upgrade hook must succeed on the epoch change"); + + let history = block_platform_state.previous_fee_versions(); + assert_eq!( + history.len(), + 1, + "a schedule that changes no rate the history serves records no new entry" + ); + let served = block_info.epoch.active_fee_version(history); + assert_eq!(served.fee_version_number, 1); + assert!( + served.dashvm.is_none(), + "the fee history never carries contract pricing" + ); + assert_eq!( + served.storage, upgraded_version.fee_version.storage, + "the history serves the same storage rates the upgraded version charges" + ); + + // Restart: the state goes through the saving format, which stores the history as + // numbers, and comes back. The standalone record is the one readable on its own; + // the per-entry structure keeps the masternode list and validator sets in the + // database and cannot be decoded from one record. + let saved = block_platform_state + .serialize_standalone_to_bytes() + .expect("the upgraded state serializes"); + let reloaded = PlatformState::versioned_deserialize_trusted(&saved, upgraded_version) + .expect("the upgraded state deserializes"); + + assert_eq!( + reloaded.current_protocol_version_in_consensus(), + PROTOCOL_VERSION_17 + ); + let reloaded_history = reloaded.previous_fee_versions(); + assert_eq!(reloaded_history.len(), 1); + let reloaded_served = block_info.epoch.active_fee_version(reloaded_history); + assert_eq!(reloaded_served.fee_version_number, 1); + assert!(reloaded_served.dashvm.is_none()); + + // The price is read from the active protocol version, which is what block execution + // obtains from platform state, before and after the restart alike. + let active_version = reloaded + .current_platform_version() + .expect("the reloaded state names a known protocol version"); + let price = active_version + .fee_version + .dashvm + .as_ref() + .expect("the 5.0 protocol version prices smart-contract computation"); + assert_eq!( + computation_units_to_credits(1_000, active_version) + .expect("the active version prices computation"), + 1_000 * price.credits_per_computation_unit + ); + assert_eq!( + computation_units_to_credits(1_000, active_version).ok(), + computation_units_to_credits( + 1_000, + block_platform_state + .current_platform_version() + .expect("the upgraded state names a known protocol version"), + ) + .ok(), + "the price is the same before and after the restart" + ); + } } diff --git a/packages/rs-platform-version/src/version/fee/dashvm/mod.rs b/packages/rs-platform-version/src/version/fee/dashvm/mod.rs index 85c60995e1f..a62cb25d58d 100644 --- a/packages/rs-platform-version/src/version/fee/dashvm/mod.rs +++ b/packages/rs-platform-version/src/version/fee/dashvm/mod.rs @@ -12,6 +12,13 @@ pub mod v1; /// validation, readiness verification, host entry, per-byte copy) are added to this group and /// its `FEE_DASHVM_VERSION*` constant in place while the protocol version that introduces it is /// unreleased. +/// +/// Consumers read this group from the active protocol version +/// (`platform_version.fee_version.dashvm`), never from the persisted epoch fee history or any +/// other lookup by `fee_version_number`: those resolve to the registered fee-history generation, +/// which serves only the storage, processing, hashing and signature groups and carries no +/// contract pricing. `dpp::fee::smart_contract_computation::computation_units_to_credits` takes +/// `&PlatformVersion` for that reason. #[derive(Clone, Debug, Encode, Decode, Default, PartialEq, Eq)] pub struct FeeDashVmVersion { /// Processing credits charged per computation unit consumed by a contract invocation. diff --git a/packages/rs-platform-version/src/version/fee/mod.rs b/packages/rs-platform-version/src/version/fee/mod.rs index 6adfa811d59..ae01fa37977 100644 --- a/packages/rs-platform-version/src/version/fee/mod.rs +++ b/packages/rs-platform-version/src/version/fee/mod.rs @@ -60,6 +60,12 @@ pub struct FeeVersion { /// Prices of smart-contract work; `None` on every schedule that predates smart contracts. /// Like `document_ttl`, never part of a stored format: `FeeVersionFieldsBeforeVersion4` /// must not gain it. + /// + /// Read from the active protocol version's schedule (`platform_version.fee_version.dashvm`), + /// never from the persisted epoch fee history: the history is keyed by `fee_version_number`, + /// which this group does not change, and serves only the storage, processing, hashing and + /// signature groups, so a schedule looked up by number (`FeeVersion::get`, `as_static`, the + /// epoch history) carries no contract pricing. See `dpp::fee::smart_contract_computation`. pub dashvm: Option, } diff --git a/packages/rs-platform-version/src/version/fee/v4.rs b/packages/rs-platform-version/src/version/fee/v4.rs index 8ae6a2e91c1..5402b01312f 100644 --- a/packages/rs-platform-version/src/version/fee/v4.rs +++ b/packages/rs-platform-version/src/version/fee/v4.rs @@ -10,7 +10,44 @@ use crate::version::fee::FeeVersion; /// the storage refund rates, and a schedule that only changes a group the history never serves /// keeps the number of the generation it agrees with. For the same reason this schedule is not /// appended to `FEE_VERSIONS`, which holds one entry per number. +/// +/// Two consequences follow, both intended. The epoch-change hook records a schedule in the fee +/// history only when its number changes, so a network upgrading to protocol version 17 keeps its +/// existing history entry, and a node restoring saved state resolves that entry to +/// [`FEE_VERSION1`](super::v1::FEE_VERSION1) through `FeeVersion::get(1)`. Neither carries the +/// `dashvm` group, and neither is meant to: contract pricing is read from the active protocol +/// version's schedule, like every other group the history does not serve. Registering this +/// schedule under a new number would not add the price to the history; it would switch every +/// storage refund at protocol version 17 onto the epoch-history refund path +/// (`fee_version_number != 1` in Drive's fee calculation) for rates that did not change. pub const FEE_VERSION4: FeeVersion = FeeVersion { dashvm: Some(FEE_DASHVM_VERSION1), ..FEE_VERSION3 }; + +#[cfg(test)] +mod tests { + use super::FEE_VERSION4; + use crate::version::fee::FeeVersion; + + /// The schedule shares its number with a registered fee-history generation, so the epoch + /// history and saved state resolve that number to the registered entry, not to this + /// schedule. That is only sound while the two agree on every group the history serves. A + /// change to a storage, processing, hashing or signature rate in this schedule needs a new + /// registered number, and this test is what says so. + #[test] + fn should_agree_with_its_registered_fee_history_generation_on_every_group_the_history_serves() { + let registered = FeeVersion::get(FEE_VERSION4.fee_version_number) + .expect("the schedule's number is registered"); + + assert_eq!(registered.storage, FEE_VERSION4.storage); + assert_eq!(registered.processing, FEE_VERSION4.processing); + assert_eq!(registered.hashing, FEE_VERSION4.hashing); + assert_eq!(registered.signature, FEE_VERSION4.signature); + + // The history never carries contract pricing; consumers read it from the active + // protocol version instead (see `dpp::fee::smart_contract_computation`). + assert!(registered.dashvm.is_none()); + assert!(FEE_VERSION4.dashvm.is_some()); + } +} diff --git a/packages/rs-platform-version/src/version/system_limits/smart_contract.rs b/packages/rs-platform-version/src/version/system_limits/smart_contract.rs index bd9e5c5288a..c7d280efa15 100644 --- a/packages/rs-platform-version/src/version/system_limits/smart_contract.rs +++ b/packages/rs-platform-version/src/version/system_limits/smart_contract.rs @@ -7,8 +7,9 @@ /// invocation under the same protocol version count exactly the same number of units, whatever /// their hardware, cache state or compiler backend. /// -/// Consumption is reported in this unit by the runtime and priced in credits by the fee -/// schedule (`FeeVersion::dashvm`), see `dpp::fee::smart_contract_computation`. +/// Consumption is reported in this unit by the runtime and priced in credits by the active +/// protocol version's fee schedule (`platform_version.fee_version.dashvm`), see +/// `dpp::fee::smart_contract_computation`. pub type ComputationUnits = u64; /// The consensus limits on smart-contract computation. @@ -40,8 +41,9 @@ pub type ComputationUnits = u64; /// /// # Credits and gas /// -/// Units become credits through the protocol-versioned price in the fee schedule -/// (`FeeVersion::dashvm.credits_per_computation_unit`, checked multiplication). The resulting +/// Units become credits through the protocol-versioned price in the active protocol version's +/// fee schedule (`platform_version.fee_version.dashvm.credits_per_computation_unit`, checked +/// multiplication; the persisted epoch fee history never carries this group). The resulting /// charge enters the processing fee of the invocation's `FeeResult`, which is what /// Tenderdash's `gas_used` and `gas_wanted` already report. Gas therefore stays denominated in /// credits; no unit equivalence between computation units and Tenderdash gas is introduced. From 8416f5a049453ea59ba0122f1bf5ecdb54a2d6c9 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 14:08:57 -0500 Subject: [PATCH 08/12] fix(drive-abci): bind computation reservations to the ledger that issued them A reservation carried only its bound, so settling it into a different `BlockComputationBudget` credited that ledger with units it never held and let it admit more than its limit. Each ledger now has a process-local identity that its reservations carry, and `settle` rejects a foreign reservation as a corrupted code execution before touching any counter. Cloning a ledger copies the counters into a ledger with its own identity, so reservations issued before the clone settle only into the original. The identity is never serialised and never consensus-visible. Two regression tests cover independent ledgers and cloned budgets. Co-Authored-By: Claude Fable 5.1 --- .../types/block_computation_budget.rs | 165 +++++++++++++++--- 1 file changed, 143 insertions(+), 22 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/types/block_computation_budget.rs b/packages/rs-drive-abci/src/execution/types/block_computation_budget.rs index c3d9e7a55e9..7b6f7dd61b6 100644 --- a/packages/rs-drive-abci/src/execution/types/block_computation_budget.rs +++ b/packages/rs-drive-abci/src/execution/types/block_computation_budget.rs @@ -16,16 +16,33 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use dpp::fee::smart_contract_computation::ComputationUnits; use dpp::version::PlatformVersion; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Process-local identity of one ledger instance, so a reservation can only be settled into +/// the ledger that issued it. Never serialised and never consensus-visible: it only ties two +/// values in the same process together. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LedgerId(u64); + +impl LedgerId { + fn next() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(1); + Self(NEXT.fetch_add(1, Ordering::Relaxed)) + } +} /// The admitted computation bound of one contract invocation, held against the block's budget /// until it is settled. /// /// Not `Clone` and consumed by [`BlockComputationBudget::settle`], so a reservation can only be -/// spent once. Dropping it without settling keeps its bound held for the rest of the block; a -/// caller that abandons an invocation before it runs settles with zero consumption instead. +/// spent once, and bound to the ledger that issued it, so it cannot be settled into another +/// ledger (which would credit that ledger with units it never held). Dropping it without +/// settling keeps its bound held for the rest of the block; a caller that abandons an invocation +/// before it runs settles with zero consumption instead. #[derive(Debug, PartialEq, Eq)] #[must_use = "an unsettled reservation keeps its bound held for the rest of the block"] pub struct ComputationReservation { + ledger: LedgerId, bound: ComputationUnits, } @@ -50,11 +67,19 @@ pub struct BlockComputationBudgetExceeded { /// already handed out. /// /// Invariant after every operation: `consumed + held + remaining == limit`, where `held` is the -/// sum of the outstanding reservations. Every operation uses checked arithmetic; the invariant -/// keeps each intermediate value within `limit`, but the checks make that proof local to each -/// method rather than something a reader has to carry across the file. -#[derive(Debug, Clone, PartialEq, Eq)] +/// sum of the outstanding reservations this ledger issued. Every operation uses checked +/// arithmetic; the invariant keeps each intermediate value within `limit`, but the checks make +/// that proof local to each method rather than something a reader has to carry across the file. +/// A reservation issued by another ledger is refused by [`settle`](Self::settle) before any +/// counter changes, because its bound was never subtracted from this ledger's `remaining`. +/// +/// Cloning a ledger (the block execution context is `Clone`) copies the counters into a new +/// ledger with its own identity: the clone continues the accounting from the same state, but +/// reservations the original issued before the clone can only be settled into the original, +/// and reservations the clone issues only into the clone. The two never share a hold. +#[derive(Debug, PartialEq, Eq)] pub struct BlockComputationBudget { + id: LedgerId, limit: ComputationUnits, /// Units settled as actually consumed. consumed: ComputationUnits, @@ -62,6 +87,18 @@ pub struct BlockComputationBudget { remaining: ComputationUnits, } +impl Clone for BlockComputationBudget { + /// A new ledger with the same counters and its own identity; see the type documentation. + fn clone(&self) -> Self { + Self { + id: LedgerId::next(), + limit: self.limit, + consumed: self.consumed, + remaining: self.remaining, + } + } +} + impl BlockComputationBudget { /// The ledger for a block executed under `platform_version`, or `None` when the version /// predates smart contracts so that callers skip the contract path entirely. @@ -76,6 +113,7 @@ impl BlockComputationBudget { /// A ledger over an explicit limit. pub fn with_limit(limit: ComputationUnits) -> Self { Self { + id: LedgerId::next(), limit, consumed: 0, remaining: limit, @@ -108,7 +146,10 @@ impl BlockComputationBudget { match self.remaining.checked_sub(bound) { Some(remaining) => { self.remaining = remaining; - Ok(ComputationReservation { bound }) + Ok(ComputationReservation { + ledger: self.id, + bound, + }) } None => Err(BlockComputationBudgetExceeded { requested: bound, @@ -121,14 +162,22 @@ impl BlockComputationBudget { /// units are recorded and the unused part of the bound is returned to the block. Returns /// the released units. /// - /// `actual` above the reserved bound is `ExecutionError::CorruptedCodeExecution`: the - /// runtime is handed the bound as its budget and cannot legally exceed it, so this is a - /// broken runtime, not an admission outcome. The ledger is unchanged in that case. + /// Two misuses are `ExecutionError::CorruptedCodeExecution`, and the ledger is unchanged in + /// both: a reservation issued by another ledger (its bound was never held here, so releasing + /// it would let this block admit more than its limit), and `actual` above the reserved bound + /// (the runtime is handed the bound as its budget and cannot legally exceed it, so this is a + /// broken runtime, not an admission outcome). pub fn settle( &mut self, reservation: ComputationReservation, actual: ComputationUnits, ) -> Result { + if reservation.ledger != self.id { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a computation reservation was settled into a ledger other than the one that issued it", + ))); + } + let released = reservation .bound .checked_sub(actual) @@ -210,12 +259,8 @@ mod tests { } ); assert_eq!( - budget, - BlockComputationBudget { - limit: 1_000, - consumed: 0, - remaining: 0, - }, + (budget.limit(), budget.consumed(), budget.remaining()), + (1_000, 0, 0), "a refused reservation must leave the ledger unchanged" ); @@ -258,16 +303,92 @@ mod tests { "expected a corrupted code execution error, got {result:?}" ); assert_eq!( - budget, - BlockComputationBudget { - limit: 10_000, - consumed: 0, - remaining: 9_000, - }, + (budget.limit(), budget.consumed(), budget.remaining()), + (10_000, 0, 9_000), "a rejected settlement must leave the ledger unchanged, with the bound still held" ); } + #[test] + fn should_reject_settling_a_reservation_issued_by_another_ledger() { + let mut issuing = ledger(100); + let mut other = ledger(100); + + let reservation = issuing.reserve(80).expect("80 units fit in 100"); + assert_eq!(issuing.remaining(), 20); + assert_eq!(other.remaining(), 100); + + let result = other.settle(reservation, 0); + + assert!( + matches!( + result, + Err(Error::Execution(ExecutionError::CorruptedCodeExecution(_))) + ), + "expected a corrupted code execution error, got {result:?}" + ); + assert_eq!( + (other.consumed(), other.remaining()), + (0, 100), + "a foreign reservation must not credit the receiving ledger" + ); + assert_eq!( + (issuing.consumed(), issuing.remaining()), + (0, 20), + "the issuing ledger keeps the bound held; the reservation was consumed by the \ + rejected settlement and cannot be returned" + ); + let refused = other + .reserve(101) + .expect_err("the other ledger must still be bounded by its own limit"); + assert_eq!(refused.remaining, 100); + } + + #[test] + fn should_give_a_clone_its_own_identity_with_the_same_counters() { + let mut original = ledger(1_000); + let before_clone = original.reserve(300).expect("300 units fit in 1,000"); + + let mut cloned = original.clone(); + assert_eq!( + (cloned.limit(), cloned.consumed(), cloned.remaining()), + (1_000, 0, 700), + "a clone continues from the same counters" + ); + assert_ne!(original, cloned, "a clone is a distinct ledger"); + + // A reservation issued before the clone belongs to the original only. + let result = cloned.settle(before_clone, 100); + assert!( + matches!( + result, + Err(Error::Execution(ExecutionError::CorruptedCodeExecution(_))) + ), + "expected a corrupted code execution error, got {result:?}" + ); + assert_eq!((cloned.consumed(), cloned.remaining()), (0, 700)); + + // Each ledger settles what it issued, and neither sees the other's settlement. + let from_original = original.reserve(200).expect("200 units fit in 700"); + let released = original + .settle(from_original, 50) + .expect("the original settles its own reservation"); + assert_eq!(released, 150); + assert_eq!((original.consumed(), original.remaining()), (50, 650)); + + let from_clone = cloned.reserve(700).expect("700 units fit in the clone"); + let released = cloned + .settle(from_clone, 700) + .expect("the clone settles its own reservation"); + assert_eq!(released, 0); + assert_eq!((cloned.consumed(), cloned.remaining()), (700, 0)); + assert_eq!( + (original.consumed(), original.remaining()), + (50, 650), + "the clone's settlement does not touch the original" + ); + } + #[test] fn should_account_many_reservations_without_double_spending() { let limit = 1_000; From 801d2be83cf68a065c71a39b7ae91ab66e228dc2 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 15:22:28 -0500 Subject: [PATCH 09/12] refactor(drive-abci): drop equality on the block computation ledger With a per-ledger identity, the derived `PartialEq` made a ledger unequal to its own clone, which `Clone` and `Eq` together promise not to happen. Two ledgers with the same counters are still different ledgers, so the type has no equality; tests compare the counters and the identity explicitly. Co-Authored-By: Claude Fable 5.1 --- .../execution/types/block_computation_budget.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/types/block_computation_budget.rs b/packages/rs-drive-abci/src/execution/types/block_computation_budget.rs index 7b6f7dd61b6..ffbe7a325f2 100644 --- a/packages/rs-drive-abci/src/execution/types/block_computation_budget.rs +++ b/packages/rs-drive-abci/src/execution/types/block_computation_budget.rs @@ -77,7 +77,12 @@ pub struct BlockComputationBudgetExceeded { /// ledger with its own identity: the clone continues the accounting from the same state, but /// reservations the original issued before the clone can only be settled into the original, /// and reservations the clone issues only into the clone. The two never share a hold. -#[derive(Debug, PartialEq, Eq)] +/// +/// The ledger has no equality: two ledgers with the same counters are still different ledgers +/// (their reservations are not interchangeable), and equality that included the identity would +/// make a ledger unequal to its own clone, which `Clone` and `Eq` together promise not to +/// happen. Compare `limit()`, `consumed()` and `remaining()` instead. +#[derive(Debug)] pub struct BlockComputationBudget { id: LedgerId, limit: ComputationUnits, @@ -217,9 +222,8 @@ mod tests { #[test] fn should_not_exist_before_the_5_0_protocol_version() { let platform_version_14 = PlatformVersion::get(14).expect("protocol version 14 exists"); - assert_eq!( - BlockComputationBudget::for_platform_version(platform_version_14), - None, + assert!( + BlockComputationBudget::for_platform_version(platform_version_14).is_none(), "protocol version 14 predates smart contracts and must have no computation ledger" ); @@ -355,7 +359,7 @@ mod tests { (1_000, 0, 700), "a clone continues from the same counters" ); - assert_ne!(original, cloned, "a clone is a distinct ledger"); + assert_ne!(original.id, cloned.id, "a clone is a distinct ledger"); // A reservation issued before the clone belongs to the original only. let result = cloned.settle(before_clone, 100); From b51a013e86bc2898727df6b1d7db4a2653ce2c31 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sun, 20 Sep 2026 09:57:59 -0500 Subject: [PATCH 10/12] docs: correct the fee version number rule and the version count in the book The fee history serves the storage, processing, hashing and signature groups, so the number changes when any of them changes, not only a storage rate. The version-array examples now show the seventeen registered versions, and the smart-contract computation section says up front that protocol version 17 carries the tables but enforces and charges nothing until the runtime wiring lands. Co-Authored-By: Claude Fable 5.1 --- book/src/fees/overview.md | 19 ++++++++++++++----- book/src/versioning/platform-version.md | 19 +++++++++++-------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 745684b9740..756d3ddf03f 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -543,11 +543,13 @@ Fee versions are stored in the `FEE_VERSIONS` array and looked up by number. The (permille = divide by 1000; a value of 1000 means no change). `fee_version_number` keys the persisted fee history and the storage refund -rates, so it only changes when a storage rate changes. A schedule that changes -only a group the history never serves keeps the number of the generation it -agrees with and is not appended to `FEE_VERSIONS`: `FEE_VERSION2` (protocol -version 9), `FEE_VERSION3` (protocol version 14) and `FEE_VERSION4` (protocol -version 17) all carry number 1. +rates, so it changes whenever a group the history serves changes: the storage, +processing, hashing or signature rates. A schedule that changes only a group +the history never serves keeps the number of the generation it agrees with and +is not appended to `FEE_VERSIONS`: `FEE_VERSION2` (protocol version 9), +`FEE_VERSION3` (protocol version 14) and `FEE_VERSION4` (protocol version 17) +all carry number 1, and a test on `FEE_VERSION4` pins that it agrees with the +registered generation on every served group. The epoch fee history (`previous_fee_versions` in platform state) records a schedule only when its number changes, is saved as numbers and restored through @@ -562,6 +564,13 @@ pricing is unaffected because nothing reads it from there. ### Smart-contract computation (protocol version 17, 5.0) +This section describes the contract the tables define. Protocol version 17 +carries the limits and the price, but nothing dispatches on them yet: the +runtime that meters invocations, the block loop that reserves against the +per-block ledger and the fee pipeline that charges the units arrive with later +5.0 tasks, so a node at protocol version 17 today enforces no computation +limit and charges no computation fee. + Smart-contract work is metered by the runtime in *computation units* (`ComputationUnits` in `rs-platform-version`): a deterministic count of the admitted guest operations and host work an invocation performs, weighted by the diff --git a/book/src/versioning/platform-version.md b/book/src/versioning/platform-version.md index 80003287265..e1908a07dfb 100644 --- a/book/src/versioning/platform-version.md +++ b/book/src/versioning/platform-version.md @@ -58,20 +58,20 @@ function version so that execution is deterministic. ## The Version Array -Each protocol version gets its own constant, defined in a separate file. At -the time of writing, the platform has fourteen versions: +Each protocol version gets its own constant, defined in a separate file. On +the 5.0 development branch the platform has seventeen versions: ```rust // packages/rs-platform-version/src/version/mod.rs pub type ProtocolVersion = u32; -pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_14; +pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_17; pub const INITIAL_PROTOCOL_VERSION: ProtocolVersion = 1; pub const ALL_VERSIONS: RangeInclusive = 1..=LATEST_VERSION; ``` -These fourteen snapshots are collected into a single static array in +These seventeen snapshots are collected into a single static array in `protocol_version.rs`: ```rust @@ -90,14 +90,17 @@ pub const PLATFORM_VERSIONS: &[PlatformVersion] = &[ PLATFORM_V12, PLATFORM_V13, PLATFORM_V14, + PLATFORM_V15, + PLATFORM_V16, + PLATFORM_V17, ]; -pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V14; +pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V17; pub const DESIRED_PLATFORM_VERSION: &PlatformVersion = LATEST_PLATFORM_VERSION; ``` The array is indexed by protocol version number minus one (since versions are -1-indexed). `PLATFORM_V1` sits at index 0, `PLATFORM_V14` at index 13. This +1-indexed). `PLATFORM_V1` sits at index 0, `PLATFORM_V17` at index 16. This simple layout is what makes the `get` function so fast. One file, one protocol version. `v14.rs` was created when the first consensus @@ -294,8 +297,8 @@ impl PlatformVersion { } ``` -This is a simple array lookup. Protocol version 1 maps to index 0, version 14 -to index 13. If the version number is out of range, you get a clear error. No +This is a simple array lookup. Protocol version 1 maps to index 0, version 17 +to index 16. If the version number is out of range, you get a clear error. No hash maps, no runtime registration, no dynamic dispatch -- just a static array of compile-time constants. From ef166e73003176ada344a2ffbedece9d69608c9f Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sun, 20 Sep 2026 13:37:19 -0500 Subject: [PATCH 11/12] docs: list the contract validation fee group among those read from the active version Co-Authored-By: Claude Fable 5.1 --- book/src/fees/overview.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 756d3ddf03f..44bacc98872 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -555,9 +555,10 @@ The epoch fee history (`previous_fee_versions` in platform state) records a schedule only when its number changes, is saved as numbers and restored through `FeeVersion::get(number)`, and serves exactly the groups `KnownCostItem` reads: storage, processing, hashing and signature. Every other group -(`data_contract_registration`, `state_transition_min_fees`, -`vote_resolution_fund_fees`, `dashvm`) is read from the active protocol -version's schedule, `platform_version.fee_version`, and never from the history. +(`data_contract_validation`, `data_contract_registration`, +`state_transition_min_fees`, `vote_resolution_fund_fees`, `dashvm`) is read +from the active protocol version's schedule, `platform_version.fee_version`, +and never from the history. Upgrading from protocol version 16 to 17 therefore records nothing new in the history and a restart resolves the existing entry to `FEE_VERSION1`; contract pricing is unaffected because nothing reads it from there. From 437efa28b15632ad7eeff76e29e3a2341140a430 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 29 Sep 2026 12:12:35 -0500 Subject: [PATCH 12/12] docs: state that v17's overridden tables must be rebased on a forward merge The placeholder rule "take the incoming v15 or v16 and every table flows into v17" is true for every table except the two v17 overrides. FEE_VERSION4 and SYSTEM_LIMITS_V5 are built on the generations current when they were written, so an incoming change to either table has to be rebased into the 5.0 generation by hand; the book and the v15 doc comment now say so and name the test that catches a missed rebase. Co-Authored-By: Claude Fable 5.1 --- book/src/versioning/platform-version.md | 10 +++++++++- packages/rs-platform-version/src/version/v15.rs | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/book/src/versioning/platform-version.md b/book/src/versioning/platform-version.md index e1908a07dfb..065cbbbae6c 100644 --- a/book/src/versioning/platform-version.md +++ b/book/src/versioning/platform-version.md @@ -120,7 +120,15 @@ struct updates over their predecessor (`PlatformVersion { protocol_version: PROTOCOL_VERSION_15, ..PLATFORM_V14 }`). A forward merge that brings the real file is resolved by taking the incoming file; because 16 and 17 are struct updates too, every table the incoming -version changes flows into them without a second edit. +version changes flows into them without a second edit, with one exception: +`PLATFORM_V17` overrides `fee_version` and `system_limits` with its own +generations (`FEE_VERSION4`, `SYSTEM_LIMITS_V5`), which are built on the +tables that were current when they were written, not on whatever version 16 +carries. If the incoming version changes either of those two tables, rebase the +5.0 generation onto the incoming one (`..FEE_VERSION`, +`..SYSTEM_LIMITS_V`) and renumber it past the incoming constant. The +test that pins version 17 to differ from version 16 only in the +smart-contract computation tables fails until that is done. `system_limits` is a public module, so `SystemLimits` and the nested limit groups it holds (such as `SmartContractComputationLimits` and the diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index d091a5f1aee..95a317db9d5 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -14,7 +14,10 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// It is written as a struct update rather than a copy of `v14.rs` on purpose: when the real /// file arrives the add/add conflict is resolved by taking the incoming file, and because v16 /// and v17 are struct updates over their predecessor every table the incoming version changes -/// flows into them without a second edit. +/// flows into them without a second edit, except the two tables v17 overrides (`fee_version` +/// and `system_limits`, whose 5.0 generations are built on the tables current when they were +/// written). If the incoming version changes either of those, rebase the 5.0 generation onto +/// the incoming one and renumber it; the inheritance test in `system_limits` fails until then. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, ..PLATFORM_V14