Derive Macros
In the previous chapter we looked at the PlatformVersionEncode and PlatformVersionedDecode traits and the platform_encode_to_vec / platform_versioned_decode_from_slice functions. But you rarely implement those traits by hand. Instead, you use three derive macros from the rs-platform-serialization-derive crate:
PlatformSerialize-- generates a high-levelserialize_to_bytes()methodPlatformDeserialize-- generates a high-leveldeserialize_from_bytes()methodPlatformSignable-- generates asignable_bytes()method that excludes signature fields
These macros live in packages/rs-platform-serialization-derive/src/lib.rs and are the glue that connects Rust struct definitions to the platform serialization system.
PlatformSerialize and PlatformDeserialize
These two macros work as a pair. Let us start with how they are used on the ConsensusError type:
#![allow(unused)] fn main() { #[derive( thiserror::Error, Debug, Encode, Decode, PlatformSerialize, PlatformDeserialize, Clone, PartialEq, )] #[platform_serialize(limit = 2000)] #[error(transparent)] pub enum ConsensusError { // ... } }
And on a leaf error struct:
#![allow(unused)] fn main() { #[derive( Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, )] #[error("Document {document_id} is already present")] #[platform_serialize(unversioned)] pub struct DocumentAlreadyPresentError { document_id: Identifier, } }
And on the StateTransition enum:
#![allow(unused)] fn main() { #[derive( Debug, Clone, Encode, Decode, PlatformSerialize, PlatformDeserialize, PlatformSignable, From, PartialEq, )] #[platform_serialize(unversioned)] #[platform_serialize(limit = 100000)] pub enum StateTransition { DataContractCreate(DataContractCreateTransition), DataContractUpdate(DataContractUpdateTransition), Batch(BatchTransition), // ... } }
Notice that StateTransition uses two #[platform_serialize] attributes -- one for unversioned and one for limit. These are combined internally.
What the derives generate
PlatformSerialize generates an implementation of one of two traits, depending on whether unversioned is set:
With unversioned -- implements PlatformSerializable:
#![allow(unused)] fn main() { // Generated code (simplified): impl PlatformSerializable for DocumentAlreadyPresentError { type Error = ProtocolError; fn serialize_to_bytes(&self) -> Result<Vec<u8>, Self::Error> { let config = bincode::config::standard() .with_big_endian() .with_no_limit(); bincode::encode_to_vec(self, config) .map_err(|e| { ProtocolError::PlatformSerializationError( format!("unable to serialize DocumentAlreadyPresentError: {}", e) ) }) } fn serialize_consume_to_bytes(self) -> Result<Vec<u8>, Self::Error> { // same as above, taking self by value } } }
Without unversioned -- implements PlatformSerializableWithPlatformVersion:
#![allow(unused)] fn main() { // Generated code (simplified): impl PlatformSerializableWithPlatformVersion for ConsensusError { type Error = ProtocolError; fn serialize_to_bytes_with_platform_version( &self, platform_version: &PlatformVersion, ) -> Result<Vec<u8>, ProtocolError> { let config = bincode::config::standard() .with_big_endian() .with_limit::<{ 2000 }>(); platform_serialization::platform_encode_to_vec(self, config, platform_version) .map_err(|e| match e { bincode::error::EncodeError::Io { inner, index } => ProtocolError::MaxEncodedBytesReachedError { max_size_kbytes: 2000, size_hit: index, }, _ => ProtocolError::PlatformSerializationError( format!("unable to serialize ConsensusError: {}", e) ), }) } } }
The key differences: the unversioned variant uses plain bincode::encode_to_vec, while the versioned variant uses platform_serialization::platform_encode_to_vec which threads the PlatformVersion through the encode chain. When a limit is set, the config uses with_limit::<{ N }>() and the error mapping converts bincode IO errors to MaxEncodedBytesReachedError.
In both cases, the derive also generates bincode Encode and Decode implementations by internally calling into derive_bincode -- this is why you still need Encode and Decode in the derive list alongside PlatformSerialize and PlatformDeserialize.
The #[platform_serialize] attributes
The #[platform_serialize(...)] attribute accepts several parameters. Here is the full list from the derive macro source in packages/rs-platform-serialization-derive/src/lib.rs:
limit = N
Sets the maximum serialized size in bytes:
#![allow(unused)] fn main() { #[platform_serialize(limit = 2000)] }
When encoding exceeds this limit, the error is MaxEncodedBytesReachedError. This is critical for types received from the network.
unversioned
Generates PlatformSerializable instead of PlatformSerializableWithPlatformVersion:
#![allow(unused)] fn main() { #[platform_serialize(unversioned)] }
Use this when the type's serialization format does not change between protocol versions. Most leaf types (individual error structs, simple data holders) use this.
passthrough
For enums, serializes by delegating directly to the inner variant's serialization method:
#![allow(unused)] fn main() { #[platform_serialize(passthrough)] }
When MyEnum::Variant(inner) is serialized with passthrough, it calls inner.serialize() directly rather than encoding the enum tag + inner data. This means the enum variant information is lost in serialization -- deserialization must use platform_version_path to know which variant to decode into.
Cannot be combined with limit, untagged, or into.
untagged
For enums, serializes without the variant tag number:
#![allow(unused)] fn main() { #[platform_serialize(untagged)] }
Similar to passthrough but still uses the enum's own encoding logic rather than delegating to the inner type. The variant index is omitted from the output. Like passthrough, deserialization requires knowing which variant to expect.
Cannot be combined with passthrough.
into = "TypePath"
For structs, converts the value to another type before serialization:
#![allow(unused)] fn main() { #[platform_serialize(into = "DataContractInSerializationFormat")] }
The generated code calls .into() to convert to the target type, then serializes that type. This is useful for types that have a different in-memory representation than their serialization format.
Cannot be used on enums.
platform_version_path = "..."
Used with passthrough or untagged to specify how to determine the correct variant during deserialization:
#![allow(unused)] fn main() { #[platform_serialize( passthrough, platform_version_path = "dpp.contract_versions.contract_serialization_version.default_current_version" )] }
The deserialization code reads this field path from the PlatformVersion to determine which variant index to decode.
crate_name = "..."
Overrides the default crate path (defaults to crate):
#![allow(unused)] fn main() { #[platform_serialize(crate_name = "dpp")] }
allow_prepend_version and force_prepend_version
These flags are defined in the code but currently not actively used in the main serialization path. They were designed for prepending version bytes to serialized output. Only one can be used at a time.
The #[platform_error_type] attribute
By default, the derives use ProtocolError as the error type. You can override this:
#![allow(unused)] fn main() { #[derive(PlatformSerialize, PlatformDeserialize)] #[platform_error_type(MyCustomError)] pub struct MyType { ... } }
The error type must have PlatformSerializationError(String) and MaxEncodedBytesReachedError { max_size_kbytes, size_hit } variants (or equivalent conversion paths).
PlatformSignable -- the signature hash derive
The PlatformSignable derive solves a specific problem: when you sign a state transition, you need to hash all the fields except the signature itself. You cannot include the signature in the data that was signed -- that would be circular.
Here is how it is used on DataContractCreateTransitionV0 in packages/rs-dpp/src/state_transition/state_transitions/contract/data_contract_create_transition/v0/mod.rs:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Encode, Decode, PartialEq, PlatformSignable)] pub struct DataContractCreateTransitionV0 { pub data_contract: DataContractInSerializationFormat, pub identity_nonce: IdentityNonce, pub user_fee_increase: UserFeeIncrease, #[platform_signable(exclude_from_sig_hash)] pub signature_public_key_id: KeyID, #[platform_signable(exclude_from_sig_hash)] pub signature: BinaryData, } }
The #[platform_signable(exclude_from_sig_hash)] attribute marks fields that should be excluded from the signature hash. The derive generates:
- A new struct
DataContractCreateTransitionV0Signable<'a>containing only the non-excluded fields asCowreferences - A
From<&DataContractCreateTransitionV0>implementation for the signable struct - An implementation of the
Signabletrait on the original struct
The generated code looks approximately like this:
#![allow(unused)] fn main() { // Generated by PlatformSignable derive: #[derive(Debug, Clone, bincode::Encode)] pub struct DataContractCreateTransitionV0Signable<'a> { data_contract: std::borrow::Cow<'a, DataContractInSerializationFormat>, identity_nonce: std::borrow::Cow<'a, IdentityNonce>, user_fee_increase: std::borrow::Cow<'a, UserFeeIncrease>, // signature_public_key_id -- excluded // signature -- excluded } impl<'a> From<&'a DataContractCreateTransitionV0> for DataContractCreateTransitionV0Signable<'a> { fn from(original: &'a DataContractCreateTransitionV0) -> Self { DataContractCreateTransitionV0Signable { data_contract: std::borrow::Cow::Borrowed(&original.data_contract), identity_nonce: std::borrow::Cow::Borrowed(&original.identity_nonce), user_fee_increase: std::borrow::Cow::Borrowed(&original.user_fee_increase), } } } impl crate::serialization::Signable for DataContractCreateTransitionV0 { fn signable_bytes(&self) -> Result<Vec<u8>, ProtocolError> { let config = bincode::config::standard().with_big_endian(); let intermediate: DataContractCreateTransitionV0Signable = self.into(); bincode::encode_to_vec(intermediate, config).map_err(|e| { ProtocolError::PlatformSerializationError( format!("unable to serialize to produce sig hash \ DataContractCreateTransitionV0: {}", e) ) }) } } }
The Cow references avoid cloning the data just to produce a hash. The intermediate struct borrows from the original and only serializes the fields that matter for the signature.
PlatformSignable on enums
When used on an enum (like StateTransition), the derive generates a corresponding Signable enum where each variant wraps the signable version of its inner type:
#![allow(unused)] fn main() { // On StateTransition: #[derive(PlatformSignable)] pub enum StateTransition { DataContractCreate(DataContractCreateTransition), DataContractUpdate(DataContractUpdateTransition), // ... } // Generates: #[derive(Debug, Clone, bincode::Encode, derive_more::From)] pub enum StateTransitionSignable<'a> { DataContractCreate(DataContractCreateTransitionSignable<'a>), DataContractUpdate(DataContractUpdateTransitionSignable<'a>), // ... } }
The signable_bytes implementation on the enum encodes a variant index (as u16) followed by the inner type's signable bytes:
#![allow(unused)] fn main() { impl Signable for StateTransition { fn signable_bytes(&self) -> Result<Vec<u8>, ProtocolError> { let config = bincode::config::standard().with_big_endian(); let signable_bytes = match self { StateTransition::DataContractCreate(ref inner) => { let mut buf = bincode::encode_to_vec(&(0u16), config).unwrap(); let inner_signable_bytes = inner.signable_bytes()?; buf.extend(inner_signable_bytes); buf } StateTransition::DataContractUpdate(ref inner) => { let mut buf = bincode::encode_to_vec(&(1u16), config).unwrap(); let inner_signable_bytes = inner.signable_bytes()?; buf.extend(inner_signable_bytes); buf } // ... }; Ok(signable_bytes) } } }
PlatformSignable attributes
exclude_from_sig_hash-- marks a field to be excluded from the signable structinto = "TypePath"-- converts a field to a different type in the signable struct (used for fields that need a different representation for hashing)derive_into-- on enums, generatesFromconversions from the original enum to the signable enumderive_bincode_with_borrowed_vec-- manually implementsbincode::Encodefor the signable struct instead of deriving it (needed when fields contain borrowedVectypes)
The relationship between Encode/Decode and PlatformSerialize/PlatformDeserialize
This is a common source of confusion. Here is how the pieces fit together:
-
Encode/Decode(from bincode) -- field-level encoding. These know how to write each field to bytes and read it back. They are the low-level building blocks. -
PlatformVersionEncode/PlatformVersionedDecode(from rs-platform-serialization) -- version-aware field-level encoding. These wrapEncode/Decodewith aPlatformVersionparameter. -
PlatformSerialize/PlatformDeserialize(derive macros) -- high-level serialization. These generate theserialize_to_bytes()anddeserialize_from_bytes()methods that configure bincode, enforce size limits, and convert errors.
When you derive all of them on a type, the call chain is:
your_type.serialize_to_bytes() // PlatformSerialize-generated method
-> platform_encode_to_vec(...) // from rs-platform-serialization
-> your_type.platform_encode(...) // PlatformVersionEncode (auto-generated)
-> field.encode(encoder) // bincode Encode for each field
You need Encode and Decode in the derive list alongside PlatformSerialize and PlatformDeserialize because the platform derives internally delegate to bincode encoding.
Rules
Do:
- Always derive
Encode, Decode, PlatformSerialize, PlatformDeserializetogether - Use
#[platform_serialize(unversioned)]for types with a stable serialization format - Use
#[platform_serialize(limit = N)]on types received from untrusted sources - Use
#[platform_signable(exclude_from_sig_hash)]on signature and signature key ID fields - Keep the
#[platform_error_type]attribute consistent with the crate's error type
Do not:
- Use
passthroughon structs (it is enum-only) - Use
intoon enums (it is struct-only) - Combine
passthroughwithlimit,untagged, orinto - Combine
force_prepend_versionwithallow_prepend_version - Forget that
PlatformSignableon enums requires each inner type to also implementSignable - Change the order of fields in a
PlatformSignablestruct -- this changes the signature hash, which invalidates existing signatures