feat(dpp): unify JSON/Value conversion traits#3573
Conversation
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>
…IndexInformation)
…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>
…ible-address-transitions
The bls_pubkey serde helper unconditionally imports crate::bls_signatures, which only exists under the `bls-signatures` feature. Wasm builds that compile rs-dpp without it (e.g. wasm-drive-verify) failed with E0433/E0432. Gate the module behind the feature its wrapped type (dashcore::blsful::PublicKey) and its only consumers (core_types validator/validator-set) already require. Reproduced with a no-default-features check of the wasm-drive-verify feature set: could not find `bls_signatures` before, compiles after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop working/planning docs that don't belong in the shipped PR: the unification plan, inventory snapshot, superseded enum-tagging spec, the next-PR wasm-dpp2 cleanup plan, and a stray v12 upgrade-boundary doc. Keep the canonical-pattern reference and remove its now-dangling links, and drop the same dangling doc-pointer comments in the DataContract serde module and platform-value converter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…map_value The JSON/Value unification removed ExtendedDocument.toObject() (its rs-dpp delegate to_json_object_for_validation was deleted), breaking every platform-test-suite caller with "toObject is not a function" across the Document, contacts and dpns specs (9 tests). Reinstate it over the canonical non-human-readable to_map_value() so identifiers and binary data — including nested document properties — serialize as Uint8Array in one pass, without walking the document type's identifier/binary paths. platform-test-suite: 9 failing before, all passing after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t comparisons DataContractWasm.toObject now serializes the contract's native format via canonical platform_value::to_value (previously force-downgraded through PlatformVersion::first()), so a V1 contract emits createdAt/updatedAt and their block-height/epoch fields. Comparing an unpublished fixture (no metadata) against a published-then-fetched contract (platform-stamped metadata) therefore diverged. Strip the contract-level metadata before the deep-equals, mirroring the Document specs' timestamp handling. platform-test-suite: 2 failing before, passing after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…de path
to_binary_bytes / into_binary_bytes matched only Value::U8 array elements, while
their to_bytes_32 / to_identifier siblings accept any integer variant via
to_integer(). A binary (ByteArray) document property that round-trips through a
schemaless JSON layer — e.g. editing and replacing a cached document — arrives as
a plain JSON int array that decodes to Value::U64 elements, so the replace failed
at serialize with "not an array of bytes". Relax both array arms to
byte.to_integer(), matching the siblings; this repairs the round-trip for every
cached row regardless of its integer encoding.
Regression test would have caught this: the array-of-U64 case errored before the
fix ("not an array of bytes"), passes after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The confirmed-document JSON handed to Swift used the legacy to_json_with_identifiers_using_bytes (base58 ids but binary as u8-arrays, no $formatVersion, unset system fields omitted), diverging from the DOC-01 list query path (dash_sdk_document_search: canonical to_object + serde_json — base64 binary, $formatVersion present, unset fields as null) that Swift actually reads. Route the create and replace/transfer/set-price/purchase confirm paths through a single canonical helper (to_object + serde_json), so the persisted body is byte-identical to what a later query returns. Drop the now-unused DocumentJsonMethodsV0 / PlatformVersion imports; update Rust + Swift docs. Adds a pin test asserting the confirmed JSON has the canonical shape (base58 id, base64 binary, $formatVersion) and equals the query mechanism's output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After the JSON/Value unification and the wallet-ffi canonicalization, three hand-rolled conversion methods have no remaining production callers: - Document::to_json_with_identifiers_using_bytes (+ the whole DocumentJsonMethodsV0 trait) - DataContract::to_validating_json - ExtendedDocument::to_pretty_json Delete them with their impls, dispatch, re-exports, and now-dead tests. Kept: DataContractJsonConversionMethodsV0::from_json (intentional final API), the value-level try_to_validating_json / try_into_validating_json JSON-Schema validation primitives, and to_map_value / into_map_value. Verified: cargo check -p dpp --all-features, the no-default-features wasm feature set, -p platform-wallet-ffi and -p wasm-dpp2 all compile; dpp lib tests 3784 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…red serializer Code review (security audit) found the earlier binary round-trip fix relaxed platform-value to_binary_bytes/into_binary_bytes to accept arrays of wider integers — but those helpers are also on the drive-abci block-serialization (app-hash) path via Document::serialize_v0, and the block path does not sanitize. Relaxing them was an ungated consensus rule change: a crafted Value::Array byteArray transition would be rejected by old nodes and stored by new ones, diverging the app hash (rolling-upgrade fork risk). Revert the shared serializer to strict Value::U8 (restoring consensus behavior), and instead normalize int-array -> bytes in the client-only DocumentType::sanitize_value_mut, which the block/consensus path never calls. This fixes the Swift seed->edit->replace round-trip for binary properties without changing which state transitions are accepted. Regression test would have caught this: sanitize leaves Array([U64,..]) untouched before the fix, converts it to Value::Bytes after. Also strengthen the wallet-ffi confirmed-JSON pin test — assert the exact base64 of the binary property and that unset system fields are present-as-null (the concrete differences from the legacy shape), dropping a tautological assertion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verified against head 603b10c. One new latest-delta blocking issue survives verification in the Swift token wrappers; the unresolved carried-forward issues are still concentrated in wasm TypeScript discriminator declarations and canonical JSON/Value conversion edge cases. CodeRabbit supplied no inline findings, and prior findings 1 and 6 are resolved because the referenced docs were removed.
Reviewed commit: 603b10c6
Source: reviewers: opus general/security-auditor/ffi-engineer failed (extra-usage quota, reset Jul 10 8am America/Chicago); gpt-5.5 general/security-auditor/ffi-engineer completed. Verifier: opus failed same quota; gpt-5.5 completed. Specialists: security-auditor, ffi-engineer.
Result: 4 blocking, 5 suggestion(s).
Prior Reconciliation
prior-1FIXED:docs/v12-upgrade-boundary-risks.mdwas removed at this head.prior-6FIXED:docs/json-value-unification-plan.mdwas removed at this head.prior-2,prior-3,prior-4,prior-5,prior-7,prior-8,prior-9, andprior-10are STILL VALID and carried forward below.
New Findings In Latest Delta
blocking: Keep the token signer alive for the whole FFI callback
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/Tokens/TokenActions.swift (lines 108-127)
This latest-delta wrapper captures only signerHandle for the actual platform_wallet_token_transfer call and uses _ = signer before the FFI call starts. KeychainSigner registers its Rust callback context with Unmanaged.passUnretained(self), so Rust later dereferences a non-owning Swift object through the signer vtable. A bare _ = signer is the last use of the object in the detached task and can be optimized/released before the synchronous Rust signing callback completes; nearby wallet wrappers now use withExtendedLifetime(signer) for this exact reason. tokenBurn has the same pattern at lines 170-192. Wrap the full marshalling and FFI call in withExtendedLifetime(signer) so short-lived signer arguments cannot be deallocated while Rust is signing.
Carried-Forward Prior Findings
blocking: AssetLockProof declarations still advertise `type` instead of `$type`
packages/wasm-dpp2/src/asset_lock_proof/proof.rs (lines 23-37)
Carried forward from prior-2. The TypeScript custom section says AssetLockProofObject and AssetLockProofJSON are discriminated by type, but the rs-dpp enum derives and deserializes with #[serde(tag = "$type")]. Because this wrapper now delegates through the canonical inner conversion path, JS payloads that satisfy the generated TypeScript shape fail at the wasm/Rust boundary with a missing $type, and runtime toObject()/toJSON() output does not satisfy the published declarations.
blocking: FeeStrategyStep declarations use `type` while deserialization requires `$type`
packages/wasm-dpp2/src/platform_address/fee_strategy.rs (lines 13-30)
Carried forward from prior-3. FeeStrategyStepObject and FeeStrategyStepJSON document { type, index }, and the wrapper comment repeats that shape. The wrapped AddressFundsFeeStrategyStep serializer emits $type, and its custom deserializer only reads $type, returning missing_field("$type") for the documented object. Wallet code following these declarations can build address-funded transitions that type-check in TypeScript but are rejected by the canonical wasm/Rust conversion.
blocking: Several wasm-dpp2 declarations still use unprefixed discriminators
packages/wasm-dpp2/src/voting/vote_poll.rs (lines 38-50)
Carried forward from prior-4, with the same root cause still present across companion wrappers. VotePollObject/VotePollJSON declare type, but rs-dpp serializes VotePoll with $type; ResourceVoteChoice and ContestedDocumentVotePollWinnerInfo declarations also use type while their manual serializers require $type; AddressWitness declarations use type while the enum is #[serde(tag = "$type")]; and GroupActionEvent/TokenEvent declarations use kind/type while runtime output is $kind/$type. The PR states that discriminated enums use $-prefixed keys, and these public TS sections now contradict the canonical conversion paths they expose, causing TS-valid payloads to fail deserialization and returned objects to violate the generated types.
suggestion: TokenPricingSchedule JSON can still emit unsafe u64 numbers
packages/rs-dpp/src/tokens/token_pricing_schedule.rs (lines 28-51)
Carried forward from prior-7. This PR adds canonical JsonConvertible/ValueConvertible impls for TokenPricingSchedule, but the enum still derives plain serde for SinglePrice(Credits) and SetPrices(BTreeMap<TokenAmount, Credits>). Both aliases are u64, and the new tests pin JSON numbers such as { "SinglePrice": 1234 } and { "SetPrices": { "5": 50 } }. Values above JavaScript's safe integer range can be rounded by JS consumers, which conflicts with the PR's stated JS-safe large-integer coverage and the explicit escape-hatch comment claiming developers handle this type safely.
suggestion: FFI data-contract JSON no longer uses the SDK protocol version
packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs (lines 110-131)
Carried forward from prior-8. The FFI fetch path returns JSON to C/Swift by calling serde_json::to_value(&contract). DataContract serde intentionally resolves PlatformVersion::get_version_or_current_or_latest(None), but this boundary used to serialize with wrapper.sdk.version(). The JSON shape returned by this SDK API can now depend on process-global/current/latest protocol state rather than the network version used for the proof-verified fetch. Keep the general DataContract serde exception, but route this FFI API through an explicit SDK-versioned serialization path.
suggestion: Reject oversized BLS public-key sequences before allocating them
packages/rs-dpp/src/serialization/dashcore/bls_pubkey.rs (lines 151-157)
Carried forward from prior-5. visit_seq accepts a byte sequence, pushes every element into a Vec, and only then calls from_compressed_g1_bytes, which rejects anything other than 48 bytes. A hostile JSON/Value payload can force allocation and parsing proportional to an arbitrarily large sequence before the fixed-size public key length check runs. Since valid compressed-G1 input is exactly 48 bytes, reject immediately when the 49th byte is seen.
suggestion: Legacy update-transition constructor does not alias `identityContractNonce`
packages/wasm-dpp/src/data_contract/state_transition/data_contract_update_transition/mod.rs (lines 62-76)
Carried forward from prior-9. The legacy wasm constructor preserves lenient JS construction by defaulting $formatVersion, userFeeIncrease, signaturePublicKeyId, and signature, then calls strict DataContractUpdateTransition::from_object(raw). The strict V0 struct now expects the nonce as $identity-contract-nonce, but this compatibility shim does not map the older JS-facing identityContractNonce key. Existing callers using the legacy field name can no longer construct an update transition through this lenient path.
suggestion: Canonical conversion tests still miss changed enum variants
packages/rs-dpp/src/data_contract/document_type/property/array.rs (lines 715-841)
Carried forward from prior-10. The new canonical wire-shape tests for ArrayItemType cover Integer, Number, String, ByteArray, and Identifier but omit Boolean and Date, even though both variants participate in the same $type representation. The related TokenDistributionInfo tests in token_distribution_key.rs only pin PreProgrammed and omit Perpetual. Because this PR's stated goal is comprehensive canonical JSON/Value round-trip coverage, add literal JSON and Value assertions for these variants so discriminator and field-shape regressions are caught.
CodeRabbit
No CodeRabbit walkthrough/summary comment was found. Only emit coderabbit_reactions for findings with a concrete inline comment id.
Inline posting was not possible because gh pr diff for this PR returns PullRequest.diff too_large (300-file GitHub diff limit), so this exact-SHA review is posted as a body-only review.
The enum-tagging sweep moved rs-dpp's data-carrying enums to #[serde(tag = "$type")] (and $kind for GroupActionEvent), but several wasm-dpp2 TypeScript custom-section declarations still advertised bare type/kind. JS payloads satisfying the published .d.ts would fail at the wasm/Rust boundary (missing $type), and toObject()/toJSON() output did not match the declarations. Update the declarations (and the wrapper doc comments that repeat the shape) to $type/$kind across AssetLockProof, AddressFundsFeeStrategyStep, VotePoll, ResourceVoteChoice, ContestedDocumentVotePollWinnerInfo, AddressWitness, TokenEvent, and GroupActionEvent, so the published types match the canonical serde wire shape. Also fix two stale rs-dpp comments (VotePoll, TokenEvent) that still described the superseded plain-type convention. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TokenPricingSchedule derived plain externally-tagged serde over SinglePrice(Credits)
and SetPrices(BTreeMap<TokenAmount, Credits>) — all u64 — so large values serialized
as raw JSON numbers (JS precision loss above 2^53), while its escape-hatch
impl JsonSafeFields {} marker asserted safety without enforcing it. The type leaks by
value into TokenSetPriceForDirectPurchaseTransitionV0, TokenEvent::ChangePriceForDirectPurchase,
and two StateTransitionProofResult variants, so the gap reached the state-transition wire.
Convert to an internally-$type-tagged Repr (#[serde(into/from)]) routing Credits through
json_safe_u64 and the map through json_safe_u64_u64_map (mirroring RewardDistributionMoment
and DistributionFunction::Stepwise). Fixing the type's serde propagates to all five sites.
Wire shape: {"SinglePrice":N} -> {"$type":"singlePrice","price":N}, large values as
strings. The bincode/consensus binary path is untouched.
New regression test pins a >2^53 value as a string; full dpp lib suite green (3786).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
visit_seq collected an arbitrarily long byte sequence into a Vec before the fixed 48-byte compressed-G1 length check ran, letting a hostile JSON/Value payload force allocation/parsing proportional to its size. Reject as soon as the sequence exceeds COMPRESSED_G1_LEN. No functional change (over-length input already errored, just after allocating); this bounds the allocation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erpetual The canonical JSON round-trip suites omitted these variants though they share the $type wire representation. Add wire-shape assertions for ArrayItemType::Boolean and ::Date, and a $type discriminator + round-trip pin for TokenDistributionInfo::Perpetual, so discriminator/field-shape regressions are caught. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ersion The JSON path used serde_json::to_value(&contract), whose DataContract serde resolves the process-global current/latest platform version, so the returned JSON shape could depend on process state rather than the network version used for the proof-verified fetch. Serialize through try_into_platform_versioned(wrapper.sdk.version()) instead, matching the fetch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
verify_active_action_infos hand-rolled a ~440-line JS representation of
GroupActionEvent/TokenEvent (bare `type`, PascalCase discriminators, string
amounts) that diverged from the canonical DPP wire shape and duplicated the serde
logic. Replace it with the wasm-dpp2 to_object pattern: platform_value::to_value(&event)
fed to a serde_wasm_bindgen serializer configured for Uint8Array bytes and BigInt
u64, plus the same typed-map-key stringification (so TokenPricingSchedule::SetPrices'
u64 keys don't fail as JS object keys).
Output is now the canonical internally-tagged shape
({"$kind":"tokenEvent","$type":"mint",...}) with JS-safe BigInt amounts,
matching wasm-dpp2 — and ~312 fewer lines. Adds pure-Rust tests pinning the
$kind/$type shape and the map-key normalization.
Also fixes a stale doc comment on GroupActionEvent that described the superseded
plain-`type`/`kind` convention and a nonexistent `data` wrapper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two leftovers from the enum-tagging sweep found in code review: a wasm-dpp2 TS doc comment claiming the rs-dpp enum uses `#[serde(tag = "type")]` (it uses `$type`), and the TokenEvent Deserialize visitor's expecting() string naming a `type` discriminator (the code reads `$type`). Comment/message only, no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cargo fmt --all over the branch's own changed files (line-wrap collapses and alphabetical module reordering) so cargo fmt --check --all is green. Confined to files this PR already touches; no base v4.1-dev files reformatted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
The latest push fixed the prior wasm-dpp2 discriminator declarations, TokenPricingSchedule JS-safe JSON shape, oversized BLS sequence handling, and the version-pinned fetch_with_serialization FFI path. Four in-scope suggestions remain: one new cumulative sibling FFI JSON regression, one carried-forward wasm-dpp compatibility regression, and two partial gaps in the PR's promised full JSON/Value wire-shape coverage. The carried-forward Swift signer lifetime issue is real but OUTDATED as a PR #3573 finding because TokenActions.swift is not changed by the cumulative PR diff.
Source: reviewers: opus general/security-auditor/ffi-engineer failed (extra-usage quota, reset Jul 10 8am America/Chicago); gpt-5.5 general/security-auditor/ffi-engineer completed. Verifier: opus failed same quota; gpt-5.5 completed. Specialists: security-auditor, ffi-engineer.
Prior reconciliation: prior-603-1 OUTDATED/out-of-scope for this PR head; prior-603-2, prior-603-3, prior-603-4, prior-603-5, prior-603-6, and prior-603-7 FIXED; prior-603-8 STILL VALID; prior-603-9 PARTIALLY STILL VALID.
🟡 4 suggestion(s)
Note: review_poster.py was invoked first, but GitHub refused the PR diff with PullRequest.diff too_large (>300 files), so these same verifier-approved findings are posted as a body-only review.
Verified active findings
suggestion: Serialize simple data-contract JSON at the SDK protocol version
packages/rs-sdk-ffi/src/data_contract/queries/fetch_json.rs (line 52-54)
This PR changed dash_sdk_data_contract_fetch_json from the explicit contract.to_json(wrapper.sdk.version()) path to plain serde_json::to_value(&contract). The manual DataContract serializer still calls PlatformVersion::get_version_or_current_or_latest(None), so this FFI endpoint now depends on the process-global/current version rather than the SDK version used for the proof-verified fetch. The latest delta fixed the same issue in dash_sdk_data_contract_fetch_with_serialization; route this simple JSON endpoint through DataContractInSerializationFormat with wrapper.sdk.version() as well so C/Swift callers do not see protocol-version-dependent JSON drift across upgrades.
suggestion: Preserve the legacy identityContractNonce constructor key
packages/wasm-dpp/src/data_contract/state_transition/data_contract_update_transition/mod.rs (line 62-76)
STILL VALID for prior-603-8. The PR replaced the legacy StateTransitionValueConvert::from_object(..., PlatformVersion::first()) path with strict ValueConvertible::from_object(raw). The old value-conversion implementation explicitly read identityContractNonce, while the new strict V0 serde shape requires $identity-contract-nonce. This constructor still preserves legacy leniency for $formatVersion, userFeeIncrease, signaturePublicKeyId, and signature, but it never aliases identityContractNonce before deserialization, so existing JS callers using the previous public nonce key fail at construction. Map identityContractNonce to $identity-contract-nonce when the canonical key is absent.
suggestion: Add ValueConvertible coverage for Boolean and Date array items
packages/rs-dpp/src/data_contract/document_type/property/array.rs (line 805-865)
STILL VALID in part for prior-603-9. The latest delta added JSON wire-shape assertions for ArrayItemType::Boolean and ArrayItemType::Date, but the ValueConvertible block still stops at Identifier. Because this PR explicitly promises comprehensive JSON/Value round-trip tests with literal wire-shape assertions, the non-human-readable Value shape for these two variants remains unpinned. Add matching value_round_trip_boolean_variant and value_round_trip_date_variant assertions.
suggestion: Pin the full Perpetual token-distribution Value shape
packages/rs-dpp/src/data_contract/associated_token/token_distribution_key.rs (line 391-407)
STILL VALID in part for prior-603-9. The new TokenDistributionInfo::Perpetual test only asserts the JSON $type discriminator and round-trips the value. It does not assert the full JSON wire shape and has no corresponding ValueConvertible assertion, so a regression in the nested moment or resolved-recipient Value representation would still pass. Add literal JSON and Value shape assertions for the Perpetual variant, matching the existing full-shape PreProgrammed tests.
Prior-finding reconciliation
- AssetLockProof declarations still advertise
typeinstead of$type— FIXED:AssetLockProofObjectandAssetLockProofJSONnow use$typefor both variants, and the surrounding comment points to the rs-dpp$typeserde tag. - FeeStrategyStep declarations use
typewhile deserialization requires$type— FIXED: the publicFeeStrategyStepObjectandFeeStrategyStepJSONTypeScript declarations now use$typefordeductFromInputandreduceOutput. - Several wasm-dpp2 declarations still use unprefixed discriminators — FIXED: the wasm-dpp2 voting, address witness, group action, and token event TypeScript surfaces now advertise
$typeor$kindconsistently with runtime serialization. - TokenPricingSchedule JSON can still emit unsafe u64 numbers — FIXED:
TokenPricingSchedulenow serializes through an internally$type-tagged representation that appliesjson_safe_u64andjson_safe_u64_u64_map, with a regression test for values aboveNumber.MAX_SAFE_INTEGER. - FFI data-contract JSON no longer uses the SDK protocol version — FIXED for the originally reported
fetch_with_serializationAPI: it now converts toDataContractInSerializationFormatwithwrapper.sdk.version()before JSON serialization. A separate sibling issue remains infetch_json.rsand is reported above. - Reject oversized BLS public-key sequences before allocating them — FIXED:
BlsPublicKeyVisitor::visit_seqnow returnsinvalid_lengthas soon as the 49th byte is encountered, before growing beyondCOMPRESSED_G1_LEN. - Keep the token signer alive for the whole FFI callback — OUTDATED as an in-scope PR #3573 finding at 0d69463: the issue is real in current source, but TokenActions.swift is not changed by the current cumulative PR diff from origin/v4.1-dev and was not changed in 603b10c..0d69463. Kept as out_of_scope follow-up context instead of a public carried-forward blocker.
- Token action wrappers should pin KeychainSigner lifetime — OUTDATED as a PR #3573 finding: the issue is real in current source, but
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/Tokens/TokenActions.swiftis not changed in the cumulative PR diff from the v4.1-dev merge base. The wrappers capturesignerHandleand use_ = signerinside detached tasks even thoughKeychainSignerregisters an unretained Swift context; nearby wallet code now useswithExtendedLifetime(signer)for this pattern.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
- [SUGGESTION] In `packages/rs-sdk-ffi/src/data_contract/queries/fetch_json.rs`:52-54: Serialize simple data-contract JSON at the SDK protocol version
This PR changed `dash_sdk_data_contract_fetch_json` from the explicit `contract.to_json(wrapper.sdk.version())` path to plain `serde_json::to_value(&contract)`. The manual `DataContract` serializer still calls `PlatformVersion::get_version_or_current_or_latest(None)`, so this FFI endpoint now depends on the process-global/current version rather than the SDK version used for the proof-verified fetch. The latest delta fixed the same issue in `dash_sdk_data_contract_fetch_with_serialization`; route this simple JSON endpoint through `DataContractInSerializationFormat` with `wrapper.sdk.version()` as well so C/Swift callers do not see protocol-version-dependent JSON drift across upgrades.
- [SUGGESTION] In `packages/wasm-dpp/src/data_contract/state_transition/data_contract_update_transition/mod.rs`:62-76: Preserve the legacy identityContractNonce constructor key
STILL VALID for prior-603-8. The PR replaced the legacy `StateTransitionValueConvert::from_object(..., PlatformVersion::first())` path with strict `ValueConvertible::from_object(raw)`. The old value-conversion implementation explicitly read `identityContractNonce`, while the new strict V0 serde shape requires `$identity-contract-nonce`. This constructor still preserves legacy leniency for `$formatVersion`, `userFeeIncrease`, `signaturePublicKeyId`, and `signature`, but it never aliases `identityContractNonce` before deserialization, so existing JS callers using the previous public nonce key fail at construction. Map `identityContractNonce` to `$identity-contract-nonce` when the canonical key is absent.
- [SUGGESTION] In `packages/rs-dpp/src/data_contract/document_type/property/array.rs`:805-865: Add ValueConvertible coverage for Boolean and Date array items
STILL VALID in part for prior-603-9. The latest delta added JSON wire-shape assertions for `ArrayItemType::Boolean` and `ArrayItemType::Date`, but the `ValueConvertible` block still stops at `Identifier`. Because this PR explicitly promises comprehensive JSON/Value round-trip tests with literal wire-shape assertions, the non-human-readable Value shape for these two variants remains unpinned. Add matching `value_round_trip_boolean_variant` and `value_round_trip_date_variant` assertions.
- [SUGGESTION] In `packages/rs-dpp/src/data_contract/associated_token/token_distribution_key.rs`:391-407: Pin the full Perpetual token-distribution Value shape
STILL VALID in part for prior-603-9. The new `TokenDistributionInfo::Perpetual` test only asserts the JSON `$type` discriminator and round-trips the value. It does not assert the full JSON wire shape and has no corresponding `ValueConvertible` assertion, so a regression in the nested `moment` or resolved-recipient Value representation would still pass. Add literal JSON and Value shape assertions for the Perpetual variant, matching the existing full-shape PreProgrammed tests.
TokenPricingSchedule is now internally $type-tagged, so fromJSON/fromObject reject
the old externally-tagged { SinglePrice: N } with "missing field $type". Update the
three TokenSetPriceResult pricingSchedule tests to { $type: 'singlePrice', price: N }
and assert the new field names. This is the wasm-sdk consumer of the intentional
TokenPricingSchedule wire-shape change (22a2cf9).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Checked out and verified head 1703be8. The latest delta from 0d69463 only updates TokenSetPriceResult pricingSchedule test fixtures, but cumulative PR scope still leaves these verified issues: prior-0d-1 STILL VALID, prior-0d-2 STILL VALID, prior-0d-3 STILL VALID, and prior-0d-4 STILL VALID. I also verified one cumulative PR regression in register_contract where the comment and intended non-validating load path no longer match the actual serde behavior.
Source: claude-general: opus failed (extra-usage quota: resets Jul 10, 8am America/Chicago); claude-security-auditor: opus failed (extra-usage quota: resets Jul 10, 8am America/Chicago); claude-ffi-engineer: opus failed (extra-usage quota: resets Jul 10, 8am America/Chicago); codex-general: gpt-5.5 completed; codex-security-auditor: gpt-5.5 completed; codex-ffi-engineer: gpt-5.5 completed; verifier: gpt-5.5 completed.
Reviewed commit: 1703be8
🟡 5 suggestion(s)
Verified findings
suggestion: Use the explicit non-validating contract loader
packages/rs-scripts/src/bin/register_contract.rs (line 121)
This script still documents that fixture contracts are loaded without full validation because it overwrites ownerId/id before submitting the normal state transition, but the code now calls serde_json::from_value::. DataContract's Deserialize impl routes through try_from_platform_versioned with full_validation = true, so structurally decodable fixtures can be rejected before the script gets to replace the owner/id. Restore DataContract::from_json(json_value, false, platform_version), or deserialize DataContractInSerializationFormat and call the versioned conversion with full_validation = false.
suggestion: Serialize fetched contract JSON with the SDK protocol version
packages/rs-sdk-ffi/src/data_contract/queries/fetch_json.rs (line 52)
prior-0d-1 STILL VALID. This FFI endpoint fetches the contract through wrapper.sdk but serializes it with serde_json::to_value(&contract). DataContract's Serialize impl uses PlatformVersion::get_version_or_current_or_latest(None), so the returned JSON can follow the process-global/current version rather than the SDK network version used for the verified fetch. The sibling fetch_with_serialization path already pins wrapper.sdk.version(); this simple JSON endpoint should use the same DataContractInSerializationFormat conversion before serializing.
suggestion: Preserve the legacy identityContractNonce constructor key
packages/wasm-dpp/src/data_contract/state_transition/data_contract_update_transition/mod.rs (line 62)
prior-0d-2 STILL VALID. The wasm constructor still fills several omitted legacy fields before delegating to strict DataContractUpdateTransition::from_object(raw), but it does not translate the old JS-facing identityContractNonce key to the current $identity-contract-nonce serde key. Existing callers using the previous public constructor shape now fail construction before a Rust transition is produced. Add an alias/migration in this shim before calling from_object.
suggestion: Add ValueConvertible coverage for Boolean and Date array items
packages/rs-dpp/src/data_contract/document_type/property/array.rs (line 805)
prior-0d-3 STILL VALID. The file now pins JSON wire shapes for ArrayItemType::Boolean and ArrayItemType::Date, but the ValueConvertible tests still stop at Identifier. Because this PR is standardizing canonical JSON/Value wire-shape behavior, add Value assertions for Boolean and Date so the non-human-readable Value representation is locked down as well.
suggestion: Pin the full Perpetual token-distribution Value shape
packages/rs-dpp/src/data_contract/associated_token/token_distribution_key.rs (line 391)
prior-0d-4 STILL VALID. The Perpetual test checks only the JSON $type discriminator and then round-trips the value. Unlike the PreProgrammed tests, it does not assert the full JSON shape and has no ValueConvertible assertion for the nested moment and resolved-recipient representation. Add full-shape JSON and Value assertions so regressions in those nested wire forms cannot pass unnoticed.
_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3573 1703be8 --dry-run
STDOUT:
STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", line 904, in
result = post_review(
File ), so I posted the same verified findings as a top-level review body._
shumkov
left a comment
There was a problem hiding this comment.
Addressed the review threads — per-comment status inline. bls_pubkey moved to serialization/dashcore; all data-carrying enums internally $type-tagged ($type everywhere, GroupActionEvent → $kind); validator_set BLS deserialize fixed via deserialize_any.
* fix(migration): prompt for wallet passwords before completing migration
Restores the migration password prompt reverted from PR #887 so it can be
reworked in isolation. This commit is the original implementation verbatim;
the review findings that caused the revert are fixed in the commits that
follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
* fix(migration): free the password prompt from the SPV overlay, add a skip
Two defects found by real-world testing of the migration password prompt.
The SPV progress overlay and the passphrase modal both painted at
`egui::Order::Foreground`. Suppressing only `ProgressOverlay::claim_input`
released the keyboard but left the overlay's pointer sink and dim/card layers
live, so they swallowed clicks aimed at the password field — the prompt was
visible but unusable whenever migration ran alongside an SPV sync (which is
always, at boot). A blocking secret prompt now owns the whole interaction
surface: while one is active the overlay stays logically in its stack but
paints no dimmer, pointer sink, card, or focus trap, and claims no keyboard.
Queued ordinary secret prompts are promoted before the frame's overlay
decision, so their first visible frame is protected too.
The prompt was also inescapable: a user who had forgotten a wallet password
could not proceed, and the only exit the UI permitted was deleting the wallet.
"Skip this wallet" now records the seed hash in a per-run exclusion set, drops
it from the published pending list, and wakes the migration task — so skipping
the last wallet still completes the migration and writes the sentinel. A
skipped wallet stays closed, keeps its legacy protected envelope, and is
registered upstream on a later ordinary unlock via the existing
`handle_wallet_unlocked` -> `bootstrap_wallet_addresses_jit` chokepoint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
* wip(migration): headless fail-fast, legacy read-only, registration single-flight
INCOMPLETE — DO NOT MERGE. Committed to preserve work across a session
restart; the Codex job producing it was cancelled mid-edit.
State: compiles clean (clippy --all-features --all-targets -D warnings, exit 0),
but the full test gate is RED (exit 101, 1771 passed / 2 failed):
context::wallet_lifecycle::tests::migrated_protected_wallet_blocks_migration_until_password_submission
context::wallet_lifecycle::tests::protected_wallet_registers_upstream_on_unlock_without_restart
Both are tests the legacy-read-only and registration-race changes must rewrite;
the job was cancelled partway through that rewrite. Whoever picks this up must
finish those two and re-run the full gate before trusting any of it.
Intended scope (per review findings + owner directives):
- P1 headless fail-fast: migration must refuse, not block, when a protected
wallet needs a password and no interactive prompt exists (mcp/resolve.rs
drives the same migration with no egui frame loop -> det-cli hung forever).
- P2 legacy DB strictly read-only: never DROP/DELETE/UPDATE the pre-migration
database; write only the new store/vault. Makes a skipped or abandoned
migration cost the user nothing.
- P3 registration race: single-flight per wallet (unlock spawned a
fire-and-forget registration while migration inline-awaited its own for the
same wallet -> spurious RegistrationIncomplete, reproduced as a real failure).
- P4 split MigrationState::is_running(), which silently came to mean
"running OR blocked on a human"; five callers inherited the conflation.
- P5 cross-wallet password bleed (modal state keyed on window title), swallowed
re-encryption failure, unified lock-poisoning policy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
* fix(shielded): gate fund-moving shielded tasks at the backend chokepoint
`run_shielded_task` had no capability check, so the five state-changing
shielded operations were reachable from any caller that dispatches a
`ShieldedTask` directly. The MCP shielded tools do exactly that, bypassing
the UI gate at `ui/wallets/shielded_tab.rs`. Shielded operations are not
defined on any current network, so `ShieldFromAssetLock` would create an
asset lock committing real L1 funds and then attempt a state transition no
network can settle — stranding the funds and burning the fee.
Enforce `FeatureGate::ShieldedOperations` as the first statement of
`run_shielded_task`, before any wallet or backend access, mirroring the
`RootKeyDerivationRefused` guard in `backend_task/wallet/mod.rs`. The UI
gate stays as defense in depth.
Scope is exactly the five fund movers: `ShieldedTask` carries only
write variants. Shielded init, sync, balance and address reads reach the
coordinator through their own paths and stay ungated, so shielded funds
remain viewable wherever the wallet runs.
Add `TaskError::ShieldedOperationsUnavailable` and a regression test that
dispatches a write task the way an MCP tool does; it is confirmed failing
without the guard, proving the refusal precedes backend access.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dashpay): stop erasing accepted accounts, double-submits and silent declines
Three independent defects in the DashPay contact flow.
Contact-info write silently erased the accepted-account allow-list.
`resolve_accepted_accounts` collapsed four distinct states — no document,
missing privateData, decrypt failure, deserialize failure — into an empty
Vec, which `create_or_update_contact_info` then re-encrypted and wrote back
over the live Platform document. Any present-but-unreadable payload (e.g. a
contact whose privateData was written by another DashPay client) lost its
allow-list irreversibly on the next rename or unhide. Only an absent document
now yields an empty list; a present payload that cannot be read aborts the
write with a typed `DashPayContactInfoRead` error. The test that asserted the
data-losing behaviour is inverted, and the missing/undecodable payload states
get their own regressions.
A failed task released every request guard, allowing a paid double-submit.
`display_task_error` cleared all Accept/Decline/Cancel guards on any error, so
an unrelated concurrent failure re-enabled an in-flight Accept and a second
click bought a second state transition. Failures from the three request actions
now carry their request ID in `DashPayContactRequestActionFailed`, so only the
guard named by the error is released. Guards no longer matched by a result
expire on a timeout instead of being cleared wholesale, so a lost result cannot
strand a row forever.
A declined request reappeared after refresh. `reject_contact_request` logged and
swallowed a failed `dashpay_mark_declined` write and still reported success,
even though that local marker is the only thing that retires the row — Platform
keeps the `contactRequest` document forever. The failure now propagates, matching
the sibling `mark_withdrawn` cancel path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(wallet): report a corrupted wallet envelope as damage, not a wrong password
A password-protected wallet whose at-rest envelope is corrupted (truncated or
otherwise wrong-length) failed the AES-GCM tag check and surfaced as "The
password is incorrect", trapping the user in a retry loop whose only escape was
deleting the wallet. Structural damage is now classified before the AEAD can
mistake it for a bad password.
- decrypt_message takes the caller's known plaintext length and rejects an
impossible ciphertext/tag or salt length as DecryptError::Malformed.
- WalletSeed::open returns the typed EncryptionError instead of a flattened
String, so callers branch on the variant rather than on message text.
- The unlock popup maps Malformed to the same "saved data looks damaged, re-add
it from your recovery phrase" sentence the unprotected path already shows, and
keeps the password hint on the wrong-password branch only.
Finish the two migration lifecycle tests left red at the previous checkpoint.
Both now install a TestPrompt::never(), which panics if asked and so pins the
contract that migration defers to the UI-owned unlock flow instead of driving a
secret prompt itself:
- the protected wallet waits, is then skipped, and data.db is asserted
byte-unchanged, holding the legacy database strictly read-only;
- the unlock path joins the migration's single registration flight
(registration_attempt_count() == 1).
Verified green on the full workspace suite (2023 passed, 0 failed), the
all-features/all-targets lint gate with warnings denied, and the nightly
formatter check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dashpay): preserve saved contact details and keep paid actions guarded
The contactInfo document is written whole, so every writer decides the fate
of the fields it does not edit. Decline, withdraw, unhide and rename each
rebuilt the payload from scratch, erasing the nickname, note and
accepted-account list stored by the user or by another DashPay client.
Replace the implicit `Vec<u32> -> AcceptedAccounts::Replace` coercion, which
made the destructive path the short one, with an explicit `ContactInfoUpdate`
that states field by field what is preserved and what is replaced. Visibility
flips now preserve everything else; only the contact-details form, which owns
the whole form, replaces.
A payload this client cannot read is no longer either silently overwritten or
a permanent dead-end: the write aborts, the user is told, and confirming an
explicit, danger-styled dialog re-runs the write with an overwrite policy, so
a contact with unreadable details can still be unhidden, declined or renamed.
The v0 parser now rejects unknown versions, invalid UTF-8, non-canonical
flags and foreign trailing bytes instead of decoding them as absent details.
Paid request actions (Accept, Decline, Cancel) keep their in-flight guard
across a routine tab switch or refresh, which previously released it and made
the row clickable again while its state transition was still running. An
identity, wallet or network change still clears the guards, since they belong
to the identity being left. Task results reach only the screen that is visible
when they land, so the wall-clock backstop is retained: without it, an action
resolved while the user was on another screen would strand its row with dead
buttons for the rest of the session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(wallet): unlock a cold-booted protected wallet with its correct password
A password-protected wallet hydrates from a secret-free model: the
Tier-2/Protected arm of cold-boot reconstruction carries a placeholder
envelope, because the real secret stays in the vault. The unlock popup
verified the password against that placeholder, so after the first
restart the CORRECT password was reported as damaged data and the owner
was permanently locked out of the wallet.
Verify the password only through the secret chokepoint, which reads the
real stored envelope, and flip the in-memory seed open solely after that
succeeds (`mark_open_after_verification`). The popup maps the resulting
typed error to user copy structurally — wrong password vs damaged vault —
instead of pre-checking the model.
Operation-only unlocks now forget the session seed through an RAII guard,
so an early return or panic in the reconciliation subtask can no longer
strand a plaintext seed in the cache. A migration unlock is operation-only
too: that prompt offers no "keep unlocked" choice, so it must not silently
retain the seed for the session.
Regression cover, both entry points against a real cold boot: the context
API and the unlock popup itself. The popup test fails (correct password →
Pending) if the model pre-check is ever reintroduced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): block input outside a non-dismissible modal prompt
Removing the progress overlay's pointer sink (so it could not cover a
secret prompt it had triggered) also removed the only barrier in front of
the app: while the storage update paused on the migration password prompt,
clicks still reached the wallet screen behind it.
Give the modal its own barrier instead. A non-dismissible `modal_chrome`
window installs a full-screen pointer sink and registers itself as egui's
modal layer, so every layer beneath it is ignored for interaction while
the window itself — drawn above the sink — stays fully interactive.
Dismissible dialogs keep their existing click-outside behaviour.
The kittest asserts the widget beneath the prompt does NOT register a
click, and that the prompt's own controls remain hittable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(migration): keep wallet work gated while the storage update awaits a password
`is_running()` had become an alias for `is_executing()`, which reports
`false` while the migration is paused on `AwaitingWalletPasswords`. Every
caller that meant "the storage update has not finished yet" therefore
opened up mid-migration: wallet-touching backend tasks slipped past the
`WalletStorageNotReady` gate and hit a half-migrated vault, the MCP
wait/join logic stopped waiting, and the wallets screen offered
Create/Import CTAs against a wallet list about to be rehydrated.
Replace it with `is_in_progress()` — `Running | AwaitingWalletPasswords` —
and use it at all three sites. `is_executing()` keeps its narrow meaning
for callers that really do mean "a step is running right now".
Covered by a test dispatching an MCP-style wallet task during
`AwaitingWalletPasswords` and asserting `WalletStorageNotReady`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(wallet): collect the redundant legacy seed envelope and stop overpromising data removal
Legacy seed-envelope garbage collection, restored for the vault copy only.
Once the current-format secret is durable — the raw seam, a Tier-2 sealed
envelope, or an eager/lazy migration write — the superseded `envelope.v1`
row in the SAME vault is deleted best-effort, so a seed has exactly one
current copy at rest instead of an indefinitely retained duplicate. A
cold-boot scheme probe repeats the sweep after an interrupted run. The
pre-update `data.db` is NOT touched: it stays a read-only recovery
artifact.
Stop promising deletions the app no longer performs. "Remove Wallet" and
"Clear Database" said they erase all local data, while an earlier
version's read-only recovery database — which may still hold wallet
recovery data — stays on disk; the copy now says so. "Clear Platform
Addresses" is disabled rather than pretending to work: its only
implementation wrote to that read-only database.
The two remaining legacy-database writers are signposted as test-only;
neither has a production caller.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): make every passphrase prompt own the interaction surface
The blocking progress overlay yields to ANY passphrase prompt — the gate is
`has_blocking_secret_prompt()`, true for cancellable and non-dismissible prompts
alike — and paints no dimmer, pointer sink, or focus trap while one is up. But
the replacement barrier was wired to dismissability (`blocks_input: !cancellable`),
so the ordinary just-in-time unlock prompt, which is cancellable, installed no
sink at all: pointer and keyboard fell straight through to the panels the overlay
exists to freeze.
Dismissal and input-blocking are orthogonal. `blocks_input` is now unconditional;
`cancellable` still governs only Cancel / X / Escape / click-outside, which read
raw pointer input and are unaffected by the sink.
The two comments asserting the prompt "supplies its own input barrier" described a
precondition the code did not establish; they now describe what it does.
Covered by a kittest that presses a control behind a cancellable prompt while an
overlay is raised (RED before this change: the control activated), plus one
pinning that the prompt still dismisses from its own Cancel button.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(migration): scope an unlocked seed to the storage update, not to one subtask
A wallet unlocked for the storage update has two consumers of the seed it just
promoted: the unlock gesture's own `wallet_unlock_registration` subtask, and the
update's `bootstrap_loaded_wallets()` pass, which re-enters the seed scope for
the very wallet it prompted for. Their lifetimes overlap in an order nobody
controls, yet the seed's lifetime was owned outright by the subtask's RAII guard.
Whichever finished first evicted the seed from under the other — and a cache miss
on a protected scope prompts, so the update raised a background passphrase prompt
for a wallet the user had just unlocked. If the user ticked "keep unlocked" on
that second prompt, it also silently restored the session-long retention the
migration prompt deliberately withholds.
Retention shorter than the session is now enforced by `SecretLease`, a ref-counted
claim at the secret chokepoint: each consumer holds a clone and the seed is
forgotten when the last one drops. The storage update takes its own lease for the
wallets it prompted for (`WalletUnlockRetention::UntilStorageUpdateComplete`) and
releases it on every exit path, so neither consumer can strand the other, and the
unlock still does not outlive the update.
The regression test drives the losing interleaving explicitly: the unlock subtask
is joined to completion first, then the update's pass must resolve the seed from
the session cache with zero prompts, and the seed must be gone once the run's
lease is released.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dashpay): release the request guard when a dispatch is refused pre-dispatch
The storage-update gate rejects every wallet-touching task — `DashPayTask`
included — with a bare `WalletStorageNotReady`, before it reaches
`run_dashpay_task`, the only place that wraps a failure into
`DashPayContactRequestActionFailed { request_id, .. }`. That typed variant is
also the only one `release_request_guard_for_error` matched, so a contact
request's Accept / Decline / Cancel clicked during the first launch after an
upgrade claimed a guard nothing would ever release: the row's buttons went dead
for the full five-minute in-flight timeout, long after the update finished.
A pre-dispatch refusal names no request precisely because nothing ran, which is
exactly the condition under which a blanket release is safe. `clear_in_flight` is
restored for that one match arm only — every other failure still keeps its guard,
so an unrelated error cannot re-enable a row whose paid action may still be in
flight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(backend): pin ShieldedTask inside the wallet-touching migration gate
The shielded family is refused during a storage update only because it is listed
in `is_wallet_touching`; nothing failed if a refactor dropped that membership.
Sibling of `wallet_task_is_rejected_while_migration_awaits_password`, dispatching
a shielded write while migration awaits passwords.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): state the disabled-tool reason once, as one translation unit
"Clear Platform Addresses" explained its own unavailability twice in the same
row — a tooltip ("...because...") and an italic label ("...while...") — giving a
translator two units for one idea, and drifting on the word that carries the
meaning: the tool is disabled permanently, so "while" is wrong. Keeps the
always-visible label (a tooltip on a disabled control is easy to miss) with the
permanent reading, and drops the tooltip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: correct the data-deletion promise and record the migration change set
NET-019 still promised to "permanently delete all local data" and that "the
action cannot be undone", which the shipped Clear Database dialog now
contradicts: it discloses that an earlier version's read-only recovery database
stays on the device and may still contain wallet recovery data. The story now
matches the dialog and its sibling WAL-007 — the population it is written for
(clearing a machine before handing it on) is the one it most misleads.
UX-001 described the progress block yielding its pointer sink to a passphrase
prompt but never said the prompt installs its own in its place, reading as if the
click-through hole were still open. It now states the hand-off as an invariant,
for every prompt, dismissible or not — an unwritten invariant is how that hole
was reopened the first time.
CHANGELOG covered only the DashPay change set. Adds the two user-visible ones it
missed: the per-wallet password prompt on the first launch after an upgrade (with
its safe skip path), the read-only recovery database that "Clear Database" and
"Remove Wallet" no longer erase and the developer tool that is disabled as a
result, and the shielded refusal message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(wallet): warn that SecretLease::lease() refcounts per call, not per scope
Independent verification of the SEC-002 fix (integration composition review)
found that lease() mints a fresh Arc on every call — two unrelated consumers
calling lease(scope) directly get two independent refcounts, so the first to
drop can evict the secret while the second is still relying on it. Not
currently reachable (the one call site correctly clones), but the type can't
enforce the invariant, so the next new consumer would reach for the public
lease() API and silently reintroduce the exact race SEC-002 just closed.
Document the footgun at the point of call rather than leave it undiscoverable.
* test(ui): prove the secret prompt's transition-frame click-through
egui resolves each frame's click at begin_pass against the previous frame's
widget geometry and modal layer. On the frame a passphrase prompt first
renders, the control beneath still existed last frame with no sink and no
modal layer above it, so the click completes on it before modal_chrome
installs the sink — mirroring AppState::update, where the visible screen
renders before render_secret_prompt.
- transition_frame_click_leaks_through_a_newly_activated_prompt: RED repro,
parked #[ignore]; un-ignore once the barrier is installed before the
visible screen renders on the activation frame.
- primed_prompt_blocks_the_same_injected_click_sequence: control (green) —
the identical injected click, with the prompt primed one frame earlier, is
absorbed. Isolates the leak to the transition frame, not the test harness.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(backend): refuse unavailable shielded ops early and scope DashPay gate rejections to one request
Two migration-gate refinements in run_backend_task, both closing bot-review
findings on PR #893.
Shielded pre-check: a shielded fund movement now short-circuits with
ShieldedOperationsUnavailable as the very first thing run_backend_task does —
before ensure_wallet_backend materializes seeds, registers upstream, and binds
Orchard for every loaded wallet just to run an op the app refuses. is_available
is a side-effect-free config read, safe before backend init; the in-handler gate
in run_shielded_task stays as belt-and-suspenders. Shielded ops are unavailable
on every network today, so this pre-check also precedes the migration gate: a
shielded write during a storage update now gets the accurate "not available"
message instead of a misleading "wait for the update".
DashPay guard scoping: the migration gate now tags a rejected contact action
(Accept/Reject/Cancel) with DashPayContactRequestActionFailed carrying its
request ID, so the Identity Hub releases only that request's in-flight guard.
The previous stopgap blanket-cleared every guard on a bare WalletStorageNotReady,
which could re-enable a different contact action's row while its paid state
transition was genuinely still in flight. release_request_guard_for_error drops
the blanket-clear arm; the now-unused clear_in_flight is removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): drop the transition-frame click when a passphrase prompt activates
egui resolves each frame's click at begin_pass against the previous frame's
widget geometry and modal layer, before update() runs. On the frame a passphrase
prompt first renders, the previous frame had no prompt and no input sink, so a
press-then-release completing now still lands on the control beneath — the modal
installs its sink one frame too late, and reordering the render within the frame
cannot help.
AppState::update now detects the prompt-activation rising edge (covering both the
just-in-time unlock and the migration password prompt, via
has_blocking_secret_prompt) and calls drop_activation_frame_pointer_click, which
clears this frame's pending pointer input before the screen beneath runs. A
widget only reports a click while a Released event is still in input.pointer, so
dropping it strands the leaked click; keyboard input is left intact for the
freshly focused password field, and the sink covers every later frame.
Un-ignores the transition-frame repro (now GREEN) and adds a migration-prompt
sibling; the primed-prompt and yielding-overlay sink tests still pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(ui): pin passphrase activation wiring in the real AppState update loop
The two existing transition-frame repro tests mirror
drop_activation_frame_pointer_click directly in a hand-rolled closure — they
never drive AppState::update(), so the production rising-edge call site in
app.rs was untested: deleting it left the suite green. Add
appstate_jit_prompt_activation_drops_transition_frame_click and
appstate_migration_prompt_activation_drops_transition_frame_click, which
mount a real AppState via build_eframe, activate a prompt through the actual
JIT (test_set_secret_prompt_active) and migration (MigrationStatus) paths,
and assert a click completed on the activation frame does not reach the
welcome screen beneath. Independently confirmed both fail when the app.rs
call site is neutralized and pass when restored.
Also corrects a stale doc comment/assertion in hub_screen.rs left over from
3e69b2fd, which removed the blanket WalletStorageNotReady guard-release arm:
the comment still described a "blanket release... scoped to refusals that
prove nothing is running" that no longer exists in the code.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(wallets): keep dialogs open on trigger clicks
Fix SND-003, WAL-005, and WAL-006 by ignoring outside-click dismissal on each dialog's opening frame.
Co-Authored-By: Codex GPT-5 <noreply@openai.com>
* build(deps): bump platform to PR3968 tip (d18020f5), pulls in the AssetLockProof rehydration fix
Updates dash-sdk / rs-sdk-trusted-context-provider / platform-wallet /
platform-wallet-storage git pins from 93b967f9 to d18020f5
(dashpay/platform#3968 tip), which includes the AssetLockEntryWire fix
for the AssetLockProof deserialize_any bug (dashpay/platform#4133) that
was blocking wallet rehydration on every relaunch once any asset lock
existed.
Adapts to unrelated upstream API drift pulled in by the same bump:
DataContractJsonConversionMethodsV0::to_json(&self, platform_version)
was removed as part of dpp's JSON/Value conversion trait unification
(dashpay/platform#3573, already a known pre-existing lint debt in this
branch). The canonical replacement for "give me this contract's
current wire-format JSON" is
DataContractInSerializationFormat::try_from_platform_versioned(...)
+ serde_json::to_value(...) — updated the 3 affected call sites
(contract_chooser_panel.rs x2, update_contract_screen.rs,
token_creator.rs); from_json usage elsewhere is unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(ui): make the opening-click regression test exercise the real guard
opening_click_does_not_immediately_dismiss previously only asserted that
seeding PassphraseModalState with an armed ModalOpeningGuard left the cache
entry readable — a plain data-cache round trip that never called
clicked_outside_window_after_open and could not fail regardless of the
guard's behavior. Rewrite it to simulate an actual outside click via
egui::RawInput and call the real function: the opening click must be
swallowed once, then a later check against the same pending click must
detect it normally. Also drops the internal commit-SHA reference in the
adjacent comment per the "describe present state, not history" convention.
Found by an independent adversarial review of this branch's merge (QA-001,
QA-002).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(contracts): don't panic when contracts can't load on Update Contract screen
UpdateDataContractScreen::new() called app_context.get_contracts().expect(...),
panicking the whole process when the contracts store errors (e.g. an unwired
wallet backend returns Err(WalletBackendNotYetWired)). Degrade gracefully
instead: fall back to an empty contract list and show a calm, actionable
MessageBanner with the underlying error attached via with_details(), matching
the established pattern in document_action_screen.rs and
group_actions_screen.rs.
QA-002. Implemented by Codex Sol, committed by the coordinator (this
sandbox's git metadata for the worktree is read-only, a recurring
environment constraint this session).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(dashpay): accept all integer encodings for contact-request key indices
derive_contact_payment_address() extracted senderKeyIndex/recipientKeyIndex
with a strict match on Value::U32, but network-fetched documents decode
integers as Value::I128, so extraction always failed with "Missing
senderKeyIndex" and DashPay payments could never succeed. Fixed by using the
canonical platform_value helper (to_integer::<u32>()) already used for the
same fields in contact_requests.rs, extracted into a small pure helper
(read_contact_request_key_indices) and unit-tested against I128/U32/I64.
Swept the rest of the DashPay backend for the same strict-match fragility and
converted three more sites the same way: contact_info.rs and contacts.rs
(derivationEncryptionKeyIndex/rootEncryptionKeyIndex) and
auto_accept_handler.rs (accountReference, previously handled with a manual
five-arm match — now the same single helper call).
DPY-006. Implemented by Codex Sol, committed by the coordinator (this
sandbox's git metadata for the worktree is read-only, a recurring
environment constraint this session).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(mcp): hydrate saved wallets before wallet-facing tools read them
ListWalletsTool::invoke (and every other tool that calls resolve::wallet(),
which reads ctx.wallets) ran before any SPV gate wired the wallet backend —
ctx.wallets is only populated inside WalletBackend::new via
AppContext::ensure_wallet_backend, and core_wallets_list deliberately skips
resolve::ensure_spv_synced. A fresh standalone det-cli process therefore
always reported {"wallets":[]} even with wallets already persisted to disk.
Added resolve::ensure_wallets_hydrated(), which wires the backend via
ctx.ensure_wallet_backend() with a throwaway sender — no SPV start, no sync
wait, idempotent on repeat calls — and called it ahead of every resolve::wallet()
call site that wasn't already behind ensure_spv_synced (17 tools across
wallet.rs, identity.rs, and shielded.rs). Updated docs/MCP.md and docs/CLI.md
to describe the new hydrate-on-demand behavior.
Verified with the exact det-cli two-process smoke flow from CLAUDE.md: import
a wallet in one process, list wallets in a fresh process against the same
data dir, confirm it appears with the expected seed hash and alias.
MCP-001. Implemented by Codex Sol, committed by the coordinator (this
sandbox's git metadata for the worktree is read-only, a recurring
environment constraint this session).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(ui): stop the opening click from immediately cancelling confirmation dialogs (IDN-006, TOK-005, TOK-011, TOK-018)
Transfer Funds, token creation/registration, token claiming, and stop-tracking
all rendered their confirmation popup in the same egui frame as the button
click that triggered it. clicked_outside_window() read that still-active
click as a dismissal, so the dialog opened and cancelled itself within the
same frame -- visually indistinguishable from the button being a no-op.
Fixes it the same way passphrase_modal.rs's opening-click bug was fixed:
ConfirmationDialog now carries a ModalOpeningGuard, armed on construction and
consulted via clicked_outside_window_after_open() instead of the raw
outside-click check. The data-contract JSON popup gets its own guard for the
same reason.
Also fixes a compounding bug in the token creator: it rebuilt a brand-new
ConfirmationDialog (and therefore a freshly-armed guard) every single frame
via Option::insert(), which meant the dialog could never observe its own
post-opening frame. Switched to get_or_insert_with() so the dialog persists
across frames once created.
TOK-011 and TOK-018 share the same ConfirmationDialog component, so the fix
covers their reported no-op behavior without separate changes.
Implemented by Codex Sol, committed by the coordinator after independent
review of every hunk and a from-scratch fmt/clippy/test verification pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* fix(ui): wire missing identity navigation and fix stale post-refresh state (IDN-008, IDN-013a, DPN-008, IDN-009)
Navigation gap (IDN-008, IDN-013a, DPN-008): KeysScreen and the "My
usernames" list existed and worked but had no reachable route from Identity
Settings -> Advanced in the current Identity Hub build. Adds "Manage keys"
and "View all usernames" entries. Also corrects an inverted gate on the
Transfer screen's key-info button -- it only appeared when the identity had
*no* transfer key, backwards from the intended "manage the key you have"
flow. Key Protection (IDN-013a) was already fully implemented; it just
needed the same navigation fix to become reachable.
Refresh staleness (IDN-009): "Refresh identity data" fetched fresh state
from the network and persisted it correctly, but the backend task returned
the stale pre-refresh identity to the UI instead of the newly-fetched one,
and the Settings screen's own selected-identity cache only updated when the
identity's ID changed -- never on same-ID refreshes, which is the only kind
a refresh produces. Combined, a refresh could add a new on-chain key and the
UI would still show the old key count indefinitely. Fixed both: the backend
now returns the updated identity, and Settings reconciles same-ID refreshes
instead of only replacing on an ID change.
Implemented by Codex Sol, committed by the coordinator after independent
review of every hunk and a from-scratch fmt/clippy/test verification pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* fix(backend): stop silent hangs and panics in backend tasks (HANG-CLASS, IDN-002, MN-001, DOC-004, TOK-003)
Task-panic watchdog (HANG-CLASS): handle_backend_task/handle_backend_tasks
spawned work via tokio::task::spawn_blocking but dropped the returned
JoinHandle, so a panic inside the spawned closure vanished silently -- the
UI just hung forever with no error. The handle is now kept and awaited by a
managed watcher task; a panic or cancellation surfaces as a new typed
TaskError::BackendTaskFailed with a calm, actionable banner. The raw panic
payload is redacted from diagnostics (BackendTaskJoinError's Debug/Display
only expose task id / cancelled / panicked, never the panic message itself)
to avoid leaking arbitrary panic content into logs.
Network-request timeouts (IDN-002, MN-001, DOC-004, TOK-003): identity
loads (primary, voter, and DPNS-name fetches), document fetches, and token
lookups could all hang indefinitely on a stalled network call with no
feedback. Added a shared await_network_request_with_timeout helper (90s,
NETWORK_REQUEST_TIMEOUT) used at every affected call site, each mapping to
its own typed, actionable TaskError variant.
Token balance refresh needed more care than a plain timeout: the upstream
sync is not safely cancellable -- dropping it mid-flight could leave
is_syncing permanently stuck, trading a hang for silently-disabled sync
forever. Added await_managed_network_request_with_timeout: the request runs
as a detached, task-manager-tracked spawn; only the caller's *wait* on it
times out, so the sync itself always runs to completion even after the UI
gives up on it. A new token_balance_refresh_in_flight flag (RAII guard,
cleared on drop even if the refresh panics) also gives the refresh
single-flight protection so overlapping requests can't race.
Also fixed two more sites with the same forbidden string-match anti-pattern
DOC-004 was originally reported against (matching literal banner text
instead of message type to know when an in-flight fetch failed): the main
Tokens screen's RefreshingStatus and the token-claims screen's FetchStatus
both had the identical fragility and are fixed the same way.
Implemented by Codex Sol, committed by the coordinator after independent
review of every hunk and a from-scratch fmt/clippy/test verification pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* docs(user-stories): drop transient review-ID citation from UX-001
SEC-004 is an internal review-finding ID with no meaning outside the
review artifact that produced it — doesn't belong in a durable spec.
Flagged by Claudius-Maginificent's PR894 review.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* fix(app): stop migration frame race and preserve vote eligibility across migration
A single per-frame migration-state snapshot now backs every input-claim
and rendering decision (has_blocking_secret_prompt, claim_overlay_input,
ProgressOverlay::render_global, MigrationReconciler::update_banner)
instead of each call re-reading live state — closing a window where a
mid-frame migration transition could let the underlying screen consume
input for a frame where a blocking prompt was about to appear.
The periodic scheduled-vote sweep now defers while migration is in
progress instead of running unconditionally and silently skipping votes
whose imported identity isn't loaded yet. On migration completion, a
recovery sweep casts any vote whose normal 120s eligibility window
overlapped the deferred period, so a password prompt left open past that
window no longer permanently drops the vote.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* fix(dashpay): make contact-request decline/cancel idempotent
DashPay decline and cancel each broadcast a paid visibility transition
(contactInfo hide) before writing a local retire marker. A crash, retry,
or a second UI surface between the broadcast and the marker could
re-broadcast the hide and re-pay. Guard the flow so the paid hide runs at
most once per request:
- Add a durable per-request recovery journal (ContactRequestActionPhase)
in the DashPay k/v sidecar, scoped to the acting identity, so a retry
resumes at the last committed phase instead of re-broadcasting.
- Add a request-wide async lock plus a process-local in-flight claim so
concurrent declines/cancels serialize on one paid hide.
- Paginate contactInfo lookup and reuse it for a hidden-state probe so a
corrective unhide only fires when the contact is actually hidden.
- Correlate a panicked paid action back to its request id via
DashPayContactRequestActionFailed so the Hub releases only that guard,
and route contact-request results/errors to a hidden Hub screen.
- Retain paid-action guards across view resets; release only on the
correlated terminal result.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(wallet): fail Clear-Database safely and hydrate legacy wallets for MCP
Two wallet-readiness gaps:
- clear_network_database silently no-op'd its wallet-secret, DashPay
sidecar, and shielded cleanup when the wallet backend was not yet wired,
so "Clear Database" could report success while persisted secrets from an
earlier run survived. Require the wired backend up front and return the
dedicated WalletDataClearUnavailable error, leaving all state intact for
a safe retry.
- Standalone/headless MCP never awaited the cold-start legacy-data
migration, so legacy wallets were invisible to wallet reads. Hydrate and
finish the pending migration in ensure_wallets_hydrated, converting a
terminal MigrationState::Failed back into its typed task error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(app): correlate task results to their originating operation
A backend-task error was routed purely by message type, so a concurrent
task's failure could trip an unrelated screen's in-flight status, and the
token-balance refresh guard could strand forever on a true hang.
- Introduce BackendTaskContext, attributed to each dispatch, and carry it
on TaskResult::{Success,Error}. Add display_backend_task_result /
display_backend_task_error so screens correlate a result to the exact
operation (document query, token-balance refresh, reward-estimate pair)
instead of matching on message text.
- Document, token, and claims screens now clear their in-flight status
only when the failing/completing task matches the pending one; an
unrelated failure no longer clears a genuine refresh banner.
- Suppress a duplicate token-balance refresh only while the first is
pending, and give the hung-refresh guard honest restart guidance.
Composes with the DashPay request-id correlation: forward_backend_task_join_error
now carries both the optional request id and the task context.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(wallet): also wipe identity private keys on Clear Database (SEC-001)
Clear Database wiped seeds, single-keys, DashPay overlays, and shielded
state but never removed identity private keys — the identity_key_priv.*
vault entries or the det:identity:* records. Those keys are Tier-1 keyless
(plaintext-recoverable) by default and include masternode voting/owner/
payout keys, so a user who chose to erase all local data still left
fund-control keys recoverable on disk.
The clear-all sweep already fans out over local_identity_ids() to drop
each identity's DashPay overlays; call the existing public helper
delete_local_qualified_identity for each identity in that same loop. It
runs clear_identity_vault_keys (-> IdentityKeyView::delete_all, wiping the
vault key bytes) and purges the identity scope + index (removing the
det:identity:* records), closing both halves of the gap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(wallet): report partial failures when Clear Database can't delete every secret (SEC-002)
forget_wallet_local_state and forget_all_wallets_local logged each failed
per-secret delete but returned success unconditionally, so a failed seed,
single-key, or identity-key delete was still reported to the user as a
completed wipe — leaving recoverable secrets on disk behind a false
"cleared" message.
Accumulate delete failures instead of swallowing them:
- forget_wallet_local_state keeps attempting every step (resilient) but
returns the first failure so a partial wipe is never reported as clean.
- forget_all_wallets_local returns a ClearAllOutcome carrying the upstream
ids to remove plus every delete failure.
- clear_network_database collects those failures and the per-identity
wipe failures, still clears the in-memory maps, then returns the new
typed TaskError::WalletDataClearIncomplete { failed, #[source] first_error }
when anything failed. Its Display tells the user to restart and retry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(dashpay): accept both decrypt-failure variants in unreadable-private-data test
read_contact_info_private_data decrypts contactInfo privateData with
unauthenticated AES-256-CBC + PKCS7 and a random IV. A wrong-key decrypt
usually fails PKCS7 unpadding (DecryptFailed), but roughly 1 in 256 the
random IV produces valid-looking padding and the garbage plaintext then
fails to parse (DeserializeFailed). Both mean the same thing — the stored
payload is present but unreadable, so the write aborts.
Two tests asserted ONLY DecryptFailed, so they flaked ~0.3% of full-suite
runs (confirmed: 4 failures in 1500 isolated runs before, 0 in 2500
after). Widen both assertions to accept DecryptFailed OR DeserializeFailed;
the product code's abort behavior is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(contracts): isolate update_contract_screen degrade test from shared contract state
constructor_degrades_when_contracts_cannot_be_loaded asserts
known_contracts.is_empty(), which only holds when get_contracts() fails:
on success it always returns the pinned system contracts (dpns,
dashpay, ...) and "dashpay" is not in the constructor's excluded set. The
test built a real AppState, which wires the wallet backend asynchronously
inside the test's Tokio runtime, so whether get_contracts() saw a wired
backend (success -> non-empty) or not (error -> empty) raced the
constructor — the flake (green in the integration gate, red in CI).
Construct the screen from a backend-less test_app_context instead: with no
wallet backend wired, get_contracts() deterministically fails and the
constructor degrades to an empty list, which is exactly the path this test
names. Drops the DASH_EVO_DATA_DIR env-var dance and its module-local lock
entirely (0 failures in 60 isolated runs, was intermittently red).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(wallet): report Clear-Database failure when the identity index can't be listed (SEC-VERIFY-001)
If local_identity_ids() fails, clear_network_database skips every
per-identity key wipe — yet it still returned Ok(()), so every identity's
private keys (incl. masternode voting/owner/payout) could survive behind a
false "cleared" message: the exact false-success class SEC-001/SEC-002
close, gated behind a listing error.
Push the listing error into the failures accumulator so it surfaces as
TaskError::WalletDataClearIncomplete instead of a silent success. The
warn log is kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): add Back navigation to the Manage Keys screen (dead-end lockout)
KeysScreen renders a read-only key list pushed onto the screen stack but
returned AppAction::None unconditionally, trapping the user with no way
back to the identity view. Add a Back control in the header row that
returns AppAction::PopScreen, matching the sibling read-only detail
screens (e.g. contact_profile_viewer). A kittest asserts the button
renders and its click pops the screen off the stack.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): show fee estimate and total before sending Dash (SND-005)
The Send Dash screen dispatched a payment with no fee or total shown, so
the user committed without seeing what would leave their balance. Add a
fee summary rendered directly above the Send button:
- Simple mode: estimated network fee, total deducted, and (when the fee
is taken out of the amount, e.g. Core -> Platform) what the recipient
receives. Covers every cleanly-estimable source/destination pair
(Core->Core/Platform/Shielded, Platform->Platform/Core/Shielded,
Identity->Core/Platform/Identity), reusing the same
model::fee_estimation estimators the amount field's "Max" reserve uses
so the two never disagree. Combinations whose fee depends on inputs the
backend selects at send time (identity top-ups, shielded spends) show a
neutral "calculated when you send" note instead of a wrong number.
- Advanced mode: estimated network fee for the count-driven paths
(Core->Core, Platform->Platform), else the same neutral note.
All fee math stays in model::fee_estimation; FeePreview only arranges
already-estimated numbers for display. Pure unit tests cover the on-top
vs deducted-from-amount total/recipient semantics and saturation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dashpay): report the correct cause when Add Contact can't resolve the recipient (NEW-002)
Sending a contact request to a recipient with no DashPay decryption key
raised DashPayError::MissingDecryptionKey, whose message ("Your identity
is missing a decryption key required for contacts") blamed the SENDER —
even though the sender's keys are fine and it is the RECIPIENT
(to_identity) that lacks the key. The Add Contact screen compounded the
error by offering an "Add Decryption Key" button that would add a key to
the sender's own identity, a remedy that cannot fix a recipient-side gap.
Rename the variant to RecipientMissingDecryptionKey and reword it to
correctly attribute the failure to the recipient with an actionable,
jargon-free message. Drop it from requires_user_action() and remove the
misleading self-remedy button — the sender has no key to add; the message
tells them to ask the recipient to finish setting up their profile. Both
error classifiers and their tests are updated to the renamed variant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: correct SND-005 fee-estimate criterion to match inline pre-send summary
The SND-005 acceptance criterion described the fee estimate as "shown in
confirmation dialog", but the HD-wallet Send Dash screen surfaces it
inline above the Send button (simple and advanced modes) before dispatch.
Reword the criterion to match the implemented behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): guard reusable modal components against opening-frame dismiss (NEW-003)
InfoPopup and SelectionDialog closed themselves on the same frame they
opened: the click that opened the popup lands outside the not-yet-rendered
window rect, so the unguarded clicked_outside_window() check fired true on
the opening frame and dismissed the popup before it was ever visible
(e.g. the token "More Info" popup never appeared).
Both components are value-constructed every frame from consumer-held state,
so a persistent ModalOpeningGuard field cannot survive across frames. Add
clicked_outside_window_after_open_by_id(), which records the last render
pass in egui temp memory keyed by a stable id and skips the outside-click
check on the opening frame — detected as a gap in rendering, so it re-arms
automatically however the popup was previously dismissed, with no teardown.
Fixing this inside the two components fixes every consumer at once
(InfoPopup: 13 call sites; SelectionDialog: no current consumers, so this
is preventive). Unit tests cover the opening-frame skip and the re-arm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): guard screen-level popups against opening-frame dismiss (NEW-003)
Six screen-level popups used the unguarded clicked_outside_window(), so the
click that opened them was seen as an outside click on the first render
frame and dismissed them before they appeared. Give each a persistent
ModalOpeningGuard field, arm it where the popup's open state is set, and
switch the check to clicked_outside_window_after_open() — mirroring the
existing wallets_screen rename-dialog and receive-dialog pattern.
Sites fixed:
- contracts_documents_screen: "Select Properties" fields dropdown
- dashpay/profile_screen: avatar-URL popup
- identities_screen: edit-alias modal (both open buttons)
- tokens my_tokens: "More Info" token popup and reward-explanation popup
- wallets add_new_wallet_screen: "Fund Wallet" receive popup
- wallets_screen/dialogs: fund-platform-address and mine-blocks dialogs
(the receive dialog in the same file was already guarded)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dashpay): show payment-history amount in DASH, not raw duffs (NEW-004)
The DashPay payment-history row printed the raw duffs value with a "Dash"
label — a 0.001 DASH payment rendered as "-100000 Dash" — because the
amount (duffs, from DashPayPaymentHistory) was formatted with `{} Dash`
and no unit conversion. Format it with `format_duffs_as_dash` so it reads
"-0.001 DASH". Fixed in both the Pay screen history (the live path) and
the contact-details history (currently unpopulated, fixed defensively);
documented that the `Credits`-aliased field actually holds duffs.
Counterparty label (NEW-004 part 2) is left as scoped follow-up: the
payment history resolves names against saved DashPay contacts only, so a
recipient paid by DPNS username (not a mutual contact) shows
"Unknown (<prefix>)". Resolving it needs a DPNS lookup by identity id or
persisting the send-time name — deeper plumbing than this cached-read
path — so it is marked with a TODO(NEW-004) rather than forced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(wallets): confirm single-key removal and refresh asset locks after creation
WAL-007: single-key wallets had a separate Remove handler that called
forget() immediately, bypassing the HD wallet's confirmation state
entirely. Both wallet types now route through the same pending-removal
state feeding the existing danger confirmation modal.
ALK-002: Loaded(empty) was a terminal cache state as reported, but the
actual navigation gap was that PopScreenAndRefresh invokes
refresh_on_arrival(), not refresh(), which is where the cache
invalidation previously lived. The selected wallet's asset-lock cache
entry is now invalidated on root-screen arrival so a freshly created
lock is picked up on the next render.
Cherry-picked from 26937906 onto fix/snd-003-receive-inert. Conflict
resolution: the single-key-remove-button block was refactored into
request_selected_wallet_removal() (theirs); the snd003 branch's
customized HD-removal confirmation message (the earlier-version
read-only recovery-database note) was preserved into that method's HD
branch. The branch's NEW-003 rename-dialog ModalOpeningGuard usage in
this file is untouched.
Co-Authored-By: Codex GPT-5 <noreply@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(wallets): restore password-modal focus without leaking background input
NEW-005 (release-blocking): the wallet-unlock / JIT secret password field
could not receive keyboard focus or typed input. `modal_chrome` registered a
separate full-screen "sink" Area as egui's modal layer; because that sink was
a different layer than the `egui::Window` holding the field, the window
resolved *below* the modal layer, so egui's `Memory::allows_interaction`
silently denied the `TextEdit` focus (and clicks on it).
Register the window's OWN layer as the modal layer instead: comparing a layer
against itself is `Equal`, so the modal's fields always resolve at/above the
modal layer and stay focusable, while every lower layer is blocked. The
full-screen sink is retained — moved to `Order::Middle`, strictly below the
`Order::Foreground` window — because it is load-bearing for background input
blocking: egui's `layer_id_at` only redirects a below-modal click to the modal
layer when some interactable area covers that position, so without full-screen
coverage a click landing outside the centered window would fall through to the
app beneath.
Add a kittest regression, passphrase_modal_password_field_focuses_and_blocks_background,
asserting the field takes focus and receives typed text (surfaced via Submit),
the modal layer is the window's own layer (not the sink), and a widget behind
the modal receives none of it. The pre-existing background-blocking and
dismissal kittests continue to pass, confirming the sink still blocks input.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(migration): gate startup migration on a minimum saved-data version
DET ran its startup data migration without checking the on-disk data
version, so migrating from an unsupported (too-old) version failed in
confusing ways. Read settings.database_version before migrating and gate
at both entry points (legacy-settings import and FinishUnwire) before any
sentinel or state is written; the legacy data.db is opened read-only.
Data versions 11..=40 migrate directly (v0.9.3 = 11 = the supported floor,
already migratable). Older data is rejected with an actionable "install
Dash Evo Tool 0.9.3 first" message; newer data fails closed. Fresh installs
(initialize writes v38 before the gate) are never rejected; a corrupt DB
missing the version row fails closed.
Typed errors SavedDataTooOld/SavedDataTooNew and LegacyDataTooOld/
LegacyDataTooNew carry numeric context and #[source] only (no user strings
in variants). Adds src/model/data_migration.rs (pure version classification).
Implemented by Codex (gpt-5.6-sol, high effort); data-safety reviewed
(gate-before-mutation, exact 11..=40 boundaries, fail-fast-no-write,
fresh-install-safe) — 0 blocking findings.
Co-Authored-By: Codex gpt-5.6-sol <noreply@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* test(migration): cover the too-new fail-fast and upper-accept boundary
Marvin flagged two LOW gaps in the startup DB-version gate. This feature is not
GUI-testable, so tests are its primary safety net.
- Add an async `finish_unwire::run` test (too_new_database_version_is_rejected_
before_migration) mirroring the existing too-old test: a version above the
ceiling (41) is rejected before any pass runs, surfacing the typed
SavedDataTooNew / LegacyDataTooNew chain, and NEITHER the completion sentinel
NOR any migration state is written (state stays Idle).
- Add an upper-accept boundary test (max_supported_database_version_is_accepted_
for_direct_migration): MAX_DIRECT_MIGRATION_VERSION (40) — the top of the
accepted 11..=40 range — is accepted, and the first version above it is
rejected as too new. Previously only 11-accept and 41-too-new were pinned; the
top of the accept range was never asserted accepted.
QA-001: document the deliberate headroom on MAX_DIRECT_MIGRATION_VERSION (40)
above DEFAULT_DB_VERSION (38), so data from a slightly newer build (39, 40) still
migrates rather than failing closed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(settings): add developer-only Wipe Platform Data control (NEW-006)
Wire the previously-orphaned SystemTask::WipePlatformData to a developer-only
button on the Identity Hub Settings tab, gated to Devnet (the backend
wipe_devnet handler only clears devnet identities, tokens, and user contracts).
Guarded by a type-"WIPE"-to-confirm destructive dialog via a new
ConfirmationDialog::require_confirmation_text builder. Implemented by Codex Sol.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* feat(contacts): add View Profile action to Identity Hub contacts (NEW-007 / DPY-005)
Each active-contact row now offers a View Profile action that opens the working
ContactProfileViewerScreen for the selected contact, alongside the existing Pay
action. Reuses the same viewer the legacy DashPay paths use; the orphaned
ContactDetailsScreen is left untouched. Implemented by Codex Sol.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* fix(wallet): fail-closed on identity-key wipe and surface Clear-Database failures (SEC-001, SEC-002)
clear_identity_vault_keys now returns Result and propagates instead of
swallowing vault read/decode/delete errors, so identity removal (Clear
Database, identities screen, masternode detail, migration) reports incomplete
rather than clean when private keys — including masternode voting/owner/payout
keys — cannot be deleted. IdentityKeyView::delete_all attempts every key and
returns the first error instead of short-circuiting. DashPay sidecar/overlay
delete failures in clear_network_database are now accumulated into the failures
list (SEC-002) rather than warn-only. Adds a masternode-removal regression test
that injects a vault-key delete failure. Fixes found by security review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* test: serialize DASH_EVO_DATA_DIR mutation with one shared lock
Replace per-module mutexes guarding DASH_EVO_DATA_DIR with a single crate-wide
lock in a new test_support module. Module-local locks let tests in different
modules race on the process-global env var under parallel execution, causing
intermittent AppState::new failures (e.g. add_token_by_id_screen's
display_task_result test). One shared lock serializes them deterministically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* fix(ui): give each modal a unique guard id so popups don't dismiss each other (QA-001)
The opening-frame dismiss guard keyed on a single global Id shared by every
InfoPopup and SelectionDialog, so two independent popups on one screen (e.g. the
profile screen's Profile-Guidelines and Avatar-Guidelines info popups) shared
render-history state: closing one via outside-click then opening the other on
the next frame dismissed the second on its own opening frame. Each InfoPopup and
SelectionDialog now takes a caller-provided per-instance Id (mirroring
passphrase_modal), so guards no longer collide. Adds a two-popup regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* fix(ui): move Wipe Platform Data beside its sibling network controls (NEW-006)
The developer-only "Wipe Platform Data" control (story NET-011) wipes data
for the whole devnet, but was rendered on the Identity Hub -> Settings tab,
a per-identity screen. Its siblings are network-scoped and live on the
Network Chooser: "Clear {Network} Database" (NET-019) in the "Database
Maintenance" section and "Clear SPV Data" (NET-020).
Move the control into "Database Maintenance", directly after the Clear
Database button, matching that file's danger-button styling and its
existing selected_role.at_least(UserRole::Developer) gating idiom.
Gating is unchanged and stays deliberately narrow: Developer role AND
Devnet. The devnet condition is load-bearing, not cosmetic, because the
backend wipe_devnet() is devnet-scoped
(delete_all_local_qualified_identities_in_devnet / _tokens_in_devnet /
clear_user_contracts).
Gate is now enforced in three places: the render check, a re-check in
show_wipe_platform_data_confirmation that dismisses the dialog if the gate
stops holding (so a dialog opened on Devnet cannot fire after a network
switch), and a fail-closed wipe_platform_data_action.
The type-WIPE confirmation and all user-facing wording are unchanged. Both
unit tests move to ui::network_chooser_screen::tests and now assert the
negative cases in both directions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m6b2TRP1e6S3uQHcaq3Cq
* fix(platform): keep cause-less transition results unconfirmed (#897)
A state transition can be accepted for broadcast while the separate result wait fails. Treat SDK broadcast-error envelopes without a structured consensus cause as submitted but unconfirmed, and direct the user to verify completion before retrying.
Co-authored-by: Codex GPT-5 <noreply@openai.com>
* docs(qa): PR892 user-story QA campaign — full retest record (175/175 stories) (#895)
* docs(qa): scaffold PR892 user-story QA campaign checklist
Populated progress.md with all 123 stories from docs/user-stories.md
(112 Implemented to test, 11 Gap pre-marked N/A). Note: source brief
referenced 152 stories incl. UX/IDH/MN categories that don't exist in
the current doc — proceeding with the doc as it actually is.
* docs(qa): confirm PR892 tx-history regression fix; WAL/SND/NET spot checks
Critical result: full quit + cold-boot relaunch on the same data dir now
correctly re-renders transaction history (was the PR892 bug). Verified
with 3 real testnet transactions via the Pasta faucet.
Also: NET-001 (switch networks) PASS, WAL-001/004/010/011/016/023/024
PASS, SND-001 PASS (nav only), SND-003 (Receive button) FAIL — no QR
code or modal appears, reproduced 3x.
* docs(qa): add shared campaign context for delegated per-category agents
* docs(qa): complete remaining WAL user-story QA pass (PR892)
Finishes WAL-002/003/005/006/007/008/012/013/017-020/021/022 a…
Summary
Unification of JSON/Value conversion across rs-dpp + wasm-dpp2: canonical
JsonConvertible/ValueConvertibletraits on every domain type, ~80 trait impls + ~200 round-trip tests, and a coordinated deprecation sweep that removed all 5 documented Critical bugs and most legacy non-canonical conversion mechanisms.What landed (high-level)
JsonConvertible/ValueConvertibleimpls on ~80 rs-dpp domain types.json_convertible_tests/value_convertible_testsmodules with full wire-shape assertions per type.StateTransitionValueConvert/StateTransitionJsonConverttraits + 68 impl files, A6/A7/A8/A9 identity traits, A10/A11 document traits down to one helper each, AssetLockProof asymmetric methods, and the_versionedDataContract method family. Replaced with canonical + (for DataContract)from_*_validated(value, &pv)opt-in validation.outpoint_serdewrapper. The blstrs_plus PR is still pending (oneValidatorSetvalue-round-trip test remains#[ignore]).Critical findings status
is_human_readableHR/non-HR divergenceserialization_traits.rswith the divergence table +ContentDeserializercaveat + pointer to canonical dual-shape visitor examples.array→bytescoercion inFrom<JsonValue> for Valuers-platform-value/src/converter/serde_json.rs). Faithful conversion: JSON array →Value::Array. Pin tests added.ExtendedDocumentnon-round-trippable#[serde(tag = "$extendedFormatVersion")]derive; round-trip tests added.DataContractserde impurityDeserializeno longer hardcodesfull_validation=true); KEEP-AS-EXCEPTION rationale documented at all 3 sites.to_canonical_objectsorts keys (assumed signature-load-bearing)PlatformSignablederive). Methods had zero production callers; deleted along with A1/A2.DataContract API — final shape
The deprecation sweep collapsed the
_versionedmethod family into a clear split by validation policy:serde_json::from_value::<DataContract>(json)/platform_value::from_value::<DataContract>(v)/serde_json::to_value(&dc)/platform_value::to_value(&dc). Use for storage reads, internal round-trips.DataContract::from_json_validated(json, &pv)/from_value_validated(value, &pv). Use on trust boundaries (SDK ingest, fixture loaders). No bool param — name implies always-validates.to_validating_json(&pv)— different concept (produces JSON-Schema-compatible output with binary as u8 arrays).to_*_versioned,into_value_versioned,from_*_versioned(_, full_validation, _).Test results
recursive_schema_validatorignores; 1 isValidatorSet::value_round_trip_with_full_wire_shape(pending blstrs_plus upstream PR).cargo check --testsclean acrossdpp/drive/drive-abci/wasm-dpp/wasm-dpp2/dash-sdk/rs-sdk-ffi.Architectural conventions established
#[serde(tag = "$formatVersion")]; all discriminated enums use a$-prefixed key ($type,$action,$transition); zero adjacent-tagged enums remain.#[json_safe_fields]rollout: 25+ V0/V1 struct leaves carry the attribute. JS-safe large-integer protection at every serialization site.json!{}/platform_value!{}literal assertions in every round-trip test (~85 tests on this convention).impl_wasm_conversions_inner!(45 sites in wasm-dpp2) for rs-dpp domain types using canonical traits;impl_wasm_conversions_serde!(20 sites) for wasm-only DTOs without rs-dpp counterparts — pattern documented and re-audited.Cross-package audit (just before shipping)
Serialize/Deserialize, 0 references to deleted legacy APIs, 38impl_wasm_serde_conversions!applications. All DTOs follow canonical patterns.IdentifierWasm,PoolingWasm,PlatformAddressWasm) — all documented production-required adapters for lenient JS-facing parsing; rest of crate flows through canonical helpers.Consensus and stored-data compatibility
Audited the full diff vs
v4.1-dev(4 independent review passes: bincode byte-compatibility, platform-value serde rewrite, rs-drive/rs-drive-abci hunks, validation-behavior tracing). Verdict: no consensus impact; all stored blockchain data remains readable.PlatformSerializedispatches to derivedbincode::Encode, independent of serde — the serde impl swaps in this PR cannot change wire, signable, or stored bytes. Noplatform_serialize/platform_signable/bincodeattribute changes anywhere;document/v0/serialize.rsand the document serialization traits are untouched; every type that swapped serde impls kept itsEncode/Decodederives intact. The one touched#[bincode(with_serde)]consensus file (asset_lock_proof/mod.rs) only changed conversion-method bodies.decode_raw_state_transitions→deserialize_from_bytes); the JSON/Value paths reshaped here are client-only. The AddressWitnessMAX_P2SH_SIGNATUREScap is still enforced at bincode decode (witness.rs), andContestedIndexFieldMatchserde is never used in contract ingestion (index parsing is manual fromValuemaps).platform_valueserde serializer cannot reach storage:Valuehas native bincode derives, and no consensus type routes aValuethrough serde-bincode (workspace-wide sweep).Breaking changes (JS-facing wire shape)
Consumer-visible JSON shape changes — downstream JS consumers must update. None of these affect consensus (see section above):
TokenPricingScheduleis now internally$type-tagged with JS-safe integers (was externally tagged with raw u64):{ "SinglePrice": N }→{ "$type": "singlePrice", "price": N }{ "SetPrices": { "5": 50 } }→{ "$type": "setPrices", "prices": { "5": 50 } }price/ amounts serialize as strings (toJSON) / BigInt (toObject) above 2^53.TokenSetPriceResult,TokenSetPriceForDirectPurchaseTransition,TokenEvent::ChangePriceForDirectPurchase, and the twoStateTransitionProofResultpricing-schedule variants.verifyActionInfosInContract{Vec,Map}(wasm-drive-verify) now returns the canonical group-action shape (rewritten to use DPP's canonical serde instead of a hand-rolled representation):{ "type": "Mint", ... }→{ "$kind": "tokenEvent", "$type": "mint", ... }note→publicNote,frozenIdentity→frozenIdentifier,burnFrom→burnFromIdentifier,personalEncryptedNote→privateEncryptedNote; encrypted-note object →[number, number, Uint8Array].AddressWitnessJSON: tag key renamedtype→$type(variantsp2pkh/p2sh, fieldredeemScript). TheMAX_P2SH_SIGNATUREScap is no longer pre-checked on the JSON/Value deserialize path — bincode decode (the path nodes use) still enforces it, so oversized witnesses are still rejected on-chain; client tooling just loses the early error.TokenDistributionRecipientJSON now only accepts the canonical{ "$type": ... }shape and explicitly rejects the pre-4.0.0-beta.4 shapes (bare"ContractOwner",{ "Identity": "<base58>" }). No system contract uses this type; only off-chain fixtures/tooling with the old shape are affected.Out of scope (separate work)
PublicKeydual-shape deserialize — pending upstream. OneValidatorSetvalue-round-trip test remains#[ignore]until it lands.wasm-dppcrate) — blocked on team decision.Docs
docs/json-value-unification-plan.md— the live plan + status doc (regularly updated through the work).docs/json-value-conversion-inventory.md— pre-pass-1 structural inventory.docs/json-value-conversion-canonical-pattern.md— the canonical-trait usage pattern, kept up to date.Test plan
cargo test -p dpp --features all_features_without_client --liband sees 3619 pass / 0 fail.docs/json-value-unification-plan.mdto confirm the architectural decisions (validation-opt-in, KEEP-AS-EXCEPTION for DataContract, tag-shape conventions) match team intent.IdentityCreditWithdrawalTransitionWasm) round-trips correctly from the JS SDK perspective.🤖 Generated with Claude Code