Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
40f0703
Initial plan
Copilot Feb 19, 2026
5adefb6
Add StateTransitionJsonConvert and StateTransitionValueConvert impls …
Copilot Feb 19, 2026
c616d6e
Fix build errors: implement StateTransitionFieldTypes and fix warnings
Copilot Feb 19, 2026
b51a410
Merge branch 'v3.1-dev' into copilot/fix-state-transition-json-conver…
lklimek Feb 19, 2026
4116373
Run cargo fmt to format Rust code
Copilot Feb 19, 2026
db07146
Add test to verify serde vs to_json behavior
Copilot Feb 19, 2026
b96d71f
Fix serde serialization for BTreeMap<PlatformAddress, V> fields
Copilot Feb 19, 2026
f0edbfe
Run cargo fmt on new code
Copilot Feb 19, 2026
b1b5829
Merge branch 'v3.1-dev' into copilot/fix-state-transition-json-conver…
lklimek Feb 19, 2026
9fde5c9
Merge branch 'v3.1-dev' into copilot/fix-state-transition-json-conver…
lklimek Feb 20, 2026
14dc7fd
Merge branch 'v3.1-dev' into copilot/fix-state-transition-json-conver…
lklimek Feb 20, 2026
314a1c6
Add state-transition-json-conversion feature to wasm-dpp2
Copilot Feb 20, 2026
5a43766
test: improve state transition JSON conversion tests with given/when/…
lklimek Feb 23, 2026
82abea6
test: clean up duplicate tests in state transition JSON conversion
lklimek Feb 23, 2026
2ec9acc
test: add wasm-dpp2 test for StateTransition JSON conversion
lklimek Feb 23, 2026
aa87b3f
chore: fmt
lklimek Feb 23, 2026
da09fcc
Merge branch 'v3.1-dev' into copilot/fix-state-transition-json-conver…
lklimek Feb 23, 2026
f9856fb
Merge branch 'v3.1-dev' into copilot/fix-state-transition-json-conver…
lklimek Mar 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/rs-dpp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ dash-sdk-features = [
"data-contract-json-conversion",
"identity-value-conversion",
"state-transition-value-conversion",
"state-transition-json-conversion",
"state-transition-signing",
"client",
"platform-value-cbor",
Expand Down
2 changes: 2 additions & 0 deletions packages/rs-dpp/src/address_funds/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ pub mod fee_strategy;
#[cfg(feature = "shielded-client")]
mod orchard_address;
mod platform_address;
#[cfg(feature = "state-transition-serde-conversion")]
pub mod platform_address_map_serde;
mod witness;
mod witness_verification_operations;

Expand Down
58 changes: 58 additions & 0 deletions packages/rs-dpp/src/address_funds/platform_address_map_serde.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/// Custom serde serialization for BTreeMap<PlatformAddress, V>
/// Converts PlatformAddress keys to strings using Display when serializing to JSON
use crate::address_funds::PlatformAddress;
use serde::de::{MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::BTreeMap;
use std::fmt;
use std::marker::PhantomData;
use std::str::FromStr;

pub fn serialize<S, V>(map: &BTreeMap<PlatformAddress, V>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
V: Serialize,
{
let mut ser_map = serializer.serialize_map(Some(map.len()))?;
for (k, v) in map {
// Convert PlatformAddress to string using Display
ser_map.serialize_entry(&k.to_string(), v)?;
}
ser_map.end()
}

pub fn deserialize<'de, D, V>(deserializer: D) -> Result<BTreeMap<PlatformAddress, V>, D::Error>
where
D: Deserializer<'de>,
V: Deserialize<'de>,
{
struct MapVisitor<V> {
marker: PhantomData<V>,
}

impl<'de, V: Deserialize<'de>> Visitor<'de> for MapVisitor<V> {
type Value = BTreeMap<PlatformAddress, V>;

fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a map with PlatformAddress keys")
}

fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut result = BTreeMap::new();
while let Some((key_str, value)) = map.next_entry::<String, V>()? {
let key = PlatformAddress::from_str(&key_str)
.map_err(|e| serde::de::Error::custom(format!("{}", e)))?;
result.insert(key, value);
}
Ok(result)
}
}

deserializer.deserialize_map(MapVisitor {
marker: PhantomData,
})
}
31 changes: 31 additions & 0 deletions packages/rs-dpp/src/state_transition/json_conversion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use crate::state_transition::{
JsonStateTransitionSerializationOptions, StateTransition, StateTransitionJsonConvert,
};
use crate::ProtocolError;
use serde_json::Value as JsonValue;

#[cfg(feature = "state-transition-json-conversion")]
impl StateTransitionJsonConvert<'_> for StateTransition {
fn to_json(
&self,
options: JsonStateTransitionSerializationOptions,
) -> Result<JsonValue, ProtocolError> {
match self {

Check failure on line 13 in packages/rs-dpp/src/state_transition/json_conversion.rs

View workflow job for this annotation

GitHub Actions / Rust packages (wasm-dpp2) / Linting

non-exhaustive patterns: `&state_transition::StateTransition::Shield(_)`, `&state_transition::StateTransition::ShieldedTransfer(_)`, `&state_transition::StateTransition::Unshield(_)` and 2 more not covered

error[E0004]: non-exhaustive patterns: `&state_transition::StateTransition::Shield(_)`, `&state_transition::StateTransition::ShieldedTransfer(_)`, `&state_transition::StateTransition::Unshield(_)` and 2 more not covered --> packages/rs-dpp/src/state_transition/json_conversion.rs:13:15 | 13 | match self { | ^^^^ patterns `&state_transition::StateTransition::Shield(_)`, `&state_transition::StateTransition::ShieldedTransfer(_)`, `&state_transition::StateTransition::Unshield(_)` and 2 more not covered | note: `state_transition::StateTransition` defined here --> packages/rs-dpp/src/state_transition/mod.rs:437:10 | 437 | pub enum StateTransition { | ^^^^^^^^^^^^^^^ ... 453 | Shield(ShieldTransition), | ------ not covered 454 | ShieldedTransfer(ShieldedTransferTransition), | ---------------- not covered 455 | Unshield(UnshieldTransition), | -------- not covered 456 | ShieldFromAssetLock(ShieldFromAssetLockTransition), | ------------------- not covered 457 | ShieldedWithdrawal(ShieldedWithdrawalTransition), | ------------------ not covered = note: the matched value is of type `&state_transition::StateTransition` help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern as shown, or multiple match arms | 28 ~ StateTransition::AddressCreditWithdrawal(st) => st.to_json(options), 29 ~ _ => todo!(), |

Check failure on line 13 in packages/rs-dpp/src/state_transition/json_conversion.rs

View workflow job for this annotation

GitHub Actions / Rust packages (dpp) / Linting

non-exhaustive patterns: `&state_transition::StateTransition::Shield(_)`, `&state_transition::StateTransition::ShieldedTransfer(_)`, `&state_transition::StateTransition::Unshield(_)` and 2 more not covered

error[E0004]: non-exhaustive patterns: `&state_transition::StateTransition::Shield(_)`, `&state_transition::StateTransition::ShieldedTransfer(_)`, `&state_transition::StateTransition::Unshield(_)` and 2 more not covered --> packages/rs-dpp/src/state_transition/json_conversion.rs:13:15 | 13 | match self { | ^^^^ patterns `&state_transition::StateTransition::Shield(_)`, `&state_transition::StateTransition::ShieldedTransfer(_)`, `&state_transition::StateTransition::Unshield(_)` and 2 more not covered | note: `state_transition::StateTransition` defined here --> packages/rs-dpp/src/state_transition/mod.rs:437:10 | 437 | pub enum StateTransition { | ^^^^^^^^^^^^^^^ ... 453 | Shield(ShieldTransition), | ------ not covered 454 | ShieldedTransfer(ShieldedTransferTransition), | ---------------- not covered 455 | Unshield(UnshieldTransition), | -------- not covered 456 | ShieldFromAssetLock(ShieldFromAssetLockTransition), | ------------------- not covered 457 | ShieldedWithdrawal(ShieldedWithdrawalTransition), | ------------------ not covered = note: the matched value is of type `&state_transition::StateTransition` help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern as shown, or multiple match arms | 28 ~ StateTransition::AddressCreditWithdrawal(st) => st.to_json(options), 29 ~ _ => todo!(), |
StateTransition::DataContractCreate(st) => st.to_json(options),
StateTransition::DataContractUpdate(st) => st.to_json(options),
StateTransition::Batch(st) => st.to_json(options),
StateTransition::IdentityCreate(st) => st.to_json(options),
StateTransition::IdentityTopUp(st) => st.to_json(options),
StateTransition::IdentityCreditWithdrawal(st) => st.to_json(options),
StateTransition::IdentityUpdate(st) => st.to_json(options),
StateTransition::IdentityCreditTransfer(st) => st.to_json(options),
StateTransition::MasternodeVote(st) => st.to_json(options),
StateTransition::IdentityCreditTransferToAddresses(st) => st.to_json(options),
StateTransition::IdentityCreateFromAddresses(st) => st.to_json(options),
StateTransition::IdentityTopUpFromAddresses(st) => st.to_json(options),
StateTransition::AddressFundsTransfer(st) => st.to_json(options),
StateTransition::AddressFundingFromAssetLock(st) => st.to_json(options),
StateTransition::AddressCreditWithdrawal(st) => st.to_json(options),
}
}
}
26 changes: 26 additions & 0 deletions packages/rs-dpp/src/state_transition/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,16 @@ pub mod errors;
use crate::util::hash::ripemd160_sha256;
use crate::util::hash::{hash_double_to_vec, hash_single};

#[cfg(feature = "state-transition-json-conversion")]
mod json_conversion;
pub mod proof_result;
mod serialization;
pub mod state_transitions;
#[cfg(test)]
mod test_json_serde_integration;
mod traits;
#[cfg(feature = "state-transition-value-conversion")]
mod value_conversion;

// pub mod state_transition_fee;

Expand Down Expand Up @@ -462,6 +468,26 @@ impl OptionallyAssetLockProved for StateTransition {
}
}

impl StateTransitionFieldTypes for StateTransition {
fn signature_property_paths() -> Vec<&'static str> {
// The top-level enum doesn't have fixed signature paths
// Each variant has its own specific paths
vec![]
}

fn identifiers_property_paths() -> Vec<&'static str> {
// The top-level enum doesn't have fixed identifier paths
// Each variant has its own specific paths
vec![]
}

fn binary_property_paths() -> Vec<&'static str> {
// The top-level enum doesn't have fixed binary paths
// Each variant has its own specific paths
vec![]
}
}

/// The state transition signing options
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
pub struct StateTransitionSigningOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ use crate::{identity::core_script::CoreScript, withdrawal::Pooling, ProtocolErro
)]
#[derive(Default)]
pub struct AddressCreditWithdrawalTransitionV0 {
#[cfg_attr(
feature = "state-transition-serde-conversion",
serde(with = "crate::address_funds::platform_address_map_serde")
)]
pub inputs: BTreeMap<PlatformAddress, (AddressNonce, Credits)>,
/// Optional output for change
pub output: Option<(PlatformAddress, Credits)>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,20 @@ mod property_names {
pub struct AddressFundingFromAssetLockTransitionV0 {
pub asset_lock_proof: AssetLockProof,
/// Inputs from existing platform addresses (optional, for combining funds)
#[cfg_attr(
feature = "state-transition-serde-conversion",
serde(with = "crate::address_funds::platform_address_map_serde")
)]
pub inputs: BTreeMap<PlatformAddress, (AddressNonce, Credits)>,
/// Outputs to fund platform addresses.
/// - `Some(credits)` = explicit amount to send to this address
/// - `None` = this address receives everything remaining after explicit outputs and fees
/// Exactly one output must be `None` to receive the remainder
/// (ensures full asset lock consumption).
#[cfg_attr(
feature = "state-transition-serde-conversion",
serde(with = "crate::address_funds::platform_address_map_serde")
)]
pub outputs: BTreeMap<PlatformAddress, Option<Credits>>,
pub fee_strategy: AddressFundsFeeStrategy,
pub user_fee_increase: UserFeeIncrease,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,15 @@ use serde::{Deserialize, Serialize};
#[platform_serialize(unversioned)]
#[derive(Default)]
pub struct AddressFundsTransferTransitionV0 {
#[cfg_attr(
feature = "state-transition-serde-conversion",
serde(with = "crate::address_funds::platform_address_map_serde")
)]
pub inputs: BTreeMap<PlatformAddress, (AddressNonce, Credits)>,
#[cfg_attr(
feature = "state-transition-serde-conversion",
serde(with = "crate::address_funds::platform_address_map_serde")
)]
pub outputs: BTreeMap<PlatformAddress, Credits>,
pub fee_strategy: AddressFundsFeeStrategy,
pub user_fee_increase: UserFeeIncrease,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ pub struct IdentityCreateFromAddressesTransitionV0 {
// When signing, we don't sign the signatures for keys
#[platform_signable(into = "Vec<IdentityPublicKeyInCreationSignable>")]
pub public_keys: Vec<IdentityPublicKeyInCreation>,
#[cfg_attr(
feature = "state-transition-serde-conversion",
serde(with = "crate::address_funds::platform_address_map_serde")
)]
pub inputs: BTreeMap<PlatformAddress, (AddressNonce, Credits)>,
/// Optional output to send remaining credits to an address
pub output: Option<(PlatformAddress, Credits)>,
Expand All @@ -56,6 +60,10 @@ pub struct IdentityCreateFromAddressesTransitionV0 {
struct IdentityCreateFromAddressesTransitionV0Inner {
// Own ST fields
public_keys: Vec<IdentityPublicKeyInCreation>,
#[cfg_attr(
feature = "state-transition-serde-conversion",
serde(with = "crate::address_funds::platform_address_map_serde")
)]
inputs: BTreeMap<PlatformAddress, (AddressNonce, Credits)>,
output: Option<(PlatformAddress, Credits)>,
fee_strategy: AddressFundsFeeStrategy,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ use serde::{Deserialize, Serialize};
pub struct IdentityCreditTransferToAddressesTransitionV0 {
// Own ST fields
pub identity_id: Identifier,
#[cfg_attr(
feature = "state-transition-serde-conversion",
serde(with = "crate::address_funds::platform_address_map_serde")
)]
pub recipient_addresses: BTreeMap<PlatformAddress, Credits>,
pub nonce: IdentityNonce,
pub user_fee_increase: UserFeeIncrease,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ use crate::ProtocolError;
)]
#[derive(Default)]
pub struct IdentityTopUpFromAddressesTransitionV0 {
#[cfg_attr(
feature = "state-transition-serde-conversion",
serde(with = "crate::address_funds::platform_address_map_serde")
)]
pub inputs: BTreeMap<PlatformAddress, (AddressNonce, Credits)>,
/// Optional output to send remaining credits to an address
pub output: Option<(PlatformAddress, Credits)>,
Expand Down
125 changes: 125 additions & 0 deletions packages/rs-dpp/src/state_transition/test_json_serde_integration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#[cfg(all(
test,
feature = "state-transition-serde-conversion",
feature = "state-transition-json-conversion"
))]
mod test_serde_json_serialization {
use crate::address_funds::PlatformAddress;
use crate::state_transition::address_funds_transfer_transition::v0::AddressFundsTransferTransitionV0;
use crate::state_transition::address_funds_transfer_transition::AddressFundsTransferTransition;
use crate::state_transition::{
JsonStateTransitionSerializationOptions, StateTransition, StateTransitionJsonConvert,
};
use std::collections::BTreeMap;

/// Recursively search a JSON value for a given key name.
fn find_key_recursive<'a>(
val: &'a serde_json::Value,
key: &str,
) -> Option<&'a serde_json::Value> {
match val {
serde_json::Value::Object(map) => {
if let Some(v) = map.get(key) {
return Some(v);
}
for v in map.values() {
if let Some(found) = find_key_recursive(v, key) {
return Some(found);
}
}
None
}
serde_json::Value::Array(arr) => {
for v in arr {
if let Some(found) = find_key_recursive(v, key) {
return Some(found);
}
}
None
}
_ => None,
}
}

/// Helper: build an AddressFundsTransfer StateTransition with known addresses.
fn make_address_funds_transfer() -> StateTransition {
let mut inputs = BTreeMap::new();
let input_address = PlatformAddress::P2pkh([1u8; 20]);
inputs.insert(input_address, (1u32, 1000u64));

let mut outputs = BTreeMap::new();
let output_address = PlatformAddress::P2pkh([2u8; 20]);
outputs.insert(output_address, 900u64);

let v0 = AddressFundsTransferTransitionV0 {
inputs,
outputs,
user_fee_increase: 0,
fee_strategy: Default::default(),
input_witnesses: vec![],
};

StateTransition::AddressFundsTransfer(AddressFundsTransferTransition::V0(v0))
}

/// Given an AddressFundsTransfer variant with BTreeMap<PlatformAddress, _> fields,
/// When calling to_json() on the inner variant directly,
/// Then serialization succeeds without "key must be a string" errors.
#[test]
fn variant_to_json_succeeds() {
let st = make_address_funds_transfer();
if let StateTransition::AddressFundsTransfer(ref inner) = st {
inner
.to_json(JsonStateTransitionSerializationOptions::default())
.expect("variant to_json should succeed");
}
}

/// Given an AddressFundsTransfer wrapped in the top-level StateTransition enum,
/// When calling to_json() on the enum and serde_json::to_string_pretty(),
/// Then both serialization paths succeed and PlatformAddress map keys are
/// rendered as human-readable strings (e.g. "P2PKH(hex...)"), not complex objects.
#[test]
fn enum_to_json_succeeds_and_serde_roundtrips() {
let st = make_address_funds_transfer();

// StateTransition::to_json() must succeed (the whole point of the fix).
let json = st
.to_json(JsonStateTransitionSerializationOptions::default())
.expect("StateTransition::to_json should succeed");

// serde_json must also work now (custom map-key serializer).
let serde_json_str = serde_json::to_string_pretty(&st)
.expect("serde_json::to_string_pretty should succeed after custom serde fix");

// The to_json output must contain string keys for PlatformAddress maps.
let inputs = json.get("inputs").expect("JSON must have 'inputs' field");
assert!(
inputs.is_object(),
"inputs must be a JSON object (string keys)"
);
for key in inputs.as_object().unwrap().keys() {
assert!(
key.starts_with("P2PKH(") || key.starts_with("P2SH("),
"map key must be a PlatformAddress Display string, got: {key}"
);
}

// The serde output must also have string keys for PlatformAddress maps.
// The exact nesting depends on enum repr; find "inputs" anywhere in the tree.
let serde_val: serde_json::Value =
serde_json::from_str(&serde_json_str).expect("re-parse must succeed");
let inputs_field = find_key_recursive(&serde_val, "inputs")
.expect("serde JSON must contain an 'inputs' field somewhere");
assert!(
inputs_field.is_object(),
"serde inputs must be a JSON object with string keys"
);
for key in inputs_field.as_object().unwrap().keys() {
assert!(
key.starts_with("P2PKH(") || key.starts_with("P2SH("),
"serde map key must be a PlatformAddress Display string, got: {key}"
);
}
}
}
Loading
Loading