Platform Version
The Problem: Deterministic Upgrades in a Distributed System
Dash Platform is a replicated state machine. Every masternode in the network processes the same transactions and must arrive at the exact same state. If even one node computes a fee differently, serializes a document with one extra byte, or validates a field that others skip, the chain forks.
Now imagine you need to ship a bug fix. In a normal application you deploy the new binary and move on. In a blockchain you have a harder constraint: not every node upgrades at the same moment. Some masternodes will still be running the old code when the new protocol version activates. The system needs a way to say "at protocol version N, use these exact behaviors" -- and it needs to be impossible for a developer to accidentally mix behaviors from different versions.
The answer is the PlatformVersion struct: a single, massive, immutable
snapshot that pins every versioned behavior in the entire platform to a
concrete value.
The PlatformVersion Struct
Open packages/rs-platform-version/src/version/protocol_version.rs and you
will find the heart of the system:
#![allow(unused)] fn main() { #[derive(Clone, Debug)] pub struct PlatformVersion { pub protocol_version: ProtocolVersion, pub dpp: DPPVersion, pub drive: DriveVersion, pub drive_abci: DriveAbciVersion, pub consensus: ConsensusVersions, pub fee_version: FeeVersion, pub system_data_contracts: SystemDataContractVersions, pub system_limits: SystemLimits, } }
Where ProtocolVersion is simply:
#![allow(unused)] fn main() { pub type ProtocolVersion = u32; }
Every field inside PlatformVersion is itself a version struct -- and those
structs contain more version structs, all the way down to individual method
version numbers. We will explore that nesting in the next chapter. For now,
the key insight is that PlatformVersion is the root of a tree. Given a
single protocol version number (like 7), you can resolve the exact version
of every method, every fee parameter, every system limit, and every data
contract schema across the entire platform.
Think of it like a lockfile in a package manager. Cargo.lock pins every
transitive dependency to a specific version so that builds are reproducible.
PlatformVersion does the same thing for runtime behavior: it pins every
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 twelve versions:
#![allow(unused)] fn main() { // packages/rs-platform-version/src/version/mod.rs pub type ProtocolVersion = u32; pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_12; pub const INITIAL_PROTOCOL_VERSION: ProtocolVersion = 1; pub const ALL_VERSIONS: RangeInclusive<ProtocolVersion> = 1..=LATEST_VERSION; }
These twelve snapshots are collected into a single static array in
protocol_version.rs:
#![allow(unused)] fn main() { pub const PLATFORM_VERSIONS: &[PlatformVersion] = &[ PLATFORM_V1, PLATFORM_V2, PLATFORM_V3, PLATFORM_V4, PLATFORM_V5, PLATFORM_V6, PLATFORM_V7, PLATFORM_V8, PLATFORM_V9, PLATFORM_V10, PLATFORM_V11, PLATFORM_V12, ]; pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V12; 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_V12 at index 11. This
simple layout is what makes the get function so fast.
What a Version Snapshot Looks Like
Here is the very first version, PLATFORM_V1, slightly abbreviated:
#![allow(unused)] fn main() { // packages/rs-platform-version/src/version/v1.rs pub const PROTOCOL_VERSION_1: ProtocolVersion = 1; pub const PLATFORM_V1: PlatformVersion = PlatformVersion { protocol_version: 1, drive: DRIVE_VERSION_V1, drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V1, methods: DRIVE_ABCI_METHOD_VERSIONS_V1, validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V1, withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V1, query: DRIVE_ABCI_QUERY_VERSIONS_V1, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, }, dpp: DPPVersion { costs: DPP_COSTS_VERSIONS_V1, validation: DPP_VALIDATION_VERSIONS_V1, state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V1, state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V1, state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V1, state_transitions: STATE_TRANSITION_VERSIONS_V1, contract_versions: CONTRACT_VERSIONS_V1, document_versions: DOCUMENT_VERSIONS_V1, identity_versions: IDENTITY_VERSIONS_V1, voting_versions: VOTING_VERSION_V1, token_versions: TOKEN_VERSIONS_V1, asset_lock_versions: DPP_ASSET_LOCK_VERSIONS_V1, methods: DPP_METHOD_VERSIONS_V1, factory_versions: DPP_FACTORY_VERSIONS_V1, }, system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V1, fee_version: FEE_VERSION1, system_limits: SYSTEM_LIMITS_V1, consensus: ConsensusVersions { tenderdash_consensus_version: 0, }, }; }
Now compare with PLATFORM_V12, the latest at the time of writing:
#![allow(unused)] fn main() { // packages/rs-platform-version/src/version/v12.rs pub const PLATFORM_V12: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_12, drive: DRIVE_VERSION_V6, // was V1 drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V1, methods: DRIVE_ABCI_METHOD_VERSIONS_V7, // was V1 validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V7, // was V1 withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V2, // was V1 query: DRIVE_ABCI_QUERY_VERSIONS_V1, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, }, dpp: DPPVersion { costs: DPP_COSTS_VERSIONS_V1, validation: DPP_VALIDATION_VERSIONS_V2, // was V1 state_transitions: STATE_TRANSITION_VERSIONS_V3, // was V1 contract_versions: CONTRACT_VERSIONS_V3, // was V1 document_versions: DOCUMENT_VERSIONS_V3, // was V1 // ... other fields, some still V1, some bumped methods: DPP_METHOD_VERSIONS_V2, // was V1 factory_versions: DPP_FACTORY_VERSIONS_V1, // ... }, fee_version: FEE_VERSION2, // was VERSION1 consensus: ConsensusVersions { tenderdash_consensus_version: 1, // was 0 }, // ... }; }
Notice how only some subsystem versions change between V1 and V12. The query versions stayed at V1 across all twelve protocol versions because the query logic never changed. The ABCI method versions, on the other hand, went from V1 all the way to V7 -- seven revisions of the block processing logic.
This is the power of the snapshot model: each subsystem version evolves at its own pace. A new protocol version does not require bumping everything. You only change the sub-constants that actually differ.
The Get Dispatch
The most important function on PlatformVersion is get:
#![allow(unused)] fn main() { impl PlatformVersion { pub fn get<'a>(version: ProtocolVersion) -> Result<&'a Self, PlatformVersionError> { if version > 0 { PLATFORM_VERSIONS.get(version as usize - 1).ok_or_else(|| { PlatformVersionError::UnknownVersionError( format!("no platform version {version}") ) }) } else { Err(PlatformVersionError::UnknownVersionError( format!("no platform version {version}") )) } } } }
This is a simple array lookup. Protocol version 1 maps to index 0, version 12 to index 11. 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.
There are also convenience methods:
#![allow(unused)] fn main() { impl PlatformVersion { pub fn first<'a>() -> &'a Self { PLATFORM_VERSIONS.first() .expect("expected to have a platform version") } pub fn latest<'a>() -> &'a Self { PLATFORM_VERSIONS.last() .expect("expected to have a platform version") } pub fn desired<'a>() -> &'a Self { DESIRED_PLATFORM_VERSION } } }
first() is used in tests that need to verify behavior under the initial
protocol. latest() is the default for new code. desired() returns the
version that nodes want to upgrade to -- it equals latest() during normal
operation but could theoretically differ during a staged rollout.
Version-Aware Traits
The rs-platform-version crate also defines traits that thread the platform
version through standard Rust conversion patterns:
#![allow(unused)] fn main() { // packages/rs-platform-version/src/lib.rs pub trait TryFromPlatformVersioned<T>: Sized { type Error; fn try_from_platform_versioned( value: T, platform_version: &PlatformVersion, ) -> Result<Self, Self::Error>; } pub trait DefaultForPlatformVersion: Sized { type Error; fn default_for_platform_version( platform_version: &PlatformVersion, ) -> Result<Self, Self::Error>; } }
These are the versioned equivalents of TryFrom and Default. When you
convert a data structure, you pass the platform version so the implementation
can pick the right serialization format, the right field set, or the right
validation rules. There is also FromPlatformVersioned for infallible
conversions, and blanket IntoPlatformVersioned implementations that mirror
the standard library pattern.
Mock Versions for Testing
The version system supports a mock-versions feature flag for tests:
#![allow(unused)] fn main() { #[cfg(feature = "mock-versions")] pub static PLATFORM_TEST_VERSIONS: OnceLock<Vec<PlatformVersion>> = OnceLock::new(); }
When this feature is enabled, PlatformVersion::get checks for a special bit
in the version number. If set, it routes to the test version array instead of
the production one. This lets tests create synthetic platform versions with
specific behaviors without polluting the production constants:
#![allow(unused)] fn main() { #[cfg(feature = "mock-versions")] { if version >> TEST_PROTOCOL_VERSION_SHIFT_BYTES > 0 { let test_version = version - (1 << TEST_PROTOCOL_VERSION_SHIFT_BYTES); let versions = PLATFORM_TEST_VERSIONS .get_or_init(|| vec![TEST_PLATFORM_V2, TEST_PLATFORM_V3]); return versions.get(test_version as usize - 2).ok_or(/* ... */); } } }
This is a clever design: tests can exercise version upgrade logic (like "what happens when we transition from test version 2 to test version 3?") without needing to create real protocol versions.
Why Immutable Snapshots?
You might wonder: why not use a mutable configuration object? Why not a
HashMap<&str, u16> that maps method names to versions?
Three reasons:
-
Determinism. A
constvalue is baked into the binary at compile time. There is no way to accidentally modify it at runtime. Every node running the same binary with the same protocol version will use the exact same values. -
Exhaustiveness. Because the version struct has named fields for every subsystem, adding a new versioned method forces you to set its version in every platform version constant. The compiler will refuse to compile if you forget one. A hash map cannot give you this guarantee.
-
Performance. Looking up a version number is a struct field access -- zero overhead at runtime. The entire version tree lives in static memory. No allocations, no lookups, no indirection.
The cost is verbosity. Each new platform version file is large and repetitive.
But this is a deliberate trade-off: the system favors correctness and
auditability over conciseness. When you read PLATFORM_V12, you can see
every single version number in one place. There is no mystery about what
version 12 means.
Rules
Do:
- Always pass
&PlatformVersion(or&DriveVersion, etc.) to functions that have versioned behavior. Never hardcode a version number at a call site. - Use
PlatformVersion::latest()for tests that do not care about a specific version. UsePlatformVersion::first()when you need to test the initial protocol behavior. - When creating a new platform version, copy the previous version file and change only the constants that differ. The compiler will catch any missing fields.
Do not:
- Never mutate platform version data at runtime. The constants are
constfor a reason. - Never add a new field to
PlatformVersionwithout also updating everyPLATFORM_V*constant. The compiler will enforce this, but be aware that the fix is updating twelve files, not one. - Never use
PlatformVersion::latest()in consensus-critical code paths. Always use the version from the current platform state, obtained viaplatform_state.current_platform_version(). The "latest" version is what the binary supports; the active version is what the network has agreed upon -- and they may differ during an upgrade window.