feat(wasm-sdk)!: add shielded pool WASM bindings and query methods#3235
feat(wasm-sdk)!: add shielded pool WASM bindings and query methods#3235QuantumExplorer merged 48 commits intov3.1-devfrom
Conversation
…thods Add WASM bindings for all 5 shielded state transition types in wasm-dpp2: - ShieldTransitionWasm - ShieldedTransferTransitionWasm - UnshieldTransitionWasm - ShieldFromAssetLockTransitionWasm - ShieldedWithdrawalTransitionWasm Each wrapper exposes type-specific getters (anchor, proof, binding signature, actions, etc.), serialization (toBytes/fromBytes/toJSON/toObject), and toStateTransition conversion following wasm-dpp2 patterns. Also adds computePlatformSighash utility for constructing the platform sighash used by Orchard binding signatures. Replaces all shielded todo!() panics in wasm-dpp2's StateTransitionWasm with proper None returns or error messages for identityContractNonce, identityNonce, setOwnerId, setIdentityContractNonce, and setIdentityNonce. Updates wasm-dpp to: - Register shielded types (15-19) in StateTransitionTypeWasm enum - Handle shielded transitions in createFromBuffer factory via serde Adds JS SDK shielded methods (platform.shielded.*): - shield, shieldedTransfer, unshield, shieldFromAssetLock, shieldedWithdrawal All accept pre-serialized transition bytes and broadcast with skipValidation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a new shielded WASM module and SDK query surface, five shielded state-transition WASM wrappers, shielded proof-result types, multiple serde/json-wire helpers and byte serializers, and applies json-conversion/serde-conversion feature-gated adjustments across shielded and address-funds types. Changes
Sequence Diagram(s)sequenceDiagram
actor JS as JavaScript Client
participant SDK as WASM SDK
participant WASM as wasm-dpp2
participant DPP as DPP Core
participant Verifier as Proof Verifier
JS->>SDK: getShieldedNullifiersWithProofInfo(nullifiers)
SDK->>WASM: request nullifier statuses + proof metadata
WASM->>DPP: fetch statuses + proof payload
DPP-->>WASM: statuses + proof payload
WASM->>Verifier: verify proof
Verifier-->>WASM: verification result
WASM->>SDK: assemble ProofMetadataResponseWasm (nullifier_map)
SDK-->>JS: return ProofMetadataResponseWasm
sequenceDiagram
actor JS as JavaScript Client
participant WASM as wasm-dpp2
participant DPP as DPP Core
participant Crypto as Crypto Libs
JS->>WASM: create ShieldTransitionWasm / call toBytes
WASM->>DPP: construct ShieldTransition::V0
WASM->>Crypto: validate proofs/signatures
Crypto-->>WASM: validation outcome
WASM->>DPP: PlatformSerializable -> bytes
WASM-->>JS: return bytes / StateTransitionWasm
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
packages/wasm-dpp2/src/shielded/mod.rs (1)
28-33: Consider consistent validation style.The length validation and conversion on lines 28-34 is correct, but
expect("checked length above")could be replaced withunwrap()for consistency, since the length is already validated. This is a minor style preference.♻️ Optional: use unwrap() after validation
if bundle_commitment.len() != 32 { return Err(crate::error::WasmDppError::invalid_argument(&format!( "bundleCommitment must be exactly 32 bytes, got {}", bundle_commitment.len() ))); } - let commitment: &[u8; 32] = bundle_commitment.try_into().expect("checked length above"); + let commitment: &[u8; 32] = bundle_commitment.try_into().unwrap();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/wasm-dpp2/src/shielded/mod.rs` around lines 28 - 33, Replace the post-validation call that uses expect("checked length above") with unwrap() for stylistic consistency: after you validate bundle_commitment.len() != 32 and return an error, use unwrap() when converting/creating the fixed-size array (the same spot currently calling expect) so the code relies on the prior check and matches the project's validation style (refer to the bundle_commitment length check and the conversion site where expect("checked length above") is used).packages/wasm-dpp2/src/shielded/shielded_transfer_transition.rs (1)
88-116: Add a round-trip test for this wasm-facing ABI.
toBytes/fromBytesplus the JS-visible getters are new public surface, and this PR explicitly ships without test updates. A small round-trip/golden test here would catch wire-format drift and wrong-variant regressions before they reach the SDK.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/wasm-dpp2/src/shielded/shielded_transfer_transition.rs` around lines 88 - 116, Add a round-trip unit test exercising the wasm ABI: construct a ShieldedTransferTransitionWasm instance, call to_bytes(), pass the bytes into from_bytes() and assert the resulting ShieldedTransferTransitionWasm equals the original; also include a JSON round-trip by calling to_json() and verifying the serialized value round-trips (or matches expected golden JSON), and verify to_state_transition() produces the expected StateTransition variant. Target the wasm-facing symbols to_bytes, from_bytes, to_json, to_state_transition and assert equality of the core inner StateTransition::ShieldedTransfer data to catch wire-format or variant regressions.packages/js-dash-sdk/src/SDK/Client/Platform/Platform.ts (1)
202-208: Consider a shared helper for the shielded broadcast path.These five bindings fan out to near-identical modules today. Once you add the missing decoded-type guard or any future validation/logging tweak, you'll need to keep five copies in sync. A small helper that takes
{ expectedType, logPrefix }would make this surface much harder to drift.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/js-dash-sdk/src/SDK/Client/Platform/Platform.ts` around lines 202 - 208, The five near-identical bindings under this.shielded (shield, shieldedTransfer, unshield, shieldFromAssetLock, shieldedWithdrawal) duplicate broadcast/validation logic; extract a small helper (e.g., createShieldedBinder(expectedType, logPrefix)) that returns a bound function given a method reference and metadata, then replace the direct .bind(this) calls with calls to that helper (pass the original method function like shieldMethod, shieldedTransferMethod, unshieldMethod, shieldFromAssetLockMethod, shieldedWithdrawalMethod and the appropriate expectedType/logPrefix) so shared decoded-type guards, validation and logging live in one place.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/js-dash-sdk/src/SDK/Client/Platform/methods/shielded/shield.ts`:
- Around line 24-31: After deserializing the transition with
dpp.stateTransition.createFromBuffer in shield(), add a type guard that checks
transition.getType() and ensure it matches the expected StateTransition type for
shield (e.g., ShieldedTransfer/Shield/ShieldFromAssetLock/ShieldedWithdrawal as
appropriate) before calling broadcastStateTransition(this, await transition,
...); if the type does not match, throw or return a clear error. Apply the same
guard/check (use .getType() on the result of
dpp.stateTransition.createFromBuffer) to all other shielded entrypoints so each
only accepts its intended StateTransition variant prior to calling
broadcastStateTransition.
In `@packages/js-dash-sdk/src/SDK/Client/Platform/Platform.ts`:
- Around line 37-41: The three new shielded module filenames use mixed/camel
casing; rename the files shieldFromAssetLock.ts, shieldedTransfer.ts, and
shieldedWithdrawal.ts to kebab-case (shield-from-asset-lock.ts,
shielded-transfer.ts, shielded-withdrawal.ts) and update all imports/exports
referencing them (e.g., the imports in Platform.ts: shieldFromAssetLockMethod,
shieldedTransferMethod, shieldedWithdrawalMethod) to use the new kebab-case
paths; also update any barrel/index exports and tests or references across the
repo to the new filenames so imports remain consistent.
In `@packages/wasm-dpp/src/state_transition/state_transition_factory.rs`:
- Around line 82-96: The Shield-related state transition variants
(StateTransition::Shield, ::ShieldedTransfer, ::Unshield, ::ShieldFromAssetLock,
::ShieldedWithdrawal) call serde_wasm_bindgen::to_value(&st) but fail to compile
because the Serialize impls are behind the rs-dpp "serde-conversion" feature;
fix this by enabling that feature for the dpp dependency in wasm-dpp's
Cargo.toml (add "serde-conversion" to the features list for dpp) so those types
derive Serialize and serde_wasm_bindgen::to_value(&st) will compile.
In `@packages/wasm-dpp2/src/shielded/mod.rs`:
- Around line 16-36: The doc comment for compute_platform_sighash_wasm
incorrectly states that extraData includes an amount for unshield and shielded
withdrawal transitions; update the comment to match the rs-dpp implementation by
removing references to amount and describing extraData as just the output
address bytes for unshield transitions and the output script bytes for shielded
withdrawal transitions (see the extra_sighash_data construction in unshield.rs
and shielded_withdrawal.rs for reference).
---
Nitpick comments:
In `@packages/js-dash-sdk/src/SDK/Client/Platform/Platform.ts`:
- Around line 202-208: The five near-identical bindings under this.shielded
(shield, shieldedTransfer, unshield, shieldFromAssetLock, shieldedWithdrawal)
duplicate broadcast/validation logic; extract a small helper (e.g.,
createShieldedBinder(expectedType, logPrefix)) that returns a bound function
given a method reference and metadata, then replace the direct .bind(this) calls
with calls to that helper (pass the original method function like shieldMethod,
shieldedTransferMethod, unshieldMethod, shieldFromAssetLockMethod,
shieldedWithdrawalMethod and the appropriate expectedType/logPrefix) so shared
decoded-type guards, validation and logging live in one place.
In `@packages/wasm-dpp2/src/shielded/mod.rs`:
- Around line 28-33: Replace the post-validation call that uses expect("checked
length above") with unwrap() for stylistic consistency: after you validate
bundle_commitment.len() != 32 and return an error, use unwrap() when
converting/creating the fixed-size array (the same spot currently calling
expect) so the code relies on the prior check and matches the project's
validation style (refer to the bundle_commitment length check and the conversion
site where expect("checked length above") is used).
In `@packages/wasm-dpp2/src/shielded/shielded_transfer_transition.rs`:
- Around line 88-116: Add a round-trip unit test exercising the wasm ABI:
construct a ShieldedTransferTransitionWasm instance, call to_bytes(), pass the
bytes into from_bytes() and assert the resulting ShieldedTransferTransitionWasm
equals the original; also include a JSON round-trip by calling to_json() and
verifying the serialized value round-trips (or matches expected golden JSON),
and verify to_state_transition() produces the expected StateTransition variant.
Target the wasm-facing symbols to_bytes, from_bytes, to_json,
to_state_transition and assert equality of the core inner
StateTransition::ShieldedTransfer data to catch wire-format or variant
regressions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2bdbcf86-45ca-47c6-ba00-fc5f4eeab635
📒 Files selected for processing (16)
packages/js-dash-sdk/src/SDK/Client/Platform/Platform.tspackages/js-dash-sdk/src/SDK/Client/Platform/methods/shielded/shield.tspackages/js-dash-sdk/src/SDK/Client/Platform/methods/shielded/shieldFromAssetLock.tspackages/js-dash-sdk/src/SDK/Client/Platform/methods/shielded/shieldedTransfer.tspackages/js-dash-sdk/src/SDK/Client/Platform/methods/shielded/shieldedWithdrawal.tspackages/js-dash-sdk/src/SDK/Client/Platform/methods/shielded/unshield.tspackages/wasm-dpp/src/identity/state_transition/transition_types.rspackages/wasm-dpp/src/state_transition/state_transition_factory.rspackages/wasm-dpp2/src/lib.rspackages/wasm-dpp2/src/shielded/mod.rspackages/wasm-dpp2/src/shielded/shield_from_asset_lock_transition.rspackages/wasm-dpp2/src/shielded/shield_transition.rspackages/wasm-dpp2/src/shielded/shielded_transfer_transition.rspackages/wasm-dpp2/src/shielded/shielded_withdrawal_transition.rspackages/wasm-dpp2/src/shielded/unshield_transition.rspackages/wasm-dpp2/src/state_transitions/base/state_transition.rs
js-dash-sdk uses wasm-dpp (not wasm-dpp2), so shielded bindings there belong in a separate effort. This PR focuses on wasm-sdk + wasm-dpp2 only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds 10 query methods to WasmSdk for the shielded pool: - getShieldedPoolState / WithProofInfo — total shielded balance - getShieldedEncryptedNotes / WithProofInfo — paginated encrypted notes - getShieldedAnchors / WithProofInfo — valid spend anchors - getMostRecentShieldedAnchor / WithProofInfo — latest anchor - getShieldedNullifiers / WithProofInfo — nullifier spent status Also adds ShieldedEncryptedNoteWasm and ShieldedNullifierStatusWasm wrapper types with TypeScript interface declarations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace todo!() panics for shielded proof result variants with proper WASM wrapper types: - VerifiedAssetLockConsumed (status + credit values) - VerifiedShieldedNullifiers (nullifier → isSpent map) - VerifiedShieldedNullifiersWithAddressInfos - VerifiedShieldedNullifiersWithWithdrawalDocument This enables broadcasting shielded state transitions through wasm-sdk without panicking on proof verification results. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The doc comment incorrectly stated that extraData includes amount for unshield and shielded withdrawal transitions. The actual rs-dpp implementation only uses the address/script bytes without amount. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix clippy::needless_borrows_for_generic_args — `&format!()` → `format!()`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use null instead of undefined for absent values in shielded queries - Add unchecked_return_type to getMostRecentShieldedAnchor - Add constructor, TypeScript types, and impl_wasm_conversions_serde macro to ShieldFromAssetLockTransitionWasm - Extract shielded proof result types into separate module Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/wasm-sdk/src/queries/shielded.rs`:
- Around line 175-185: The current nullifier_arrays mapping in functions using
`nullifiers.iter()` and `Uint8Array::new(&n)` (the `nullifier_arrays`
construction and the similar block at the second occurrence) silently truncates
or zero-pads inputs; change this to validate that `Uint8Array::length()` (or the
vector length produced by `to_vec()`) is exactly 32 and return an error
(propagate an Err/JsValue back to the caller) when length != 32 instead of using
`len().min(32)` and copying/padding; update both the `nullifier_arrays` mapping
and the analogous block at the other occurrence so invalid-length inputs are
rejected with a clear error message identifying the offending nullifier.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 482ab228-743d-48c4-a268-2e18dfc07964
📒 Files selected for processing (8)
packages/wasm-dpp2/src/shielded/mod.rspackages/wasm-dpp2/src/shielded/shield_from_asset_lock_transition.rspackages/wasm-dpp2/src/state_transitions/mod.rspackages/wasm-dpp2/src/state_transitions/proof_result.rspackages/wasm-dpp2/src/state_transitions/proof_result_shielded.rspackages/wasm-sdk/src/lib.rspackages/wasm-sdk/src/queries/mod.rspackages/wasm-sdk/src/queries/shielded.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/wasm-dpp2/src/shielded/shield_from_asset_lock_transition.rs
- packages/wasm-dpp2/src/shielded/mod.rs
Instead of silently truncating/padding, return an InvalidArgument error when a nullifier Uint8Array is not exactly 32 bytes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/wasm-sdk/src/queries/shielded.rs`:
- Around line 175-190: The code uses Uint8Array::new(&n) which silently coerces
numeric JS values into zero-filled arrays; update the nullifier parsing (the
nullifiers -> nullifier_arrays mapping where Uint8Array::new(&n) is called) to
first dyn_into::<js_sys::Uint8Array>() the JsValue `n` and return a
WasmSdkError::invalid_argument if the cast fails, then call to_vec(), check
length == 32, copy into [u8;32], and propagate errors; apply the same
dyn_into-based validation to the second nullifier-parsing block referenced in
the review so both locations reject non-Uint8Array inputs instead of coercing
numbers.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a0485101-3553-4505-8ff4-25940161772c
📒 Files selected for processing (1)
packages/wasm-sdk/src/queries/shielded.rs
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
This PR adds WASM/JS bindings for shielded pool state transitions and query methods. The code follows established wrapper patterns and is structurally sound. The main concern is an inconsistency where getShieldedPoolState silently maps a missing pool to BigInt(0) while its WithProofInfo variant correctly returns null, which would cause different observable behavior depending on which API a consumer uses. Test coverage for this security-critical surface is also absent.
🔴 1 blocking | 🟡 3 suggestion(s)
🤖 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.
In `packages/wasm-sdk/src/queries/shielded.rs`:
- [BLOCKING] lines 99-100: Inconsistent None-handling: getShieldedPoolState returns 0 but WithProofInfo variant returns null
`get_shielded_pool_state` coerces `None` to `BigInt(0)` via `unwrap_or(0)` (line 99), while `get_shielded_pool_state_with_proof_info` (line 222) maps `None` to `JsValue::NULL`. This means the non-proof variant silently conflates "pool doesn't exist" with "pool has zero balance", while the proof variant correctly distinguishes the two. A JS consumer switching between APIs would observe different semantics for the same underlying state. The `WithProofInfo` behavior (returning null) is correct — the non-proof variant should match.
- [SUGGESTION] lines 1-362: No tests for any of the new shielded query methods or transition wrappers
This PR adds 5 state transition wrapper types, 1 utility function (`computePlatformSighash`), 5 query methods, 5 `WithProofInfo` query variants, and 4 new proof result types — all without corresponding tests. The inconsistency flagged above (None → 0 vs None → null) is exactly the kind of behavioral bug that a test comparing both API variants would catch. Given that this is security-critical shielded pool functionality, at minimum serialization roundtrip tests for the transition wrappers and mock-based tests for the query methods would provide important coverage.
- [SUGGESTION] lines 175-189: Duplicated nullifier parsing and validation logic
The nullifier parsing block (convert JS `Array<Uint8Array>` to `Vec<[u8; 32]>` with 32-byte validation) is copy-pasted identically between `get_shielded_nullifiers` (lines 175–189) and `get_shielded_nullifiers_with_proof_info` (lines 327–341). Extract to a shared helper to avoid the two copies drifting apart.
Additionally, `Uint8Array::new(&n)` doesn't type-check its argument — if a non-Uint8Array value is passed, it may silently produce a zero-length array rather than erroring. Using `n.dyn_into::<Uint8Array>()` would provide a clearer error message.
In `packages/wasm-dpp2/src/shielded/shield_transition.rs`:
- [SUGGESTION] lines 1-156: Four shielded transition wrappers lack JS constructors (intentional?)
`ShieldFromAssetLockTransitionWasm` has a `#[wasm_bindgen(constructor)]` plus `impl_wasm_conversions_serde!` for `fromObject`/`fromJSON`, but `ShieldTransitionWasm`, `ShieldedTransferTransitionWasm`, `UnshieldTransitionWasm`, and `ShieldedWithdrawalTransitionWasm` can only be constructed via `fromBytes()`. This is likely intentional since ZK-proof-based transitions are built in Rust (where the proof is generated), not from plain JS objects. If so, a brief doc comment noting that `fromBytes` is the intended construction path would prevent confusion. If JS construction is eventually needed, the `Serialize`/`Deserialize` derives are missing from these four types (they'd need to be added for serde-based constructors to work).
Address shumkov, thepastaclaw, and CodeRabbit review comments:
Shielded transition wrappers (wasm-dpp2):
- Add Serialize/Deserialize derives, TypeScript interface declarations,
extern "C" typed JS/JSON types, constructors, and impl_wasm_conversions_serde
macro to ShieldTransition, ShieldedTransferTransition,
ShieldedWithdrawalTransition, and UnshieldTransition (matching the
pattern already used by ShieldFromAssetLockTransition)
Shielded queries (wasm-sdk):
- Change JsValue::NULL to JsValue::UNDEFINED for missing values (JS
convention: missing = undefined, explicit empty = null)
- Fix getShieldedPoolState to return undefined for None instead of
BigInt(0), making None-handling consistent with WithProofInfo variant
- Add unchecked_return_type annotations for TypeScript ("bigint | undefined",
"Uint8Array | undefined")
- Extract duplicated nullifier parsing into parse_nullifiers() helper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address ivan's review comments on shielded transition TS types: - Change $version to $formatVersion in all interfaces - Use AssetLockProofObject/JSON instead of generic object - Define SerializedOrchardAction interface with typed fields - Define FeeStrategyStepObject, AddressWitnessObject types - Use proper types for inputs, feeStrategy, inputWitnesses - Fix JSON byte fields: number[] → string (base64-encoded) - Use typed arrays for actions instead of Array<object> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## v3.1-dev #3235 +/- ##
============================================
- Coverage 88.29% 88.28% -0.01%
============================================
Files 2474 2479 +5
Lines 300927 301529 +602
============================================
+ Hits 265707 266217 +510
- Misses 35220 35312 +92
🚀 New features to boost your workflow:
|
SerializedOrchardActionJSON had byte fields as number[] but they should be string (base64-encoded) to match the JSON serialization convention. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
JSON cannot represent BigInt natively — serde serializes u64 values as numbers (when they fit in f64) or strings (when they exceed 2^53). Update all JSON TypeScript interfaces to use `number | string` for u64 fields: valueBalance, amount, unshieldingAmount. The Object interfaces keep `bigint` since wasm_bindgen maps u64 to BigInt directly. The u64 getters are already correct — wasm_bindgen automatically returns BigInt to JS. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
This incremental push addresses all four prior findings: None→undefined consistency, duplicated nullifier parsing, missing constructors, and macro usage. The changes are well-structured. Two remaining issues: the TypeScript interfaces declare $formatVersion but the Rust shielded enums use serde(tag = "$version"), creating a mismatch; and the FeeStrategyStepObject interface shape doesn't match the actual externally-tagged serde output.
Reviewed commit: 7f216ec
🔴 1 blocking | 🟡 2 suggestion(s)
🤖 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.
In `packages/wasm-dpp2/src/shielded/shield_transition.rs`:
- [BLOCKING] line 68: TS interfaces declare `$formatVersion` but Rust enums serialize `$version`
All five shielded transition Rust enums (`ShieldTransition`, `ShieldedTransferTransition`, etc.) use `serde(tag = "$version")`, while every other versioned type in the codebase uses `serde(tag = "$formatVersion")`. The new TypeScript interfaces all declare `$formatVersion: string`, which doesn't match the actual serialization output (`$version`). This means:
- Objects from `toObject()`/`toJSON()` will have a `$version` key, not `$formatVersion`
- JS consumers constructing objects per the interface will use `$formatVersion`, causing deserialization failures
Either rename the Rust tag to `$formatVersion` for consistency, or update all TS interfaces to use `$version`.
- [SUGGESTION] lines 15-18: `FeeStrategyStepObject` doesn't match actual serde shape
`AddressFundsFeeStrategyStep` is an externally-tagged enum with `rename_all = "camelCase"`, so it serializes as `{ "deductFromInput": 0 }` or `{ "reduceOutput": 1 }`. The TS interface declares `{ type: string; index: number }`, which is a different shape entirely. JS consumers following the interface will construct objects the deserializer rejects.
- [SUGGESTION] line 69: `inputs` typed as `Map` but serialization emits a plain object
The `toObject` path uses `serialize_maps_as_objects(true)`, so `inputs` (a Rust `BTreeMap`) is emitted as a plain JS object with string keys, not a JS `Map`. The Object interface should use `Record<string, [number, bigint]>` instead of `Map<object, [number, bigint]>`. The JSON interface already correctly uses `Record<string, [number, number]>`.
Upstream commit ddf64eb (refactor: rust-dashcore wallet event-bus API) flattened `CoreChangeSet.chain.synced_height` to a direct `CoreChangeSet.synced_height` field but didn't update the test that read through the old nested path. v3.1-dev's own CI skipped the Rust workspace test job on the merge runs so the broken test compile slipped through; surfaces here because this PR triggers the suite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Three blocking issues: AssetLockProof::try_from(Value) is no longer aligned with the new internally-tagged serde wire shape (breaks IdentityTopUp/Create from_object round-trip), and two TypeScript interface declarations (UnshieldTransitionObject.outputAddress, ShieldedWithdrawalTransitionJSON.pooling) advertise types that the actual serializers do not emit or accept. Beyond these, the new shielded WASM surface has several allocation-DoS gaps at the JS→WASM boundary (unbounded encryptedNote, unbounded Array<...> lengths, unbounded signature/redeemScript in AddressWitness), missing structural validation in ShieldTransition::new, toJSON() paths that leak js_sys::Map instances, and fromJSON/fromObject paths that bypass the constructor size caps. A handful of quality and breaking-change callouts round out the review.
Reviewed commit: 321b18c
🔴 3 blocking | 🟡 9 suggestion(s)
2 additional findings
🔴 blocking: `AssetLockProof::try_from(Value)` no longer accepts the wire shape produced by the new serde derive
packages/rs-dpp/src/identity/state_transition/asset_lock_proof/mod.rs (lines 191-260)
This PR changed AssetLockProof to #[serde(tag = "type", rename_all = "camelCase")], so platform_value::to_value(asset_lock_proof) (and the parent transition's to_object) now produces maps shaped like {"type": "instant", ...flattened inner fields}. But both TryFrom<&Value> and TryFrom<Value> (lines 191-260) still only accept either a numeric type (get_optional_integer("type")) or the pre-serde capitalized wrapper forms ({"Instant": ...}/{"Chain": ...}). With the new wire shape, get_optional_integer is called on a Value::Text("instant") and errors with a value-type mismatch (Error::StructureError), which is propagated as ProtocolError::ValueError. As a result, IdentityTopUpTransitionV0::from_object (packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/value_conversion.rs:35) and IdentityCreateTransitionV0::from_object (.../identity_create_transition/v0/value_conversion.rs:44) now reject the value form their own to_object produces — the canonical to_object/from_object round-trip for these transitions is broken. The handwritten Value decoder must be updated alongside the serde change to accept the new internally-tagged shape (string type, no inner wrapper key).
💡 Suggested change
impl TryFrom<&Value> for AssetLockProof {
type Error = ProtocolError;
fn try_from(value: &Value) -> Result<Self, Self::Error> {
platform_value::from_value(value.clone()).or_else(|serde_err| {
let proof_type_int: Option<u8> = value
.get_optional_integer("type")
.map_err(ProtocolError::ValueError)?;
if let Some(proof_type_int) = proof_type_int {
let proof_type = AssetLockProofType::try_from(proof_type_int)?;
return match proof_type {
AssetLockProofType::Instant => Ok(Self::Instant(value.clone().try_into()?)),
AssetLockProofType::Chain => Ok(Self::Chain(value.clone().try_into()?)),
};
}
let map = value.as_map().ok_or_else(|| ProtocolError::DecodingError(format!(
"error decoding asset lock proof: {}", serde_err
)))?;
let (key, asset_lock_value) = map.first().ok_or_else(|| ProtocolError::DecodingError(format!(
"error decoding asset lock proof as it was empty: {}", serde_err
)))?;
match key.as_str().ok_or_else(|| ProtocolError::DecodingError(format!(
"error decoding asset lock proof: {}", serde_err
)))? {
"Instant" => Ok(Self::Instant(asset_lock_value.clone().try_into()?)),
"Chain" => Ok(Self::Chain(asset_lock_value.clone().try_into()?)),
_ => Err(ProtocolError::DecodingError(format!(
"error decoding asset lock proof: {}", serde_err
))),
}
})
}
}
impl TryFrom<Value> for AssetLockProof {
type Error = ProtocolError;
fn try_from(value: Value) -> Result<Self, Self::Error> {
Self::try_from(&value)
}
}
🟡 suggestion: Address-based transition setters changed from infallible to `WasmDppResult<()>` — breaking JS API
packages/wasm-dpp2/src/platform_address/transitions/address_credit_withdrawal_transition.rs (lines 224-235)
The inputs / outputs setters on the address-based transitions (this file plus address_funding_from_asset_lock_transition.rs, address_funds_transfer_transition.rs, identity_create_from_addresses_transition.rs, identity_top_up_from_addresses_transition.rs) changed return type from infallible to WasmDppResult<()>. Under wasm-bindgen this means JS assignments like transition.inputs = [...] now throw on duplicate addresses where they previously silently overwrote. The duplicate-detection in inputs_to_btree_map is the right safety call, but it's a breaking JS API change for anyone using the setter form — call out alongside the JSON wire-shape changes in release notes.
🤖 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.
In `packages/rs-dpp/src/identity/state_transition/asset_lock_proof/mod.rs`:
- [BLOCKING] lines 191-260: `AssetLockProof::try_from(Value)` no longer accepts the wire shape produced by the new serde derive
This PR changed `AssetLockProof` to `#[serde(tag = "type", rename_all = "camelCase")]`, so `platform_value::to_value(asset_lock_proof)` (and the parent transition's `to_object`) now produces maps shaped like `{"type": "instant", ...flattened inner fields}`. But both `TryFrom<&Value>` and `TryFrom<Value>` (lines 191-260) still only accept either a numeric `type` (`get_optional_integer("type")`) or the pre-serde capitalized wrapper forms (`{"Instant": ...}`/`{"Chain": ...}`). With the new wire shape, `get_optional_integer` is called on a `Value::Text("instant")` and errors with a value-type mismatch (`Error::StructureError`), which is propagated as `ProtocolError::ValueError`. As a result, `IdentityTopUpTransitionV0::from_object` (`packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/value_conversion.rs:35`) and `IdentityCreateTransitionV0::from_object` (`.../identity_create_transition/v0/value_conversion.rs:44`) now reject the value form their own `to_object` produces — the canonical `to_object`/`from_object` round-trip for these transitions is broken. The handwritten `Value` decoder must be updated alongside the serde change to accept the new internally-tagged shape (string `type`, no inner wrapper key).
- [SUGGESTION] lines 37-43: Switching `AssetLockProof` from `untagged` to `tag = "type"` is a breaking JSON wire change
`AssetLockProof` now serializes as internally-tagged `{ "type": "instant"|"chain", ...inner fields }` instead of the prior untagged shape (which emitted just the inner struct). Anywhere `AssetLockProof` shows up nested (`IdentityCreateTransition.assetLockProof`, `IdentityTopUpTransition.assetLockProof`, `AddressFundingFromAssetLockTransitionV0.asset_lock_proof`, `ShieldFromAssetLockTransitionV0.asset_lock_proof`), the JSON output now carries the new tag. Bincode `Encode`/`Decode` are unaffected, so the consensus binary format is unchanged, but external JSON consumers (DAPI client snapshots, fixtures, third-party tooling) that parsed the old untagged shape will break. Worth calling out as a deliberate breaking change in release notes / migration docs.
In `packages/wasm-dpp2/src/shielded/unshield_transition.rs`:
- [BLOCKING] lines 35-43: `UnshieldTransitionObject.outputAddress` advertises `string`, but `toObject()` emits `Uint8Array`
The TypeScript interface declares `outputAddress: string`, but the underlying `PlatformAddress` Serialize impl emits raw bytes in non-human-readable formats (`packages/rs-dpp/src/address_funds/platform_address.rs:55-67`) and `to_object` routes through `platform_value_to_object` with `serialize_bytes_as_arrays(false)` (`packages/wasm-dpp2/src/serialization/conversions.rs:308-316`). So `toObject()` produces a `Uint8Array`, not a hex string. Worse, `fromObject()` goes through the non-human-readable deserializer path, so callers who follow the exported type and pass a hex string will hit a deserialization error. Either change the declared type to `Uint8Array` (matching the actual round-trip) or change the serde implementation to emit a hex string in this format too — the declared type and runtime behaviour need to agree.
In `packages/wasm-dpp2/src/shielded/shielded_withdrawal_transition.rs`:
- [BLOCKING] lines 56-66: `ShieldedWithdrawalTransitionJSON.pooling` advertises `string`, but serde only accepts/emits a number
`Pooling` derives `Serialize_repr`/`Deserialize_repr` over `#[repr(u8)]` (`packages/rs-dpp/src/withdrawal/mod.rs:8-17`), so `toJSON()` (which routes through serde with `is_human_readable() == true`) emits `pooling` as the numeric discriminant 0/1/2, and `fromJSON()` will reject any string. The declared TS shape lies to callers: `ShieldedWithdrawalTransition.fromJSON({ pooling: "standard", ... })` errors at runtime. Match the declaration to the actual numeric representation.
In `packages/wasm-dpp2/src/shielded/orchard_action.rs`:
- [SUGGESTION] lines 100-102: `encryptedNote` is unbounded at the JS→WASM boundary while every other action field is fixed-size
All other byte fields on `SerializedOrchardActionWasm` are validated immediately at `try_to_fixed_bytes::<32>` / `<64>`, but `encryptedNote` is read as an arbitrary `Vec<u8>` via `try_to_bytes`. The protocol specifies exactly 216 bytes (epk 32 + enc_ciphertext 104 + out_ciphertext 80 — see `packages/rs-dpp/src/shielded/mod.rs:133-142`, builder allocates `Vec::with_capacity(216)`), and the same DoS rationale that motivated `MAX_HALO2_PROOF_BYTES` / `MAX_CORE_SCRIPT_BYTES` in this PR applies here. Each `SerializedAction` flows into a transition that holds an unbounded `Vec<SerializedAction>`, so a single JS call can allocate arbitrary WASM linear memory before any consensus validation rejects the size mismatch. Reject any non-216-byte input at the constructor, mirroring the fixed-size checks already done for `nullifier`, `rk`, `cmx`, `cvNet`, and `spendAuthSig`.
In `packages/wasm-sdk/src/queries/shielded.rs`:
- [SUGGESTION] lines 91-110: Caller-controlled JS arrays iterated unbounded at the WASM boundary (`parse_nullifiers`, `actions_from_js_options`, `input_witnesses_from_js_options`)
`parse_nullifiers` validates the per-element type and length but places no cap on the array length itself. The same issue affects `actions_from_js_options` (`packages/wasm-dpp2/src/shielded/orchard_action.rs:170-189`) and `input_witnesses_from_js_options` (`packages/wasm-dpp2/src/shielded/address_witness.rs:183-202`), both of which iterate caller-controlled JS arrays of WASM instances without a length cap. The protocol enforces `max_shielded_transition_actions = 16` (`packages/rs-platform-version/src/version/system_limits/v1.rs:14`), and `AddressWitnessWasm::p2sh` already demonstrates the right pattern by hard-checking `MAX_P2SH_SIGNATURES` before the per-element loop (`packages/wasm-dpp2/src/shielded/address_witness.rs:102-108`). Apply the same up-front length check to these helpers (a generous cap is sufficient — even 4096 prevents OOM) so the boundary-layer code rejects oversized inputs locally instead of allocating GB-scale buffers and forwarding malformed queries to the network.
In `packages/wasm-dpp2/src/shielded/shield_transition.rs`:
- [SUGGESTION] lines 117-157: `ShieldTransition::new` does not validate `inputs.len() == input_witnesses.len()`
`ShieldTransitionWasm::new` reads `inputs` and `input_witnesses` independently, then collapses inputs through `inputs_to_btree_map(inputs)?` (`packages/wasm-dpp2/src/platform_address/...`), which deduplicates by address. The constructor never compares the resulting input count with `input_witnesses.len()`, so a JS caller who passes 3 inputs and 2 witnesses (or 3 inputs that collapse to 2 distinct addresses with 3 witnesses) constructs an in-memory transition that will only fail much later, with a less-actionable error from network round-trip / consensus. The same shape-mismatch hole likely exists in the other address-based wrappers that reuse `inputs_from_js_options` plus a separate witnesses extraction (`IdentityCreateFromAddressesTransition`, `IdentityTopUpFromAddressesTransition`, `AddressFundsTransferTransition`). Surface the mismatch at construction time so JS callers get a clear, immediate error.
- [SUGGESTION] lines 285-290: Shielded transition `fromJSON`/`fromObject` bypass the size caps the constructors enforce
`ShieldTransitionWasm::new` enforces `MAX_HALO2_PROOF_BYTES` (`check_max_len` at lines 131-139), but `impl_wasm_conversions_serde!` also exposes `fromJSON`/`fromObject` for the same wrapper. Those routes go through the generic serde paths in `packages/wasm-dpp2/src/serialization/conversions.rs:263-300`, which deserialize attacker-controlled byte fields directly into `Vec<u8>` / fixed arrays without reapplying the constructor limits. The same pattern applies to the new `Unshield`, `ShieldedTransfer`, `ShieldedWithdrawal`, and `ShieldFromAssetLock` wrappers for `proof`, `outputScript`, and `signature`. A malicious dApp/imported payload that calls `ShieldTransition.fromJSON(...)` with a multi-GB base64 `proof` will exhaust WASM linear memory before any validation runs. Either apply the same size caps to the inner type's `Deserialize`, or have the macro-generated `from_*` re-validate via a post-deserialize check that mirrors the constructor's bounds.
In `packages/wasm-dpp2/src/state_transitions/proof_result_shielded.rs`:
- [SUGGESTION] lines 86-216: Shielded proof-result `toJSON()` returns embedded `js_sys::Map` instances — not JSON-safe
`VerifiedShieldedNullifiersWasm::to_json` (line 106-109), `VerifiedShieldedNullifiersWithAddressInfosWasm::to_json` (line 149-152), and `VerifiedShieldedNullifiersWithWithdrawalDocumentWasm::to_json` (line 198-201) all delegate to `to_object`, which embeds `js_sys::Map` instances directly via `js_obj`. `JSON.stringify(result.toJSON())` produces `{"nullifiers":{}}` — Map entries silently disappear. The crate already has a `Map → plain-object` normalization path used elsewhere for exactly this reason (`packages/wasm-dpp2/src/serialization/conversions.rs:80-122`), but these new wrappers bypass it. Convert the maps to plain `{ [hex]: value }` objects in `to_json` (or back the structs with `BTreeMap<String, _>` and route through `impl_wasm_conversions_serde!`, the same pattern `VerifiedShieldedPoolStateWasm` uses).
In `packages/wasm-dpp2/src/shielded/address_witness.rs`:
- [SUGGESTION] lines 81-123: `AddressWitness::p2pkh`/`p2sh` byte fields are unbounded at the JS→WASM boundary
`AddressWitness.p2pkh` accepts any-length `Vec<u8>` for `signature`, and `AddressWitness.p2sh` caps the *count* of signatures at `MAX_P2SH_SIGNATURES = 17` but applies no byte-length cap to each signature or to `redeem_script`. The same DoS rationale that motivated `MAX_HALO2_PROOF_BYTES` / `MAX_CORE_SCRIPT_BYTES` / `MAX_RECOVERABLE_ECDSA_SIGNATURE_BYTES` elsewhere in this PR applies here: a caller can hand over multi-megabyte signatures or scripts and force them into linear memory before consensus rejects them. Reject signatures longer than `MAX_RECOVERABLE_ECDSA_SIGNATURE_BYTES` (and empty/65-byte only, ideally) and bound `redeemScript` by the standard `MAX_CORE_SCRIPT_BYTES`-style cap before cloning into `BinaryData`.
In `packages/rs-dpp/src/address_funds/mod.rs`:
- [SUGGESTION] lines 5-6: `serde_helpers` parent gate is `serde-conversion` but the module gates itself on `json-conversion`
`address_funds/mod.rs` exports `pub mod serde_helpers` under `#[cfg(feature = "serde-conversion")]`, but `serde_helpers/mod.rs` is itself gated `#![cfg(feature = "json-conversion")]` and depends on `crate::serialization::json_safe_fields`, which only exists with `json-conversion`. With `serde-conversion` enabled and `json-conversion` disabled, the parent re-exports an empty module. It compiles today only because the `#[serde(with = "...")]` attributes that reference these helpers are also gated on `json-conversion`. Aligning both gates on `json-conversion` (the actual prerequisite) prevents future helpers from silently failing to find the module.
In `packages/wasm-dpp2/src/platform_address/transitions/address_credit_withdrawal_transition.rs`:
- [SUGGESTION] lines 224-235: Address-based transition setters changed from infallible to `WasmDppResult<()>` — breaking JS API
The `inputs` / `outputs` setters on the address-based transitions (this file plus `address_funding_from_asset_lock_transition.rs`, `address_funds_transfer_transition.rs`, `identity_create_from_addresses_transition.rs`, `identity_top_up_from_addresses_transition.rs`) changed return type from infallible to `WasmDppResult<()>`. Under wasm-bindgen this means JS assignments like `transition.inputs = [...]` now throw on duplicate addresses where they previously silently overwrote. The duplicate-detection in `inputs_to_btree_map` is the right safety call, but it's a breaking JS API change for anyone using the setter form — call out alongside the JSON wire-shape changes in release notes.
Add two regression tests proving that `BinaryData::Deserialize` correctly
handles bytes through both code paths CodeRabbit raised concerns about:
1. **Non-HR path** (`bincode`, `serde_wasm_bindgen::from_value` via the
`dashpay/serde-wasm-bindgen` fork's HR=false default): bytes arrive via
`deserialize_bytes` → `visit_bytes`. Straightforward.
2. **Nested-deserializer path**: when `BinaryData` lives inside an
internally-tagged enum (`#[serde(tag = "type")]`), serde wraps the inner
field in `ContentDeserializer` which reports `is_human_readable() == true`
regardless of the outer format. So `BinaryData::Deserialize` takes the
HR branch and calls `deserialize_string(StringVisitor)`. The test proves
that when the actual data is `Content::Bytes(...)` (e.g. from
`Value::Bytes`, which is what `platform_value_from_object` produces for
JS `Uint8Array`), the bytes still reach `visit_bytes` because:
- `deserialize_string` is a hint, not a constraint, on self-describing
formats; and
- `StringVisitor` implements **both** `visit_str` and `visit_bytes`,
letting bytes arrive on the HR-string path and vice versa.
This is the exact path our wasm-dpp2 `fromObject` runs for any byte field
inside an internally-tagged transition wrapper. CodeRabbit's review
suggested switching to `deserialize_any`; these tests show that's
unnecessary for our deserializer + visitor combination, and the existing
`deserialize_string` is correct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…engthen conversion tests
Addresses thepastaclaw review on `unshield_transition.rs:43`:
`UnshieldTransitionObject.outputAddress` declared `string`, but `toObject()`
actually emits a `Uint8Array` (the `PlatformAddress::Serialize` impl emits raw
bytes in non-HR mode, and `to_object` routes through `platform_value_to_object`
with `serialize_bytes_as_arrays(false)`). The TypeScript type was lying to
consumers. The JSON form already correctly declared `string` (hex) so it stays
unchanged.
Also strengthens 5 weak `.to.exist()` assertions in toObject/toJSON test
sections — these existence-only checks would have caught the outputAddress
type lie if they had been typed-shape assertions instead. They're the same
class of test that the next reviewer wave caught.
- ShieldFromAssetLockTransition.spec: assert AssetLockProof internal-tagged
shape, byte field lengths in Object/JSON forms.
- AddressCreditWithdrawalTransition.spec / IdentityCreateFromAddressesTransition.spec:
swap `output.to.exist()` for `output.to.be.an('object')` so the guard also
enforces shape, not just truthiness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ype ref
Addresses thepastaclaw review on `shielded_withdrawal_transition.rs:66`:
`Pooling` derives `Serialize_repr`/`Deserialize_repr` over `#[repr(u8)]`, so
JSON serialization emits the numeric discriminant (0/1/2). But the TS
interface declared `pooling: string` and `fromJSON({pooling: "standard"})`
was rejected at runtime with "expected u8".
Add `crate::withdrawal::pooling_serde` — a per-field serde helper that emits
camelCase strings (`"never"`, `"ifAvailable"`, `"standard"`) in human-readable
mode while keeping the original `u8` discriminant in non-human-readable mode.
Bincode (consensus binary format) is untouched. The deserialize path uses a
visitor that accepts both string and number regardless of the deserializer's
HR flag, mirroring the BinaryData pattern — necessary because
`platform_value::to_value` reports HR=false but `platform_value::from_value`
reports HR=true, so the fromObject(toObject()) round-trip otherwise fails.
Apply via `#[serde(with = "...")]` to the four production transitions that
expose `pooling` to JS:
- IdentityCreditWithdrawalTransitionV0
- IdentityCreditWithdrawalTransitionV1
- ShieldedWithdrawalTransitionV0
- AddressCreditWithdrawalTransitionV0
Also fix a broken pre-existing type reference: the wasm-dpp2 TS interfaces
referenced `CreditWithdrawalTransitionPooling` (referenced 3 times, never
defined anywhere). Replace with the actually-generated `PoolingWasm` enum.
Update one stale test in IdentityCreditWithdrawalTransition.spec that
asserted the old broken numeric output (`json.pooling === 0`) — flip to the
new corrected camelCase string (`json.pooling === 'never'`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rs DPP) Reverses an earlier wrong call. Initial review of thepastaclaw's #3 finding concluded "DPP doesn't enforce a specific encrypted_note size, so wasm shouldn't either" — a comprehensive cross-layer audit shows that's wrong: - DPP: `pub const ENCRYPTED_NOTE_SIZE: usize = 216;` in `state_transitions/shielded/common_validation.rs:12` - DPP enforces `action.encrypted_note.len() != ENCRYPTED_NOTE_SIZE` → `ShieldedEncryptedNoteSizeMismatchError` (line 65), called from all 5 shielded transition validators. - Drive-ABCI imports the same const + adds a `const _: ()` compile-time assertion that 32 + 104 + 80 = 216 (`shielded_common/mod.rs:55-58`) and re-validates at runtime (line 95). - The grovedb Orchard library's `TransmittedNoteCiphertext` exposes fixed-size slices (`[u8; 32]`, `[u8; 104]`, `[u8; 80]`) — the layout is hardware-locked. The developer comment in `shielded/builder/mod.rs:358` showing uncertainty ("wait — 84+512?") is outdated. The canonical size is 216 bytes, enforced at multiple layers. Mirror that constraint at the wasm boundary by switching `try_to_bytes` to `try_to_fixed_bytes::<ENCRYPTED_NOTE_SIZE>` in `SerializedOrchardActionWasm`'s constructor. Reuses the DPP constant directly so the size never drifts. Now JS callers get an immediate, clear "encryptedNote must be exactly 216 bytes" at construction instead of a `ShieldedEncryptedNoteSizeMismatchError` later during DPP validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rapper rule Reverts e87d363. The "mirror DPP at the wasm boundary" approach that commit applied to `encryptedNote` is the wrong principle — wasm-dpp2 is a thin TypeScript convenience layer over rs-dpp, not a validation tier, and should not duplicate rs-dpp logic even when DPP enforces the same rule. Two reasons: 1. Single source of truth — constraints live in rs-dpp so all consumers (Rust SDK, drive-abci, wasm-dpp2) see the same definition of "valid". A second copy in wasm-dpp2 drifts the moment rs-dpp changes its rule and creates two slightly different rejection paths. 2. Layering — validation tiers decide when to reject. The wasm boundary's job is to construct the type, not gate-keep ahead of those tiers. `SerializedOrchardActionWasm::constructor` goes back to `try_to_bytes` for `encryptedNote` (no length check); the eventual `ShieldedEncryptedNoteSizeMismatchError` from DPP is the right and only rejection point. Codify the rule in CONVENTIONS.md with a new "wasm-dpp2 is a thin TypeScript convenience layer" section that explicitly lists what wasm-dpp2 does NOT do (field-length, range, count checks duplicated from rs-dpp; business rules; pre-validation of state-transition structure) along with the narrow exceptions that look like validation but aren't (structural conversions required by the Rust type system, information-preserving guards like duplicate-address rejection in BTreeMap conversions, ergonomic hex/Uint8Array input parsing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply the thin-wrapper principle (codified in CONVENTIONS.md): wasm-dpp2 does not duplicate or pre-empt rs-dpp validation logic. Audit found four size caps in wasm-dpp2 that violated the principle: - MAX_HALO2_PROOF_BYTES = 64 KB (wasm-only invention; rs-dpp does not validate proof byte length) - MAX_CORE_SCRIPT_BYTES = 10 KB (wasm-only invention) - MAX_RECOVERABLE_ECDSA_SIGNATURE_BYTES = 65 (wasm-only invention) - MAX_P2SH_SIGNATURES = 17 in AddressWitness::p2sh constructor (duplicates rs-dpp's check in custom Serialize/Deserialize at packages/rs-dpp/src/address_funds/witness.rs) Drop all four. The 5 shielded transition constructors no longer call `check_max_len` for proof / output_script / signature; the AddressWitness P2SH constructor no longer rejects signature counts above 17. DPP catches each constraint at the appropriate validation tier: - proof emptiness via `validate_proof_not_empty` - output_script via dashcore parse on consumption - ECDSA signature via `BinaryData` deserialization in the parent transition - P2SH count via the custom Serialize/Deserialize on `AddressWitness` This also resolves thepastaclaw's review #8 (fromJSON/fromObject bypassed the constructor caps) by making the inconsistency moot — there are no caps in either path now. The fixed-size byte conversions for `anchor: [u8; 32]` and `binding_signature: [u8; 64]` stay (`try_vec_to_fixed_bytes`) because they're structural conversions required by the Rust type system, not validation — covered by the explicit narrow exception in CONVENTIONS.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…appers
js_sys::Map has no enumerable own properties, so wrappers that embed a Map
in their toObject() output produced JSON.stringify({...}) === '{...:{}}',
silently dropping every entry. Route every such toJSON through
normalize_js_value_for_json so the Map is converted to a plain object.
Same bug fixed in 8 wrappers:
- VerifiedShieldedNullifiers (+ WithAddressInfos / WithWithdrawalDocument)
- VerifiedTokenIdentitiesBalances, VerifiedDocuments, VerifiedAddressInfos
- VerifiedIdentityFullWithAddressInfos, VerifiedIdentityWithAddressInfos
Also expose fromObject/fromJSON on the three shielded wrappers for symmetry
with the other Map-based verified-result types and to make the regression
testable from JS.
Add tests asserting JSON.stringify(result.toJSON()) actually preserves Map
entries — none of the affected wrappers had toJSON coverage before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… body Parent re-export was gated on `serde-conversion`, but the module body itself is `#![cfg(feature = "json-conversion")]` and depends on `json_safe_fields` which only exists with `json-conversion`. With `serde-conversion` enabled and `json-conversion` disabled the parent re-exported an empty module — harmless today only because every `#[serde(with = "...")]` site is also gated on `json-conversion`, but a footgun for future helpers. Aligning both gates on the actual prerequisite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the 10 read-only shielded query methods on WasmSdk (getShieldedPoolState, getShieldedEncryptedNotes, getShieldedAnchors, getMostRecentShieldedAnchor, getShieldedNullifiers + WithProof variants) behind a thin facade exposed as `sdk.shielded.*`. Mirrors the existing TokensFacade pattern: short method names, `*WithProof` suffix on proof variants, plain delegation to the underlying wasm methods. Building / signing / broadcasting shielded transitions is intentionally out of scope; that requires the Orchard prover and is tracked separately. Add 12 unit tests with sinon stubs verifying every facade method forwards positional arguments correctly to the wrapped wasm method. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cover ShieldedEncryptedNote and ShieldedNullifierStatus end-to-end: fromObject + getters, toObject round-trip, toJSON / fromJSON round-trip with byte fields encoded as base64 strings (per the bytes_b64 helper's HR mode). 9 tests, all green; pins the dual-representation behaviour so later refactors of bytes_b64 don't silently break the JSON form. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Local rustfmt produced a different layout than CI's; conform to CI's formatting (collapsed visit_str message, single-line format! call) so the rustfmt check passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ports - address_funds/serde_helpers/mod.rs: drop the inner #![cfg(feature = "json-conversion")] now that the parent re-export carries the same gate (clippy::duplicated_attributes). - withdrawal/pooling_serde: drop the unused `Deserialize` import — the module uses `Deserializer` for the visitor + `Serialize`/`Serializer` for serialize, not the `Deserialize` trait directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cover all 12 shielded query methods against a local dashmate node: pool state, encrypted notes, anchors, most recent anchor, nullifiers plus their *WithProofInfo variants. Shape-only assertions so the suite passes whether the local pool is empty or has prior shielded activity — the contract under test is the wasm/grpc surface, not on-chain data. Pinned by direct local run: 12/12 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s/shielded.rs The hand-written `IShieldedEncryptedNote` / `IShieldedNullifierStatus` interfaces had: - zero references in the codebase - wasm-bindgen already auto-generates equivalent class declarations with all readonly properties from `#[wasm_bindgen(getter)]` - inconsistent naming style (I-prefix isn't used anywhere else in wasm-sdk; other query files use `typescript_custom_section` only for input parameter shapes, not result class types) Deleting the section entirely; behaviour and types in dist/ unchanged (class declarations remain via wasm-bindgen autogen). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ppers Add `#[dpp_json_convertible_derive::json_safe_fields(crate = "dash_sdk::dpp")]` to `ShieldedEncryptedNoteWasm` and `ShieldedNullifierStatusWasm`, matching the convention used by every other wasm-sdk result wrapper (`PlatformAddressInfoWasm`, `IdentityBalanceAndRevisionWasm`, `ProtocolVersionUpgradeStateWasm`, `ResponseMetadataWasm`, etc). Behaviour unchanged today — these structs only have `Vec<u8>` (with `#[serde(with = "bytes_b64")]`, skipped by the macro) and `bool`, so there are no `u64` fields to auto-stringify in JSON. The macro adds: - `JsonSafeFields` marker impl - compile-time assertions that all field types implement `JsonSafeFields`, so any future `u64` field added without proper handling fails to compile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously `pub(crate)` — inaccessible from downstream crates. The `#[json_safe_fields]` proc-macro auto-generates paths to these helpers (`crate::serialization::serde_bytes_var` for `Vec<u8>` fields, `serde_bytes` for `[u8; N]`), which fails to compile with E0603 from any crate other than rs-dpp. Strict relaxation of visibility; no behaviour change. Note: as of today, downstream wasm-sdk wrappers still use `wasm_dpp2::serialization::bytes_b64` rather than these because the serde_bytes_var/serde_bytes deserializers don't currently handle the `visit_bytes` path used by serde_wasm_bindgen. That's a separate bug to fix; the pub change here is the prerequisite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… queries
Two distinct conventions, applied consistently:
**Plain Option<T> returns** → undefined (Rust-idiomatic via wasm-bindgen):
- get_shielded_pool_state: Result<JsValue, _> → Result<Option<u64>, _>
(wasm-bindgen produces `bigint | undefined`, drops the manual JsValue
construction and the unchecked_return_type annotation)
- get_most_recent_shielded_anchor: same shape, returns Option<Uint8Array>
**Inside ProofMetadataResponse.data** → null (preserves the field in
JSON.stringify, so {data, metadata, proof} stays a uniform object shape
even when the underlying value is absent):
- get_shielded_pool_state_with_proof_info: data uses JsValue::NULL,
unchecked_return_type updated to `bigint | null`
- get_most_recent_shielded_anchor_with_proof_info: same, `Uint8Array | null`
Functional tests + js-evo-sdk facade types updated to match. Both unit
and functional suites green; facade tsc passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…buffers Previously the non-HR branch went through `<Vec<u8>>::deserialize`, which asks for `deserialize_seq` and only accepts `visit_seq`. That works fine for bincode (where Vec<u8> is length-prefixed seq) but fails on serde_wasm_bindgen — Uint8Array deserializes as a *byte buffer*, not a sequence — and on platform_value::Value::Bytes (whose strict `deserialize_seq` errors with `invalid type: byte array, expected array`). Replace with a Visitor that accepts all three forms: - `visit_bytes` — borrowed byte slices - `visit_byte_buf` — owned byte buffers (serde_wasm_bindgen / platform_value) - `visit_seq` — length-prefixed sequences (bincode) Both helpers' existing JSON / bincode round-trip tests still pass; the wider impact is that any field annotated with these helpers (directly or via `#[json_safe_fields]` auto-injection) now round-trips correctly through `fromObject` on the wasm-bindgen side. This was a latent bug — no rs-dpp / wasm-dpp2 test exercised the failing path because none of the existing specs do `fromObject(toObject())` on byte fields. PR #3235 surfaced it via wasm-sdk's ShieldedEncryptedNote round-trip test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…test convention With the upstream rs-dpp serde_bytes_var fix (visit_bytes path), the explicit #[serde(with = "bytes_b64")] annotations on ShieldedEncryptedNote and ShieldedNullifierStatus byte fields are no longer needed — #[json_safe_fields] auto-injects the correct helper. Also document the round-trip + per-property test convention in wasm-dpp2/CONVENTIONS.md: every wrapper with toObject/fromObject/ toJSON/fromJSON must have all four covered with shape assertions, plus fromObject(toObject()) and fromJSON(toJSON()) round-trip tests. The audit done while writing this convention shows ~46 of 78 wasm-dpp2 specs are missing fromObject coverage and ~37 missing fromJSON coverage — that gap is what hid the deserialize bug above. Adding the convention to the migration backlog as a separate cleanup PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_nullifiers The hand-rolled `dyn_into::<Uint8Array>` + length check + copy was literally what `try_to_fixed_bytes::<N>` already does in wasm-dpp2 utils. Replace with the helper and add per-element field name (`nullifiers[i]`) for clearer error messages. Functional tests covering both happy path (32-byte arrays) and rejection path (wrong-length input) still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cleanups: 1. Add `unchecked_param_type = "Uint8Array[]"` to both `getShieldedNullifiers` methods so the TS surface declares `Uint8Array[]` instead of the loose `Array<any>` produced by `js_sys::Array` autogen. 2. Drop the `parse_nullifiers` helper and inline `try_to_fixed_bytes::<32>` at each call site (only two of them, no abstraction warranted). 3. Normalize array typing to the dominant codebase convention `X[]` (277 uses) over `Array<X>` (59 uses) — applies to the 6 unchecked_return_type annotations in shielded.rs and 6 mirrored types in the js-evo-sdk ShieldedFacade. Now matches the existing `Uint8Array[]` style in address_witness.rs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dAnchors
CI rustfmt collapsed the multi-line attribute now that the
unchecked_return_type fits on one line ("Uint8Array[]" vs the previous
"Array<Uint8Array>"). Apply the same locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Convert `state_transitions/proof_result.rs` (981 lines) into a directory module `state_transitions/proof_result/` with 10 submodules per domain: proof_result/ ├── mod.rs # only mod decls + pub use re-exports ├── helpers.rs # js_obj, doc_to_wasm, build_*_map (pub(super)) ├── convert.rs # TS union + StateTransitionProofResultTypeJs + │ # convert_proof_result() dispatcher ├── data_contract.rs # VerifiedDataContract ├── identity.rs # VerifiedIdentity, VerifiedPartialIdentity, │ # VerifiedBalanceTransfer ├── token.rs # 11 token-related variants ├── document.rs # VerifiedDocuments ├── voting.rs # VerifiedMasternodeVote, VerifiedNextDistribution ├── address_funds.rs # VerifiedAddressInfos + the two │ # *WithAddressInfos composite variants └── shielded.rs # 5 shielded variants (was proof_result_shielded.rs) `proof_result_shielded.rs` folded into `proof_result/shielded.rs`. `mod.rs` contains *only* `mod` declarations and `pub use` re-exports per the codebase convention. Helpers stay `pub(super)` (not re-exported). Private fields used by `convert_proof_result()` were promoted to `pub(super)` (`balance`, `balances`, `documents`, `address_infos`) so sibling `convert.rs` can construct the wrappers without rewriting the dispatcher to use ctor functions. Net +83 lines (per-file headers and grouped `use` statements). External API unchanged — `wasm-sdk/state_transitions/broadcast.rs` and other consumers keep importing `convert_proof_result` / `StateTransitionProofResultTypeJs` from `wasm_dpp2::state_transitions::proof_result::*`. Tests: 1120 passing / 7 pending — same count before and after. cargo check + build clean for both wasm-dpp2 and wasm-sdk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue being fixed or feature implemented
Adds the JS/WASM layer for shielded-pool functionality across
wasm-dpp2,wasm-sdk, andjs-evo-sdk.The PR is scoped to read-only queries + wire-format wrappers. High-level shielded transition methods in
wasm-sdk(build → prove → sign → broadcast) are deferred to a follow-up: they require enabling theshielded-clientfeature, which drags the Halo2 prover (~10MB+) into the bundle and needs a separate browser-vs-server proving design.What was done?
wasm-dpp2: Shielded state transition wrappers
ShieldTransitionWasm— shield (deposit) with inputs, actions, amount, proof, binding signatureShieldFromAssetLockTransitionWasm— shield from asset lock with proof verificationShieldedTransferTransitionWasm— private transfer between shielded addressesUnshieldTransitionWasm— withdraw from shielded pool to transparent addressShieldedWithdrawalTransitionWasm— withdraw from shielded pool to core chaincomputePlatformSighashutility function exposed to JStoBytes/fromBytes/toJSON/toObject/toStateTransition/getModifiedDataIdstodo!()panics inStateTransitionWasmfor shielded variants with proper handlingwasm-sdk: Shielded pool query methods
getShieldedPoolState— total shielded pool balance (BigInt)getShieldedEncryptedNotes(startIndex, count)— paginated encrypted notesgetShieldedAnchors— valid anchors for Orchard spend proofsgetMostRecentShieldedAnchor— latest 32-byte anchorgetShieldedNullifiers(nullifiers)— check nullifier spent/unspent statusWithProofInfovariants returningProofMetadataResponse<T>ShieldedEncryptedNoteWasmandShieldedNullifierStatusWasmwrapper types with TypeScript interfacesjs-evo-sdk: ShieldedFacade
Read-only access to the shielded pool exposed at
sdk.shielded.*, mirroring the existingTokensFacadepattern:poolState()/poolStateWithProof()encryptedNotes(startIndex, count)/encryptedNotesWithProof(...)anchors()/anchorsWithProof()mostRecentAnchor()/mostRecentAnchorWithProof()nullifiers(nullifiers)/nullifiersWithProof(...)Building / signing / broadcasting shielded transitions is intentionally out of scope here — that requires the Halo2 prover and is tracked as a follow-up.
Additional fixes during review
toJSONbug in 8 verified-result wrappers (VerifiedShieldedNullifiers*,VerifiedTokenIdentitiesBalances,VerifiedDocuments,VerifiedAddressInfos,VerifiedIdentityFullWithAddressInfos,VerifiedIdentityWithAddressInfos) —js_sys::Maphas no enumerable own properties, soJSON.stringifywas silently dropping every entry. Now routed throughnormalize_js_value_for_json.pooling_serdeHR-aware helper in rs-dpp — emits"never"/"ifAvailable"/"standard"in JSON (was numeric 0/1/2),u8in non-HR; deserialize accepts both for backwards compat. Applied to all 4 transitions surfacingPooling.address_funds::serde_helpers— field-level helpers reshapingBTreeMap<PlatformAddress, …>andOption<(PlatformAddress, …)>into self-describing{address, nonce?, amount}arrays in JSON. Bincode unaffected, consensus byte-identical.AddressFundsFeeStrategyStep— customSerialize/Deserializeto emit{type, index}shape.PlatformAddressInput,PlatformAddressOutput,FeeStrategyStep,AddressWitness.wasm-dpp2/CONVENTIONS.md— wasm-dpp2 doesn't duplicate DPP validation.serde_byteshelper (const-generic for fixed-size byte arrays).How Has This Been Tested?
cargo check/cargo clippy/cargo fmt— green workspace-widewasm-dpp2JS suite — 1120 tests passing (+11 new toJSON regression tests)wasm-sdkJS unit suite — 394 tests passing (+9 new shielded type tests)wasm-sdkJS functional suite — local dashmate node (+12 new shielded query tests)js-evo-sdkJS unit suite — 191 tests passing (+12 new ShieldedFacade tests)Breaking Changes
JSON wire shape only — no consensus / binary impact.
AssetLockProofJSON serialisation changed from#[serde(untagged)]to#[serde(tag = "type", rename_all = "camelCase")]. JSON consumers now see atype: "instant" | "chain"discriminator alongside the inner fields. BincodeEncode/Decodederives are independent of serde, soPlatformSerializable/PlatformSignablesighashes and on-wire transitions are byte-identical.Affected JSON-form consumers:
IdentityCreateTransition.assetLockProof,IdentityTopUpTransition.assetLockProof,AddressFundingFromAssetLockTransitionV0.asset_lock_proof,ShieldFromAssetLockTransitionV0.asset_lock_proof. Most JSON parsers ignore the extratypekey automatically; only consumers that hand-construct JSON to feed back in need to add it.Rationale: aligns with the convention used by
AddressWitness,AddressFundsFeeStrategyStep, and the new shielded transition enums in this PR; gives JS consumers an unambiguous discriminator instead of relying on inner-field shape inspection.Checklist
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes