From 1a6a20b3b489b434c41f6d0eaa97b48f934c6d82 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 22 Sep 2026 20:14:14 -0500 Subject: [PATCH] feat(dpp): exact untrusted decode and per-kind untagged state transition decode The derived PlatformDeserializableUntrusted decoders drop bincode's consumed-byte count, so a value followed by anything decodes as the value alone. PlatformDeserializableUntrustedExact (blanket over DecodeUntrusted) refuses leftover bytes, and StateTransition::deserialize_untagged_untrusted_exact decodes one transition of a given StateTransitionType serialized without the StateTransition variant tag, which is what the inner transitions' serialize_to_bytes produce. Wallets that show a user a transition and then sign it need both, instead of guessing bincode tag bytes. Also pins that IdentityPublicKeyInCreation::from(&IdentityPublicKey) carries a V1 key's budget and expiry into a V1 key in creation and keeps a V0 key V0. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/serialization/serialization_traits.rs | 46 ++++ packages/rs-dpp/src/state_transition/mod.rs | 53 ++++ .../src/state_transition/serialization.rs | 257 +++++++++++++++++- .../identity/public_key_in_creation/mod.rs | 33 ++- 4 files changed, 385 insertions(+), 4 deletions(-) diff --git a/packages/rs-dpp/src/serialization/serialization_traits.rs b/packages/rs-dpp/src/serialization/serialization_traits.rs index 537a7010a0e..b47d2793726 100644 --- a/packages/rs-dpp/src/serialization/serialization_traits.rs +++ b/packages/rs-dpp/src/serialization/serialization_traits.rs @@ -99,6 +99,52 @@ pub trait PlatformDeserializableUntrusted { Self: Sized; } +/// [`PlatformDeserializableUntrusted`] that also refuses bytes left over after the value. +/// +/// The derived untrusted decoders discard how many bytes they consumed, so any suffix after a +/// valid value is silently ignored. A caller that shows a user what the bytes contain and then +/// signs them must know the value is all there is. +/// +/// Decodes with the same configuration the derive emits for `unversioned` types without `into`, +/// bounded by [`UNTRUSTED_EXACT_DECODE_LIMIT`] (the `StateTransition` wire limit) because the +/// input is untrusted. +pub trait PlatformDeserializableUntrustedExact: Sized { + fn deserialize_from_bytes_untrusted_exact(data: &[u8]) -> Result; +} + +/// Byte budget of [`PlatformDeserializableUntrustedExact`]; the `StateTransition` wire limit. +pub const UNTRUSTED_EXACT_DECODE_LIMIT: usize = 100_000; + +impl> PlatformDeserializableUntrustedExact for T { + fn deserialize_from_bytes_untrusted_exact(data: &[u8]) -> Result { + let config = bincode::config::standard() + .with_big_endian() + .with_limit::(); + let (value, consumed) = + bincode::decode_from_slice_untrusted(data, config).map_err(|e| match e { + bincode::error::DecodeError::Io { .. } + | bincode::error::DecodeError::LimitExceeded => { + ProtocolError::MaxEncodedBytesReachedError { + max_size_kbytes: UNTRUSTED_EXACT_DECODE_LIMIT, + size_hit: data.len(), + } + } + _ => ProtocolError::PlatformDeserializationError(format!( + "unable to deserialize {}: {e}", + std::any::type_name::() + )), + })?; + if consumed != data.len() { + return Err(ProtocolError::PlatformDeserializationError(format!( + "unable to deserialize {}: {} bytes left over after the value", + std::any::type_name::(), + data.len() - consumed + ))); + } + Ok(value) + } +} + /// We will deserialize a versioned structure into a code structure /// For example we have DataContractV0 and DataContractV1 /// The system version will tell which version to deserialize into diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 3c338489596..b75a88c8a01 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -894,6 +894,59 @@ mod json_convertible_tests { "shieldFromIdentity", ); } + + /// Every kind decodes from its untagged bytes (the inner transition serialized on its own) + /// into the same transition, and from its tagged bytes exactly. + #[test] + fn every_kind_decodes_untagged_and_exactly() { + use crate::serialization::{PlatformDeserializableUntrustedExact, PlatformSerializable}; + + let transitions = [ + StateTransition::DataContractCreate(crate::state_transition::data_contract_create_transition::json_convertible_tests::fixture()), + StateTransition::DataContractUpdate(crate::state_transition::data_contract_update_transition::json_convertible_tests::fixture()), + StateTransition::Batch(crate::state_transition::batch_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreate(crate::state_transition::identity_create_transition::json_convertible_tests::fixture()), + StateTransition::IdentityTopUp(crate::state_transition::identity_topup_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreditWithdrawal(crate::state_transition::identity_credit_withdrawal_transition::json_convertible_tests::fixture()), + StateTransition::IdentityUpdate(crate::state_transition::identity_update_transition::json_convertible_tests::fixture()), + StateTransition::IdentityKeyLimitsUpdate(crate::state_transition::identity_key_limits_update_transition::json_convertible_tests::fixture()), + StateTransition::ContractUserModeration(crate::state_transition::contract_user_moderation_transition::json_convertible_tests::fixture()), + StateTransition::ContractFeeClaim(crate::state_transition::contract_fee_claim_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreditTransfer(crate::state_transition::identity_credit_transfer_transition::json_convertible_tests::fixture()), + StateTransition::MasternodeVote(crate::state_transition::masternode_vote_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreditTransferToAddresses(crate::state_transition::identity_credit_transfer_to_addresses_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreateFromAddresses(crate::state_transition::identity_create_from_addresses_transition::json_convertible_tests::fixture()), + StateTransition::IdentityTopUpFromAddresses(crate::state_transition::identity_topup_from_addresses_transition::json_convertible_tests::fixture()), + StateTransition::AddressFundsTransfer(crate::state_transition::address_funds_transfer_transition::json_convertible_tests::fixture()), + StateTransition::AddressFundingFromAssetLock(crate::state_transition::address_funding_from_asset_lock_transition::json_convertible_tests::fixture()), + StateTransition::AddressCreditWithdrawal(crate::state_transition::address_credit_withdrawal_transition::json_convertible_tests::fixture()), + StateTransition::Shield(crate::state_transition::shield_transition::json_convertible_tests::fixture()), + StateTransition::ShieldedTransfer(crate::state_transition::shielded_transfer_transition::json_convertible_tests::fixture()), + StateTransition::Unshield(crate::state_transition::unshield_transition::json_convertible_tests::fixture()), + StateTransition::ShieldFromAssetLock(crate::state_transition::shield_from_asset_lock_transition::json_convertible_tests::fixture()), + StateTransition::ShieldedWithdrawal(crate::state_transition::shielded_withdrawal_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreateFromShieldedPool(crate::state_transition::identity_create_from_shielded_pool_transition::json_convertible_tests::fixture()), + StateTransition::IdentityTopUpFromShieldedPool(crate::state_transition::identity_top_up_from_shielded_pool_transition::json_convertible_tests::fixture()), + StateTransition::ShieldFromIdentity(crate::state_transition::shield_from_identity_transition::json_convertible_tests::fixture()), + ]; + for transition in transitions { + let tagged = transition.serialize_to_bytes().expect("serializes"); + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted_exact(&tagged).expect("tagged"), + transition + ); + // The variant tags are all below 251, so bincode's varint writes them in one byte. + let untagged = &tagged[1..]; + assert_eq!( + StateTransition::deserialize_untagged_untrusted_exact( + transition.state_transition_type(), + untagged + ) + .unwrap_or_else(|e| panic!("{} untagged: {e}", transition.name())), + transition + ); + } + } } impl OptionallyAssetLockProved for StateTransition { diff --git a/packages/rs-dpp/src/state_transition/serialization.rs b/packages/rs-dpp/src/state_transition/serialization.rs index c3e12dbb61c..bd5b621f67a 100644 --- a/packages/rs-dpp/src/state_transition/serialization.rs +++ b/packages/rs-dpp/src/state_transition/serialization.rs @@ -1,5 +1,5 @@ -use crate::serialization::PlatformDeserializableUntrusted; -use crate::state_transition::StateTransition; +use crate::serialization::{PlatformDeserializableUntrusted, PlatformDeserializableUntrustedExact}; +use crate::state_transition::*; use crate::ProtocolError; impl StateTransition { @@ -13,6 +13,125 @@ impl StateTransition { }) .collect() } + + /// Decodes one transition of `state_transition_type` serialized on its own, without the + /// `StateTransition` variant tag in front (what `IdentityUpdateTransition::serialize_to_bytes` + /// produces, for example). Bytes left over after the transition are refused. + pub fn deserialize_untagged_untrusted_exact( + state_transition_type: StateTransitionType, + bytes: &[u8], + ) -> Result { + let state_transition: Self = match state_transition_type { + StateTransitionType::DataContractCreate => { + DataContractCreateTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::DataContractUpdate => { + DataContractUpdateTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::Batch => { + BatchTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityCreate => { + IdentityCreateTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityTopUp => { + IdentityTopUpTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityCreditWithdrawal => { + IdentityCreditWithdrawalTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::IdentityUpdate => { + IdentityUpdateTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityCreditTransfer => { + IdentityCreditTransferTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::MasternodeVote => { + MasternodeVoteTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityCreditTransferToAddresses => { + IdentityCreditTransferToAddressesTransition::deserialize_from_bytes_untrusted_exact( + bytes, + )? + .into() + } + StateTransitionType::IdentityCreateFromAddresses => { + IdentityCreateFromAddressesTransition::deserialize_from_bytes_untrusted_exact( + bytes, + )? + .into() + } + StateTransitionType::IdentityTopUpFromAddresses => { + IdentityTopUpFromAddressesTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::AddressFundsTransfer => { + AddressFundsTransferTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::AddressFundingFromAssetLock => { + AddressFundingFromAssetLockTransition::deserialize_from_bytes_untrusted_exact( + bytes, + )? + .into() + } + StateTransitionType::AddressCreditWithdrawal => { + AddressCreditWithdrawalTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::Shield => { + ShieldTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::ShieldedTransfer => { + ShieldedTransferTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::Unshield => { + UnshieldTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::ShieldFromAssetLock => { + ShieldFromAssetLockTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::ShieldedWithdrawal => { + ShieldedWithdrawalTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityCreateFromShieldedPool => { + IdentityCreateFromShieldedPoolTransition::deserialize_from_bytes_untrusted_exact( + bytes, + )? + .into() + } + StateTransitionType::ShieldFromIdentity => { + ShieldFromIdentityTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityTopUpFromShieldedPool => { + IdentityTopUpFromShieldedPoolTransition::deserialize_from_bytes_untrusted_exact( + bytes, + )? + .into() + } + StateTransitionType::IdentityKeyLimitsUpdate => { + IdentityKeyLimitsUpdateTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::ContractUserModeration => { + ContractUserModerationTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::ContractFeeClaim => { + ContractFeeClaimTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + }; + // Every arm converts into `StateTransition`, so a mismatched arm would still compile. + if state_transition.state_transition_type() != state_transition_type { + return Err(ProtocolError::CorruptedCodeExecution(format!( + "untagged {state_transition_type} bytes decoded as {}", + state_transition.state_transition_type() + ))); + } + Ok(state_transition) + } } #[cfg(test)] @@ -32,7 +151,9 @@ mod tests { use crate::prelude::AssetLockProof; use crate::serialization::PlatformMessageSignable; use crate::serialization::Signable; - use crate::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; + use crate::serialization::{ + PlatformDeserializableUntrusted, PlatformDeserializableUntrustedExact, PlatformSerializable, + }; use crate::state_transition::data_contract_create_transition::DataContractCreateTransition; use crate::state_transition::data_contract_update_transition::{ DataContractUpdateTransition, DataContractUpdateTransitionV0, @@ -56,6 +177,8 @@ mod tests { use crate::state_transition::identity_credit_withdrawal_transition::v0::IdentityCreditWithdrawalTransitionV0; use crate::state_transition::identity_topup_transition::v0::IdentityTopUpTransitionV0; use crate::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; + use crate::state_transition::identity_update_transition::IdentityUpdateTransition; + use crate::state_transition::StateTransitionType; use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Setters; use crate::state_transition::StateTransition; use crate::tests::fixtures::{ @@ -770,4 +893,132 @@ mod tests { assert_eq!(recovered[1], st2); assert_eq!(recovered[2], st3); } + + #[test] + #[cfg(feature = "random-identities")] + fn exact_decode_refuses_trailing_bytes() { + let platform_version = PlatformVersion::latest(); + let identity = Identity::random_identity(5, Some(5), platform_version) + .expect("expected a random identity"); + let st: StateTransition = IdentityCreditWithdrawalTransitionV0 { + identity_id: identity.id(), + amount: 1000000, + core_fee_per_byte: 34, + pooling: Pooling::Standard, + output_script: CoreScript::from_bytes((0..23).collect::>()), + nonce: 1, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: [1u8; 65].to_vec().into(), + } + .into(); + let bytes = st.serialize_to_bytes().unwrap(); + + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted_exact(&bytes).unwrap(), + st + ); + + let mut padded = bytes.clone(); + padded.push(0); + // The derived decoder ignores the suffix; the exact one refuses it. + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted(&padded).unwrap(), + st + ); + assert!(matches!( + StateTransition::deserialize_from_bytes_untrusted_exact(&padded), + Err(ProtocolError::PlatformDeserializationError(message)) + if message.contains("1 bytes left over") + )); + } + + /// A tagged transition followed by another decodes loosely as the first alone; only the + /// exact decoder reports the suffix. + #[test] + fn exact_decode_refuses_a_transition_followed_by_another() { + let update: StateTransition = IdentityUpdateTransitionV0 { + identity_id: Identifier::from([0x21; 32]), + revision: 1, + nonce: 1, + disable_public_keys: vec![1], + ..Default::default() + } + .into(); + let withdrawal: StateTransition = IdentityCreditWithdrawalTransitionV0 { + identity_id: Identifier::from([0x21; 32]), + amount: 1000000, + core_fee_per_byte: 34, + pooling: Pooling::Standard, + output_script: CoreScript::from_bytes((0..23).collect::>()), + nonce: 2, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + + let mut both = update.serialize_to_bytes().unwrap(); + both.extend_from_slice(&withdrawal.serialize_to_bytes().unwrap()); + + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted(&both).unwrap(), + update + ); + assert!(matches!( + StateTransition::deserialize_from_bytes_untrusted_exact(&both), + Err(ProtocolError::PlatformDeserializationError(message)) if message.contains("left over") + )); + } + + #[test] + #[cfg(feature = "random-identities")] + fn untagged_decode_matches_the_tagged_transition() { + let platform_version = PlatformVersion::latest(); + let identity = Identity::random_identity(5, Some(5), platform_version) + .expect("expected a random identity"); + let update = IdentityUpdateTransitionV0 { + signature: [7u8; 65].to_vec().into(), + signature_public_key_id: 0, + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: identity + .public_keys() + .values() + .map(|public_key| public_key.into()) + .collect(), + disable_public_keys: vec![], + user_fee_increase: 0, + }; + let inner_bytes = IdentityUpdateTransition::from(update.clone()) + .serialize_to_bytes() + .unwrap(); + let st: StateTransition = update.into(); + + let decoded = StateTransition::deserialize_untagged_untrusted_exact( + StateTransitionType::IdentityUpdate, + &inner_bytes, + ) + .expect("untagged identity update decodes"); + assert_eq!(decoded, st); + assert_eq!( + &decoded.serialize_to_bytes().unwrap()[1..], + &inner_bytes[..] + ); + + assert!(StateTransition::deserialize_untagged_untrusted_exact( + StateTransitionType::Batch, + &inner_bytes, + ) + .is_err()); + + let mut padded = inner_bytes.clone(); + padded.push(0); + assert!(StateTransition::deserialize_untagged_untrusted_exact( + StateTransitionType::IdentityUpdate, + &padded, + ) + .is_err()); + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/mod.rs index 23d13da9b2a..e3bc87f1243 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/mod.rs @@ -133,7 +133,9 @@ mod test { use super::*; use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use crate::identity::{KeyType, Purpose, SecurityLevel}; - use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; + use crate::state_transition::public_key_in_creation::accessors::{ + IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV1Getters, + }; use crate::state_transition::public_key_in_creation::methods::IdentityPublicKeyInCreationMethodsV0; use crate::version::LATEST_PLATFORM_VERSION; use platform_value::BinaryData; @@ -208,6 +210,35 @@ mod test { assert_eq!(back.id(), key.id()); } + /// The limits are part of what the identity signs, so a V1 key (one carrying a budget or an + /// expiry) must become a V1 key in creation with the limits intact, and a V0 key must stay V0 + /// so identities that do not use limits keep their historical bytes. + #[test] + fn test_from_identity_public_key_keeps_limits() { + let plain: IdentityPublicKey = make_high_key(5).into(); + let plain_in_creation = IdentityPublicKeyInCreation::from(&plain); + assert!(matches!( + plain_in_creation, + IdentityPublicKeyInCreation::V0(_) + )); + assert!(!plain_in_creation.has_limits()); + + for (total_budget, expires_at) in [ + (Some(10_000_000_000), None), + (None, Some(1_800_000_000_000)), + (Some(10_000_000_000), Some(1_800_000_000_000)), + ] { + let limited = plain.clone().with_limits(total_budget, expires_at); + assert!(matches!(limited, IdentityPublicKey::V1(_))); + + let in_creation = IdentityPublicKeyInCreation::from(&limited); + assert!(matches!(in_creation, IdentityPublicKeyInCreation::V1(_))); + assert_eq!(in_creation.total_budget(), total_budget); + assert_eq!(in_creation.expires_at(), expires_at); + assert_eq!(IdentityPublicKey::from(in_creation), limited); + } + } + #[test] fn test_into_identity_public_key_method() { let key = make_master_key(0);