Skip to content

feat(dpp): unify JSON/Value conversion traits + comprehensive round-trip tests#3573

Draft
shumkov wants to merge 101 commits intov3.1-devfrom
feat/json-convertible-address-transitions
Draft

feat(dpp): unify JSON/Value conversion traits + comprehensive round-trip tests#3573
shumkov wants to merge 101 commits intov3.1-devfrom
feat/json-convertible-address-transitions

Conversation

@shumkov
Copy link
Copy Markdown
Collaborator

@shumkov shumkov commented Apr 30, 2026

Summary

Unification work for the canonical JsonConvertible / ValueConvertible traits across packages/rs-dpp. Two passes:

Pass 1 — added trait impls to ~80 domain types (commit 9f23d675af). All types now expose the canonical conversion surface.

Pass 2 — added 197 round-trip tests using non-default fixtures + per-property assertions per the new test convention. Surfaced 3 platform_value bugs that were previously silent.

Test results

  • 3619 dpp lib tests pass, 18 ignored, 0 failed (no regressions vs. base branch).
  • 197 dedicated json_convertible_tests across ~95 types.
  • 12 of the 197 are #[ignore]d — each documents either a real bug found or a structural issue (BLS keys, untagged-enum ambiguity, known-broken serde).

Bugs surfaced (logged in docs/json-value-unification-plan.md §10b)

  1. OutPoint round-trip via platform_value::Value::Map fails — affects ChainAssetLockProof, AddressFundingFromAssetLockTransition, ShieldFromAssetLockTransition. JSON works.
  2. [u8; N] fixed-array fields with custom serializers fail platform_value round-trip ("Invalid symbol 17, offset 0") — affects ExtendedBlockInfo signature and all 5 shielded transitions' Orchard fields. JSON works.
  3. DataContract document_schemas lose sized integer types via JSON round-trip (U32(63)U64(63), I32(0)U64(0)) — Critical-1 manifestation; affects every state transition embedding a DataContract. Value round-trip works.

Convention added

Every J/V test follows docs/json-value-unification-plan.md §8:

  1. Round-trip via to_jsonfrom_json (and same for value).
  2. Non-default fixture with distinguishable values per field.
  3. Per-property assertions on the recovered struct — catches silent field drops, type narrowing, custom-PartialEq quirks.
  4. Tagged-enum $formatVersion preservation test where applicable.

Fixtures source priority: hand-built struct literals > random_* constructors > from_* factories > crate::tests::fixtures::* helpers > Default::default() only for unit-only enums.

Code improvements

  • Derived PartialEq on StateTransitionProofResult + StoredAssetLockInfo (was missing, blocked round-trip assert_eq).

Out of scope (genuinely residual — needs upstream work)

  • ValidatorSet — needs real BLS public key construction (crypto setup).
  • ExtendedDocument — Critical-3 known-broken serde (writes version, reads $version).
  • StateTransition umbrella — serde(untagged), deserialize ambiguity (logged #[ignore]).

Docs

  • docs/json-value-conversion-inventory.md — pre-pass-1 structural inventory.
  • docs/json-value-unification-plan.md — full plan with phases, bug log, lessons learned, fixture conventions.

Test plan

  • Reviewer runs cargo test -p dpp --features=json-conversion,value-conversion,serde-conversion and sees 3619 pass / 0 fail.
  • Reviewer skims docs/json-value-unification-plan.md §10b for the 3 surfaced bugs and decides priority for follow-up fix.
  • Reviewer confirms the test convention in §8 is appropriate for the project's testing style.

🤖 Generated with Claude Code

shumkov and others added 30 commits April 30, 2026 15:29
Adds JsonConvertible / ValueConvertible impls (canonical traits in
packages/rs-dpp/src/serialization/serialization_traits.rs) to the
domain types catalogued in docs/json-value-conversion-inventory.md.

This is the unification first pass — round-trip correctness, tagged-
enum tag preservation, and integer-precision tests are deferred to the
second pass per the plan. Some impls may produce broken JSON or fail
round-trip until pass 2 fixes them; that's expected.

Coverage:
- Symmetrize V-only and J-only types (15+1).
- Add J+V to types missing both: top priorities (DataContract,
  StateTransition, BatchTransition, Document, AssetLockProof,
  AddressCreditWithdrawalTransition, Pooling) plus 22 batch transitions
  and 19 leaf serde types.

Skipped: types without serde derives, lifetime-param refs, and the
wasm-dpp legacy crate per minimum-touch policy.

Approach: derive(JsonConvertible/ValueConvertible) where the type
already opts into the json_safe_fields macro ecosystem; empty manual
impl X {} (§6 escape hatch) elsewhere to bypass the JsonSafeFields
cascade. Both paths use the trait's default serde-delegating methods.

Adds planning docs:
- docs/json-value-conversion-inventory.md — structural inventory.
- docs/json-value-unification-plan.md — phased plan with critical
  findings and per-mechanism deprecation decisions.

cargo check -p dpp passes with --features=json-conversion,value-conversion,serde-conversion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates the unification plan with:
- Progress table tracking the 5 passes (1 done, 2 in progress).
- Phase B/C status updated: ~80 types now have canonical impls.
- Skip-list rationale for types we deliberately did NOT migrate
  (no serde derives, lifetime params, internal indirection).
- Section 11 "Lessons learned from pass 1" — the JsonSafeFields
  cascade, BTreeMap-of-enum-keys serde helpers, what shipped in the
  481 commits we pulled, test-fixture pattern, sandbox/sccache/gpg
  gotchas.
- Reference to pass-1 commit 9f23d67.

Companion doc gets a status banner pointing back to the plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ity)

Adding empty impl JsonConvertible/ValueConvertible for DataContract in
pass 1 collided with the existing DataContractJsonConversionMethodsV0::
to_json(&self, &PlatformVersion) at every call site that passes a
PlatformVersion — Rust E0034 (multiple applicable items in scope).

Per the unification plan §3.11 step 10, DataContract is KEEP-AS-EXCEPTION
(version-aware serde via DataContractInSerializationFormat). The proper
unification path renames the legacy methods to *_versioned first, then
the canonical traits can layer on. That's a follow-up.

For now, leave a comment in data_contract/mod.rs explaining the absence
and pointing readers at DataContractInSerializationFormat (which DOES
have the canonical traits) when they need a JSON shape.

cargo test -p dpp --features=json-conversion,value-conversion,serde-conversion
--lib json_convertible_tests now passes (10/10 — the 5 address-transition
round-trip + tag-preservation tests from pass 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds json_round_trip + value_round_trip tests for 11 types covered
by the pass-1 unification commit (9f23d67). All 28 tests in the
new modules pass; no regressions in the existing 3432 dpp lib tests.

Types covered:
- Identity, IdentityV0, IdentityPublicKey
- AddressCreditWithdrawalTransition
- TokenContractInfo, TokenPaymentInfo
- Document
- Pooling
- GroupStateTransitionInfo

Types skipped with TODO (V0 inner lacks Default):
- AssetLockValue (AssetLockValueV0)
- GroupAction (GroupActionV0 has GroupActionEvent field with no Default)

Pass-2 work continues: more types to follow, then bug discovery
(StateTransition untagged, ExtendedDocument bug, Critical-1 / -2 / -4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds round-trip tests for TokenEmergencyAction, GasFeesPaidBy, and
YesNoAbstainVoteChoice — all flat enums with derive(Default).
Also marks TokenMarketplaceRules and other types whose V0 lacks Default
with TODO(unification pass 2) comments — they need explicit fixtures.

34 json_convertible_tests pass, no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…DistributionType (pass 2)

DocumentPatch has Default and J+V impls — round-trips cleanly.
TokenDistributionType has Default but the J+V impls are on its variants
(TokenDistributionTypeWithResolvedRecipient, TokenDistributionInfo),
neither of which has Default — left as TODO for explicit fixture.

36/36 json_convertible_tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…perty assertions

Per user direction, every J/V test must:
1. Use a NON-DEFAULT fixture (distinguishable values per field).
2. Round-trip via to_json/from_json (and to_object/from_object).
3. Assert each field of the recovered value individually — catches
   silent field drops, type narrowing, and PartialEq quirks that
   whole-struct equality can miss.

IdentityCreateFromAddressesTransition is the canonical example —
fixture has 6 non-default fields including a 2-entry inputs map
with both P2PKH+P2SH addresses, a populated public key, two
witness types, custom fee strategy, and non-zero user_fee_increase.
All three tests pass (json_round_trip, value_round_trip,
format_version_tag).

Plan §8 updated with the new mandatory convention and rationale.
Existing tests with Default fixtures are now legacy and will be
upgraded as we revisit each type in pass 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sferToAddresses tests

Apply the new mandatory convention (non-default fixture + per-property
assertions + round-trip) to two more address transitions. Both fixtures
use distinguishable values for every field (identity_id, recipient_addresses,
nonce, signature, fee strategy, witnesses, etc.) so the per-property
assertions actually exercise data preservation.

3/5 address transitions now on the new convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upgrade AddressFundingFromAssetLockTransition, AddressFundsTransferTransition,
and AddressCreditWithdrawalTransition tests to non-default fixture +
per-property assertions per the new convention.

Bug surfaced: AddressFundingFromAssetLockTransition.value_round_trip
fails — `OutPoint` inside `ChainAssetLockProof` cannot deserialize from
`platform_value::Value::Map` ("invalid type: map, expected an OutPoint").
JSON round-trip works fine. Marked the value test #[ignore] with the
reason and logged in plan §10b for pass-3 fix.

5/5 address transitions now on the new convention. 46 json_convertible_tests
pass, 3 ignored (1 OutPoint bug + 2 StateTransition untagged-enum known
failures).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…erty assertions

Replaces the legacy Identity::default() fixture with one that has:
- id: Identifier::new([0x42; 32])
- balance: 1_000_000
- revision: 7
- public_keys: BTreeMap with 2 distinct entries

Per-property assertions check id, balance, revision, and public_keys count.
Removes the duplicate empty-fixture test module that was leftover.

401 dpp lib tests pass (filtered to identity::identity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… tests

Apply non-default fixture + per-property assertion convention to:
- IdentityPublicKey (8 distinguishable fields incl. disabled_at, contract_bounds)
- TokenContractInfo (contract_id + token_contract_position; note: untagged enum)
- Pooling (test all 3 variants — Never/IfAvailable/Standard)

48 json_convertible_tests pass, 3 ignored (1 OutPoint bug, 2 StateTransition).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces single-Default-fixture tests for unit enums with
each_variant() pattern that exercises all variants in turn. This is
the per-property-assertion equivalent for unit-only enums where each
discriminant is the only "field".

Upgrades:
- TokenEmergencyAction (Pause, Resume)
- GasFeesPaidBy (DocumentOwner, ContractOwner, PreferContractOwner)
- YesNoAbstainVoteChoice (YES, NO, ABSTAIN)

48 json_convertible_tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply non-default fixture + per-property assertion convention to:
- GroupStateTransitionInfo (group_contract_position=5, action_id=[0x33;32], action_is_proposer=true)
- DocumentPatch (id=[0x77;32], 2 properties, revision=3, updated_at=1.7T)

48 json_convertible_tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…per-property

5-field fixture with all Option fields populated and gas_fees_paid_by
set to a non-default variant. Per-property assertion verifies each field
preserves through round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er-property

5-field fixture (owner_id, transitions, user_fee_increase,
signature_public_key_id, signature) with distinguishable values.
transitions vec is empty since DocumentTransition sub-types are tested
in their own modules. Per-property assertion verifies each field
preserves through round-trip.

49 json_convertible_tests pass, 3 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rk list

Updates the plan with:
- Pass-2 status table — 17/~80 types upgraded, 1 bug surfaced.
- Explicit list of types still on Default fixtures or without tests.
- Cost estimate: ~10-15 hours of focused work to finish pass 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds basic round-trip + format version tag tests for:
- IdentityCreateTransition (json/value tests #[ignore]: V0::default()
  has structurally invalid asset_lock_proof — needs explicit fixture)
- IdentityTopUpTransition
- IdentityCreditTransferTransition
- MasternodeVoteTransition
- IdentityPublicKeyInCreation
- IdentityUpdateTransition
- IdentityCreditWithdrawalTransition

DataContractCreateTransition and DataContractUpdateTransition skipped:
their V0 inners lack Default — needs explicit fixtures (TODO).

68 json_convertible_tests pass, 5 ignored (3 prior + 2 new
IdentityCreateTransition pending real fixture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds basic round-trip tests using Default fixture for:
- BlockInfo (struct with Default)
- Vote (manual Default impl)
- VotePoll (manual Default impl)
- ResourceVoteChoice (derived Default with #[default] variant)
- InstantAssetLockProof (manual Default impl)

Marks 6 types as TODO (no Default — needs explicit fixture):
- ContractBoundSpecification, ChainAssetLockProof,
- ExtendedBlockInfo, ExtendedEpochInfo, FinalizedEpochInfo,
- IdentityTokenInfo, TokenStatus.

78 json_convertible_tests pass, 5 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces TODOs with hand-built fixtures for:
- IdentityTokenInfo (frozen=true)
- TokenStatus (paused=true)
- ExtendedEpochInfo (6 fields, distinguishable values)
- FinalizedEpochInfo (12 fields incl. block_proposers map)
- ExtendedBlockInfo (8 fields incl. signature [u8;96])

Bug surfaced: ExtendedBlockInfo value_round_trip fails on signature
field round-trip via platform_value::Value ("Invalid symbol 17"). JSON
works. Marked #[ignore] and logged in plan §10b.

87 conversion tests pass, 6 ignored (3 prior + 1 new bug + 2 needs-fixture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AssetLockValue uses AssetLockValue::new() factory (V0 fields are
pub(super), can't be set directly).
ChainAssetLockProof uses OutPoint::from_str factory; value test
ignored due to known OutPoint round-trip bug.

90 conversion tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ourceVotePoll + ContestedDocumentVotePollWinnerInfo

102 conversion tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ansition

Both use fully-qualified trait syntax to disambiguate from legacy
StateTransitionValueConvert::to_object/to_json methods on the same
type — known E0034 ambiguity per plan §3.11.

106 conversion tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DocumentReplaceTransition, DocumentTransferTransition,
DocumentPurchaseTransition, DocumentUpdatePriceTransition — all use
fully-qualified trait syntax to disambiguate from legacy methods.

116 conversion tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nMint

122 conversion tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…troyFrozenFunds

128 tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y/Claim/DirectPurchase/SetPrice)

136 conversion tests pass, 7 ignored. All 17 of 19 batch sub-transitions
now tested (only TokenConfigUpdate remaining — needs TokenConfigurationChangeItem fixture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shumkov and others added 24 commits May 4, 2026 21:36
Replace the post-round-trip per-property `assert_v0_fields(&recovered)`
helper with **literal wire-shape assertions** using `serde_json::json!`
and `platform_value::platform_value!`. Every test now reads as a complete
spec for the type's serialized form — a reader sees exactly what JSON /
Value bytes the type produces.

The new pattern, applied uniformly:

```rust
let json = original.to_json().unwrap();
assert_eq!(json, json!({"$formatVersion": "0", "field": <literal>, ...}));
let recovered = T::from_json(json).unwrap();
assert_eq!(original, recovered);

let value = original.to_object().unwrap();
assert_eq!(value, platform_value!({"$formatVersion": "0", "field": <literal_with_suffix>, ...}));
let recovered = T::from_object(value).unwrap();
assert_eq!(original, recovered);
```

Two key benefits over the old structural-only `assert_eq!(original, recovered)`:

1. **Locks in the literal wire shape** — key names, encodings, types.
   Catches bugs in the serialize side that PartialEq+structural assert_eq
   would silently miss because they're symmetric (a serialize bug that
   always outputs zero would be matched by a deserialize bug that always
   reads zero).

2. **Locks in sized integer variants** in the Value path. Numeric literals
   in `platform_value!` carry their Rust type via explicit suffixes
   (`7u16`, `0u8`, `1_000_000u64`); a bare `7` would produce
   `Value::I32(7)` and the assertion would fail — that's the catch.
   Anywhere a sized integer field has its size erased on the JSON wire
   (because JSON has only one number type), the JSON-side assertion has
   a comment pointing the reader to the value-path assertion that locks
   the typed variant.

Tier handling:
- Tier 1+2 (most files): full inline `json!`/`platform_value!` literals.
- Tier 3 (`extended_document`, `token_configuration`): envelope-only —
  inline shape would be 200+ lines for the embedded `DataContract` /
  schemas. We assert wrapper-specific keys and trust the nested types'
  own per-type round-trip tests for inner content.
- Tier 4 (`shield_from_asset_lock_transition`, `identity_create/topup`):
  envelope-only on the non-deterministic `instant_asset_lock_proof_fixture`
  field (random bytes per call); deterministic siblings get full literal
  assertions.

Surprises documented inline at the assertion site:
- `OutPoint` — JSON uses `"<txid>:<vout>"` string (dashcore string-impl);
  Value uses `{txid: Bytes32, vout: U32}` typed map.
- `BTreeMap<PlatformAddress, ...>` — serializes as array of
  `{address, nonce, amount}` objects (custom serde helper), not as a
  JSON map.
- `Validator` — externally-tagged enum (no `#[serde(tag)]`), so wire
  shape is `{"V0": {...}}` (uppercase) with snake_case fields.
- `token_set_price_for_direct_purchase_transition` — stale
  `serde(rename = "issuedToIdentityId")` on the `price` field
  (copy-paste from mint transition); documented as the actual wire key.
- `Vec<u8>` renders as `Value::Array([U8(...), ...])`, not `Value::Bytes`
  — typed differently from `Bytes32` / `BinaryData` wrappers.

Bonus: the `address_funds/witness.rs` AddressWitness tests had a bare
`each_variant` round-trip pattern (missed by the initial 49-file grep
that targeted `fn json_round_trip()`); upgraded to per-variant tests
with full wire-shape assertions and base64-encoded `BinaryData`
literals matching the actual serialized form.

Test counts: 3625 passing, 8 ignored (same 6 unrelated
recursive_schema_validator + 2 StateTransition umbrella). 1036 platform-value
passing. Net `#[test]` change: +2 (from witness 2-→4 split). The slight
overall count drop is from a few `each_variant` patterns being
consolidated into single iterating tests with stronger assertions.

49 files changed:
- data_contract: config, group, change_control_rules, 7 associated_token
  types
- voting: contender, contested_document_resource_vote_poll, resource_vote,
  contested_document_vote_poll_winner_info
- identity transitions: 7 transitions
- shielded transitions: 5 transitions
- batch_transition sub-types: 17 (6 document, 11 token)
- core_types: validator
- document: Document, ExtendedDocument
- state_transition: top-level umbrella, proof_result, asset_lock_proof
- address_funds: witness

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second wave of the wire-shape assertion rollout — covers the 35 round-trip
tests the initial 49-file sweep missed (their files lived outside the
explicit batch lists). Same pattern as commit `8b198eb3ce`: replace the
post-round-trip `assert_v0_fields(&recovered)` per-property helper with
literal wire-shape assertions using `serde_json::json!` and
`platform_value::platform_value!`, and split each-variant tests into
per-variant tests.

Files (35) — block / data_contract / identity / tokens / voting /
state_transition / asset_lock / chain_asset_lock_proof / withdrawal /
group / document_patch / array / storage_requirements:

- 12 block / data_contract / asset_lock / identity-misc / group /
  document_patch (batch 4)
- 13 state_transition / token transitions / DataContract create&update
  (batch 5; envelope-only Tier 3 on the two DataContract transitions)
- 10 identity / chain_asset_lock_proof / tokens / voting / withdrawal /
  validator_set (batch 6; BLS keys interpolated via `to_value(&pk)` to
  avoid 96-char hex literals dominating the assertion)

Special handling locked in:

- `data_contract_create/update_transition` keep their `assert_v0_fields`
  helper because the JSON test still uses
  `normalize_integer_variants_for_json_round_trip` (the Critical-1
  serde_json single-Number-type limitation). Value-side moved to
  envelope-only — embedded `DataContractInSerializationFormat` would
  inline to hundreds of lines. Both kept as documented exceptions.
- `validator_set` interpolates BLS pubkeys via the seeded-RNG fixture
  values; structural shape (externally-tagged "V0", snake_case fields,
  ProTxHash-keyed BTreeMap) is asserted inline. `bls_pubkey_serde`
  unit tests independently cover BLS round-trip.
- `chain_asset_lock_proof` documents the OutPoint dual-shape inline:
  HR emits `"<txid>:<vout>"` string; non-HR emits `{txid: Bytes32,
  vout: U32}` struct. Includes a sanity check pinning the Bitcoin-
  reversed-byte-order Txid convention.

Surprising wire shapes documented inline:

- `KeyType::ECDSA_HASH160 = 2`, `Purpose::TRANSFER = 3` (`#[repr(u8)]`
  + `Serialize_repr`).
- 20-byte `BinaryData` → `Value::Bytes20` (typed sized-bytes variant),
  not generic `Value::Bytes`.
- `BTreeMap<Identifier, u64>` keys become base58 strings in JSON HR via
  `json_safe_identifier_u64_map`; non-HR keeps `Value::Identifier` keys.
- `ArrayItemType` is untagged: unit variants serialize as bare strings
  (`"Integer"`); tuple variants as `{"String": [3, 50]}`.
- `GasFeesPaidBy` and `Validator` keep PascalCase (no `rename_all`)
  inside otherwise camelCase contexts.
- `data_contract_update_transition` field name is `$identity-contract-nonce`
  (kebab-case + dollar prefix), not camelCase.
- `document_base_transition` is externally-tagged (no `serde(tag)`),
  so V0 wraps as `{"V0": {...}}`.
- `tokens/contract_info` is `serde(untagged)` — emits flat object with
  no `$formatVersion` discriminator.

Test counts: 3641 passing (+16 vs `8b198eb3ce`), 8 ignored (unchanged).
The +16 is from each-variant tests being split into per-variant tests
(more granular coverage). 1036 platform-value tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nContractInfo

Per the wasm-dpp2 CONVENTIONS doc (`packages/wasm-dpp2/CONVENTIONS.md`,
"Versioning" section): every versioned protocol enum should use
`#[serde(tag = "$formatVersion")]` with each variant renamed to its
version string. Two top-level versioned enums weren't following this:

- `AssetLockValue` had no `serde(tag)` at all → wire shape was
  `{"V0": {...}}` (default externally-tagged form).
- `TokenContractInfo` was deliberately `serde(untagged)` → wire shape
  was a flat object with no version tag, looking superficially correct
  but carrying no version information at all.

Both are top-level types not embedded behind `serde(flatten)` anywhere,
so adding the tag is a clean fix. Wire shape changes from:

    {"V0": {...inner fields...}}                  (old AssetLockValue)
    {...inner fields...}                          (old TokenContractInfo)

to the canonical:

    {"$formatVersion": "0", ...inner fields...}

Tests updated to assert the new wire shape.

Out of scope here:
- Validator / ValidatorSet are also externally-tagged but contain
  dashcore hash newtypes (ProTxHash, PubkeyHash, QuorumHash) whose
  serde uses two disjoint visitors — adding `tag = "$formatVersion"`
  routes deserialization through serde's `ContentDeserializer` which
  always reports `is_human_readable: true`, breaking the bytes path
  (same root cause as the OutPoint/Txid issue fixed in commit
  09c0a2b with the local `outpoint_serde` wrapper). Fixing this
  properly needs the same dual-shape-visitor wrapper applied to all
  the dashcore hash newtypes used in ValidatorSet — a larger refactor
  out of scope for a convention sweep. Left for future work.
- The 17 inner Document/Token sub-transitions (DocumentBaseTransition,
  TokenBaseTransition, etc.) are flattened into parent transitions
  via `serde(flatten)`. Adding a tag would clash with the parent's
  own `$formatVersion` (the same parent/child collision pattern fixed
  in ExtendedDocument, commit 95554c8). Per user direction, deferred.

dpp lib: 3641 passing (unchanged), 8 ignored (unchanged). Wire shape now
matches the documented convention for the two affected types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the wasm-dpp2 CONVENTIONS.md "Versioning" section: every versioned
protocol enum should use `#[serde(tag = "$formatVersion")]`. `Validator`
and `ValidatorSet` were the last two top-level versioned enums still
defaulting to externally-tagged `{"V0": {...}}` form.

Wire shape changes from:

    {"V0": {"pro_tx_hash": "...", ...}}

to the canonical:

    {"$formatVersion": "0", "pro_tx_hash": "...", ...}

JSON-side tests pass — dashcore hash newtypes (`ProTxHash`, `PubkeyHash`,
`QuorumHash`) deserialize cleanly from hex strings on the HR path.

Value-side tests are `#[ignore]`'d pending dashcore PR #708
(dashpay/rust-dashcore#708) — the dashcore hash
newtypes need dual-shape visitors so they round-trip through serde's
`ContentDeserializer`, which always reports `is_human_readable: true`
even when wrapping bytes from a non-HR source like `platform_value::Value`.
This is the same root cause as the OutPoint/Txid bug fixed locally in
commit 09c0a2b; ProTxHash/PubkeyHash trip the same wire on
`tag = "$formatVersion"` deserialization through ContentDeserializer.

Once that PR lands and we bump the dashcore dependency, drop the
`#[ignore]`s on the two value tests.

Note: `ValidatorSetV0::members` is `BTreeMap<ProTxHash, ValidatorV0>`
(not `BTreeMap<ProTxHash, Validator>`), so members are the bare V0
struct on the wire without their own `$formatVersion` tag — the test
documents this inline.

dpp lib: 3639 passing, 10 ignored (+2 from the new value-path
`#[ignore]`s, otherwise unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…or ignores

PR #708 only fixed the `serde_struct_human_string_impl!` macro (used by
`OutPoint`), not the `hashes::serde_macros::SerdeHash` macro family used
by `ProTxHash`/`PubkeyHash`/`QuorumHash`. They have the same kind of bug
(string-only HR visitor → fails through `ContentDeserializer`) but live
in a different macro and need their own fix.

Update the `#[ignore]` notes on the two value-side tests to:
- Remove the misleading PR #708 link.
- Spell out the actual error (`HexVisitor::visit_str` sees the 32-byte
  buffer interpreted as 32 chars instead of the expected 64-char hex
  form, hence "bad hex string length 32 (expected 64)").
- Frame as a "follow-up dashcore PR" pending in the same family.

No code changes — just clarifies what we're actually waiting on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e PR

dashcore PR #729 (dashpay/rust-dashcore#729) is
the companion to #708 — same `ContentDeserializer` HR-quirk root cause,
but for the separate `hashes::serde_macros::SerdeHash` macro family
that generates `Txid` / `BlockHash` / `ProTxHash` / `PubkeyHash` /
`QuorumHash` etc. (vs. #708 which fixed `OutPoint` via
`serde_struct_human_string_impl!`).

Update the two `#[ignore]` notes on `Validator::value_round_trip` and
`ValidatorSet::value_round_trip` to reference #729 instead of the vague
"follow-up PR" phrasing. When #729 lands and we bump dashcore, drop the
`#[ignore]`s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tagged)

The umbrella `StateTransition` enum was `#[serde(untagged)]`, which made
deserialize ambiguous — serde tries each of the 20 variants in order until
one matches structurally. The two umbrella round-trip tests had been
`#[ignore]`'d for that reason since pass 2.

Apply `#[serde(tag = "type", rename_all = "camelCase")]` matching the
existing codebase convention for **semantically-different-variant**
enums:

- `AssetLockProof` (Instant/Chain) — `tag = "type", rename_all = "camelCase"`
- `ContractBoundSpecification` — same
- `ActionEvent` — `tag = "type", content = "data", rename_all = "camelCase"`

This is distinct from `tag = "$formatVersion"` which is used for **versioning**
(V0/V1 of the same logical type) — `StateTransition` discriminates between
20 different transition kinds, not versions of one transition.

Wire shape changes from:

    {<inner-variant fields directly>}        (old, ambiguous)

to:

    {"type": "dataContractCreate", ...}      (canonical, self-describing)

The `type` key doesn't collide with any inner enum's `$formatVersion`
(different namespace) or with inner serde fields named `type` (umbrella
tag is resolved before serde descends into the variant body).

Verified non-breaking for all observed downstream consumers:
- `PlatformSerialize` binary path (used by gRPC, GroveDB, proof-verifier,
  rs-sdk) is unchanged — only JSON/Value paths see the new shape.
- `wasm-dpp2`'s `StateTransitionWasm` only exposes
  `toBytes/fromBytes/toHex/fromHex/toBase64/fromBase64` — no `toJSON()`
  or `toObject()` over the umbrella, so JS callers go through inner-variant
  wrappers (already using `tag = "$formatVersion"`).
- `cargo check -p drive -p drive-abci -p dash-sdk -p wasm-dpp2` all pass.
- Zero `StateTransition::to_json` / `to_object` / `from_json` / `from_object`
  call sites across rs-drive / rs-drive-abci / rs-sdk / dapi-grpc.

Tests updated: the two previously-`#[ignore]`'d umbrella round-trip tests
in `state_transition::mod::json_convertible_tests` are now active and
asserting the new wire shape.

dpp lib: 3641 passing (+2 from unignored umbrella tests), 8 ignored
(was 10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…atch

The umbrella `StateTransition` enum has 20 variants but the previous
test exercised only one (IdentityCreateFromAddresses). With the
`tag = "type", rename_all = "camelCase"` change in commit 4fcb3d4
each variant's tag dispatch is its own potential failure mode (e.g. an
inner field named `type` clashing with the umbrella tag, or a serde
rename resolving unexpectedly). Cover all 20.

Approach: expose each inner transition's existing
`json_convertible_tests::fixture()` as `pub(crate)` so the umbrella
can reuse it instead of duplicating fixture construction. Each fixture
already has the per-type wire-shape coverage; the umbrella test layer
just asserts the tag dispatch boundary:

1. JSON wire emits `{"type": "<expected camelCase tag>", ...}`.
2. Round-trip preserves variant (via `std::mem::discriminant`).
3. Round-trip preserves structural equality (via PartialEq).
4. Same three assertions on the Value path.

Helper: `assert_umbrella_round_trip(stx, expected_tag)` for the 18
variants where bit-exact equality holds, and
`assert_umbrella_round_trip_lossy_json_int_variants` for
`DataContractCreate`/`DataContractUpdate` which embed a `DataContract`
whose `document_schemas` carry sized-int variants (`U32`/`I32`) that
JSON's single Number type cannot preserve. The lossy helper applies
the existing `normalize_integer_variants_for_json_round_trip` to both
sides before comparing — same Critical-1 mitigation used for the
per-type tests in commit 7397c73. The Value path keeps its strict
bit-exact assertion (platform_value preserves sized ints).

Test count delta: +18 (20 new umbrella tests replace the previous 2).

Files changed:
- `state_transition/mod.rs` — replace 2-test umbrella with 20-test
  per-variant module + helpers.
- 20 inner-transition `mod.rs` files — `mod json_convertible_tests`
  and `fn fixture()` made `pub(crate)`.

dpp lib: 3659 passing (+18), 8 ignored (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply tag = "type" to DocumentTransition / TokenTransition / BatchedTransition
umbrella enums (was externally-tagged); migrate 17 leaf transition wrappers
from externally-tagged {"V0": {...}} to tag = "$formatVersion"; switch
TokenBaseTransition + DocumentBaseTransition to tag = "$baseFormatVersion"
(kept serde(flatten)'d into leaves) so the full transition wire shape becomes
flat with both discriminators at the top level.

DocumentCreateTransitionV0 / DocumentReplaceTransitionV0 carry manual
Deserialize impls because their serde(flatten) data: BTreeMap<String, Value>
catchall would otherwise steal the base's discriminator + struct fields.
Each impl reads the wire into a BTreeMap, peels off a BASE_FIELD_NAMES const
set into the base, then routes remaining keys to data. Auto-derive Serialize
retained on both. In-source warning notes that adding a base field requires
updating BASE_FIELD_NAMES in both impls.

Add round-trip tests for 10 leaf JsonConvertible/ValueConvertible types
that lacked them: PlatformAddress, GroupActionStatus, GroupActionEvent,
TokenEvent, TokenPricingSchedule, SerializedAction, TokenTransferTransition,
DistributionFunction, RewardDistributionType, StoredAssetLockInfo. Add
per-variant umbrella round-trip tests for DocumentTransition (6),
TokenTransition (11), BatchedTransition (2) reusing pub(crate) leaf fixtures.

Refresh docs/json-value-unification-plan.md with the test count, tag-key
conventions table, and the catchall-collision rationale for the manual
Deserialize impls.

3621 -> 3716 dpp lib tests passing (+95), 8 ignored (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Apply `#[json_safe_fields]` to `DocumentBaseTransitionV0`,
  `DocumentBaseTransitionV1`, `TokenPaymentInfoV0`, and
  `DocumentCreateTransitionV0`.
  - `entropy: [u8; 32]` now serializes as base64 string in JSON HR (was
    array of numbers) — matches shielded-transition byte-field convention.
  - `identity_contract_nonce: u64` (= IdentityNonce), token amounts, and
    token payment info u64s now go through `json_safe_u64` /
    `json_safe_option_u64`: native u64 in non-HR; large values stringify
    in JSON HR to avoid JS Number precision loss.
- Add `JsonSafeFields` impls for `DocumentBaseTransition`,
  `TokenPaymentInfo`, `GasFeesPaidBy`.
- Add `json_safe_option_string_u64_tuple` helper module to protect the u64
  inside `Option<(String, Credits)>` (the tuple shape can't be auto-routed
  by the macro). Apply it via `serde(with = ...)` on
  `DocumentCreateTransitionV0::prefunded_voting_balance`.
- Update `DocumentCreateTransitionV0`'s manual `Deserialize` impl to handle
  the three entropy wire shapes after `serde_bytes` injection: `Value::Text`
  (base64-decoded), `Value::Bytes32` (direct), `Value::Bytes` (length-checked
  conversion).
- Update the `document_create_transition` test wire shape to expect the new
  base64 entropy / u64-stringification / `Value::Bytes32` shape.

Note: this commit also includes whitespace-only changes from running
`cargo fmt --all` across the workspace (a stray side-effect of the format
pass). The behavioral changes are confined to the files listed above.

3716 -> 3716 dpp lib tests passing, 8 ignored (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comprehensive audit found 11 V0 structs with u64-alias fields lacking
JS-safe wire-shape protection. Apply `#[json_safe_fields]` to:
- TokenBaseTransitionV0 (identity_contract_nonce: IdentityNonce)
- TokenBurnTransitionV0 (burn_amount: u64)
- TokenMintTransitionV0 (amount: u64)
- TokenDirectPurchaseTransitionV0 (token_count, total_agreed_price)
- DocumentReplaceTransitionV0 (revision)
- DocumentUpdatePriceTransitionV0 (revision, price)
- DocumentTransferTransitionV0 (revision)
- DocumentPurchaseTransitionV0 (revision, price)
- DocumentPatch (revision, updated_at)

Add JsonSafeFields impls for the wrapper enums:
- TokenBaseTransition (wraps json_safe_fields-annotated V0)
- GroupStateTransitionInfo (only has u16/Identifier/bool fields)

TokenTransferTransitionV0 cannot use the macro because its
`Option<SharedEncryptedNote>` and `Option<PrivateEncryptedNote>` fields are
tuples containing `Vec<u8>` that can't be auto-routed by the macro and
would also need a custom serde helper to base64-encode the byte payload.
Apply `serde(with = "json_safe_u64")` directly to the `amount` field
instead — surgical fix; the encrypted-note shape stays as before.

Update the manual Deserialize on DocumentReplaceTransitionV0 to accept
both numeric and string forms for `$revision` (matching the new
json_safe_u64 stringification of large values) — the existing test
fixtures use small values that stay numeric, but production-scale
revisions could exceed MAX_SAFE_INTEGER.

Skipped: Epoch::key (`[u8; 2]` — small enough that JSON-array shape is
fine; not a real wire-shape concern).

Out of scope: TokenTransferTransitionV0's encrypted-note tuples need a
dedicated serde helper for `(u32, u32, Vec<u8>)` to produce base64 in JSON
HR. Tracked as future work in source comments.

3716 -> 3716 dpp lib tests passing, 8 ignored (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pted-note tuple alias

Register `SharedEncryptedNote` and `PrivateEncryptedNote` (both
`(u32, u32, Vec<u8>)` shape) as known type aliases in the
`json_safe_fields` proc macro. New `Option<encrypted-note-alias>` field
type is auto-routed to a new `json_safe_option_encrypted_note` serde
helper that:
- HR (JSON): emits `[u32, u32, "<base64>"]` — base64-encoded `Vec<u8>`
- non-HR (platform_value, bincode): emits `[u32, u32, <raw bytes>]`
- Deserialize accepts both via `deserialize_any`

With the alias registered, `TokenTransferTransitionV0` can now use the
plain `#[json_safe_fields]` attribute — the macro injects `json_safe_u64`
on `amount: u64`, `json_safe_option_encrypted_note` on
`shared_encrypted_note` and `private_encrypted_note`, and skips the
flatten on `base`. Replaces the per-field `serde(with = "json_safe_u64")`
workaround from the previous commit.

Pattern: when a new tuple-shaped alias appears that can't be auto-routed
by serde, add the alias name to `ENCRYPTED_NOTE_ALIASES` (or extend the
match in `serde_with_suffix_for_type`) and provide a matching helper
module — same convention as `U64_ALIASES`.

3716 -> 3716 dpp lib tests passing, 8 ignored (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the wasm-dpp2 convention, every serde-injected discriminator key carries
a \$ prefix so it never collides with user-data field names. Migrate the
transition umbrellas:

- BatchedTransition: tag = "type", content = "data" (adjacent) -> tag = "\$transition"
  (internal). Drops the "data" wrapper. Wire shape is now flat:
  {"\$transition": "document", "\$action": "create", "\$formatVersion": "0", ...}.
- DocumentTransition: tag = "type" -> tag = "\$action". Cannot use \$type
  because the flattened DocumentBaseTransition exposes document_type_name as
  \$type (long-standing DPP convention). Variant names (create/replace/
  delete/transfer/updatePrice/purchase) read naturally as actions; matches
  the existing PROPERTY_ACTION = "\$action" constant.
- TokenTransition: tag = "type" -> tag = "\$action". Symmetric with
  DocumentTransition so consumers discriminate the inner shape with the
  same key regardless of \$transition value.
- StateTransition: tag = "type" -> tag = "\$type". Outermost umbrella with
  no flatten path that could collide; \$type fits naturally for transition
  *kinds* (Batch, IdentityCreate, DataContractCreate, ...).

Net wire-shape result: every system field uses a \$ prefix —
\$transition / \$action / \$type / \$formatVersion / \$baseFormatVersion /
\$id / \$dataContractId / etc. User-data fields (camelCase) never collide
with system fields.

Update umbrella round-trip test helpers to assert on the new keys.

3716 -> 3716 dpp lib tests passing, 8 ignored (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply the wire-shape convention rule from this session:
- Discriminator key uses `$` prefix only when the same wire-shape level
  has other `$`-prefixed fields. Plain `type` otherwise.
- Sum types should be internally tagged (no `data` wrapper) where the
  variant shape allows it.

Vote (wraps ResourceVote which uses `tag = "$formatVersion"`):
  Inner has `$formatVersion` at the same flattened level → use `$type`
  internal tagging. Wire shape:
    {"$type": "resourceVote", "$formatVersion": "0", "votePoll": {...}, "resourceVoteChoice": {...}}

VotePoll (wraps ContestedDocumentResourceVotePoll, plain camelCase fields):
  No `$`-prefixed fields at the flattened level → use plain `type`
  internal tagging. Wire shape:
    {"type": "contestedDocumentResourceVotePoll", "contractId": ..., "documentTypeName": ..., ...}

GroupActionEvent (wraps TokenEvent which uses `tag = "type", content = "data"`):
  Inner exposes `type`/`data` only (no `$`-prefixed fields). The rule
  prescribes plain `type` internal tagging — but `type` collides with
  TokenEvent's `type`, and TokenEvent is consensus-binary-locked
  (cannot rename). Adjacent tagging is the only rule-consistent shape
  here; reverted to original `tag = "type", content = "data"`.

Update the four affected test wire shapes (resource_vote, masternode_vote
inside json_round_trip + value_round_trip).

Decisions remaining:
- ResourceVoteChoice + ContestedDocumentVotePollWinnerInfo — both have
  tuple variants of `Identifier` (which serializes as base58 string, not
  a struct), so internal tagging fails natively. Will need either custom
  Serialize/Deserialize or struct-variant refactor — separate decisions.

3716 -> 3716 dpp lib tests passing, 8 ignored (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously kept adjacent (`tag = "type", content = "data"`) because plain
`type` collides with the locked `TokenEvent` discriminator and `\$type`
would violate the rule (no other `\$`-fields at the wire level).

Use `kind` instead: distinct from the inner `TokenEvent`'s `type`,
plain prefix per the rule, and reads naturally ("the kind is
tokenEvent"). Drops the `data` wrapper. Wire shape:
  {"kind": "tokenEvent", "type": "mint", "data": [...]}

3716 -> 3716 dpp lib tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nnerInfo via custom serde impls

Both enums have a tuple variant wrapping `Identifier` (`TowardsIdentity` /
`WonByIdentity`). `Identifier` serializes as a base58 string (not a map),
so serde's auto-derive can't internal-tag — the convention prescribes
custom Serialize / Deserialize emitting a flat shape (precedents:
`AddressFundsFeeStrategyStep`, `AddressWitness`).

Wire-shape change:
- `ResourceVoteChoice::TowardsIdentity(id)`:
  before  {"type": "towardsIdentity", "data": "<base58>"}
  after   {"type": "towardsIdentity", "identity": "<base58>"}
- `ContestedDocumentVotePollWinnerInfo::WonByIdentity(id)`:
  before  {"type": "wonByIdentity", "data": "<base58>"}
  after   {"type": "wonByIdentity", "identity": "<base58>"}
- Unit variants (`Abstain` / `Lock` / `NoWinner` / `Locked`) keep their
  `{"type": "..."}` shape.

The synthesized field name is `identity` (matches the struct-variant form
that serde would have emitted if these had been refactored to
`TowardsIdentity { identity: Identifier }`). Bincode `Encode`/`Decode`
derives are untouched — consensus binary path unchanged.

Update test wire-shape assertions in `resource_vote`,
`masternode_vote_transition`, and `contested_document_vote_poll_winner_info`.

3716 -> 3716 dpp lib tests passing, 8 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Custom Serialize / Deserialize that emits internally-tagged flat shape
with named fields per variant. The earlier "skip TokenEvent because of
consensus" was incorrect — bincode (Encode/Decode) and serde
(Serialize/Deserialize) are independent paths in this codebase
(per CONVENTIONS.md). Reshaping serde for ergonomics is safe as long
as the bincode derives stay in place, which they do here.

Wire-shape change (JSON / platform_value only):
- Mint:                 {type, data: [amount, recipient, note]}
                     -> {type, amount, recipient, publicNote}
- Burn:                 {type, data: [amount, from, note]}
                     -> {type, amount, burnFromIdentifier, publicNote}
- Freeze / Unfreeze:    {type, data: [frozen, note]}
                     -> {type, frozenIdentifier, publicNote}
- DestroyFrozenFunds:   {type, data: [frozen, amount, note]}
                     -> {type, frozenIdentifier, amount, publicNote}
- Transfer:             {type, data: [recipient, note, shared, private, amount]}
                     -> {type, recipient, publicNote, sharedEncryptedNote,
                         privateEncryptedNote, amount}
- Claim:                {type, data: [distributionType, amount, note]}
                     -> {type, distributionType, amount, publicNote}
- EmergencyAction:      {type, data: [action, note]}
                     -> {type, action, publicNote}
- ConfigUpdate:         {type, data: [change, note]}
                     -> {type, configurationChange, publicNote}
- ChangePriceForDirectPurchase: {type, data: [schedule, note]}
                     -> {type, pricingSchedule, publicNote}
- DirectPurchase:       {type, data: [amount, credits]}
                     -> {type, amount, credits}

u64 fields (TokenAmount / Credits) route through json_safe_u64
(stringify above MAX_SAFE_INTEGER in JSON HR).
sharedEncryptedNote / privateEncryptedNote (Option<(u32,u32,Vec<u8>)>)
route through json_safe_option_encrypted_note (base64 the inner Vec<u8>
in JSON HR).

Bincode Encode/Decode unchanged — consensus binary path unaffected.

Side effect: GroupActionEvent's wire shape becomes
  {kind: "tokenEvent", type: "mint", amount: ..., recipient: ..., ...}
(the inner TokenEvent's flat shape merges with the outer `kind` tag).

Update test wire-shape assertions in token_event and action_event.

3716 -> 3716 dpp lib tests passing, 8 ignored (unchanged).

Maintenance trap (called out in source): adding a new TokenEvent
variant requires updating both Serialize and Deserialize blocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… test

The comment described the pre-flatten state of VotePoll / ResourceVoteChoice
(adjacent tagging with "data" wrapper). Both have moved to flat shapes
this session — VotePoll uses internal tagging on "type", ResourceVoteChoice
uses a custom Serialize/Deserialize emitting a synthesized "identity" field.

3716 -> 3716 dpp lib tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Status pinned to commit 91b16e4 (2026-05-06).

Updates:
- Add passes 2.7 (tag-shape sweep) and 2.8 (json_safe_fields rollout)
  with status entries.
- Add per-commit table rows for each step of the convention sweep:
  fe92868, d14ce1a, 017c308, cd56289, 38d1388,
  f11fdb5, c36f93b, 2674d95, 71d2e75, 91b16e4.
- Rewrite the Tag-key conventions section to reflect the final state:
  - Codify the core rule: internal tagging only (no `data` wrapper);
    `$`-prefix discriminator only when wire-shape level has other
    `$`-prefixed fields.
  - Drop the `tag = "type", content = "data"` row entirely — zero
    remaining usages in rs-dpp.
  - Add `$type` / `$action` / `$transition` / `kind` rows with examples.
  - Document the custom serde impl precedents (TokenEvent,
    ResourceVoteChoice, ContestedDocumentVotePollWinnerInfo) for
    tuple-variant enums where auto-derive can't internal-tag.
  - Document the maintenance trap on `DocumentCreate/Replace`'s manual
    `BASE_FIELD_NAMES` lists.

Tests: 3716 passing, 8 ignored (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 wasm wrappers that wrap a rs-dpp domain type with JsonConvertible /
ValueConvertible (now uniformly available in rs-dpp via PR #3573) now
delegate to the inner traits via impl_wasm_conversions_inner! instead of
the generic-serde fallback impl_wasm_conversions_serde!. Same JSON / Value
wire output; cleaner dependency on rs-dpp's canonical conversion path.

Migrated:
- Shielded transitions (5): ShieldTransition, UnshieldTransition,
  ShieldedTransferTransition, ShieldedWithdrawalTransition,
  ShieldFromAssetLockTransition.
- shielded::orchard_action: SerializedOrchardActionWasm wrapping rs-dpp's
  SerializedAction (note the name divergence — wasm wrapper is
  SerializedOrchardAction in JS but the rs-dpp type is SerializedAction).
- asset_lock_proof::proof::AssetLockProofWasm.
- tokens::contract_info::TokenContractInfoWasm.
- state_transitions::batch::token_payment_info::TokenPaymentInfoWasm.
- 6 platform_address transitions (IdentityCreateFromAddresses,
  IdentityCreditTransferToAddresses, IdentityTopUpFromAddresses,
  AddressFundingFromAssetLock, AddressCreditWithdrawal,
  AddressFundsTransfer).

20 _serde! call sites remain (the proof_result wasm-only DTOs in
state_transitions/proof_result/{voting,shielded,identity,token}.rs).
These wrap variants of rs-dpp's StateTransitionProofResult enum but hold
extracted fields directly — they're wasm-DTOs, not rs-dpp wrappers.
Generic serde is the right path for those; documented in the plan.

Tangential: drop unused `self` import surfaced by the rs-dpp lib check
in tokens/token_event.rs.

Plan: docs/wasm-dpp2-cleanup-plan.md (added in this commit) — covers
migration phases, JS-test wire-shape audit (deferred per direction
"didn't update tests"), and improvements to port back to rs-dpp.

3716 -> 3716 dpp lib tests passing; wasm-dpp2 cargo check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dary

Regression introduced by ec43a2a (fix(platform-value): typed map keys —
drop string-only MapKeySerializer): rs-dpp's platform_value now preserves
typed keys for `BTreeMap<u32, _>` / `BTreeMap<Identifier, _>` etc., emitting
`Value::U32` / `Value::Identifier` keys. Great for round-trips with binary
formats; bad for the wasm boundary where `serde_wasm_bindgen` requires JS
plain-object keys to be strings.

Surfaced by `PartialIdentity` tests in wasm-dpp2:
  `platform_value_to_object: Map key is not a string and cannot be an object key`
on `loaded_public_keys: BTreeMap<KeyID, IdentityPublicKey>`.

Fix on the wasm-dpp2 side (rs-dpp wire shape unchanged): walk the
platform_value tree before serializing and stringify any non-Text map
keys. Numeric variants use Display, Identifier becomes base58, bytes
become base64. Same normalization applied to `convert_value_for_json`
so the JSON path doesn't have the same problem.

This is wasm-dpp2-only and doesn't affect rs-dpp / consensus / persistence.

Tests: 1075 → 1077 passing, 45 → 43 failing in wasm-dpp2 unit suite.
The 43 remaining failures are all test fixtures hardcoding pre-PR
wire shapes (data wrappers, plain `type` vs `$type`/`$action`/
`$transition`/`kind`, byte arrays vs base64) — to be updated in Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates JS test fixtures to match the new rs-dpp wire shapes from PR #3573
(json-value-conversion unification). No production code changes.

Groups updated (25 tests, all now passing):

Group 1 — ContestedDocumentVotePollWinnerInfo (4 tests):
  WonByIdentity wire shape: `data` -> `identity`
  Custom Serialize emits flat `{type, identity}` instead of adjacent
  `{type, data: <id>}`. Identifier remains base58 in JSON, Uint8Array in
  toObject().

Group 2 — GroupAction (6 tests):
  Inherits wire-shape change from inner GroupActionEvent + TokenEvent.
  GroupAction itself unchanged: `tag = "$formatVersion"`, V0 -> "0",
  V0 fields snake_case at top level (contract_id, proposer_id,
  token_contract_position, event).

Group 3 — GroupActionEvent (8 tests):
  Internally tagged `kind:` (was adjacent `type/data`). Inner TokenEvent
  flatten at the same level. Wire shape:
    {kind: "tokenEvent", type: "mint", amount, recipient, publicNote}
  `kind` chosen over `type` to avoid collision with inner TokenEvent's
  own `type` discriminator.

Group 4 — TokenEvent (7 tests):
  Custom Serialize maps positional tuple fields to named JSON keys:
    Mint:    {type, amount, recipient, publicNote}
    Burn:    {type, amount, burnFromIdentifier, publicNote}
    Freeze:  {type, frozenIdentifier, publicNote}
  Internally tagged `type:`, no `data` wrapper. Was adjacent with
  positional tuple in `data: [...]`.

Test results: 1077 -> 1102 passing (+25), 43 -> 18 failing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final batch of JS test fixture updates for the rs-dpp PR #3573 json-value
unification wire-shape changes. No production code changes.

Group 5 — StateTransitionProofResult (VerifiedMasternodeVote /
         VerifiedNextDistribution, 2 tests):
  Inner Vote shape updated. The `vote` field now carries a flat Vote with
  `$type`/`$formatVersion` at the top level (was adjacent `type`/`data`
  with nested `data: {...}`).

Group 6 — ResourceVote (2 tests):
  VotePoll inside is internally tagged — fields flat at votePoll level,
  no `data` wrapper. Tests now read `json.votePoll.contractId` instead
  of `json.votePoll.data.contractId`.

Group 7 — ResourceVoteChoice (4 tests):
  Custom serde renames `data` -> `identity` for TowardsIdentity. Flat
  `{type, identity}` shape. `toObject()` puts a Uint8Array at `identity`.

Group 8 — TokenContractInfo (5 tests):
  Versioned enum gained `tag = "$formatVersion"`. Fixtures now include
  `$formatVersion: "0"` at the top level alongside `contractId` /
  `tokenContractPosition`.

Group 9 — Vote (2 tests):
  Internal tag `$type` ($-prefix because the level also carries the inner
  ResourceVote's `$formatVersion`). The single ResourceVote variant
  flattens its V0 body — no `data` wrapper.

Group 10 — VotePoll (3 tests):
  Internal tag `type` (plain — no $-prefixed neighbors). Inner
  ContestedDocumentResourceVotePoll fields flatten at the same level —
  no `data` wrapper.

Test results: 1102 -> 1120 passing (+18), 18 -> 0 failing.

PR #3573 wire-shape migration is now complete on the JS test side. The
remaining work is the wasm-dpp2 TypeScript type definitions (e.g.
VoteJSON in src/voting/vote.rs) which still document the old shape;
those are docstring-only and will be addressed in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates the typescript_custom_section blocks that document VoteObject /
VoteJSON / VotePollObject / VotePollJSON / ResourceVoteChoiceObject /
ResourceVoteChoiceJSON / ContestedDocumentVotePollWinnerInfoObject /
ContestedDocumentVotePollWinnerInfoJSON / GroupActionEventObject /
GroupActionEventJSON / TokenEventObject / TokenEventJSON /
TokenContractInfoObject / TokenContractInfoJSON / GroupActionObject /
GroupActionJSON.

These types are surfaced through the generated dist/dpp.d.ts and were
still describing the pre-PR-#3573 wire shapes (`type/data` adjacent
tagging with `data: {...}` wrappers, plus a few drift items like the
GroupAction type using camelCase fields when the Rust struct emits
snake_case).

Updates per type:
- Vote:               internal tag `$type` + flat ResourceVote body
- VotePoll:           internal tag `type` + flat poll body, no `data`
- ResourceVoteChoice: flat `{type, identity?}` (was `{type, data}`)
- WinnerInfo:         flat `{type, identity?}` (was `{type, data}`)
- GroupActionEvent:   internal tag `kind` intersected with TokenEvent
- TokenEvent:         flexible interface — variant-specific fields
                      documented in the JSDoc comment (Mint, Burn,
                      Freeze, Transfer, Claim, …)
- TokenContractInfo:  added `$formatVersion: "0"` discriminator
- GroupAction:        snake_case fields (matches Rust no-rename),
                      `$formatVersion: "0"` discriminator typed

Build verified, 1120 JS unit tests still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shumkov shumkov changed the title feat(rs-dpp): unify JSON/Value conversion traits + comprehensive round-trip tests feat(dpp): unify JSON/Value conversion traits + comprehensive round-trip tests May 7, 2026
shumkov and others added 5 commits May 7, 2026 12:44
Audit conclusion: the "delete \`impl_wasm_conversions_serde!\` macro
entirely" goal is infeasible without inventing 17 new rs-dpp types
that exist solely to mirror StateTransitionProofResult tuple variants.

The 17 remaining \`_serde!\` callers in
\`packages/wasm-dpp2/src/state_transitions/proof_result/\` are all
wasm-only DTOs decomposing tuple variants (e.g., VerifiedBalanceTransfer
(PartialIdentity, PartialIdentity)) into named-field JS classes
({ sender, recipient }). They have no rs-dpp counterpart with
JsonConvertible/ValueConvertible impls, and the named-field form is
JS-ergonomics, not a domain concept worth promoting to rs-dpp.

Updates:
- Macro doc: reframe \`_serde!\` from "fallback awaiting migration" to
  "canonical path for wasm-only DTOs". \`_inner!\` remains the preferred
  path when an rs-dpp domain type with the canonical traits exists.
- Plan doc: mark Phase E with corrected scope, document the audit of
  manual Serialize/Deserialize impls (IdentifierWasm, PlatformAddressWasm
  — JS interop adapters, not backport candidates) and manual to_*/from_*
  methods (carry context the trait signatures don't accept).
- Note one small follow-up backport candidate: rs-dpp's serde_bytes
  module currently handles only [u8; N], but wasm-dpp2's bytes_b64 is a
  Vec<u8> variant with a single user — could be unified or deleted in a
  separate PR.

No production code changes. cargo check passes; 1120 wasm-dpp2 unit
tests still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wasm-dpp2 \`serialization::bytes_b64\` module duplicated functionality
already provided in dpp:

- \`Vec<u8>\` codec — already in \`dpp::serialization::serde_bytes_var\` with
  the more robust dual-shape visitor that handles serde's
  ContentDeserializer for internally-tagged enums
- \`[u8; N]\` codec — already in \`dpp::serialization::serde_bytes\`

The only thing missing in dpp was the \`Option<[u8; N]>\` flavor, used by
wasm-dpp2 for the optional \`\$entropy\` field on a Document.

Changes:
- Add \`dpp::serialization::serde_bytes::option\` submodule for
  \`Option<[u8; N]>\`. Uses the parent module's codec for the inner bytes
  via a Serialize-wrapper struct so the outer serializer writes the
  Option variant tag (avoids bincode "UnexpectedVariant" mismatches when
  the inner shape is bytes vs. Option<bytes>). Tests cover JSON null /
  Some round-trips and bincode binary round-trip.
- Switch the single \`Option<[u8; 32]>\` user (wasm-dpp2 DocumentWasm
  \$entropy field) from \`serialization::bytes_b64::option\` to
  \`dpp::serialization::serde_bytes::option\`.
- Switch the 5 \`Vec<u8>\` users in wasm-sdk (queries/mod.rs:
  ResponseMetadata.chain_id, ProofInfo.{grovedb_proof, quorum_hash,
  signature, block_id_hash}) from \`wasm_dpp2::serialization::bytes_b64\`
  to \`dash_sdk::dpp::serialization::serde_bytes_var\` (aliased as
  \`bytes_b64\` to avoid touching every \`#[serde(with = ...)]\` attr).
- Delete \`packages/wasm-dpp2/src/serialization/bytes_b64.rs\` and remove
  its module declaration.

Wire shape unchanged across all callers — both helpers emit the same
base64-string-in-HR / raw-bytes-in-binary shape.

Test results:
- rs-dpp \`serialization::serde_bytes\` tests: 11 passing (was 8 — added
  3 for the option submodule).
- wasm-dpp2 unit tests: 1120 passing, 0 failing (unchanged).
- cargo check -p wasm-dpp2 -p wasm-sdk: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Found four manual implementations in wasm-dpp2 that wrap rs-dpp domain
types and duplicate logic that lives in dpp:

1. BatchTransitionWasm: manual to_object/to_json/from_object/from_json
   using generic serde via wasm-dpp2::serialization::{to,from}_{object,
   json}. dpp::BatchTransition has JsonConvertible/ValueConvertible
   from Phase C — switch to impl_wasm_conversions_inner! to delegate.

2. GroupWasm: same pattern. Bonus cleanup: the manual to_object had a
   workaround comment noting it intentionally called toJSON to handle
   BTreeMap<Identifier, u32>. That workaround is now obsolete after
   commit 4e9d1ee added stringify_map_keys_for_object at the
   wasm-dpp2 boundary, so toObject can route through the canonical
   ValueConvertible path correctly.

3. TokenConfigurationLocalizationWasm: same pattern.

4. PoolingWasm Deserialize: 38-line custom impl that accepts both
   string variants ("never"/"ifAvailable"/"standard") and numeric
   discriminants (0/1/2). dpp::withdrawal::pooling_serde::deserialize
   already does exactly this with a richer visitor (also accepts
   "Never"/"IfAvailable"/"Standard" capitalized variants). Delegate to
   it and convert dpp::Pooling -> PoolingWasm via the existing From
   impl. Net: -28 lines, fewer divergent paths to keep in sync.

For the remaining manual to_*/from_* methods in wasm-dpp2 (Identity,
PartialIdentity, IdentityPublicKey, Document, DataContract): these
take context arguments (platform_version, data_contract) that the
canonical trait signatures don't accept, OR rely on version-aware
methods like to_cleaned_object / from_json_object. Cannot migrate to
_inner! — they're legitimate wasm-side extensions.

Test results: 1120 passing, 0 failing (unchanged).
cargo check -p wasm-dpp2: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…entity

Identity and PartialIdentity wasm wrappers had to_json / from_json /
to_object methods that went through generic serde via the wasm-side
helper (\`serialization::to_json\` etc.). These calls produce the same
wire shape as the canonical \`JsonConvertible\` / \`ValueConvertible\`
trait methods on dpp::Identity / dpp::PartialIdentity, but bypass the
trait — so any future custom impl on dpp wouldn't propagate to the
wasm boundary.

Switch to canonical traits where possible:

IdentityWasm:
- to_object: already used canonical \`self.0.to_object()\` (no change)
- to_json:   \`serialization::to_json\` -> \`self.0.to_json()\` + json_to_js
- from_json: \`serialization::from_json\` -> \`Identity::from_json(json)\`
- from_object: KEPT — uses \`Identity::try_from_platform_versioned\`
  because the wasm API dispatches on the \`platform_version\` arg, not
  on the value's \`\$formatVersion\` tag (intentional SDK convention).

PartialIdentityWasm:
- to_object: \`platform_value::to_value\` -> \`self.0.to_object()\` (canonical)
- to_json:   \`platform_value::to_value\` -> \`self.0.to_object()\` then
             \`platform_value_to_json\`
- from_object / from_json: KEPT — manual field-by-field with
  \`platform_version\` for inner IdentityPublicKey deserialization.

For IdentityPublicKey: the wasm \`to_object\` calls \`to_cleaned_object\`
(which strips disabledAt: None) — different semantic from canonical
to_object, so kept as-is to avoid changing the JS wire shape.

Disambiguation: PartialIdentity now imports \`ValueConvertible\`, which
collides with \`IdentityPublicKeyPlatformValueConversionMethodsV0::from_object\`
on IdentityPublicKey (both have a \`from_object\` method). Used the
fully-qualified \`<IdentityPublicKey as IdentityPublicKeyPlatformValueConversionMethodsV0>::from_object\`
form at the one collision site.

Test results: 1120 passing, 0 failing (unchanged).
cargo check -p wasm-dpp2: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dpp traits

Two more callers of the wasm-side generic-serde helper that wrap a
versioned dpp type with the canonical traits available — switched to
delegate via JsonConvertible / ValueConvertible:

- \`DataContractWasm::config()\` getter (data_contract/model.rs:397):
  was \`serialization::to_object(self.0.config())\` (generic serde).
  Now \`config().to_object()\` -> platform_value -> JS. DataContractConfig
  is a versioned enum with \`#[derive(JsonConvertible, ValueConvertible)]\`.

- \`TokenConfigurationLocalizationWasm::TryFrom<&JsValue>\` fallback path
  (tokens/configuration/localization.rs:121): was
  \`serialization::from_object(...)\`. Now goes through
  \`platform_value_from_object\` then
  \`TokenConfigurationLocalization::from_object\` (the canonical
  ValueConvertible trait method).

After these and the prior commits in this branch, the only remaining
callers of wasm-dpp2's generic-serde helpers (\`serialization::to_object\`
/ \`from_object\`) are over leaf types that aren't versioned dpp
structures — \`BTreeMap<String, JsonSchema>\` (document_schemas) and
\`BTreeMap<String, Value>\` (document data). Those are fine to keep on
generic serde.

Manual audit summary across all wasm-dpp2 wrappers of dpp domain types:

✅ Already routed through dpp methods (no change):
  IdentityPublicKeyWasm    - to_cleaned_object / to_json_object /
                              from_object / from_json_object
  DocumentWasm             - to_map_value / from_platform_value /
                              Document::to_json / from_json_value
  DataContractWasm         - to_value / from_value / from_json /
                              from_bytes / to_bytes (all dpp methods)

✅ Migrated in this branch:
  IdentityWasm             - to_object / to_json / from_json now via
                              JsonConvertible / ValueConvertible (was
                              generic serde via wasm helper)
  PartialIdentityWasm      - to_object / to_json now via
                              ValueConvertible (was direct
                              platform_value::to_value)
  BatchTransitionWasm      - now via _inner! macro (delegates to
                              JsonConvertible / ValueConvertible)
  GroupWasm                - same
  TokenConfigurationLocalizationWasm - same
  PoolingWasm Deserialize  - delegates to dpp::pooling_serde

❌ Cannot migrate (legitimate context-aware extensions):
  IdentityWasm.from_object         - wasm SDK convention: dispatch on
                                     platform_version arg, not value's
                                     \$formatVersion tag
  PartialIdentityWasm.from_*       - manual field-by-field deserialization
                                     with platform_version for inner keys
  IdentityPublicKeyWasm.{to,from}_*- already context-aware via dpp methods
  DocumentWasm.from_*              - composite wrapper (Document + metadata)
  DataContractWasm.{to,from}_*     - take \`platform_version\` + \`full_validation\`

❌ Cannot migrate (semantic divergence):
  IdentityPublicKeyWasm.to_object  - to_cleaned_object strips disabledAt:None

❌ Cannot migrate (JS interop adapters):
  IdentifierWasm / PlatformAddressWasm Serialize/Deserialize -
    visit_seq for Uint8Array, visit_map for {type,data} JS quirks

Test results: 1120 passing, 0 failing (unchanged).
cargo check -p wasm-dpp2: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant