Skip to content

fix(sdk): validate batch base structure before signing#3133

Draft
thepastaclaw wants to merge 27 commits into
dashpay:v3.1-devfrom
thepastaclaw:feat/sdk-validate-all-transitions
Draft

fix(sdk): validate batch base structure before signing#3133
thepastaclaw wants to merge 27 commits into
dashpay:v3.1-devfrom
thepastaclaw:feat/sdk-validate-all-transitions

Conversation

@thepastaclaw
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw commented Feb 20, 2026

Summary

Issue being fixed or feature implemented

Follow-up to #3096 per shumkov's review:
extend client-side base-structure validation coverage for SDK batch transition builders where DPP already exposes it.

PR #3096 added validation to identity and address transitions. This PR applies the same DPP constructor-backed validation path to document and token transition builders.

What was done?

Enabled the dpp batch-base-structure-validation feature for dash-sdk, so the DPP BatchTransition::new_* constructors validate base structure before signing.

  • Token builders now surface invalid token base-structure errors client-side before returning the transition.
  • Document builders now run the batch-level checks currently exposed by DPP's batch validator before signing. This is intentionally not document/token parity: SDK create builders normalize document IDs before calling the DPP constructor, so the constructor-only create-ID check is defense-in-depth rather than a user-reachable SDK builder error. Non-create transition-local checks live in the corresponding DPP from_document constructors, and broader per-document pre-sign validation parity remains follow-up work.

Document transitions touched:

  • create.rs, delete.rs, replace.rs, purchase.rs, set_price.rs,
    transfer.rs

Token builders touched:

  • burn.rs, claim.rs, config_update.rs, destroy.rs, purchase.rs, emergency_action.rs, freeze.rs, mint.rs, set_price.rs, transfer.rs,
    unfreeze.rs

Contract transitions: put_contract.rs already had structure validation via ensure_valid_state_transition_structure, so no changes were needed there.

Review follow-up removed the redundant post-sign SDK validate_batch_base_structure wrapper calls. The DPP constructors are now the single source of truth, and signing only adds signature fields that the base-structure validator does not inspect.

Latest review follow-ups preserve both singular ProtocolError::ConsensusError(...) and plural ProtocolError::ConsensusErrors(...) across wasm-sdk state-transition boundaries as structured WasmSdkError.consensusErrors entries. Singular wasm consensus errors now also promote their consensus code to the top-level WasmSdkError.code, while plural batches keep code = -1 and expose per-error codes in consensusErrors. They also remove a redundant inner batch-base-structure-validation cfg inside the state-transition-signing-only DPP sign helper, keep token pre-sign validation accumulating batch-level and per-transition consensus errors, clarify document-side pre-sign scope, and map rs-sdk-ffi protocol consensus errors to readable DashSDKError { code, message } values without changing the C ABI.

The Swift SDKError public case set remains frozen for source compatibility: public throwing wrappers continue to throw scalar SDKError values, and protocol failures map to .protocolError(String) with the original human-readable message and no hidden embedded payload. Structured Swift consensus details are available only while the original FFI DashSDKError* is alive via SDKError.fromDashSDKErrorWithConsensusErrors(_:) / SDKError.consensusErrors(fromDashSDKError:), or when a caller explicitly wraps known details in SDKDetailedError. They are intentionally not retained on scalar SDKError after the wrapper frees the FFI pointer; this avoids the previously attempted best-effort (code, message) sidecar because it could misattribute same-signature concurrent failures.

How Has This Been Tested?

  • cargo fmt --all -- --check
  • cargo check -p dpp --no-default-features --features state-transition-signing
    (passes with existing unused-import warnings in dpp under that reduced
    feature set)
  • cargo check -p wasm-sdk
  • cargo test -p wasm-sdk protocol_consensus_errors --lib
  • cargo check -p rs-sdk-ffi
  • cargo test -p dpp validate_base_structure --lib
  • cargo clippy -p dpp --no-default-features --features state-transition-signing --all-targets -- -D warnings
  • cargo clippy -p dpp --no-default-features --features state-transition-signing --all-targets -- -D warnings
  • cargo clippy -p wasm-sdk -p rs-sdk-ffi --all-targets -- -D warnings
  • cargo test --manifest-path packages/rs-sdk-ffi/Cargo.toml --lib sdk_protocol_consensus -- --nocapture
  • cargo clippy -p rs-sdk-ffi --all-targets -- -D warnings
  • cargo test -p wasm-sdk protocol_consensus_errors --lib
  • cargo clippy -p wasm-sdk --all-targets -- -D warnings
  • cargo check -p wasm-sdk
  • xcrun swift-format lint --strict packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift
  • git diff --check
  • pre-commit hook during git commit --amend: cargo fmt --all -- --check +
    workspace cargo check
  • swift build in packages/swift-sdk attempted locally; blocked because
    DashSDKFFI.xcframework does not contain a binary artifact in this worktree
  • (cd packages/swift-sdk/SwiftTests && swift test --disable-sandbox --filter SDKErrorTests) attempted locally; blocked because DashSDKFFI.xcframework does not contain a binary artifact in this worktree

Breaking Changes

  • dpp::ProtocolError is now #[non_exhaustive] and includes plural ConsensusErrors, so downstream Rust crates that exhaustively match ProtocolError need a wildcard arm.
  • None for valid state transitions. Invalid token transitions now fail earlier client-side. Document builders now also run the batch-level validation currently exposed by DPP; deeper per-document client-side structure validation remains follow-up work.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 20, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7fa8db14-7650-4c5e-84fd-a4449acc1b78

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds runtime base-structure validation for Batch state transitions across many document and token transition builders, enables the validation feature for the dpp dependency, updates .gitignore to ignore worktrees/, and introduces extensive test suites for document and token transition validation paths.

Changes

Cohort / File(s) Summary
Configuration & Dependency
/.gitignore, packages/rs-sdk/Cargo.toml
Added worktrees/ to gitignore; enabled the validation feature for the dpp dependency (also in dev-dependencies).
Document Transition Builders
packages/rs-sdk/src/platform/documents/transitions/create.rs, .../delete.rs, .../purchase.rs, .../replace.rs, .../set_price.rs, .../transfer.rs, .../mod.rs
After constructing state transitions, builders now validate BatchTransition base structure: return InvalidStateTransitionType for non-Batch and propagate the first validation error as a ConsensusError. Added small test modules and module-level test inclusion.
Document Transition Tests
packages/rs-sdk/src/platform/documents/transitions/tests.rs
New, large test module with TestSigner, helpers, many assertion helpers, mock SDK setup, and async tests covering nonce masking and base-structure validation for document transitions.
Token Transition Builders
packages/rs-sdk/src/platform/tokens/builders/*.rs, packages/rs-sdk/src/platform/tokens/builders/mod.rs
Added post-construction BatchTransition base-structure validation in multiple token builders and signer flows; return InvalidStateTransitionType for non-Batch and ConsensusError for validation failures. Added test module inclusion.
Token Transition Tests
packages/rs-sdk/src/platform/tokens/builders/tests.rs
Huge new test module with TestSigner, helpers, many assert_*_validate_base_structure_error functions, mock SDK helpers, and numerous tokio async tests exercising token transition validation and error paths.
WASM Fixture
packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts
Added maxLength: 9 constraint to the rarity string field in the card document schema.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through builders, checks in hand,

Batch shapes inspected across the land.
Tests lined up, each error I chase,
Guarding transitions with a careful pace.
A twitch, a hop — validation's in place.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title accurately summarizes the main purpose of the changeset: adding validation of batch base structure before signing state transitions across document and token builders.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (7)
packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs (2)

188-202: Extract the repeated validation block into a shared helper to eliminate copy-paste across 17 files.

The identical 14-line block (lines 188–202 here, and equivalent blocks in every other builder) is copy-pasted verbatim into all 17 token and document transition builders in this PR. This is a significant DRY violation that multiplies any future fix or change.

Consider adding a helper (e.g., in packages/rs-sdk/src/platform/transition/mod.rs or a new validation.rs) and calling it from each builder:

♻️ Proposed helper extraction
// In a shared module, e.g. packages/rs-sdk/src/platform/transition/validate.rs
use crate::Error;
use dpp::state_transition::StateTransition;
use dpp::version::PlatformVersion;
use dpp::state_transition::batch_transition::methods::v1::DocumentsBatchTransitionMethodsV1;

pub fn validate_state_transition_is_valid_batch(
    state_transition: &StateTransition,
    platform_version: &PlatformVersion,
) -> Result<(), Error> {
    let validation_result = match state_transition {
        StateTransition::Batch(batch_transition) => {
            batch_transition.validate_base_structure(platform_version)?
        }
        _ => {
            return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType(
                "expected Batch transition".to_string(),
            )));
        }
    };
    if let Some(first_error) = validation_result.errors.into_iter().next() {
        return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
    }
    Ok(())
}

Each builder's sign method then becomes:

-        // Validate the transition structure before returning
-        let validation_result = match &state_transition {
-            StateTransition::Batch(batch_transition) => {
-                batch_transition.validate_base_structure(platform_version)?
-            }
-            _ => {
-                return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType(
-                    "expected Batch transition".to_string(),
-                )));
-            }
-        };
-        if !validation_result.is_valid() {
-            let first_error = validation_result.errors.into_iter().next().unwrap();
-            return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
-        }
+        validate_state_transition_is_valid_batch(&state_transition, platform_version)?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs` around lines 188 -
202, Extract the repeated 14-line validation into a shared helper function
(e.g., validate_state_transition_is_valid_batch) placed in a common module such
as platform/transition/validate.rs or platform/transition/mod.rs; the helper
should accept &StateTransition and &PlatformVersion, perform the match on
StateTransition::Batch, call
batch_transition.validate_base_structure(platform_version) and convert any
validation_result.errors.first() into
Error::Protocol(dpp::ProtocolError::ConsensusError(...)) or return
Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType("expected Batch
transition".to_string())) for non-Batch variants; then replace the duplicated
block in builders (e.g., in unfreeze.rs sign method referencing
state_transition, validation_result, batch_transition.validate_base_structure)
with a single call to this helper and propagate its Result.

199-201: Replace unwrap() with the idiomatic if let Some(...) pattern.

validation_result.errors.into_iter().next().unwrap() relies on the implicit invariant that is_valid() == false implies errors is non-empty. Idiomatic Rust makes this invariant explicit without unwrap(), and the is_valid() guard becomes redundant:

♻️ Proposed fix (applies identically to all 17 builder files in this PR)
-        if !validation_result.is_valid() {
-            let first_error = validation_result.errors.into_iter().next().unwrap();
-            return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
-        }
+        if let Some(first_error) = validation_result.errors.into_iter().next() {
+            return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs` around lines 199 -
201, Replace the unwrap() call on validation_result.errors.into_iter().next()
with an explicit pattern match (if let Some(...) or match) to avoid panicking;
extract the first_error via if let Some(first_error) =
validation_result.errors.into_iter().next() { return
Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
} else { return Err(/* return a sensible Protocol/Consensus error for the
no-error case */); } — update the code around validation_result and first_error
(the Error::Protocol(dpp::ProtocolError::ConsensusError(...)) return) so it
never calls unwrap().
.gitignore (1)

87-88: Consider adding a comment or separate section for worktrees/.

This entry is placed directly under the gRPC coverage report section without a grouping comment. A brief # Git worktrees comment would make the intent clear to other contributors.

✏️ Suggested addition
 # gRPC coverage report
 grpc-coverage-report.txt
+
+# Git worktrees
 worktrees/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.gitignore around lines 87 - 88, The .gitignore has a standalone entry
"worktrees/" placed under the grpc-coverage-report section—add a brief grouping
comment (e.g. "# Git worktrees") or create a separate section header above the
"worktrees/" entry so its purpose is clear to contributors; update the
.gitignore near the existing "worktrees/" line to include that comment.
packages/rs-sdk/src/platform/tokens/builders/mint.rs (1)

209-223: Extract the repeated validation block into a shared helper function.

This exact 14-line validation snippet is duplicated verbatim across all token and document transition builders (~17 call sites per the PR description). A small helper would eliminate the repetition and ensure any future changes (e.g., reporting all errors, different error wrapping) are applied in one place.

♻️ Suggested helper (e.g., in a shared utility module)
/// Validates that `state_transition` is a `Batch` variant with a valid base structure.
pub fn validate_batch_base_structure(
    state_transition: &StateTransition,
    platform_version: &PlatformVersion,
) -> Result<(), Error> {
    let validation_result = match state_transition {
        StateTransition::Batch(batch_transition) => {
            batch_transition.validate_base_structure(platform_version)?
        }
        _ => {
            return Err(Error::Protocol(
                dpp::ProtocolError::InvalidStateTransitionType(
                    "expected Batch transition".to_string(),
                ),
            ));
        }
    };
    if !validation_result.is_valid() {
        let first_error = validation_result.errors.into_iter().next().unwrap();
        return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
            Box::new(first_error),
        )));
    }
    Ok(())
}

Each builder then collapses to a single call:

-        // Validate the transition structure before returning
-        let validation_result = match &state_transition {
-            StateTransition::Batch(batch_transition) => {
-                batch_transition.validate_base_structure(platform_version)?
-            }
-            _ => {
-                return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType(
-                    "expected Batch transition".to_string(),
-                )));
-            }
-        };
-        if !validation_result.is_valid() {
-            let first_error = validation_result.errors.into_iter().next().unwrap();
-            return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
-        }
+        validate_batch_base_structure(&state_transition, platform_version)?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/mint.rs` around lines 209 - 223,
Extract the duplicated 14-line validation into a shared helper named something
like validate_batch_base_structure(&StateTransition, &PlatformVersion) ->
Result<(), Error>; move the match on StateTransition::Batch, the call to
batch_transition.validate_base_structure(platform_version) and the
is_valid/error handling into that helper (preserving the
InvalidStateTransitionType and ConsensusError wrapping), then replace the inline
block in each builder (where the code matches StateTransition and checks
validation_result) with a single call to
validate_batch_base_structure(state_transition, platform_version). Ensure the
helper returns Ok(()) on success and the same Error variants on failure so
callers like the mint/builders can simply propagate the Result.
packages/rs-sdk/src/platform/documents/transitions/delete.rs (1)

210-224: Identical duplicated validation block — same refactor applies.

Same verbatim copy as set_price.rs lines 211–225. Once the shared helper from the set_price.rs comment is extracted, this block reduces to a single call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/documents/transitions/delete.rs` around lines
210 - 224, This block duplicates the batch state transition validation used in
set_price.rs; extract the shared validation logic into a helper (e.g.,
validate_batch_state_transition or reuse the helper you created for
set_price.rs) that accepts &state_transition and platform_version and returns
Result<(), ProtocolError>/ValidationResult; then replace the inline
match/validation_result/error-unwrapping in delete.rs with a single call to that
helper (handling the returned error by mapping it to
Error::Protocol(dpp::ProtocolError::ConsensusError(...)) to preserve existing
behavior), ensuring you still check the Batch variant and propagate
protocol/consensus errors as before.
packages/rs-sdk/src/platform/documents/transitions/create.rs (1)

170-184: Identical duplicated validation block — same refactor applies.

Same verbatim copy as set_price.rs lines 211–225. Once the shared helper from the set_price.rs comment is extracted, this block reduces to a single call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/documents/transitions/create.rs` around lines
170 - 184, Duplicate validation logic: extract the repeated block that checks
for StateTransition::Batch and calls batch_transition.validate_base_structure
into a shared helper (e.g., validate_batch_base_structure) that returns
Result<(), Error>; replace the block in create.rs (the state_transition
match/validation_result handling referencing StateTransition::Batch,
batch_transition.validate_base_structure, validation_result, and the
Error::Protocol/Dpp errors) with a single call to that helper, and update the
analogous block in set_price.rs to call the same helper so both files reuse the
extracted validation logic.
packages/rs-sdk/src/platform/documents/transitions/set_price.rs (1)

211-225: Create a shared helper for Batch transition validation in builder modules.

There are 17+ builder files across packages/rs-sdk/src/platform/documents/transitions/ and packages/rs-sdk/src/platform/tokens/builders/ duplicating this exact 14-line block:

let validation_result = match &state_transition {
    StateTransition::Batch(batch_transition) => {
        batch_transition.validate_base_structure(platform_version)?
    }
    _ => {
        return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType(
            "expected Batch transition".to_string(),
        )));
    }
};
if !validation_result.is_valid() {
    let first_error = validation_result.errors.into_iter().next().unwrap();
    return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
}

Extract this to a shared helper function in the appropriate module (e.g., packages/rs-sdk/src/platform/helpers/ or within the builders/transitions modules). Note: ensure_valid_state_transition_structure exists in the transition module and serves a different purpose (generic state transition validation with UnsupportedFeatureError handling), so this extraction should be specific to Batch transition validation in builders.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/documents/transitions/set_price.rs` around lines
211 - 225, Extract the duplicated 14-line Batch transition validation into a
shared helper named something like ensure_batch_transition_structure that takes
a &StateTransition and platform_version and returns Result<ValidationResult,
Error> (or Result<(), Error> after converting errors), locate and call it from
builder modules instead of repeating the match; inside the helper perform the
match on StateTransition::Batch, call
batch_transition.validate_base_structure(platform_version) and map non-valid
results to
Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))) and
non-Batch cases to
Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType("expected Batch
transition".to_string())); do not change ensure_valid_state_transition_structure
(it serves a different purpose).
🤖 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/rs-sdk/src/platform/documents/transitions/create.rs`:
- Around line 181-183: Replace the unsafe unwrap when extracting the first
validation error from validation_result.errors with an if let pattern: attempt
to get the first error via if let Some(first_error) =
validation_result.errors.into_iter().next() { return
Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
} else { return
Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(<create a new
error/message indicating "no validation errors found" or similar>)))); } This
change should be applied to the block using validation_result and first_error in
create.rs to avoid a potential panic.

In `@packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- Around line 221-223: Replace the unwrap() on
validation_result.errors.into_iter().next() with an if let Some(...) pattern: if
let Some(first_error) = validation_result.errors.into_iter().next() { return
Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
} else { return
Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(/* create a
small fallback/generic consensus error indicating validation failed with no
details */)))); } This removes the panic risk in the validation branch that uses
validation_result.is_valid() and mirrors the safe handling used in set_price.rs.

In `@packages/rs-sdk/src/platform/documents/transitions/set_price.rs`:
- Around line 222-224: The code uses unwrap() when pulling the first error from
validation_result (validation_result.errors.into_iter().next().unwrap()), which
can panic if the invariant is ever broken; change this to a safe pattern: check
for Some(err) with if let Some(first_error) =
validation_result.errors.into_iter().next() { return
Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
} else { return
Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(/* create a
fallback error or use a generic consensus error here */)))); }—apply the same
if-let pattern to all similar occurrences in set_price.rs and other builder
files to avoid unwrap panics and provide a sensible fallback error when no items
are present.

---

Duplicate comments:
In `@packages/rs-sdk/src/platform/documents/transitions/purchase.rs`:
- Around line 224-238: This block duplicates validation logic from mint.rs;
factor it into a shared helper (e.g., validate_batch_state_transition_structure)
and call it from purchase.rs instead of repeating the match. The helper should
accept the StateTransition reference and platform_version, perform the match
against StateTransition::Batch, call
batch_transition.validate_base_structure(platform_version), check
validation_result.is_valid(), and return a Result<(), dpp::ProtocolError>
(wrapping the first consensus error when invalid). Replace the existing
match/validation in purchase.rs (the state_transition / StateTransition::Batch
branch and subsequent error mapping) with a single call to that new helper.

In `@packages/rs-sdk/src/platform/documents/transitions/replace.rs`:
- Around line 163-177: The validation block duplicating logic (matching
StateTransition::Batch, calling
batch_transition.validate_base_structure(platform_version), checking
validation_result.is_valid(), and converting the first error into
Error::Protocol(dpp::ProtocolError::ConsensusError(...))) should be extracted
into a shared helper (e.g., validate_state_transition_base_structure) and used
here instead of duplicating; update replace.rs to call that helper with the
state_transition and platform_version, remove the inline match/validation_result
logic, and ensure the helper returns the same Result<(), Error> so both
replace.rs and mint.rs can reuse it (reference symbols: StateTransition::Batch,
batch_transition.validate_base_structure, validation_result.is_valid()).

In `@packages/rs-sdk/src/platform/documents/transitions/transfer.rs`:
- Around line 210-224: This duplicate validation block should be replaced with
the shared helper introduced for mint.rs: extract the common logic that matches
on StateTransition, calls validate_base_structure(platform_version) for
StateTransition::Batch, and converts an invalid ValidationResult into
Error::Protocol(ProtocolError::ConsensusError(...)); then call that helper here
instead of duplicating the match/validation logic in transfer.rs (refer to
symbols StateTransition, Batch, validate_base_structure, ValidationResult, and
the Error::Protocol/ProtocolError::ConsensusError conversion to implement the
helper and invoke it from this function).

In `@packages/rs-sdk/src/platform/tokens/builders/burn.rs`:
- Around line 182-196: The validation block for state_transition duplicates
logic and uses unwrap() unsafely; extract a small helper (e.g.,
validate_batch_base_structure or validate_state_transition_base) and replace the
duplicated code in burn.rs (and the identical block in unfreeze.rs) to call that
helper; have the helper match on state_transition (StateTransition::Batch ->
call batch_transition.validate_base_structure(platform_version) otherwise return
ProtocolError::InvalidStateTransitionType) and then check
validation_result.is_valid(), returning a clear ConsensusError with the first
error if any, but avoid unwrap() by handling the Option from
errors.into_iter().next() (return a generic ConsensusError if none) so no panic
occurs.

In `@packages/rs-sdk/src/platform/tokens/builders/claim.rs`:
- Around line 168-182: The validation block for StateTransition::Batch
duplicates logic and uses unwrap() unsafely; refactor to remove duplication and
avoid panics by handling empty error lists. Replace the direct match and
subsequent unwrap with a helper function (e.g., validate_batch_structure) that
accepts &state_transition, matches StateTransition::Batch, calls
batch_transition.validate_base_structure(platform_version) and returns
Result<(), Error>, and ensure when validation_result.is_valid() is false you
safely extract the first error without unwrap (e.g., using
validation_result.errors.into_iter().next().ok_or_else(...) to produce a
ProtocolError) so StateTransition::Batch matching, calling
validate_base_structure, and error conversion are centralized and safe.

In `@packages/rs-sdk/src/platform/tokens/builders/config_update.rs`:
- Around line 190-204: Extract the duplicated validation logic into a shared
helper, e.g. a function validate_batch_base_structure(state_transition:
&StateTransition, platform_version: PlatformVersion) -> Result<(), Error> that
matches StateTransition::Batch and calls
batch_transition.validate_base_structure(platform_version)? and maps
validation_result errors into the same
Error::Protocol(dpp::ProtocolError::ConsensusError(...)) or
InvalidStateTransitionType as needed; then replace the duplicated block in
config_update.rs (the match on StateTransition::Batch + validation_result
handling) with a call to validate_batch_base_structure(...) and do the same in
mint.rs so both files reuse the helper and keep identical error behavior.

In `@packages/rs-sdk/src/platform/tokens/builders/destroy.rs`:
- Around line 188-202: This block repeats the same validate-and-unwrap pattern
and unsafely calls unwrap() on validation_result.errors.next(); replace it with
a safe-option-to-error conversion (e.g., use .into_iter().next().ok_or(...) or
match to produce a ProtocolError when there are no errors) and propagate that
error instead of panicking, and remove duplication by extracting the logic into
a shared helper (e.g., a function named validate_batch_base_structure that
accepts a &StateTransition, calls
batch_transition.validate_base_structure(platform_version), checks is_valid(),
and returns Result<(), Error::Protocol(...)>) and call that helper from both
destroy.rs and unfreeze.rs; reference symbols: StateTransition::Batch,
batch_transition.validate_base_structure, validation_result, and the
construction of Error::Protocol(dpp::ProtocolError::ConsensusError(...)).

In `@packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs`:
- Around line 216-230: The validation block for StateTransition::Batch in
emergency_action.rs is a duplicate of the one in mint.rs; extract this logic
into a shared helper (e.g., validate_batch_state_transition or
validate_state_transition_base_structure) that accepts a StateTransition
reference and platform_version and returns a validated result or maps errors to
Error::Protocol (preserving the dpp::ProtocolError::InvalidStateTransitionType
and ConsensusError wrapping behavior). Replace the inline match/validation in
emergency_action.rs (the StateTransition::Batch match, validate_base_structure
call, and subsequent invalid-result error mapping) with a call to the new helper
so both emergency_action.rs and mint.rs reuse the common function. Ensure the
helper returns the same error types and messages so callers' error handling
remains unchanged.

In `@packages/rs-sdk/src/platform/tokens/builders/freeze.rs`:
- Around line 188-202: This block duplicates validation logic found in mint.rs:
replace the inline match/validation in freeze.rs (around StateTransition::Batch
/ batch_transition.validate_base_structure) with the shared helper function used
by mint.rs (the common validator that takes a StateTransition and
platform_version and returns validation_result or an error); call that helper
instead of duplicating the match, preserve the same error mapping to
Error::Protocol / dpp::ProtocolError::InvalidStateTransitionType and
ConsensusError when validation_result.is_valid() is false, and remove the
duplicated code block so both mint.rs and freeze.rs use the single helper.

In `@packages/rs-sdk/src/platform/tokens/builders/purchase.rs`:
- Around line 156-170: The validation block for StateTransition::Batch
duplicates logic elsewhere and uses unwrap() unsafely; refactor by extracting a
helper (e.g., validate_batch_base_structure or
validate_transition_base_structure) that accepts a &StateTransition, matches
StateTransition::Batch, calls
batch_transition.validate_base_structure(platform_version) and returns
Result<(), Error>, then replace the repeated blocks in purchase.rs and
unfreeze.rs with calls to that helper; also remove the unwrap() by explicitly
handling the Option from validation_result.errors.into_iter().next()—return an
appropriate Protocol::ConsensusError when no error is present (instead of
panicking).

In `@packages/rs-sdk/src/platform/tokens/builders/set_price.rs`:
- Around line 233-247: This block duplicates validation logic from mint.rs —
replace the inline match on state_transition and the subsequent
validation_result handling with the shared helper used in mint.rs (e.g., call
the common validate_state_transition_base_structure helper/function), keeping
the same semantics: accept only StateTransition::Batch, call
batch_transition.validate_base_structure(platform_version) via the helper, and
convert invalid ValidationResult into
Error::Protocol(dpp::ProtocolError::ConsensusError(...)) or the existing
InvalidStateTransitionType error; update references to StateTransition::Batch
and batch_transition.validate_base_structure to use the shared function to avoid
duplication.

In `@packages/rs-sdk/src/platform/tokens/builders/transfer.rs`:
- Around line 214-228: The validation block for state_transition duplicates code
and uses unwrap() unsafely; in the StateTransition::Batch match arm (and
surrounding logic referencing state_transition, StateTransition::Batch,
batch_transition.validate_base_structure, validation_result, first_error)
replace the duplicated pattern with a shared helper or common function that
returns a Result<ValidationResult, Error> and avoid unwrap() by handling the
Option from validation_result.errors.into_iter().next() — if no error exists
return a sensible Protocol Error (or map to an existing ConsensusError) instead
of panicking; keep the same error wrapping with
Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(...))) when an
actual error is present.

---

Nitpick comments:
In @.gitignore:
- Around line 87-88: The .gitignore has a standalone entry "worktrees/" placed
under the grpc-coverage-report section—add a brief grouping comment (e.g. "# Git
worktrees") or create a separate section header above the "worktrees/" entry so
its purpose is clear to contributors; update the .gitignore near the existing
"worktrees/" line to include that comment.

In `@packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- Around line 170-184: Duplicate validation logic: extract the repeated block
that checks for StateTransition::Batch and calls
batch_transition.validate_base_structure into a shared helper (e.g.,
validate_batch_base_structure) that returns Result<(), Error>; replace the block
in create.rs (the state_transition match/validation_result handling referencing
StateTransition::Batch, batch_transition.validate_base_structure,
validation_result, and the Error::Protocol/Dpp errors) with a single call to
that helper, and update the analogous block in set_price.rs to call the same
helper so both files reuse the extracted validation logic.

In `@packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- Around line 210-224: This block duplicates the batch state transition
validation used in set_price.rs; extract the shared validation logic into a
helper (e.g., validate_batch_state_transition or reuse the helper you created
for set_price.rs) that accepts &state_transition and platform_version and
returns Result<(), ProtocolError>/ValidationResult; then replace the inline
match/validation_result/error-unwrapping in delete.rs with a single call to that
helper (handling the returned error by mapping it to
Error::Protocol(dpp::ProtocolError::ConsensusError(...)) to preserve existing
behavior), ensuring you still check the Batch variant and propagate
protocol/consensus errors as before.

In `@packages/rs-sdk/src/platform/documents/transitions/set_price.rs`:
- Around line 211-225: Extract the duplicated 14-line Batch transition
validation into a shared helper named something like
ensure_batch_transition_structure that takes a &StateTransition and
platform_version and returns Result<ValidationResult, Error> (or Result<(),
Error> after converting errors), locate and call it from builder modules instead
of repeating the match; inside the helper perform the match on
StateTransition::Batch, call
batch_transition.validate_base_structure(platform_version) and map non-valid
results to
Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))) and
non-Batch cases to
Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType("expected Batch
transition".to_string())); do not change ensure_valid_state_transition_structure
(it serves a different purpose).

In `@packages/rs-sdk/src/platform/tokens/builders/mint.rs`:
- Around line 209-223: Extract the duplicated 14-line validation into a shared
helper named something like validate_batch_base_structure(&StateTransition,
&PlatformVersion) -> Result<(), Error>; move the match on
StateTransition::Batch, the call to
batch_transition.validate_base_structure(platform_version) and the
is_valid/error handling into that helper (preserving the
InvalidStateTransitionType and ConsensusError wrapping), then replace the inline
block in each builder (where the code matches StateTransition and checks
validation_result) with a single call to
validate_batch_base_structure(state_transition, platform_version). Ensure the
helper returns Ok(()) on success and the same Error variants on failure so
callers like the mint/builders can simply propagate the Result.

In `@packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs`:
- Around line 188-202: Extract the repeated 14-line validation into a shared
helper function (e.g., validate_state_transition_is_valid_batch) placed in a
common module such as platform/transition/validate.rs or
platform/transition/mod.rs; the helper should accept &StateTransition and
&PlatformVersion, perform the match on StateTransition::Batch, call
batch_transition.validate_base_structure(platform_version) and convert any
validation_result.errors.first() into
Error::Protocol(dpp::ProtocolError::ConsensusError(...)) or return
Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType("expected Batch
transition".to_string())) for non-Batch variants; then replace the duplicated
block in builders (e.g., in unfreeze.rs sign method referencing
state_transition, validation_result, batch_transition.validate_base_structure)
with a single call to this helper and propagate its Result.
- Around line 199-201: Replace the unwrap() call on
validation_result.errors.into_iter().next() with an explicit pattern match (if
let Some(...) or match) to avoid panicking; extract the first_error via if let
Some(first_error) = validation_result.errors.into_iter().next() { return
Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
} else { return Err(/* return a sensible Protocol/Consensus error for the
no-error case */); } — update the code around validation_result and first_error
(the Error::Protocol(dpp::ProtocolError::ConsensusError(...)) return) so it
never calls unwrap().

Comment thread packages/rs-sdk/src/platform/documents/transitions/create.rs Outdated
Comment thread packages/rs-sdk/src/platform/documents/transitions/delete.rs Outdated
Comment thread packages/rs-sdk/src/platform/documents/transitions/set_price.rs Outdated
@thepastaclaw thepastaclaw force-pushed the feat/sdk-validate-all-transitions branch 2 times, most recently from 3b7eb4d to 47269ca Compare February 21, 2026 19:26
@github-actions github-actions Bot added this to the v3.1.0 milestone Feb 21, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/rs-sdk/src/platform/tokens/builders/freeze.rs (1)

32-32: ⚠️ Potential issue | 🟡 Minor

Copy-paste doc comment error: "mint" should be "freeze".

The doc comment for new was copied from mint.rs and still reads "Start building a mint tokens request."

📝 Proposed fix
-    /// Start building a mint tokens request for the provided DataContract.
+    /// Start building a freeze tokens request for the provided DataContract.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/freeze.rs` at line 32, The doc
comment for the constructor in freeze.rs was copied from mint.rs and incorrectly
says "Start building a mint tokens request"; update the documentation for the
function/impl named `new` (or the builder type in freeze.rs) to describe that it
"Start building a freeze tokens request" (or equivalent phrasing mentioning
"freeze tokens") so the comment matches the freeze builder's purpose; ensure the
doc comment above the `new` function/struct is changed accordingly.
🧹 Nitpick comments (16)
.gitignore (1)

86-88: Misplaced entry and consider anchoring the pattern

worktrees/ is a git worktree artefact and is unrelated to gRPC coverage reports, so placing it immediately under the # gRPC coverage report comment is misleading. Additionally, without a leading / the pattern matches any directory named worktrees at any depth in the tree; if worktrees are only ever created at the repository root (the typical git worktree add worktrees/<name> convention), anchoring with a leading / makes the intent explicit.

♻️ Proposed fix
 # gRPC coverage report
 grpc-coverage-report.txt
-worktrees/
+
+# Git worktrees
+/worktrees/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.gitignore around lines 86 - 88, The .gitignore currently places the
worktrees/ entry under the "# gRPC coverage report" comment and leaves it
unanchored; move the worktrees/ line out from beneath the gRPC comment into a
more appropriate section (e.g., a worktree or local dev artifacts section) and
anchor it by prepending a slash so the pattern reads "/worktrees/" to ensure it
only matches a top-level worktrees directory; update the comment grouping so the
grpc-coverage-report.txt stays under the gRPC section and "/worktrees/" is
clearly associated with worktree artifacts.
packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts (1)

52-63: maxLength: 9 is redundant alongside the enum constraint but harmless.

All four enum values already fit within 9 characters ('legendary' is exactly 9). Since the enum constraint already fully restricts the field to those values, maxLength adds no additional restriction at runtime. If the intent is to exercise the maxLength validation path in the new structural validation tests, that is fine — just worth a brief inline comment so future readers understand why both exist together.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts`
around lines 52 - 63, The rarity property currently has both enum and maxLength
(maxLength: 9) which is redundant because each enum value already fits within 9
chars; either remove the maxLength field from the rarity definition (in the
rarity object) or, if the intent is to explicitly exercise the maxLength
validation path in tests, add a short inline comment next to the
rarity/maxLength entry explaining that maxLength is intentionally kept to
trigger structural validation tests (reference symbols: rarity, maxLength,
enum).
packages/rs-sdk/src/platform/tokens/builders/claim.rs (1)

168-185: Consider extracting the validation block into a shared helper to reduce duplication across all builders.

This exact validation pattern (match on Batch, call validate_base_structure, return first error as ConsensusError) is repeated identically across ~17 builder files (all document transitions + all token builders). The test modules already define validate_transition_like_builder which does exactly this. A production-side counterpart would eliminate the duplication:

♻️ Suggested shared helper (e.g., in a common transitions utility module)
pub(crate) fn validate_batch_base_structure(
    state_transition: &StateTransition,
    platform_version: &PlatformVersion,
) -> Result<(), Error> {
    let validation_result = match state_transition {
        StateTransition::Batch(batch_transition) => {
            batch_transition.validate_base_structure(platform_version)?
        }
        _ => {
            return Err(Error::Protocol(
                dpp::ProtocolError::InvalidStateTransitionType(
                    "expected Batch transition".to_string(),
                ),
            ));
        }
    };
    if let Some(first_error) = validation_result.errors.into_iter().next() {
        return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
            Box::new(first_error),
        )));
    }
    Ok(())
}

Each builder then reduces to a single call:

-        // Validate the transition structure before returning
-        let validation_result = match &state_transition {
-            StateTransition::Batch(batch_transition) => {
-                batch_transition.validate_base_structure(platform_version)?
-            }
-            _ => {
-                return Err(Error::Protocol(
-                    dpp::ProtocolError::InvalidStateTransitionType(
-                        "expected Batch transition".to_string(),
-                    ),
-                ));
-            }
-        };
-        if let Some(first_error) = validation_result.errors.into_iter().next() {
-            return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
-                Box::new(first_error),
-            )));
-        }
+        validate_batch_base_structure(&state_transition, platform_version)?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/claim.rs` around lines 168 -
185, Extract the repeated validation into a shared helper (e.g.,
validate_batch_base_structure) that takes &StateTransition and &PlatformVersion
and performs the match on StateTransition::Batch, calls
batch_transition.validate_base_structure(platform_version) and converts the
first validation error into
Error::Protocol(dpp::ProtocolError::ConsensusError(...)) or returns Ok(()); then
replace the inline block in claim.rs (the match on StateTransition::Batch,
validate_base_structure, and the first_error handling) with a single call to
that helper; ensure the helper is placed in a common transitions utility module
and update all other builders to call this helper instead of duplicating the
logic.
packages/rs-sdk/src/platform/documents/transitions/tests.rs (2)

28-62: Test utilities (TestSigner, test_identity_public_key, test_data_contract, validate_transition_like_builder, new_mock_sdk_with_contract_nonce) are duplicated verbatim in packages/rs-sdk/src/platform/tokens/builders/tests.rs.

Consider extracting these shared helpers into a common test utility module (e.g., packages/rs-sdk/src/test_utils.rs gated with #[cfg(test)]) to avoid maintaining two copies.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/documents/transitions/tests.rs` around lines 28
- 62, Extract the duplicated test helpers (TestSigner, test_identity_public_key,
test_data_contract, validate_transition_like_builder,
new_mock_sdk_with_contract_nonce) into a single test utility module annotated
with #[cfg(test)] (e.g., a new test_utils.rs) and make the helper symbols
pub(crate) so both tests can import them; replace the duplicated definitions in
both packages/rs-sdk/src/platform/documents/transitions/tests.rs and
packages/rs-sdk/src/platform/tokens/builders/tests.rs with imports from the new
module (use crate::test_utils::* or appropriate path) and run tests to ensure
visibility and imports are correct.

392-407: The second assertion is redundant after assert!(result.is_ok(), ...).

If the is_ok() assertion on line 392 passes, result is Ok(...) and the !matches!(result, Err(...)) on line 398 can never fail. This pattern repeats in all six nonce-masking tests in this file.

You could safely remove the second assertions, or alternatively, remove the first and keep only the more specific second assertion (if you want to test specifically that the nonce error variant doesn't occur, even if other errors are acceptable).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/documents/transitions/tests.rs` around lines 392
- 407, The second assertion that checks !matches!(result,
Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) ...) is
redundant because the earlier assert!(result.is_ok(), ...) already guarantees
result is Ok; remove the redundant matches-based assertion (the block starting
with assert!(!matches!(result, Err(...))) in the nonce-masking tests) in each of
the six tests, or if you prefer to assert specifically that the
NonceOutOfBoundsError never occurs even when other errors might, replace the
initial assert!(result.is_ok(), ...) with only the more specific matches-based
assertion in those tests; update the tests referencing result and the
consensus_error pattern accordingly to keep one clear assertion per test.
packages/rs-sdk/src/platform/tokens/builders/transfer.rs (1)

237-243: Test module name deviates from Rust convention.

The module is named validation_tests; idiomatic Rust names inline test modules tests. There is no functional difference, but tests is what cargo test discoverers and tooling (e.g., cargo-tarpaulin, IDEs) typically highlight.

♻️ Suggested rename
 #[cfg(test)]
-mod validation_tests {
+mod tests {
     #[test]
     fn validate_base_structure_error_case() {
         super::super::tests::assert_token_transfer_validate_base_structure_error();
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/transfer.rs` around lines 237 -
243, Rename the test module from validation_tests to the conventional tests to
match Rust tooling expectations: change the module declaration mod
validation_tests { ... } to mod tests { ... } in transfer.rs and keep the inner
test function validate_base_structure_error_case unchanged; ensure any
references to validation_tests (if any) are updated to tests so cargo test and
tools like cargo-tarpaulin/IDEs recognize the inline tests.
packages/rs-sdk/src/platform/tokens/builders/burn.rs (2)

187-193: _ => arm is unreachable — prefer unreachable!() over a silent Err

BatchTransition::new_token_burn_transition is a BatchTransition constructor and always wraps into StateTransition::Batch, so the wildcard arm can never be reached. Returning a graceful Err here masks a programming invariant violation; unreachable!() would surface it loudly during development and testing. This pattern is duplicated across all ~17 builder files, so the suggestion applies uniformly.

♻️ Proposed refactor
-            _ => {
-                return Err(Error::Protocol(
-                    dpp::ProtocolError::InvalidStateTransitionType(
-                        "expected Batch transition".to_string(),
-                    ),
-                ));
-            }
+            _ => unreachable!(
+                "new_token_burn_transition always produces StateTransition::Batch"
+            ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/burn.rs` around lines 187 - 193,
The wildcard match arm currently returning Err(...) should be replaced with an
explicit unreachable!() to reflect the invariant that
BatchTransition::new_token_burn_transition always yields StateTransition::Batch;
locate the match handling the StateTransition (the arm returning Err::Protocol
with InvalidStateTransitionType) within the builder (e.g.,
new_token_burn_transition in burn.rs) and change that `_ =>` branch to call
unreachable!() so failures surface loudly during development and testing; apply
the same change to the corresponding wildcard arms in the other builder files
following the same pattern.

207-210: Test bypasses the sign() builder path

assert_token_burn_validate_base_structure_error calls BatchTransition::new_token_burn_transition directly and invokes validate_transition_like_builder, so the new validation block inside TokenBurnTransitionBuilder::sign() (lines 182-199) is never exercised by this test. Valid transitions through the builder are also untested here.

Consider adding an async integration test (or extending an existing one) that drives TokenBurnTransitionBuilder::sign() end-to-end with a mock SDK, to confirm validation is correctly wired into the builder path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/burn.rs` around lines 207 - 210,
The test currently bypasses TokenBurnTransitionBuilder::sign() by calling
BatchTransition::new_token_burn_transition directly (via
assert_token_burn_validate_base_structure_error), so the new validation added
inside TokenBurnTransitionBuilder::sign() (lines ~182-199) is never executed;
add or extend an async integration test that constructs a TokenBurnTransition
through the builder path (use TokenBurnTransitionBuilder::new/... then call
TokenBurnTransitionBuilder::sign()) and wire a mock SDK/crypto provider to
exercise the signing flow, then call validate_transition_like_builder (or
assert_token_burn_validate_base_structure_error behavior) to assert the
validation error is raised; ensure the test uses the same inputs (invalid base
structure) as the existing helper so you assert the builder-based sign path
triggers the same validation failure.
packages/rs-sdk/src/platform/tokens/builders/freeze.rs (1)

188-205: Validation block is correct; only the first error is surfaced.

The match/validate/early-return pattern is consistent with the rest of the PR and handles both the impossible non-Batch case and validation failures cleanly. One trade-off worth noting: validation_result.errors.into_iter().next() silently discards every error after the first, so callers have no way to see the full set of validation failures from a single call. If multi-error diagnostics ever matter at the SDK boundary, consider accumulating all errors (or at least including a count in the message).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/freeze.rs` around lines 188 -
205, Currently the code only returns the first validation error from
validation_result.errors in the StateTransition::Batch branch, so modify the
handling in the block using state_transition / StateTransition::Batch /
batch_transition.validate_base_structure to aggregate all validation errors (or
at minimum include the total count) into a single ProtocolError; collect
validation_result.errors into a Vec, format them (or their count) and return
Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(...))) containing
either a combined error that wraps all errors or a message that includes the
number of errors and the joined messages so callers can see full diagnostics
instead of only the first error.
packages/rs-sdk/src/platform/documents/transitions/delete.rs (1)

308-314: Test delegates to a helper that bypasses sign — new validation branches not directly exercised.

assert_document_delete_validate_base_structure_error constructs the BatchTransition directly via BatchTransition::new_document_deletion_transition_from_document and calls validate_transition_like_builder, never entering sign. The two new code paths in sign — the _ => early return (lines 215–221) and the if let Some(first_error) error return (lines 223–227) — are not covered by this test.

Consider an async integration test (or a mock-SDK unit test) that calls sign with an invalid nonce so it walks the validation block in-situ.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/documents/transitions/delete.rs` around lines
308 - 314, The existing unit test uses
BatchTransition::new_document_deletion_transition_from_document and
validate_transition_like_builder which bypasses BatchTransition::sign, so the
new branches in sign (the `_ =>` early return and the `if let Some(first_error)`
error return) are not exercised; add a test that constructs a BatchTransition
for deletion and calls the sign method directly (or via an async
integration/mock-SDK test) using an intentionally invalid nonce to force the
validation path inside sign to run, asserting that the `_ =>` branch and the
`first_error` early return produce the expected errors; target the sign function
on the BatchTransition type and reuse or adapt
assert_document_delete_validate_base_structure_error to invoke sign rather than
constructing the transition via new_document_deletion_transition_from_document
alone.
packages/rs-sdk/src/platform/documents/transitions/set_price.rs (2)

309-315: Test module only covers the error path — consider adding a happy-path assertion.

validate_base_structure_error_case confirms that an invalid transition is rejected, but there is no test asserting that a structurally-valid transition passes through sign (or the equivalent builder path) without error. A regression that makes validate_base_structure reject valid transitions would go undetected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/documents/transitions/set_price.rs` around lines
309 - 315, Add a complementary happy-path test next to
validate_base_structure_error_case inside the validation_tests module that
builds a structurally-valid SetPrice transition via the same test helpers
(instead of the error helper) and asserts validate_base_structure accepts it and
that the subsequent sign/builder step (call the sign method or the builder
function used in other tests) completes without error; locate
validate_base_structure_error_case and replace-or-extend its usage of
super::super::tests::assert_document_set_price_validate_base_structure_error()
with a new test that calls the positive helper (or constructs a valid
transition), then invokes validate_base_structure and sign (or the transition
builder) and asserts success.

211-228: Validation logic is correct; consider extracting the repeated block into a shared helper.

The if let Some pattern is properly used (previous unwrap() concern resolved), the borrow/ownership split is correct, and the early-return on the first consensus error is consistent with the PR pattern.

However, this exact match-validate-propagate block is duplicated across all ~17 builder files in this PR. Extracting it into a module-level or crate-internal helper would remove the duplication and make future changes (e.g., switching to returning all errors, or changing error types) a single-site edit:

♻️ Suggested helper (applicable to all builder files)
+/// Validates a freshly-built state transition's base structure and returns the
+/// first consensus error, if any.
+pub(crate) fn validate_batch_base_structure(
+    state_transition: &StateTransition,
+    platform_version: &PlatformVersion,
+) -> Result<(), Error> {
+    let validation_result = match state_transition {
+        StateTransition::Batch(batch_transition) => {
+            batch_transition.validate_base_structure(platform_version)?
+        }
+        _ => {
+            return Err(Error::Protocol(
+                dpp::ProtocolError::InvalidStateTransitionType(
+                    "expected Batch transition".to_string(),
+                ),
+            ));
+        }
+    };
+    if let Some(first_error) = validation_result.errors.into_iter().next() {
+        return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
+            Box::new(first_error),
+        )));
+    }
+    Ok(())
+}

Then in each sign / build method:

-        let validation_result = match &state_transition {
-            StateTransition::Batch(batch_transition) => {
-                batch_transition.validate_base_structure(platform_version)?
-            }
-            _ => {
-                return Err(Error::Protocol(
-                    dpp::ProtocolError::InvalidStateTransitionType(
-                        "expected Batch transition".to_string(),
-                    ),
-                ));
-            }
-        };
-        if let Some(first_error) = validation_result.errors.into_iter().next() {
-            return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
-                Box::new(first_error),
-            )));
-        }
+        validate_batch_base_structure(&state_transition, platform_version)?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/documents/transitions/set_price.rs` around lines
211 - 228, The match-validate-propagate block for StateTransition::Batch
(matching on state_transition, calling
batch_transition.validate_base_structure(platform_version), checking
validation_result.errors and returning
Error::Protocol(dpp::ProtocolError::ConsensusError(...))) is duplicated across
many builders; extract it into a shared helper (e.g., a crate- or module-level
function like validate_batch_state_transition(state_transition:
&StateTransition, platform_version: ...) -> Result<(), Error>) that performs the
match, calls batch_transition.validate_base_structure(platform_version) and maps
the first validation error into the same
Error::Protocol(dpp::ProtocolError::ConsensusError(...)) return, then replace
the duplicated blocks in each sign/build method to call that helper (keeping the
same error mapping and early-return behavior).
packages/rs-sdk/src/platform/tokens/builders/config_update.rs (1)

191-207: Extract the repeated validation block into a shared helper to eliminate cross-file duplication.

The same ~10-line block:

let validation_result = match &state_transition {
    StateTransition::Batch(batch_transition) => {
        batch_transition.validate_base_structure(platform_version)?
    }
    _ => { return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType(...))); }
};
if let Some(first_error) = validation_result.errors.into_iter().next() {
    return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error))));
}

is copy-pasted verbatim across all 17 builder files touched in this PR (11 token builders + 6 document builders). Any future change to the validation strategy (e.g., collecting all errors instead of the first, or changing the error type) requires 17 coordinated edits.

Consider adding a free function in the shared tokens/builders (or a common transition utility module), e.g.:

♻️ Proposed helper extraction
// In e.g. packages/rs-sdk/src/platform/transition/validate.rs (new file)
// or packages/rs-sdk/src/platform/tokens/builders/mod.rs

pub(crate) fn validate_batch_transition_base_structure(
    state_transition: &StateTransition,
    platform_version: &PlatformVersion,
) -> Result<(), Error> {
    let validation_result = match state_transition {
        StateTransition::Batch(batch_transition) => {
            batch_transition.validate_base_structure(platform_version)?
        }
        _ => {
            return Err(Error::Protocol(
                dpp::ProtocolError::InvalidStateTransitionType(
                    "expected Batch transition".to_string(),
                ),
            ));
        }
    };
    if let Some(first_error) = validation_result.errors.into_iter().next() {
        return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
            Box::new(first_error),
        )));
    }
    Ok(())
}

Each sign() method then reduces to a single call:

-        let validation_result = match &state_transition {
-            StateTransition::Batch(batch_transition) => {
-                batch_transition.validate_base_structure(platform_version)?
-            }
-            _ => {
-                return Err(Error::Protocol(
-                    dpp::ProtocolError::InvalidStateTransitionType(
-                        "expected Batch transition".to_string(),
-                    ),
-                ));
-            }
-        };
-        if let Some(first_error) = validation_result.errors.into_iter().next() {
-            return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
-                Box::new(first_error),
-            )));
-        }
+        validate_batch_transition_base_structure(&state_transition, platform_version)?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/config_update.rs` around lines
191 - 207, Extract the duplicated validation block into a single helper function
(e.g., validate_batch_transition_base_structure) that accepts &StateTransition
and &PlatformVersion and returns Result<(), Error>; move the match on
StateTransition::Batch and the check for the first
validation_result.errors.into_iter().next() into that function, returning the
same Protocol errors on failure, and then replace the duplicated block in each
sign() method (and other builders) with a single call to this helper to
centralize validation logic and avoid repetition.
packages/rs-sdk/src/platform/tokens/builders/set_price.rs (1)

234-244: The _ wildcard arm is unreachable dead code.

new_token_change_direct_purchase_price_transition (Line 217) always returns a StateTransition::Batch, so the _ branch can never be reached. While the defensive guard is consistent with the PR's pattern across all builders, it's still dead code and may trigger a clippy::unreachable_patterns lint. Using an if let/else unreachable!() pattern is idiomatic and makes the invariant explicit.

♻️ Proposed refactor using if let + unreachable!
-        let validation_result = match &state_transition {
-            StateTransition::Batch(batch_transition) => {
-                batch_transition.validate_base_structure(platform_version)?
-            }
-            _ => {
-                return Err(Error::Protocol(
-                    dpp::ProtocolError::InvalidStateTransitionType(
-                        "expected Batch transition".to_string(),
-                    ),
-                ));
-            }
-        };
+        let StateTransition::Batch(batch_transition) = &state_transition else {
+            unreachable!("new_token_change_direct_purchase_price_transition always returns a Batch transition");
+        };
+        let validation_result = batch_transition.validate_base_structure(platform_version)?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/set_price.rs` around lines 234 -
244, The match on state_transition producing validation_result contains an
unreachable `_` arm because new_token_change_direct_purchase_price_transition
always returns StateTransition::Batch; replace the match with an if let to
extract Batch and call
batch_transition.validate_base_structure(platform_version), and in the else
branch use unreachable!() (or panic with an explanatory message) to make the
invariant explicit; reference StateTransition::Batch, validation_result, and
new_token_change_direct_purchase_price_transition when locating and updating the
code.
packages/rs-sdk/src/platform/tokens/builders/mint.rs (2)

232-238: Test does not exercise TokenMintTransitionBuilder::sign, so the added validation path (lines 209–226) is untested.

assert_token_mint_validate_base_structure_error constructs a transition directly via BatchTransition::new_token_mint_transition and calls validate_transition_like_builder — it never invokes the builder's sign method. A regression that removed or mis-wired the validation block in sign would not be caught by this test. Consider adding a test that calls TokenMintTransitionBuilder::sign with an invalid input and asserts the expected ProtocolError::ConsensusError is returned.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/mint.rs` around lines 232 - 238,
Add a test that exercises TokenMintTransitionBuilder::sign so the validation
path in sign (lines ~209–226) is covered: create the same invalid token mint
input used by assert_token_mint_validate_base_structure_error, build a
TokenMintTransitionBuilder for it, call TokenMintTransitionBuilder::sign
(instead of constructing via BatchTransition::new_token_mint_transition), and
assert that the call returns the expected ProtocolError::ConsensusError (or the
same error type asserted by validate_transition_like_builder); reference the
existing helper assert_token_mint_validate_base_structure_error and
validate_transition_like_builder to reuse input construction and error
expectations when writing this new test.

210-221: Consider replacing the unreachable _ arm with let…else.

BatchTransition::new_token_mint_transition always produces StateTransition::Batch, so the wildcard arm is dead code. The idiomatic Rust replacement (stable since 1.65) flattens the nesting and makes the single expected variant explicit:

♻️ Proposed refactor using let…else
-        let validation_result = match &state_transition {
-            StateTransition::Batch(batch_transition) => {
-                batch_transition.validate_base_structure(platform_version)?
-            }
-            _ => {
-                return Err(Error::Protocol(
-                    dpp::ProtocolError::InvalidStateTransitionType(
-                        "expected Batch transition".to_string(),
-                    ),
-                ));
-            }
-        };
+        let StateTransition::Batch(batch_transition) = &state_transition else {
+            return Err(Error::Protocol(
+                dpp::ProtocolError::InvalidStateTransitionType(
+                    "expected Batch transition".to_string(),
+                ),
+            ));
+        };
+        let validation_result = batch_transition.validate_base_structure(platform_version)?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/tokens/builders/mint.rs` around lines 210 - 221,
The match on state_transition that handles StateTransition::Batch with a
wildcard `_` arm is dead code; replace it with a let…else to make the single
expected variant explicit: destructure the reference using let
StateTransition::Batch(batch_transition) = &state_transition else { return
Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType("expected
Batch transition".to_string()))); }; then call
batch_transition.validate_base_structure(platform_version)? to produce
validation_result. This removes the unreachable `_` arm and flattens the nesting
while keeping the same error return.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/rs-sdk/src/platform/tokens/builders/freeze.rs`:
- Line 32: The doc comment for the constructor in freeze.rs was copied from
mint.rs and incorrectly says "Start building a mint tokens request"; update the
documentation for the function/impl named `new` (or the builder type in
freeze.rs) to describe that it "Start building a freeze tokens request" (or
equivalent phrasing mentioning "freeze tokens") so the comment matches the
freeze builder's purpose; ensure the doc comment above the `new` function/struct
is changed accordingly.

---

Duplicate comments:
In `@packages/rs-sdk/src/platform/documents/transitions/transfer.rs`:
- Around line 210-227: The duplicated validation block for ensuring a Batch
StateTransition (matching on StateTransition::Batch and calling
batch_transition.validate_base_structure(platform_version) and mapping any first
error into Error::Protocol) should be extracted into a shared helper function
(e.g., validate_batch_base_structure or validate_state_transition_base) and
reused from both transfer.rs and claim.rs; implement a function that accepts a
&StateTransition and &PlatformVersion (or the exact types used), performs the
match on StateTransition::Batch, calls
batch_transition.validate_base_structure(platform_version) and converts the
first consensus error into Error::Protocol as done now, then replace the
duplicate block in transfer.rs (the state_transition match, validation_result
handling, and error mapping) with a call to that helper.

In `@packages/rs-sdk/src/platform/tokens/builders/tests.rs`:
- Around line 40-153: The tests duplicate utilities (TestSigner,
test_identity_public_key, test_data_contract, validate_transition_like_builder,
and new_mock_sdk_with_contract_nonce) already defined in documents transitions
tests; refactor by moving these shared helpers into a common test-utils module
(e.g., a new crate/module under packages/rs-sdk/src/platform/test_utils or
tests/common) and update the current file to import and reuse TestSigner,
test_identity_public_key, test_data_contract, validate_transition_like_builder,
and new_mock_sdk_with_contract_nonce instead of redefining them; ensure
visibility (pub) and adjust imports in both tests to use the shared helpers and
run cargo test to confirm no symbol or visibility regressions.

---

Nitpick comments:
In @.gitignore:
- Around line 86-88: The .gitignore currently places the worktrees/ entry under
the "# gRPC coverage report" comment and leaves it unanchored; move the
worktrees/ line out from beneath the gRPC comment into a more appropriate
section (e.g., a worktree or local dev artifacts section) and anchor it by
prepending a slash so the pattern reads "/worktrees/" to ensure it only matches
a top-level worktrees directory; update the comment grouping so the
grpc-coverage-report.txt stays under the gRPC section and "/worktrees/" is
clearly associated with worktree artifacts.

In `@packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- Around line 308-314: The existing unit test uses
BatchTransition::new_document_deletion_transition_from_document and
validate_transition_like_builder which bypasses BatchTransition::sign, so the
new branches in sign (the `_ =>` early return and the `if let Some(first_error)`
error return) are not exercised; add a test that constructs a BatchTransition
for deletion and calls the sign method directly (or via an async
integration/mock-SDK test) using an intentionally invalid nonce to force the
validation path inside sign to run, asserting that the `_ =>` branch and the
`first_error` early return produce the expected errors; target the sign function
on the BatchTransition type and reuse or adapt
assert_document_delete_validate_base_structure_error to invoke sign rather than
constructing the transition via new_document_deletion_transition_from_document
alone.

In `@packages/rs-sdk/src/platform/documents/transitions/set_price.rs`:
- Around line 309-315: Add a complementary happy-path test next to
validate_base_structure_error_case inside the validation_tests module that
builds a structurally-valid SetPrice transition via the same test helpers
(instead of the error helper) and asserts validate_base_structure accepts it and
that the subsequent sign/builder step (call the sign method or the builder
function used in other tests) completes without error; locate
validate_base_structure_error_case and replace-or-extend its usage of
super::super::tests::assert_document_set_price_validate_base_structure_error()
with a new test that calls the positive helper (or constructs a valid
transition), then invokes validate_base_structure and sign (or the transition
builder) and asserts success.
- Around line 211-228: The match-validate-propagate block for
StateTransition::Batch (matching on state_transition, calling
batch_transition.validate_base_structure(platform_version), checking
validation_result.errors and returning
Error::Protocol(dpp::ProtocolError::ConsensusError(...))) is duplicated across
many builders; extract it into a shared helper (e.g., a crate- or module-level
function like validate_batch_state_transition(state_transition:
&StateTransition, platform_version: ...) -> Result<(), Error>) that performs the
match, calls batch_transition.validate_base_structure(platform_version) and maps
the first validation error into the same
Error::Protocol(dpp::ProtocolError::ConsensusError(...)) return, then replace
the duplicated blocks in each sign/build method to call that helper (keeping the
same error mapping and early-return behavior).

In `@packages/rs-sdk/src/platform/documents/transitions/tests.rs`:
- Around line 28-62: Extract the duplicated test helpers (TestSigner,
test_identity_public_key, test_data_contract, validate_transition_like_builder,
new_mock_sdk_with_contract_nonce) into a single test utility module annotated
with #[cfg(test)] (e.g., a new test_utils.rs) and make the helper symbols
pub(crate) so both tests can import them; replace the duplicated definitions in
both packages/rs-sdk/src/platform/documents/transitions/tests.rs and
packages/rs-sdk/src/platform/tokens/builders/tests.rs with imports from the new
module (use crate::test_utils::* or appropriate path) and run tests to ensure
visibility and imports are correct.
- Around line 392-407: The second assertion that checks !matches!(result,
Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) ...) is
redundant because the earlier assert!(result.is_ok(), ...) already guarantees
result is Ok; remove the redundant matches-based assertion (the block starting
with assert!(!matches!(result, Err(...))) in the nonce-masking tests) in each of
the six tests, or if you prefer to assert specifically that the
NonceOutOfBoundsError never occurs even when other errors might, replace the
initial assert!(result.is_ok(), ...) with only the more specific matches-based
assertion in those tests; update the tests referencing result and the
consensus_error pattern accordingly to keep one clear assertion per test.

In `@packages/rs-sdk/src/platform/tokens/builders/burn.rs`:
- Around line 187-193: The wildcard match arm currently returning Err(...)
should be replaced with an explicit unreachable!() to reflect the invariant that
BatchTransition::new_token_burn_transition always yields StateTransition::Batch;
locate the match handling the StateTransition (the arm returning Err::Protocol
with InvalidStateTransitionType) within the builder (e.g.,
new_token_burn_transition in burn.rs) and change that `_ =>` branch to call
unreachable!() so failures surface loudly during development and testing; apply
the same change to the corresponding wildcard arms in the other builder files
following the same pattern.
- Around line 207-210: The test currently bypasses
TokenBurnTransitionBuilder::sign() by calling
BatchTransition::new_token_burn_transition directly (via
assert_token_burn_validate_base_structure_error), so the new validation added
inside TokenBurnTransitionBuilder::sign() (lines ~182-199) is never executed;
add or extend an async integration test that constructs a TokenBurnTransition
through the builder path (use TokenBurnTransitionBuilder::new/... then call
TokenBurnTransitionBuilder::sign()) and wire a mock SDK/crypto provider to
exercise the signing flow, then call validate_transition_like_builder (or
assert_token_burn_validate_base_structure_error behavior) to assert the
validation error is raised; ensure the test uses the same inputs (invalid base
structure) as the existing helper so you assert the builder-based sign path
triggers the same validation failure.

In `@packages/rs-sdk/src/platform/tokens/builders/claim.rs`:
- Around line 168-185: Extract the repeated validation into a shared helper
(e.g., validate_batch_base_structure) that takes &StateTransition and
&PlatformVersion and performs the match on StateTransition::Batch, calls
batch_transition.validate_base_structure(platform_version) and converts the
first validation error into
Error::Protocol(dpp::ProtocolError::ConsensusError(...)) or returns Ok(()); then
replace the inline block in claim.rs (the match on StateTransition::Batch,
validate_base_structure, and the first_error handling) with a single call to
that helper; ensure the helper is placed in a common transitions utility module
and update all other builders to call this helper instead of duplicating the
logic.

In `@packages/rs-sdk/src/platform/tokens/builders/config_update.rs`:
- Around line 191-207: Extract the duplicated validation block into a single
helper function (e.g., validate_batch_transition_base_structure) that accepts
&StateTransition and &PlatformVersion and returns Result<(), Error>; move the
match on StateTransition::Batch and the check for the first
validation_result.errors.into_iter().next() into that function, returning the
same Protocol errors on failure, and then replace the duplicated block in each
sign() method (and other builders) with a single call to this helper to
centralize validation logic and avoid repetition.

In `@packages/rs-sdk/src/platform/tokens/builders/freeze.rs`:
- Around line 188-205: Currently the code only returns the first validation
error from validation_result.errors in the StateTransition::Batch branch, so
modify the handling in the block using state_transition / StateTransition::Batch
/ batch_transition.validate_base_structure to aggregate all validation errors
(or at minimum include the total count) into a single ProtocolError; collect
validation_result.errors into a Vec, format them (or their count) and return
Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(...))) containing
either a combined error that wraps all errors or a message that includes the
number of errors and the joined messages so callers can see full diagnostics
instead of only the first error.

In `@packages/rs-sdk/src/platform/tokens/builders/mint.rs`:
- Around line 232-238: Add a test that exercises
TokenMintTransitionBuilder::sign so the validation path in sign (lines ~209–226)
is covered: create the same invalid token mint input used by
assert_token_mint_validate_base_structure_error, build a
TokenMintTransitionBuilder for it, call TokenMintTransitionBuilder::sign
(instead of constructing via BatchTransition::new_token_mint_transition), and
assert that the call returns the expected ProtocolError::ConsensusError (or the
same error type asserted by validate_transition_like_builder); reference the
existing helper assert_token_mint_validate_base_structure_error and
validate_transition_like_builder to reuse input construction and error
expectations when writing this new test.
- Around line 210-221: The match on state_transition that handles
StateTransition::Batch with a wildcard `_` arm is dead code; replace it with a
let…else to make the single expected variant explicit: destructure the reference
using let StateTransition::Batch(batch_transition) = &state_transition else {
return
Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType("expected
Batch transition".to_string()))); }; then call
batch_transition.validate_base_structure(platform_version)? to produce
validation_result. This removes the unreachable `_` arm and flattens the nesting
while keeping the same error return.

In `@packages/rs-sdk/src/platform/tokens/builders/set_price.rs`:
- Around line 234-244: The match on state_transition producing validation_result
contains an unreachable `_` arm because
new_token_change_direct_purchase_price_transition always returns
StateTransition::Batch; replace the match with an if let to extract Batch and
call batch_transition.validate_base_structure(platform_version), and in the else
branch use unreachable!() (or panic with an explanatory message) to make the
invariant explicit; reference StateTransition::Batch, validation_result, and
new_token_change_direct_purchase_price_transition when locating and updating the
code.

In `@packages/rs-sdk/src/platform/tokens/builders/transfer.rs`:
- Around line 237-243: Rename the test module from validation_tests to the
conventional tests to match Rust tooling expectations: change the module
declaration mod validation_tests { ... } to mod tests { ... } in transfer.rs and
keep the inner test function validate_base_structure_error_case unchanged;
ensure any references to validation_tests (if any) are updated to tests so cargo
test and tools like cargo-tarpaulin/IDEs recognize the inline tests.

In `@packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts`:
- Around line 52-63: The rarity property currently has both enum and maxLength
(maxLength: 9) which is redundant because each enum value already fits within 9
chars; either remove the maxLength field from the rarity definition (in the
rarity object) or, if the intent is to explicitly exercise the maxLength
validation path in tests, add a short inline comment next to the
rarity/maxLength entry explaining that maxLength is intentionally kept to
trigger structural validation tests (reference symbols: rarity, maxLength,
enum).

@PastaPastaPasta
Copy link
Copy Markdown
Member

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 22, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/rs-sdk/src/platform/documents/transitions/tests.rs (1)

156-311: Extract a shared test-context helper to eliminate the repeated setup.

All six assert_document_* helpers repeat the same four-line setup (platform_version, data_contract, owner_id, document). Extracting it reduces noise and makes future changes easier.

♻️ Suggested refactor
+struct TestContext {
+    platform_version: &'static dpp::version::PlatformVersion,
+    data_contract: Arc<dpp::data_contract::DataContract>,
+    document: Document,
+    document_type: &'static dpp::data_contract::document_type::DocumentType,
+}
+
+fn make_test_context() -> (
+    &'static dpp::version::PlatformVersion,
+    Arc<dpp::data_contract::DataContract>,
+    Document,
+) {
+    let platform_version = dpp::version::PlatformVersion::latest();
+    let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME);
+    let owner_id = Identifier::random();
+    let document = test_document(owner_id);
+    (platform_version, data_contract, document)
+}

Each assert_document_* function can then open with:

let (platform_version, data_contract, document) = make_test_context();
let document_type = data_contract
    .document_type_for_name(TEST_DOCUMENT_TYPE_NAME)
    .expect("expected test document type");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/documents/transitions/tests.rs` around lines 156
- 311, Multiple test helpers repeat the same setup (platform_version,
data_contract, owner_id, document); extract a shared helper (e.g.,
make_test_context) that returns (platform_version, data_contract, document) and
use it from each assert_document_* function
(assert_document_create_validate_base_structure_error,
assert_document_delete_validate_base_structure_error,
assert_document_purchase_validate_base_structure_error,
assert_document_replace_validate_base_structure_error,
assert_document_set_price_validate_base_structure_error,
assert_document_transfer_validate_base_structure_error) so you replace the four
repeated lines with let (platform_version, data_contract, document) =
make_test_context(); and keep the existing document_type lookup and rest of each
test unchanged.
🤖 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/rs-sdk/src/platform/documents/transitions/tests.rs`:
- Line 337: Replace the hardcoded "testDoc" literal with the existing
TEST_DOCUMENT_TYPE_NAME constant used elsewhere in the test suite; locate the
assignment where document_type_name is set (currently let document_type_name =
"testDoc";) and change it to reference TEST_DOCUMENT_TYPE_NAME so the test stays
in sync with the canonical constant.

---

Nitpick comments:
In `@packages/rs-sdk/src/platform/documents/transitions/tests.rs`:
- Around line 156-311: Multiple test helpers repeat the same setup
(platform_version, data_contract, owner_id, document); extract a shared helper
(e.g., make_test_context) that returns (platform_version, data_contract,
document) and use it from each assert_document_* function
(assert_document_create_validate_base_structure_error,
assert_document_delete_validate_base_structure_error,
assert_document_purchase_validate_base_structure_error,
assert_document_replace_validate_base_structure_error,
assert_document_set_price_validate_base_structure_error,
assert_document_transfer_validate_base_structure_error) so you replace the four
repeated lines with let (platform_version, data_contract, document) =
make_test_context(); and keep the existing document_type lookup and rest of each
test unchanged.

// which masks out-of-bounds bits. This makes `validate_base_structure`
// nonce-out-of-bounds errors unreachable through the builder API.
// One test suffices since all document builders use the same SDK nonce path.
let document_type_name = "testDoc";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use TEST_DOCUMENT_TYPE_NAME constant instead of the hardcoded literal.

Line 337 duplicates the "testDoc" string already defined as TEST_DOCUMENT_TYPE_NAME at Line 112. If the constant is ever renamed or changed, this test silently diverges.

🔧 Proposed fix
-    let document_type_name = "testDoc";
+    let document_type_name = TEST_DOCUMENT_TYPE_NAME;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let document_type_name = "testDoc";
let document_type_name = TEST_DOCUMENT_TYPE_NAME;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rs-sdk/src/platform/documents/transitions/tests.rs` at line 337,
Replace the hardcoded "testDoc" literal with the existing
TEST_DOCUMENT_TYPE_NAME constant used elsewhere in the test suite; locate the
assignment where document_type_name is set (currently let document_type_name =
"testDoc";) and change it to reference TEST_DOCUMENT_TYPE_NAME so the test stays
in sync with the canonical constant.

Copy link
Copy Markdown
Member

@QuantumExplorer QuantumExplorer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are validating that the function that produced a state transition doesn’t produce one that is incorrect… but normally the function that produces a state transition should not be able to produce one that is incorrect.

@thepastaclaw thepastaclaw marked this pull request as draft February 25, 2026 08:21
@thepastaclaw thepastaclaw force-pushed the feat/sdk-validate-all-transitions branch from 7ec7663 to 085905c Compare February 25, 2026 16:22
@thepastaclaw thepastaclaw force-pushed the feat/sdk-validate-all-transitions branch 2 times, most recently from a49b186 to c30a1bf Compare March 12, 2026 20:18
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Well-structured PR that consistently adds client-side validate_base_structure() to all 17 document and token SDK transition builders. Test coverage is solid on the token side with real builder integration tests. The main concerns are significant code duplication (an existing shared-helper pattern in validation.rs was not reused) and the unconditional addition of the 'validation' feature to rs-sdk's main dependencies.

Reviewed commit: c30a1bf

🟡 6 suggestion(s) | 💬 2 nitpick(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/rs-sdk/src/platform/tokens/builders/mint.rs`:
- [SUGGESTION] lines 209-226: Identical validation block duplicated across 17 builder files
  The same 18-line validation block (match on `StateTransition::Batch`, call `validate_base_structure`, extract first error) is copy-pasted verbatim in all 6 document builders and all 11 token builders. The codebase already has a shared validation helper pattern in `packages/rs-sdk/src/platform/transition/validation.rs` (`ensure_valid_state_transition_structure`). A similar `pub(crate)` helper for `validate_base_structure` would reduce 306 lines to ~18 + 17 one-line calls.
- [SUGGESTION] lines 183-207: Nonce reserved before validation — local failure burns a cache slot
  get_identity_contract_nonce(..., true, ...) bumps the nonce cache before validate_base_structure() runs. If validation fails on a purely local error (e.g. note too long), the bumped nonce is consumed without broadcast. This is a pre-existing pattern (same flow in put_contract.rs, put_document.rs, transfer_document.rs, purchase_document.rs via ensure_valid_state_transition_structure), so it is not a regression introduced by this PR. Noting for awareness.

In `packages/rs-sdk/Cargo.toml`:
- [SUGGESTION] lines 10-13: 'validation' feature moved from dev-dependencies to unconditional dependency
  Previously `validation` was only in `[dev-dependencies]` for dpp. This PR adds it to the main `[dependencies]`, which pulls in `jsonschema` (custom fork), `json-schema-compatibility-validator`, `platform-value-json`, and enables `dashcore/eddsa` (ed25519-dalek) for all SDK consumers. The SDK only uses `BatchTransition::validate_base_structure()` from this feature. Consider gating behind an optional SDK feature flag (e.g., `client-validation`) so consumers wanting minimal binary size can opt out.

In `packages/rs-sdk/src/platform/documents/transitions/tests.rs`:
- [SUGGESTION] lines 156-179: Document error-path tests bypass the builder API
  All six assert_document_*_validate_base_structure_error functions construct BatchTransitions directly with INVALID_NONCE and call validate_transition_like_builder(). They prove validate_base_structure catches the error, but they don't exercise the builders' sign() methods. By contrast, the token tests include real builder integration tests that exercise sign() end-to-end with semantically invalid inputs. Consider adding analogous builder-level integration tests for at least one document builder.

In `packages/rs-sdk/src/platform/tokens/builders/tests.rs`:
- [SUGGESTION] lines 40-153: Test infrastructure duplicated between document and token test modules
  TestSigner, test_identity_public_key(), test_data_contract(), validate_transition_like_builder(), new_mock_sdk_with_contract_nonce(), and constants are duplicated verbatim between documents/transitions/tests.rs and tokens/builders/tests.rs. Extract into a shared #[cfg(test)] module (e.g. platform/test_helpers.rs).

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 182-185: Only the first validation error is reported; remaining errors silently dropped
  validation_result.errors.into_iter().next() discards all errors after the first. validate_base_structure can return multiple independent errors (e.g. invalid nonce AND note too big). The existing ensure_valid_state_transition_structure converts the full ValidationResult into an Error via .into(). Consider preserving all errors, or at minimum documenting that only the first is surfaced.

Comment on lines +209 to +226
// Validate the transition structure before returning
let validation_result = match &state_transition {
StateTransition::Batch(batch_transition) => {
batch_transition.validate_base_structure(platform_version)?
}
_ => {
return Err(Error::Protocol(
dpp::ProtocolError::InvalidStateTransitionType(
"expected Batch transition".to_string(),
),
));
}
};
if let Some(first_error) = validation_result.errors.into_iter().next() {
return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
Box::new(first_error),
)));
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Identical validation block duplicated across 17 builder files

The same 18-line validation block (match on StateTransition::Batch, call validate_base_structure, extract first error) is copy-pasted verbatim in all 6 document builders and all 11 token builders. The codebase already has a shared validation helper pattern in packages/rs-sdk/src/platform/transition/validation.rs (ensure_valid_state_transition_structure). A similar pub(crate) helper for validate_base_structure would reduce 306 lines to ~18 + 17 one-line calls.

Suggested change
// Validate the transition structure before returning
let validation_result = match &state_transition {
StateTransition::Batch(batch_transition) => {
batch_transition.validate_base_structure(platform_version)?
}
_ => {
return Err(Error::Protocol(
dpp::ProtocolError::InvalidStateTransitionType(
"expected Batch transition".to_string(),
),
));
}
};
if let Some(first_error) = validation_result.errors.into_iter().next() {
return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
Box::new(first_error),
)));
}
// In a shared module (e.g. validation.rs):
pub(crate) fn validate_batch_base_structure(
state_transition: &StateTransition,
platform_version: &PlatformVersion,
) -> Result<(), Error> {
let validation_result = match state_transition {
StateTransition::Batch(batch_transition) => {
batch_transition.validate_base_structure(platform_version)?
}
_ => {
return Err(Error::Protocol(
dpp::ProtocolError::InvalidStateTransitionType(
"expected Batch transition".to_string(),
),
));
}
};
if let Some(first_error) = validation_result.errors.into_iter().next() {
return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
Box::new(first_error),
)));
}
Ok(())
}
// Then in each builder:
validate_batch_base_structure(&state_transition, platform_version)?;

source: unknown

🤖 Fix this 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-sdk/src/platform/tokens/builders/mint.rs`:
- [SUGGESTION] lines 209-226: Identical validation block duplicated across 17 builder files
  The same 18-line validation block (match on `StateTransition::Batch`, call `validate_base_structure`, extract first error) is copy-pasted verbatim in all 6 document builders and all 11 token builders. The codebase already has a shared validation helper pattern in `packages/rs-sdk/src/platform/transition/validation.rs` (`ensure_valid_state_transition_structure`). A similar `pub(crate)` helper for `validate_base_structure` would reduce 306 lines to ~18 + 17 one-line calls.

Comment thread packages/rs-sdk/Cargo.toml
Comment thread packages/rs-sdk/src/platform/documents/transitions/tests.rs Outdated
Comment on lines +40 to +153
#[derive(Debug)]
struct TestSigner;

impl Signer<IdentityPublicKey> for TestSigner {
fn sign(&self, _key: &IdentityPublicKey, _data: &[u8]) -> Result<BinaryData, ProtocolError> {
Ok(BinaryData::from(vec![1; 65]))
}

fn sign_create_witness(
&self,
_key: &IdentityPublicKey,
_data: &[u8],
) -> Result<AddressWitness, ProtocolError> {
Err(ProtocolError::CorruptedCodeExecution(
"sign_create_witness is not used in these tests".to_string(),
))
}

fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool {
true
}
}

fn test_identity_public_key() -> IdentityPublicKey {
IdentityPublicKey::V0(IdentityPublicKeyV0 {
id: 1,
purpose: Purpose::AUTHENTICATION,
security_level: SecurityLevel::CRITICAL,
contract_bounds: None,
key_type: KeyType::ECDSA_SECP256K1,
read_only: false,
data: BinaryData::from(vec![2; 33]),
disabled_at: None,
})
}

fn test_data_contract(document_type_name: &str) -> Arc<dpp::data_contract::DataContract> {
let platform_version = dpp::version::PlatformVersion::latest();
let config =
DataContractConfig::default_for_version(platform_version).expect("create contract config");

let schema = platform_value!({
"type": "object",
"properties": {
"a": {
"type": "string",
"maxLength": 10,
"position": 0
}
},
"additionalProperties": false,
});

let document_type = DocumentType::try_from_schema(
Identifier::random(),
1,
config.version(),
document_type_name,
schema,
None,
&BTreeMap::new(),
&config,
true,
&mut vec![],
platform_version,
)
.expect("create test document type");

let mut document_types: BTreeMap<String, Value> = BTreeMap::new();
document_types.insert(
document_type.name().to_string(),
document_type.schema().clone(),
);

let contract = DataContractFactory::new(platform_version.protocol_version)
.expect("create data contract factory")
.create(
Identifier::random(),
0,
platform_value!(document_types),
None,
None,
)
.expect("create test data contract")
.data_contract_owned();

Arc::new(contract)
}

const TEST_DOCUMENT_TYPE_NAME: &str = "testDoc";
const TEST_TOKEN_POSITION: u16 = 0;
const INVALID_NONCE: u64 = 1_u64 << 50;

fn validate_transition_like_builder(state_transition: &StateTransition) -> Result<(), Error> {
let platform_version = dpp::version::PlatformVersion::latest();
let validation_result = match state_transition {
StateTransition::Batch(batch_transition) => {
batch_transition.validate_base_structure(platform_version)?
}
_ => {
return Err(Error::Protocol(
dpp::ProtocolError::InvalidStateTransitionType(
"expected Batch transition".to_string(),
),
))
}
};
if let Some(first_error) = validation_result.errors.into_iter().next() {
return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
Box::new(first_error),
)));
}
Ok(())
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Test infrastructure duplicated between document and token test modules

TestSigner, test_identity_public_key(), test_data_contract(), validate_transition_like_builder(), new_mock_sdk_with_contract_nonce(), and constants are duplicated verbatim between documents/transitions/tests.rs and tokens/builders/tests.rs. Extract into a shared #[cfg(test)] module (e.g. platform/test_helpers.rs).

source: unknown

🤖 Fix this 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-sdk/src/platform/tokens/builders/tests.rs`:
- [SUGGESTION] lines 40-153: Test infrastructure duplicated between document and token test modules
  TestSigner, test_identity_public_key(), test_data_contract(), validate_transition_like_builder(), new_mock_sdk_with_contract_nonce(), and constants are duplicated verbatim between documents/transitions/tests.rs and tokens/builders/tests.rs. Extract into a shared #[cfg(test)] module (e.g. platform/test_helpers.rs).

Comment on lines +182 to +185
if let Some(first_error) = validation_result.errors.into_iter().next() {
return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
Box::new(first_error),
)));
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Only the first validation error is reported; remaining errors silently dropped

validation_result.errors.into_iter().next() discards all errors after the first. validate_base_structure can return multiple independent errors (e.g. invalid nonce AND note too big). The existing ensure_valid_state_transition_structure converts the full ValidationResult into an Error via .into(). Consider preserving all errors, or at minimum documenting that only the first is surfaced.

source: unknown

🤖 Fix this 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-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 182-185: Only the first validation error is reported; remaining errors silently dropped
  validation_result.errors.into_iter().next() discards all errors after the first. validate_base_structure can return multiple independent errors (e.g. invalid nonce AND note too big). The existing ensure_valid_state_transition_structure converts the full ValidationResult into an Error via .into(). Consider preserving all errors, or at minimum documenting that only the first is surfaced.

Comment thread packages/rs-sdk/src/platform/tokens/builders/mint.rs Outdated
Comment thread packages/rs-sdk/src/platform/documents/transitions/tests.rs Outdated
Comment on lines +174 to +180
_ => {
return Err(Error::Protocol(
dpp::ProtocolError::InvalidStateTransitionType(
"expected Batch transition".to_string(),
),
));
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Non-Batch match arm is unreachable

All BatchTransition::new_*() methods return StateTransition::Batch. The _ => arm can never execute. This is defensive and harmless, but worth noting — if the shared helper from finding #1 is extracted, the defensive arm only needs to exist in one place.

source: unknown

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Well-executed incremental refactoring that resolves 4 of 8 prior findings. The 17-file validation duplication is properly centralized, test infrastructure is shared via a cfg(test)-gated module, dead imports are cleaned, and test naming is consistent. Two prior suggestions (first-error-only behavior, error-path tests bypassing builder API) remain open but are now easier to address. No blocking issues.

Reviewed commit: 72a0d81

🟡 2 suggestion(s) | 💬 1 nitpick(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/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 28-31: Only the first validation error is reported; remaining errors are silently dropped
  `.into_iter().next()` at line 28 discards all validation errors except the first. This is consistent with the crate-wide pattern (seen in dpns_usernames/mod.rs:471, document_query.rs:188, and 6transition result handlers), and the refactoring correctly centralized it as a single fix point. However, when multiple independent validation errors exist, callers only see the first — leading to a fix-one-resubmit-fix-another cycle. Carryover from prior review; now a one-line fix instead of a 17-file sweep.

In `packages/rs-sdk/src/platform/documents/transitions/tests.rs`:
- [SUGGESTION] lines 225-253: Happy-path builder integration test only covers DocumentDeleteTransitionBuilder
  The prior review flagged that error-path tests bypass the builder API. This commit adds a builder-level sign() integration test, but only for the delete builder. The nonce-masking test at line 191 justifies single-builder coverage with 'all document builders use the same SDK nonce path' — reasonable for nonce-specific behavior. But the happy-path test at line 225 has no analogous justification; different builders construct different transition sub-types (create, replace, purchase, etc.) that exercise different validation code paths in validate_base_structure.

Comment on lines +28 to +31
if let Some(first_error) = validation_result.errors.into_iter().next() {
return Err(Error::Protocol(ProtocolError::ConsensusError(Box::new(
first_error,
))));
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Only the first validation error is reported; remaining errors are silently dropped

.into_iter().next() at line 28 discards all validation errors except the first. This is consistent with the crate-wide pattern (seen in dpns_usernames/mod.rs:471, document_query.rs:188, and 6transition result handlers), and the refactoring correctly centralized it as a single fix point. However, when multiple independent validation errors exist, callers only see the first — leading to a fix-one-resubmit-fix-another cycle. Carryover from prior review; now a one-line fix instead of a 17-file sweep.

source: ['claude']

🤖 Fix this 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-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 28-31: Only the first validation error is reported; remaining errors are silently dropped
  `.into_iter().next()` at line 28 discards all validation errors except the first. This is consistent with the crate-wide pattern (seen in dpns_usernames/mod.rs:471, document_query.rs:188, and 6transition result handlers), and the refactoring correctly centralized it as a single fix point. However, when multiple independent validation errors exist, callers only see the first — leading to a fix-one-resubmit-fix-another cycle. Carryover from prior review; now a one-line fix instead of a 17-file sweep.

Comment thread packages/rs-sdk/src/platform/documents/transitions/tests.rs
Comment thread packages/rs-sdk/src/platform/test_helpers.rs
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Both incremental commits cleanly address the two prior review suggestions. The tracing::warn! fix properly logs dropped validation errors, and all 6 document builder types now have happy-path sign() tests. No blocking issues. Two low-priority suggestions remain around test assertion depth and test coverage symmetry.

Reviewed commit: ebc6d26

🟡 2 suggestion(s) | 💬 1 nitpick(s)

1 additional finding(s) omitted (not in diff).

🤖 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-sdk/src/platform/documents/transitions/tests.rs`:
- [SUGGESTION] lines 259-409: Happy-path sign() tests only assert is_ok() without verifying transition contents
  All five new tests assert `result.is_ok()` but never inspect the returned `BatchTransition`. A bug that swaps the internal transition kind (e.g., sign() on a replace builder accidentally producing a create transition) would pass these tests as long as the result is structurally valid. Even one assertion per test — e.g., checking the transition variant or document ID — would catch constructor-wiring bugs.

In `packages/rs-sdk/src/platform/tokens/builders/tests.rs`:
- [SUGGESTION] lines 312-801: Token builders have no happy-path sign() tests (asymmetry with document builders)
  The PR adds happy-path sign() tests for all 6 document builder types but no equivalent tests for token builders. The token test file contains only negative-path tests. This is outside the scope of the prior review feedback (which only mentioned document builders), so flagging for awareness rather than as a blocking issue.

Comment thread packages/rs-sdk/src/platform/documents/transitions/tests.rs
Comment thread packages/rs-sdk/src/platform/documents/transitions/tests.rs
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the two suggestions from the automated review:

  1. Happy-path sign() tests now assert transition contents — Each happy-path document builder test now also verifies st.signature().is_some_and(|sig| !sig.is_empty()) to confirm the returned StateTransition is actually signed (not just that the call didn't error).

  2. Added happy-path sign() tests for all 11 token builders — Added token_{mint,burn,transfer,freeze,unfreeze,destroy,emergency_action,config_update,claim,purchase,set_price}_sign_succeeds_for_valid_input tests, matching the pattern from the document builder tests. All 27 token builder tests pass (16 existing error-path + 11 new happy-path).

Commits: 6f7cd0b, 6343a25

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Follow-up exact-SHA review: the token-builder side looks correct, but the document-builder path still does not provide the client-side structure validation promised by this PR.

Reviewed commit: 6343a25

🔴 1 blocking

🤖 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-sdk/src/platform/transition/validation.rs`:
- [BLOCKING] lines 14-20: Document builders still do not get real client-side structure validation
  `validate_batch_base_structure()` delegates document builders to `BatchTransition::validate_base_structure()`, but DPP's batch validator only performs document-level checks for nonce bounds and duplicate document IDs (`packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs:96-125`). Unlike the token branch right below it, there is no dispatch into any per-document `Document*Transition::validate_structure()` implementation. Because each SDK document builder constructs exactly one transition, the duplicate-ID path can never fire, and your own new test acknowledges that the remaining nonce check is masked away by `Sdk::get_identity_contract_nonce()` before the builder returns (`packages/rs-sdk/src/platform/documents/transitions/tests.rs:195-226`). The result is that the new document-builder calls are almost a no-op in real usage, so invalid document transitions will still be rejected only by the network instead of being caught client-side as the PR description promises.

Comment thread packages/rs-sdk/src/platform/transition/validation.rs Outdated
…oken transitions

Add structural validation to all document and token SDK transition
builders, matching the pattern from PR dashpay#3096 (identity/address
transitions). Calls validate_base_structure() on BatchTransition after
construction but before broadcast, catching invalid transitions early.

Applied to:
- Document transitions: create, delete, replace, purchase, set_price, transfer
- Token builders: burn, claim, config_update, destroy, purchase,
  emergency_action, freeze, mint, set_price, transfer, unfreeze
- Enabled dpp 'validation' feature for dash-sdk crate
CodeRabbit correctly flagged that validation_result.errors.into_iter()
.next().unwrap() could panic if is_valid() returns false but the errors
vec is somehow empty. Use if-let pattern instead for safe handling.
The validation feature added to rs-sdk's dpp dependency now enforces
that indexed string properties must have maxLength <= 63. The rarity
field in the crypto card game test fixture was missing this constraint,
causing all DataContract tests to fail.
…and token builders

Tests cover:
- InvalidTokenAmountError via TokenMintTransitionBuilder with amount=0
- InvalidActionIdError via TokenMintTransitionBuilder with mismatched group action ID
- Document nonce masking (validates nonce out-of-bounds is unreachable via builder API)

Documents that several error paths (empty transitions, max exceeded,
duplicate transitions, invalid token ID) are unreachable through the
single-transition builder API by design.
All 6 document_*_sign_masks_nonce tests tested the same underlying
behavior (Sdk::get_identity_contract_nonce masks out-of-bounds bits).
Keep one representative test, remove the other 5.

Also removes dead second assert that was always true (result was Ok)
and unused test_document_for_create helper.
@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Review follow-up pushed in 99568032c3.

  • Fixed the wasm-sdk structured-error gap from the review body by mapping identity, contract, broadcast, and adjacent address state-transition errors through WasmSdkError::from instead of flattening them into generic strings.
  • Left the createPublicKeyHandle Optional-path note as follow-up/non-blocking for this PR: that helper still does not throw, so preserving its exact optional-selection behavior is a separate Swift API cleanup rather than part of this structured-consensus fix.

Validation added for this follow-up: cargo fmt --all -- --check, cargo check -p wasm-sdk, cargo clippy -p wasm-sdk --all-targets -- -D warnings, git diff --check, and xcrun swift-format lint --strict on SDKErrorTests.swift. Swift tests are still blocked locally by the missing/invalid DashSDKFFI.xcframework binary artifact in this worktree.

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR is consensus-safe at HEAD 9956803. Validation feature wiring, ProtocolError plumbing, and FFI sidecar lifecycle are coherent. The blocker-tier earlier issues (Swift thrown-type widening, single-slot sidecar race for distinct signatures) are resolved. One concrete consistency gap remains in wasm-sdk: three address-path call sites still flatten dash_sdk::Error via WasmSdkError::generic(format!(...)) rather than the new WasmSdkError::from, so JS callers of those paths cannot observe the structured consensusErrors that the rest of the wasm surface now exposes. The Swift sidecar's (code, message) keying is an acknowledged documented limitation with a race-free escape hatch (fromDashSDKErrorWithConsensusErrors). One pre-existing nitpick (.expect in errors_to_consensus_protocol_error) carries forward.

Reviewed commit: 9956803

🟡 3 suggestion(s) | 💬 2 nitpick(s)

3 additional findings

🟡 suggestion: transfer_credits_to_addresses still flattens ProtocolError into a generic JS string, dropping consensusErrors

packages/wasm-sdk/src/state_transitions/addresses.rs (lines 519-521)

This PR migrates four sites in this same file (178, 301, 467, 758) plus broadcast.rs, contract.rs, identity.rs, and the token transitions to .map_err(WasmSdkError::from) so ProtocolError::ConsensusError(s) survives across the wasm boundary as the structured consensusErrors array (with kind=Protocol, numeric code, isRetriable). transfer_credits_to_addresses still does WasmSdkError::generic(format!("Failed to transfer credits to addresses: {}", e)). identity.transfer_credits_to_addresses returns dash_sdk::Error, which legitimately surfaces SdkError::Protocol(_) for pre-sign/base-structure/nonce/signature failures (the very class of error this PR exposes structurally). With the current wrapper, JS callers see kind="Generic", code=-1, consensusErrors=undefined — exactly the asymmetry this PR set out to remove. The fix is mechanical and matches the pattern used at line 178.

💡 Suggested change
        let (address_infos, new_balance) = identity
            .transfer_credits_to_addresses(
                self.inner_sdk(),
                outputs_map,
                signing_key,
                &signer,
                settings,
            )
            .await
            .map_err(WasmSdkError::from)?;
🟡 suggestion: create_identity_from_addresses still flattens put_with_address_funding errors into Generic

packages/wasm-sdk/src/state_transitions/addresses.rs (lines 917-929)

Same root cause as the transfer_credits_to_addresses site above. put_with_address_funding can return SdkError::Protocol(_) for malformed identity-create transitions and pre-broadcast validation failures, but this wrapper still uses WasmSdkError::generic(format!("Failed to create identity from addresses: {}", e)). That collapses the kind to Generic and erases the consensusErrors array on what is one of the higher-level builders most likely to encounter pre-broadcast validation failures. Mirror the migrated sites and route through WasmSdkError::from so the structured Protocol payload reaches JS callers.

💡 Suggested change
        let (created_identity, address_infos) = identity
            .put_with_address_funding(
                self.inner_sdk(),
                inputs,
                change_output,
                &identity_signer,
                &address_signer,
                settings,
            )
            .await
            .map_err(WasmSdkError::from)?;
💬 nitpick: fetch_nonces_into_address_map keeps the same generic-flatten on AddressInfo::fetch_many

packages/wasm-sdk/src/state_transitions/addresses.rs (lines 952-959)

The nonce-prefetch helper used by create_identity_from_addresses also wraps dash_sdk::query_types::AddressInfo::fetch_many with WasmSdkError::generic(format!(...)). Whether this query path returns ProtocolError-tagged errors today depends on the underlying fetch implementation, but for consistency with the rest of the migrated wasm surface — and to avoid silently dropping structured details if the query layer ever surfaces a ProtocolError — route this through WasmSdkError::from as well. Lower priority than the two transition entrypoints above because nonce fetching is a query, not a state transition.

💡 Suggested change
    let fetched_addresses = dash_sdk::query_types::AddressInfo::fetch_many(sdk, input_addresses)
        .await
        .map_err(WasmSdkError::from)?
        .into_iter()
        .filter_map(|(k, v)| v.map(|info| (k, info)))
        .collect::<BTreeMap<_, _>>();
🤖 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/state_transitions/addresses.rs`:
- [SUGGESTION] lines 519-521: transfer_credits_to_addresses still flattens ProtocolError into a generic JS string, dropping consensusErrors
  This PR migrates four sites in this same file (178, 301, 467, 758) plus broadcast.rs, contract.rs, identity.rs, and the token transitions to .map_err(WasmSdkError::from) so ProtocolError::ConsensusError(s) survives across the wasm boundary as the structured consensusErrors array (with kind=Protocol, numeric code, isRetriable). transfer_credits_to_addresses still does WasmSdkError::generic(format!("Failed to transfer credits to addresses: {}", e)). identity.transfer_credits_to_addresses returns dash_sdk::Error, which legitimately surfaces SdkError::Protocol(_) for pre-sign/base-structure/nonce/signature failures (the very class of error this PR exposes structurally). With the current wrapper, JS callers see kind="Generic", code=-1, consensusErrors=undefined — exactly the asymmetry this PR set out to remove. The fix is mechanical and matches the pattern used at line 178.
- [SUGGESTION] lines 917-929: create_identity_from_addresses still flattens put_with_address_funding errors into Generic
  Same root cause as the transfer_credits_to_addresses site above. put_with_address_funding can return SdkError::Protocol(_) for malformed identity-create transitions and pre-broadcast validation failures, but this wrapper still uses WasmSdkError::generic(format!("Failed to create identity from addresses: {}", e)). That collapses the kind to Generic and erases the consensusErrors array on what is one of the higher-level builders most likely to encounter pre-broadcast validation failures. Mirror the migrated sites and route through WasmSdkError::from so the structured Protocol payload reaches JS callers.

In `packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 362-523: SDKError consensus sidecar is keyed by (code, message) and process-wide — same-signature concurrent throws can mis-attribute details
  The new SDKErrorConsensusSidecar (362-406) is a 32-entry LRU keyed by Signature(code, message), shared across the process. consumeDashSDKError reads the pointer-scoped FFI sidecar, throws the scalar SDKError, and the consensusErrors getter (522-524) re-keys by (code, message) to fetch the bundled details. Two failure modes follow from the keying decision:

1. Concurrent throws across Tasks: ~38 sites in StateTransitionExtensions.swift dispatch on DispatchQueue.global() and resume continuations with consumeDashSDKError. If Task A and Task B both throw ProtocolError("Document not found") concurrently, the second upsert overwrites the first and the awaiter that runs second observes the wrong consensus details.
2. Manually constructed SDKError values (e.g. SDKError.protocolError("Protocol mismatch") in user code) inherit whatever the sidecar last stored under that signature. The new test testConsensusSidecarMayBeReadFromAnotherSameSignatureSDKErrorValue codifies this behavior.

This is now explicitly documented (lines 510-521) with a race-free escape hatch (fromDashSDKErrorWithConsensusErrors) for callers that need per-throw correctness, so the design is bounded — but two structurally tighter options would close the gap without changing the public enum shape: (a) carry the consensus payload via @TaskLocal scoped through Swift Concurrency, or (b) reshape SDKError as a struct with a Kind enum plus an optional [SDKConsensusError] payload property so the value itself owns the details. Either is a larger change than this commit; flagging so the limitation is not lost when the next refactor lands.

Fresh dispatcher-required run for this queue item; an earlier same-SHA automated review may already exist, so this records the fresh verification.

Comment on lines +362 to +523
@@ -332,10 +425,39 @@ public enum SDKError: Error {
case internalError(String)
case unknown(String)

/// Map a Rust FFI `DashSDKError` into a Swift `SDKError`.
///
/// This pointer-typed overload only produces the scalar mapping. To also
/// retrieve structured consensus details before the pointer is freed, use
/// `fromDashSDKErrorWithConsensusErrors(_:)` or
/// `consensusErrors(fromDashSDKError:)`.
public static func fromDashSDKError(_ error: UnsafePointer<DashSDKError>) -> SDKError {
let raw = error.pointee
let message = raw.message != nil ? String(cString: raw.message!) : "Unknown error"
return mapScalar(code: raw.code, message: message)
}

/// Source-compatibility overload that maps a value-typed `DashSDKError`
/// into an `SDKError`. This overload cannot resolve the structured
/// consensus-error sidecar (which is keyed on the heap pointer returned by
/// the FFI, not the value); sidecar-aware code paths must use the
/// pointer-typed `fromDashSDKError(_:)` overload combined with
/// `consensusErrors(fromDashSDKError:)`.
@available(
*, deprecated,
message:
"Use the pointer-typed fromDashSDKError(_:) overload before freeing the FFI error so structured consensus details remain accessible."
)
public static func fromDashSDKError(_ error: DashSDKError) -> SDKError {
let message = error.message != nil ? String(cString: error.message!) : "Unknown error"
return mapScalar(code: error.code, message: message)
}

switch error.code {
private static func mapScalar(
code: DashSDKErrorCode,
message: String
) -> SDKError {
switch code {
case DashSDKErrorCode(rawValue: 1): // Invalid parameter
return .invalidParameter(message)
case DashSDKErrorCode(rawValue: 2): // Invalid state
@@ -360,38 +482,185 @@ public enum SDKError: Error {
return .unknown(message)
}
}

private static let consensusSidecar = SDKErrorConsensusSidecar()

private var sidecarSignature: SDKErrorConsensusSidecar.Signature {
SDKErrorConsensusSidecar.Signature(code: code, message: message)
}

static func updateConsensusSidecar(
for error: SDKError,
consensusErrors: [SDKConsensusError]?
) {
guard let consensusErrors, !consensusErrors.isEmpty else {
consensusSidecar.remove(signature: error.sidecarSignature)
return
}

consensusSidecar.upsert(
SDKErrorConsensusSidecar.Entry(
signature: error.sidecarSignature,
consensusErrors: consensusErrors
)
)
}

/// Structured consensus details captured when a matching FFI error was most
/// recently consumed by `consumeDashSDKError(_:)`.
///
/// This accessor is source-compatible with existing `catch let sdkError as
/// SDKError` flows: wrappers continue throwing `SDKError`, while callers that
/// need protocol details can inspect `sdkError.consensusErrors` immediately in
/// the catch scope. The process-wide sidecar retains a bounded set of recent
/// entries keyed by SDK error code plus message, so recently consumed errors
/// with distinct signatures do not clobber each other. Because `SDKError`
/// remains a scalar public enum, same-signature concurrent errors and
/// manually created `SDKError` values cannot be disambiguated. Direct FFI
/// callers that need race-free structured details should use
/// `fromDashSDKErrorWithConsensusErrors(_:)` before freeing the pointer.
public var consensusErrors: [SDKConsensusError]? {
SDKError.consensusSidecar.consensusErrors(for: sidecarSignature)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: SDKError consensus sidecar is keyed by (code, message) and process-wide — same-signature concurrent throws can mis-attribute details

The new SDKErrorConsensusSidecar (362-406) is a 32-entry LRU keyed by Signature(code, message), shared across the process. consumeDashSDKError reads the pointer-scoped FFI sidecar, throws the scalar SDKError, and the consensusErrors getter (522-524) re-keys by (code, message) to fetch the bundled details. Two failure modes follow from the keying decision:

  1. Concurrent throws across Tasks: ~38 sites in StateTransitionExtensions.swift dispatch on DispatchQueue.global() and resume continuations with consumeDashSDKError. If Task A and Task B both throw ProtocolError("Document not found") concurrently, the second upsert overwrites the first and the awaiter that runs second observes the wrong consensus details.
  2. Manually constructed SDKError values (e.g. SDKError.protocolError("Protocol mismatch") in user code) inherit whatever the sidecar last stored under that signature. The new test testConsensusSidecarMayBeReadFromAnotherSameSignatureSDKErrorValue codifies this behavior.

This is now explicitly documented (lines 510-521) with a race-free escape hatch (fromDashSDKErrorWithConsensusErrors) for callers that need per-throw correctness, so the design is bounded — but two structurally tighter options would close the gap without changing the public enum shape: (a) carry the consensus payload via @TaskLocal scoped through Swift Concurrency, or (b) reshape SDKError as a struct with a Kind enum plus an optional [SDKConsensusError] payload property so the value itself owns the details. Either is a larger change than this commit; flagging so the limitation is not lost when the next refactor lands.

source: ['claude', 'codex']

🤖 Fix this 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/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 362-523: SDKError consensus sidecar is keyed by (code, message) and process-wide — same-signature concurrent throws can mis-attribute details
  The new SDKErrorConsensusSidecar (362-406) is a 32-entry LRU keyed by Signature(code, message), shared across the process. consumeDashSDKError reads the pointer-scoped FFI sidecar, throws the scalar SDKError, and the consensusErrors getter (522-524) re-keys by (code, message) to fetch the bundled details. Two failure modes follow from the keying decision:

1. Concurrent throws across Tasks: ~38 sites in StateTransitionExtensions.swift dispatch on DispatchQueue.global() and resume continuations with consumeDashSDKError. If Task A and Task B both throw ProtocolError("Document not found") concurrently, the second upsert overwrites the first and the awaiter that runs second observes the wrong consensus details.
2. Manually constructed SDKError values (e.g. SDKError.protocolError("Protocol mismatch") in user code) inherit whatever the sidecar last stored under that signature. The new test testConsensusSidecarMayBeReadFromAnotherSameSignatureSDKErrorValue codifies this behavior.

This is now explicitly documented (lines 510-521) with a race-free escape hatch (fromDashSDKErrorWithConsensusErrors) for callers that need per-throw correctness, so the design is bounded — but two structurally tighter options would close the gap without changing the public enum shape: (a) carry the consensus payload via @TaskLocal scoped through Swift Concurrency, or (b) reshape SDKError as a struct with a Kind enum plus an optional [SDKConsensusError] payload property so the value itself owns the details. Either is a larger change than this commit; flagging so the limitation is not lost when the next refactor lands.

Comment on lines +93 to +101
pub fn errors_to_consensus_protocol_error(mut self) -> Option<ProtocolError> {
match self.errors.len() {
0 => None,
1 => Some(ProtocolError::ConsensusError(Box::new(
self.errors.pop().expect("len == 1"),
))),
_ => Some(ProtocolError::ConsensusErrors(self.errors)),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: errors_to_consensus_protocol_error keeps a length-guarded .expect() in a load-bearing helper

Carry-forward, still valid at HEAD. The match dispatches on self.errors.len() and only enters the 1 arm when the vec has exactly one element, so self.errors.pop().expect("len == 1") is unreachable today. This helper is now central to singular/plural ProtocolError construction (used by validate_base_structure_pre_sign and from_document.rs constructor sites). Project guidance discourages .expect() in production paths even when mathematically guaranteed. swap_remove(0) is mechanically panic-free without changing observable behavior.

💡 Suggested change
Suggested change
pub fn errors_to_consensus_protocol_error(mut self) -> Option<ProtocolError> {
match self.errors.len() {
0 => None,
1 => Some(ProtocolError::ConsensusError(Box::new(
self.errors.pop().expect("len == 1"),
))),
_ => Some(ProtocolError::ConsensusErrors(self.errors)),
}
}
pub fn errors_to_consensus_protocol_error(mut self) -> Option<ProtocolError> {
match self.errors.len() {
0 => None,
1 => Some(ProtocolError::ConsensusError(Box::new(
self.errors.swap_remove(0),
))),
_ => Some(ProtocolError::ConsensusErrors(self.errors)),
}
}

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR is in solid shape at HEAD 9956803. The Swift LRU sidecar refactor is sound and the wasm-sdk migration to WasmSdkError::from(e) correctly preserves structured Protocol/consensus_errors for nine sites. Two suggestion-level issues remain: three peer SDK-call sites in addresses.rs were not migrated and still flatten protocol errors via WasmSdkError::generic(format!(...)), and the Swift consensusErrors accessor is keyed only on (code, message), so unrelated same-signature SDKError values can read each other's structured details (the new test suite explicitly pins this behavior).

Reviewed commit: 9956803

🟡 2 suggestion(s)

2 additional findings

🟡 suggestion: Three peer SDK-call sites in addresses.rs still flatten protocol errors via `WasmSdkError::generic(format!(...))`

packages/wasm-sdk/src/state_transitions/addresses.rs (lines 519-521)

Nine wasm-sdk state-transition sites were migrated in this PR from WasmSdkError::generic(format!("Failed to X: {}", e)) to WasmSdkError::from(e) so that dash_sdk::Error::Protocol(_) is preserved as WasmSdkError { kind: Protocol, code, consensus_errors } instead of collapsing into WasmSdkErrorKind::Generic. Three sibling SDK-call sites in this same file were not migrated and retain the old pattern: transfer_credits_to_addresses (line 520), create_identity_from_addresses (line 928), and the AddressInfo::fetch_many call in identity creation (line 955). All three wrap SDK call results — exactly the shape of the migrated sites — so structured protocol/consensus errors raised by those calls cross the JS boundary as kind = Generic with consensus_errors = []. JS code branching on error.kind === 'Protocol' or reading error.consensusErrors will silently miss validation failures from these three operations while the other nine produce structured details. Fix is mechanical and identical to the migrated sites. Confirmed via rg 'WasmSdkError::generic\(format!' packages/wasm-sdk/src/state_transitions returning exactly these three SDK-wrapping sites (the fourth match at line 45 builds a synthetic message and is unrelated).

💡 Suggested change
            .map_err(WasmSdkError::from)?;
🟡 suggestion: Swift `SDKError.consensusErrors` is keyed on `(code, message)`, so unrelated same-signature `SDKError` values can observe each other's FFI consensus details

packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift (lines 486-524)

The Rust C ABI scopes structured consensus details to a heap *mut DashSDKError pointer with a message_ptr integrity check (rs-sdk-ffi/src/error.rs:233-255), so two distinct FFI calls cannot read each other's details even when their (code, message) collide. consumeDashSDKError honors that contract pointer-side, but finalizeConsumedDashSDKError then upserts the consumed details into a process-wide LRU keyed only on Signature { code, message } (SDK.swift:362-405, 488-490). Lookups via SDKError.consensusErrors (522-523) hit the same global map. The new test testConsensusSidecarMayBeReadFromAnotherSameSignatureSDKErrorValue (SDKErrorTests.swift:148-159) explicitly pins this: a separately-constructed SDKError.protocolError("Protocol mismatch") returns the consensus errors that were upserted for an unrelated consumed error. Practical impact: (a) two concurrent FFI failures with identical (code, message) overwrite each other; the most-recent upsert wins, so a caller still holding errA from call A will read call B's consensusErrors if the catch sites interleave; (b) downstream Swift code that programmatically constructs an SDKError with no FFI origin can read structured consensus payload from a prior unrelated FFI call. The accessor's docstring acknowledges this and points race-sensitive callers at fromDashSDKErrorWithConsensusErrors(_:), but the public surface still encourages catch let sdkError as SDKError flows that read sdkError.consensusErrors and will misattribute under same-signature collisions. Optional hardening that preserves source compatibility: thread an opaque per-throw token onto SDKError (or carry the array on a wrapper Error type) so the lookup keys on Swift error-instance identity rather than (code, message) content.

🤖 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/state_transitions/addresses.rs`:
- [SUGGESTION] lines 519-521: Three peer SDK-call sites in addresses.rs still flatten protocol errors via `WasmSdkError::generic(format!(...))`
  Nine wasm-sdk state-transition sites were migrated in this PR from `WasmSdkError::generic(format!("Failed to X: {}", e))` to `WasmSdkError::from(e)` so that `dash_sdk::Error::Protocol(_)` is preserved as `WasmSdkError { kind: Protocol, code, consensus_errors }` instead of collapsing into `WasmSdkErrorKind::Generic`. Three sibling SDK-call sites in this same file were not migrated and retain the old pattern: `transfer_credits_to_addresses` (line 520), `create_identity_from_addresses` (line 928), and the `AddressInfo::fetch_many` call in identity creation (line 955). All three wrap SDK call results — exactly the shape of the migrated sites — so structured protocol/consensus errors raised by those calls cross the JS boundary as `kind = Generic` with `consensus_errors = []`. JS code branching on `error.kind === 'Protocol'` or reading `error.consensusErrors` will silently miss validation failures from these three operations while the other nine produce structured details. Fix is mechanical and identical to the migrated sites. Confirmed via `rg 'WasmSdkError::generic\(format!' packages/wasm-sdk/src/state_transitions` returning exactly these three SDK-wrapping sites (the fourth match at line 45 builds a synthetic message and is unrelated).

In `packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 486-524: Swift `SDKError.consensusErrors` is keyed on `(code, message)`, so unrelated same-signature `SDKError` values can observe each other's FFI consensus details
  The Rust C ABI scopes structured consensus details to a heap `*mut DashSDKError` pointer with a `message_ptr` integrity check (rs-sdk-ffi/src/error.rs:233-255), so two distinct FFI calls cannot read each other's details even when their `(code, message)` collide. `consumeDashSDKError` honors that contract pointer-side, but `finalizeConsumedDashSDKError` then upserts the consumed details into a process-wide LRU keyed only on `Signature { code, message }` (SDK.swift:362-405, 488-490). Lookups via `SDKError.consensusErrors` (522-523) hit the same global map. The new test `testConsensusSidecarMayBeReadFromAnotherSameSignatureSDKErrorValue` (SDKErrorTests.swift:148-159) explicitly pins this: a separately-constructed `SDKError.protocolError("Protocol mismatch")` returns the consensus errors that were upserted for an unrelated consumed error. Practical impact: (a) two concurrent FFI failures with identical `(code, message)` overwrite each other; the most-recent upsert wins, so a caller still holding `errA` from call A will read call B's `consensusErrors` if the catch sites interleave; (b) downstream Swift code that programmatically constructs an `SDKError` with no FFI origin can read structured consensus payload from a prior unrelated FFI call. The accessor's docstring acknowledges this and points race-sensitive callers at `fromDashSDKErrorWithConsensusErrors(_:)`, but the public surface still encourages `catch let sdkError as SDKError` flows that read `sdkError.consensusErrors` and will misattribute under same-signature collisions. Optional hardening that preserves source compatibility: thread an opaque per-throw token onto `SDKError` (or carry the array on a wrapper Error type) so the lookup keys on Swift error-instance identity rather than `(code, message)` content.

Fresh dispatcher gate review for 99568032; posted as a top-level review because review_poster.py detected an existing same-SHA review and would otherwise skip this required gate run.

@thepastaclaw thepastaclaw force-pushed the feat/sdk-validate-all-transitions branch from 9956803 to 025aa3b Compare May 9, 2026 18:04
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR is consensus-safe at HEAD 025aa3b. Major prior concerns are resolved: consumeDashSDKError throws SDKError preserving source compat, wasm-sdk and FFI structurally preserve consensus errors, the 23 duplicated validate-and-sign sites are consolidated, state-transition-signing transitively enables batch-base-structure-validation, and sidecar mutex poisoning is recovered. Five non-blocking observations remain — the Swift sidecar's (code,message) keying can drop or cross-attribute structured details for same-signature concurrent throws, the FFI active sidecar still relies on heap-pointer identity (caller-UB territory), errors_to_consensus_protocol_error keeps a length-guarded .expect(), the document-create pre-sign id check is unreachable through the public builder, and 11 token-builder files retain identically-named validation_tests wrappers.

Reviewed commit: 025aa3b

🟡 1 suggestion(s) | 💬 4 nitpick(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/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 362-528: Swift consensus sidecar keyed on (code,message) drops or cross-attributes structured details for same-signature errors
  `SDKErrorConsensusSidecar` (SDK.swift:362-408) keys entries on `Signature { code, message }` with a 32-entry FIFO cap, and `consensusErrors` (line 526-527) consumes the entry on first read. Three concrete consequences for callers using the new structured surface: (1) two concurrent throws that map to the same `SDKError` code+message (plausible for shapes like `.protocolError("Nonce is out of bounds")` triggered on different keys/clients) collide in `upsert` (lines 378-389) — the second clobbers the first's structured details before the first's catch can read them; (2) any catch that reads `.consensusErrors`, then rethrows or fans out to logging plus UI, gets `nil` from the second read because `consumeConsensusErrors` removes the entry; (3) under load, 32 distinct unrelated signatures evict the oldest before a delayed catch site reads it. The Rust FFI sidecar already has full-fidelity per-pointer details and `fromDashSDKErrorWithConsensusErrors(_:)` exposes them race-free at the FFI boundary, but the in-process Swift wrapper API surfaced through `consumeDashSDKError` cannot disambiguate without changing the public `SDKError` shape. The lines 514-525 docstring acknowledges this and steers race-sensitive callers to the FFI accessor; flagged because the contract is the surface a future caller may unintentionally rely on. Mechanically closing it requires either making `SDKError` carry the optional payload (the source-break the PR is explicitly avoiding) or threading a non-recyclable cookie through `DashSDKError` and `SDKError` so identity isn't degraded to a string match.

Comment on lines +362 to +528
@@ -332,10 +427,39 @@ public enum SDKError: Error {
case internalError(String)
case unknown(String)

/// Map a Rust FFI `DashSDKError` into a Swift `SDKError`.
///
/// This pointer-typed overload only produces the scalar mapping. To also
/// retrieve structured consensus details before the pointer is freed, use
/// `fromDashSDKErrorWithConsensusErrors(_:)` or
/// `consensusErrors(fromDashSDKError:)`.
public static func fromDashSDKError(_ error: UnsafePointer<DashSDKError>) -> SDKError {
let raw = error.pointee
let message = raw.message != nil ? String(cString: raw.message!) : "Unknown error"
return mapScalar(code: raw.code, message: message)
}

/// Source-compatibility overload that maps a value-typed `DashSDKError`
/// into an `SDKError`. This overload cannot resolve the structured
/// consensus-error sidecar (which is keyed on the heap pointer returned by
/// the FFI, not the value); sidecar-aware code paths must use the
/// pointer-typed `fromDashSDKError(_:)` overload combined with
/// `consensusErrors(fromDashSDKError:)`.
@available(
*, deprecated,
message:
"Use the pointer-typed fromDashSDKError(_:) overload before freeing the FFI error so structured consensus details remain accessible."
)
public static func fromDashSDKError(_ error: DashSDKError) -> SDKError {
let message = error.message != nil ? String(cString: error.message!) : "Unknown error"
return mapScalar(code: error.code, message: message)
}

switch error.code {
private static func mapScalar(
code: DashSDKErrorCode,
message: String
) -> SDKError {
switch code {
case DashSDKErrorCode(rawValue: 1): // Invalid parameter
return .invalidParameter(message)
case DashSDKErrorCode(rawValue: 2): // Invalid state
@@ -360,38 +484,187 @@ public enum SDKError: Error {
return .unknown(message)
}
}

private static let consensusSidecar = SDKErrorConsensusSidecar()

private var sidecarSignature: SDKErrorConsensusSidecar.Signature {
SDKErrorConsensusSidecar.Signature(code: code, message: message)
}

static func updateConsensusSidecar(
for error: SDKError,
consensusErrors: [SDKConsensusError]?
) {
guard let consensusErrors, !consensusErrors.isEmpty else {
consensusSidecar.remove(signature: error.sidecarSignature)
return
}

consensusSidecar.upsert(
SDKErrorConsensusSidecar.Entry(
signature: error.sidecarSignature,
consensusErrors: consensusErrors
)
)
}

/// Structured consensus details captured when a matching FFI error was most
/// recently consumed by `consumeDashSDKError(_:)`.
///
/// This accessor is source-compatible with existing `catch let sdkError as
/// SDKError` flows: wrappers continue throwing `SDKError`, while callers that
/// need protocol details can inspect `sdkError.consensusErrors` immediately in
/// the catch scope. The process-wide sidecar retains a bounded set of recent
/// entries keyed by SDK error code plus message, so recently consumed errors
/// with distinct signatures do not clobber each other. Reading this property
/// consumes the matching sidecar entry, so same-signature details are only
/// available once on a best-effort basis. Because `SDKError` remains a scalar
/// public enum, same-signature concurrent errors and manually created
/// `SDKError` values cannot be disambiguated. Direct FFI callers that need
/// race-free structured details should use
/// `fromDashSDKErrorWithConsensusErrors(_:)` before freeing the pointer.
public var consensusErrors: [SDKConsensusError]? {
SDKError.consensusSidecar.consumeConsensusErrors(for: sidecarSignature)
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Swift consensus sidecar keyed on (code,message) drops or cross-attributes structured details for same-signature errors

SDKErrorConsensusSidecar (SDK.swift:362-408) keys entries on Signature { code, message } with a 32-entry FIFO cap, and consensusErrors (line 526-527) consumes the entry on first read. Three concrete consequences for callers using the new structured surface: (1) two concurrent throws that map to the same SDKError code+message (plausible for shapes like .protocolError("Nonce is out of bounds") triggered on different keys/clients) collide in upsert (lines 378-389) — the second clobbers the first's structured details before the first's catch can read them; (2) any catch that reads .consensusErrors, then rethrows or fans out to logging plus UI, gets nil from the second read because consumeConsensusErrors removes the entry; (3) under load, 32 distinct unrelated signatures evict the oldest before a delayed catch site reads it. The Rust FFI sidecar already has full-fidelity per-pointer details and fromDashSDKErrorWithConsensusErrors(_:) exposes them race-free at the FFI boundary, but the in-process Swift wrapper API surfaced through consumeDashSDKError cannot disambiguate without changing the public SDKError shape. The lines 514-525 docstring acknowledges this and steers race-sensitive callers to the FFI accessor; flagged because the contract is the surface a future caller may unintentionally rely on. Mechanically closing it requires either making SDKError carry the optional payload (the source-break the PR is explicitly avoiding) or threading a non-recyclable cookie through DashSDKError and SDKError so identity isn't degraded to a string match.

source: ['claude-general', 'claude-security-auditor', 'claude-ffi-engineer', 'codex-general', 'codex-ffi-engineer']

🤖 Fix this 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/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 362-528: Swift consensus sidecar keyed on (code,message) drops or cross-attributes structured details for same-signature errors
  `SDKErrorConsensusSidecar` (SDK.swift:362-408) keys entries on `Signature { code, message }` with a 32-entry FIFO cap, and `consensusErrors` (line 526-527) consumes the entry on first read. Three concrete consequences for callers using the new structured surface: (1) two concurrent throws that map to the same `SDKError` code+message (plausible for shapes like `.protocolError("Nonce is out of bounds")` triggered on different keys/clients) collide in `upsert` (lines 378-389) — the second clobbers the first's structured details before the first's catch can read them; (2) any catch that reads `.consensusErrors`, then rethrows or fans out to logging plus UI, gets `nil` from the second read because `consumeConsensusErrors` removes the entry; (3) under load, 32 distinct unrelated signatures evict the oldest before a delayed catch site reads it. The Rust FFI sidecar already has full-fidelity per-pointer details and `fromDashSDKErrorWithConsensusErrors(_:)` exposes them race-free at the FFI boundary, but the in-process Swift wrapper API surfaced through `consumeDashSDKError` cannot disambiguate without changing the public `SDKError` shape. The lines 514-525 docstring acknowledges this and steers race-sensitive callers to the FFI accessor; flagged because the contract is the surface a future caller may unintentionally rely on. Mechanically closing it requires either making `SDKError` carry the optional payload (the source-break the PR is explicitly avoiding) or threading a non-recyclable cookie through `DashSDKError` and `SDKError` so identity isn't degraded to a string match.

Comment on lines +93 to +101
pub fn errors_to_consensus_protocol_error(mut self) -> Option<ProtocolError> {
match self.errors.len() {
0 => None,
1 => Some(ProtocolError::ConsensusError(Box::new(
self.errors.pop().expect("len == 1"),
))),
_ => Some(ProtocolError::ConsensusErrors(self.errors)),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: errors_to_consensus_protocol_error keeps a length-guarded .expect() in a load-bearing helper

errors_to_consensus_protocol_error matches on self.errors.len() and only enters the 1 arm when there is exactly one element, so the self.errors.pop().expect("len == 1") is mathematically unreachable today. The helper is now load-bearing for the singular/plural ProtocolError mapping used by validate_base_structure_pre_sign and the per-variant from_document constructor sites, so it sits squarely on the new pre-sign error-mapping path. Project guidance discourages .expect() in production code even when the value is mathematically guaranteed, since a future refactor that splits the dispatch differently turns a guaranteed branch into a real panic site. A tidier mechanically-panic-free shape consumes the vector via swap_remove(0) in the 1 arm.

💡 Suggested change
Suggested change
pub fn errors_to_consensus_protocol_error(mut self) -> Option<ProtocolError> {
match self.errors.len() {
0 => None,
1 => Some(ProtocolError::ConsensusError(Box::new(
self.errors.pop().expect("len == 1"),
))),
_ => Some(ProtocolError::ConsensusErrors(self.errors)),
}
}
pub fn errors_to_consensus_protocol_error(self) -> Option<ProtocolError> {
match (self.errors.len(), self.errors) {
(0, _) => None,
(1, mut errors) => Some(ProtocolError::ConsensusError(Box::new(errors.swap_remove(0)))),
(_, errors) => Some(ProtocolError::ConsensusErrors(errors)),
}
}

source: ['claude-general', 'claude-rust-quality', 'codex-general', 'codex-rust-quality']

Comment on lines +156 to +167
let mut document = self.document.clone();
// The public create builder always normalizes the document id here before
// calling the DPP constructor, so the constructor's create-id pre-sign
// validation acts as defense-in-depth for this SDK path rather than a
// user-reachable builder failure.
document.set_id(Document::generate_document_id_v0(
self.data_contract.id_ref(),
&document.owner_id(),
&self.document_type_name,
self.document_state_transition_entropy.as_slice(),
));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Document-create pre-sign id check is unreachable through the public SDK builder

DocumentCreateTransitionBuilder::sign overwrites document.id via Document::generate_document_id_v0(...) immediately before invoking BatchTransition::new_document_creation_transition_from_document (lines 161-166). The only document-side per-transition pre-sign check the constructor's validate_base_structure_pre_sign hook runs for Create is the entropy-derived id-equality test in DocumentCreateTransitionStructureValidationV0::validate_structure_v0, but the input id is by construction correct after the SDK normalization, so InvalidDocumentTransitionIdError cannot fire through the public builder. The other five document variants (Replace, Delete, Transfer, Purchase, UpdatePrice) have no per-transition validate_structure content (their v0 modules return Ok-by-default), so net through the public SDK builder the document-side base-structure pre-sign hook is unreachable, while token builders surface real client-side validation errors. The new comment at lines 157-160 honestly acknowledges this is defense-in-depth, but the PR title and README still imply parity that does not yet exist for document builders. Either narrow the public-facing description or land the follow-up per-document validate_structure coverage.

source: ['claude-general', 'claude-rust-quality', 'codex-general']

Comment on lines +233 to +255
fn with_active_consensus_errors<R>(
error_ptr: *const DashSDKError,
f: impl FnOnce(&[ConsensusErrorEntry]) -> R,
) -> Option<R> {
if error_ptr.is_null() {
return None;
}
// Snapshot the current message pointer for identity verification *before*
// taking the lock to keep the unsafe deref scope small. This dereference
// is sound under the documented contract (caller passes a live pointer
// returned by the SDK that has not yet been freed); a stale dangling
// pointer is UB at this point and unavoidable.
let current_message = unsafe { (*error_ptr).message } as usize;
let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
let entry = guard.get(&(error_ptr as usize))?;
if entry.message_ptr != current_message {
// Pointer key matched but the message field doesn't match the value
// we recorded at boxing time — almost certainly a recycled heap
// allocation now occupied by a different error. Treat as no sidecar.
return None;
}
Some(f(entry.entries.as_slice()))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Active sidecar lookup still depends on raw heap-pointer identity for protection against post-free reuse

with_active_consensus_errors keys on error_ptr as usize and verifies the recorded message_ptr against the live (*error_ptr).message (lines 248-252); active_sidecar_rejects_message_pointer_mismatch pins the common stale-entry case. The residual failure mode requires caller contract violation: caller A holds *mut DashSDKError P and calls dash_sdk_error_free(P); an unrelated FFI call boxes a fresh error and Box::into_raw reuses heap address P; if CString::into_raw for the new error's message also reuses the original M (allocator-dependent but possible after the original CString was just freed), the integrity check passes and a stale alias resolves the unrelated error's structured details. This is post-free UB at the caller per the documented move-only contract (lines 18-43, 184-190), so it is contract-violation territory rather than a defect today; the residual exposure is that the structured-details API turns a single-allocation use-after-free at the C/Swift seam into cross-error metadata attribution (consensus-error code/kind/name/message — no secrets). A non-recyclable cookie threaded through ActiveSidecarEntry and a private trailing field on DashSDKError (e.g. AtomicU64::fetch_add(1), invisible to C clients) would mechanically close the gap without breaking the frozen ABI.

source: ['claude-security-auditor', 'claude-ffi-engineer', 'codex-ffi-engineer']

Comment on lines +187 to +193
#[cfg(test)]
mod validation_tests {
#[tokio::test]
async fn validate_base_structure_error_case() {
super::super::tests::assert_token_burn_validate_base_structure_error().await;
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: 11 token-builder files retain identically-named validation_tests wrapper modules

burn.rs, claim.rs, config_update.rs, destroy.rs, emergency_action.rs, freeze.rs, mint.rs, purchase.rs, set_price.rs, transfer.rs, and unfreeze.rs each end with a #[cfg(test)] mod validation_tests whose single #[tokio::test] is named validate_base_structure_error_case, all delegating to helpers in tokens/builders/tests.rs. Every wrapper test shares the same leaf name, so CI failure logs and IDE jump-to-definition need the module path to disambiguate, and the extra hop dilutes any #[track_caller] attached to the helpers. The analogous wrappers were dropped from the document-builder side earlier in this PR series, sharpening the asymmetry. Drop the per-builder modules and let helpers in tokens/builders/tests.rs carry #[tokio::test] directly with distinct names (e.g. token_burn_validate_base_structure_error).

source: ['claude-general', 'claude-rust-quality', 'codex-rust-quality']

@thepastaclaw thepastaclaw force-pushed the feat/sdk-validate-all-transitions branch from 025aa3b to ea12f77 Compare May 10, 2026 01:04
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Findings validated against HEAD ea12f77. The PR is consensus-safe with no blocking issues. Convergent observations from claude-general/codex-general and claude-ffi-engineer/codex-ffi-engineer flag that public Swift throwing wrappers cannot surface the new structured FFI consensus details (now an explicit, documented design choice). Convergent quality nits: a length-guarded .expect() in a load-bearing helper, identically-named token-builder validation_tests wrappers across 11 files, and a residual FFI sidecar mis-attribution edge case. Document-side pre-sign id check is unreachable through the SDK builder.

Reviewed commit: ea12f77

🟡 2 suggestion(s) | 💬 4 nitpick(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/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 501-512: Public Swift throwing wrappers discard structured FFI consensus details before freeing the pointer
  `consumeDashSDKError(_:)` (SDK.swift:507-512) maps the FFI error to the scalar `SDKError`, calls `finalizeConsumedDashSDKError(mapped, consensusErrors: nil)` with explicit `nil`, and frees the FFI pointer via `dash_sdk_error_free`. Every public throwing wrapper migrated in this PR — `SDK.init`, `loadKnownContracts`, `getStatus`, `Identities.getBalance/fetchBalances`, the ~13 sites in `Address/Addresses.swift`, and ~24 token/document throwing helpers in `FFI/StateTransitionExtensions.swift` (via `consumeDashSDKErrorOrInternal`) — uniformly route through this helper. The new C ABI structured surface (`dash_sdk_error_consensus_error_count`/`_at`) and the Swift readers (`consensusErrors(fromDashSDKError:)`, `fromDashSDKErrorWithConsensusErrors(_:)`) are wired and tested but unreachable from the normal Swift API: by the time the thrown `SDKError` reaches the caller, the structured `[SDKConsensusError]` payload has been freed. The doc comments at SDK.swift:440-451 and 503-506 explicitly call this out as the deliberate source-compatibility trade-off (the prior `SDKDetailedError` widen-then-revert episode). The in-tree example app still references `sdkError.consensusErrors` in `QueryDetailView.swift` and `DiagnosticsView.swift`, which now permanently returns `nil`. Either capture `consensusErrors(fromDashSDKError:)` in the throwing wrappers before the free runs and surface it on `SDKError` (non-source-breaking, e.g. associated payload on the existing cases), or accept the asymmetry and document in CHANGELOG that callers needing structured details must drop down to the FFI directly. Also consider `@available(*, deprecated, message:)` on `SDKError.consensusErrors` so adopters of intermediate intra-PR commits get a compiler diagnostic instead of silent `nil`.

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 156-167: Document-create pre-sign id check is unreachable through the SDK builder; document/token parity not delivered
  `DocumentCreateTransitionBuilder::sign` overwrites `document.id` via `Document::generate_document_id_v0(...)` (lines 161-166) immediately before invoking `BatchTransition::new_document_creation_transition_from_document`. The only document-side per-transition pre-sign check the constructor's `validate_base_structure_pre_sign` hook can run for Create is the entropy-derived ID equality test in `DocumentCreateTransitionStructureValidationV0::validate_structure_v0`, and that check cannot fire through the SDK builder because the input id is by construction correct after this normalization. The other five document variants (Replace, Delete, Transfer, Purchase, UpdatePrice) have empty `validate_structure` no-op stubs. The new comment at lines 157-160 acknowledges this is defense-in-depth, and the document branch in `validate_basic_structure/v0/mod.rs:97-129` is batch-level only (nonce bounds + duplicate IDs) — it does not iterate per-transition `validate_structure()` like the token branch at line 132+. Token builders therefore deliver real client-side structural errors while document builders do not, despite the framing of pre-sign validation parity. Either drop the SDK-side id normalization so user-supplied invalid ids surface a structured pre-sign error, or land per-document `validate_structure` content for the remaining variants in a follow-up.

Comment on lines +501 to +512
/// Frees the owned FFI error pointer after mapping it to a Swift error.
///
/// Always returns the mapped `SDKError`, preserving the public thrown runtime
/// type for existing `catch let sdkError as SDKError` handlers. Structured
/// consensus details, if needed, must be read from the original pointer with
/// `fromDashSDKErrorWithConsensusErrors(_:)` before it is freed.
static func consumeDashSDKError(_ error: UnsafeMutablePointer<DashSDKError>) -> SDKError {
let mapped = fromDashSDKError(UnsafePointer(error))
let finalized = finalizeConsumedDashSDKError(mapped, consensusErrors: nil)
dash_sdk_error_free(error)
return finalized
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Public Swift throwing wrappers discard structured FFI consensus details before freeing the pointer

consumeDashSDKError(_:) (SDK.swift:507-512) maps the FFI error to the scalar SDKError, calls finalizeConsumedDashSDKError(mapped, consensusErrors: nil) with explicit nil, and frees the FFI pointer via dash_sdk_error_free. Every public throwing wrapper migrated in this PR — SDK.init, loadKnownContracts, getStatus, Identities.getBalance/fetchBalances, the ~13 sites in Address/Addresses.swift, and ~24 token/document throwing helpers in FFI/StateTransitionExtensions.swift (via consumeDashSDKErrorOrInternal) — uniformly route through this helper. The new C ABI structured surface (dash_sdk_error_consensus_error_count/_at) and the Swift readers (consensusErrors(fromDashSDKError:), fromDashSDKErrorWithConsensusErrors(_:)) are wired and tested but unreachable from the normal Swift API: by the time the thrown SDKError reaches the caller, the structured [SDKConsensusError] payload has been freed. The doc comments at SDK.swift:440-451 and 503-506 explicitly call this out as the deliberate source-compatibility trade-off (the prior SDKDetailedError widen-then-revert episode). The in-tree example app still references sdkError.consensusErrors in QueryDetailView.swift and DiagnosticsView.swift, which now permanently returns nil. Either capture consensusErrors(fromDashSDKError:) in the throwing wrappers before the free runs and surface it on SDKError (non-source-breaking, e.g. associated payload on the existing cases), or accept the asymmetry and document in CHANGELOG that callers needing structured details must drop down to the FFI directly. Also consider @available(*, deprecated, message:) on SDKError.consensusErrors so adopters of intermediate intra-PR commits get a compiler diagnostic instead of silent nil.

source: ['claude-general', 'claude-ffi-engineer', 'codex-general', 'codex-ffi-engineer']

🤖 Fix this 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/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 501-512: Public Swift throwing wrappers discard structured FFI consensus details before freeing the pointer
  `consumeDashSDKError(_:)` (SDK.swift:507-512) maps the FFI error to the scalar `SDKError`, calls `finalizeConsumedDashSDKError(mapped, consensusErrors: nil)` with explicit `nil`, and frees the FFI pointer via `dash_sdk_error_free`. Every public throwing wrapper migrated in this PR — `SDK.init`, `loadKnownContracts`, `getStatus`, `Identities.getBalance/fetchBalances`, the ~13 sites in `Address/Addresses.swift`, and ~24 token/document throwing helpers in `FFI/StateTransitionExtensions.swift` (via `consumeDashSDKErrorOrInternal`) — uniformly route through this helper. The new C ABI structured surface (`dash_sdk_error_consensus_error_count`/`_at`) and the Swift readers (`consensusErrors(fromDashSDKError:)`, `fromDashSDKErrorWithConsensusErrors(_:)`) are wired and tested but unreachable from the normal Swift API: by the time the thrown `SDKError` reaches the caller, the structured `[SDKConsensusError]` payload has been freed. The doc comments at SDK.swift:440-451 and 503-506 explicitly call this out as the deliberate source-compatibility trade-off (the prior `SDKDetailedError` widen-then-revert episode). The in-tree example app still references `sdkError.consensusErrors` in `QueryDetailView.swift` and `DiagnosticsView.swift`, which now permanently returns `nil`. Either capture `consensusErrors(fromDashSDKError:)` in the throwing wrappers before the free runs and surface it on `SDKError` (non-source-breaking, e.g. associated payload on the existing cases), or accept the asymmetry and document in CHANGELOG that callers needing structured details must drop down to the FFI directly. Also consider `@available(*, deprecated, message:)` on `SDKError.consensusErrors` so adopters of intermediate intra-PR commits get a compiler diagnostic instead of silent `nil`.

Comment on lines +156 to +167
let mut document = self.document.clone();
// The public create builder always normalizes the document id here before
// calling the DPP constructor, so the constructor's create-id pre-sign
// validation acts as defense-in-depth for this SDK path rather than a
// user-reachable builder failure.
document.set_id(Document::generate_document_id_v0(
self.data_contract.id_ref(),
&document.owner_id(),
&self.document_type_name,
self.document_state_transition_entropy.as_slice(),
));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Document-create pre-sign id check is unreachable through the SDK builder; document/token parity not delivered

DocumentCreateTransitionBuilder::sign overwrites document.id via Document::generate_document_id_v0(...) (lines 161-166) immediately before invoking BatchTransition::new_document_creation_transition_from_document. The only document-side per-transition pre-sign check the constructor's validate_base_structure_pre_sign hook can run for Create is the entropy-derived ID equality test in DocumentCreateTransitionStructureValidationV0::validate_structure_v0, and that check cannot fire through the SDK builder because the input id is by construction correct after this normalization. The other five document variants (Replace, Delete, Transfer, Purchase, UpdatePrice) have empty validate_structure no-op stubs. The new comment at lines 157-160 acknowledges this is defense-in-depth, and the document branch in validate_basic_structure/v0/mod.rs:97-129 is batch-level only (nonce bounds + duplicate IDs) — it does not iterate per-transition validate_structure() like the token branch at line 132+. Token builders therefore deliver real client-side structural errors while document builders do not, despite the framing of pre-sign validation parity. Either drop the SDK-side id normalization so user-supplied invalid ids surface a structured pre-sign error, or land per-document validate_structure content for the remaining variants in a follow-up.

source: ['claude-general', 'codex-general']

🤖 Fix this 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-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 156-167: Document-create pre-sign id check is unreachable through the SDK builder; document/token parity not delivered
  `DocumentCreateTransitionBuilder::sign` overwrites `document.id` via `Document::generate_document_id_v0(...)` (lines 161-166) immediately before invoking `BatchTransition::new_document_creation_transition_from_document`. The only document-side per-transition pre-sign check the constructor's `validate_base_structure_pre_sign` hook can run for Create is the entropy-derived ID equality test in `DocumentCreateTransitionStructureValidationV0::validate_structure_v0`, and that check cannot fire through the SDK builder because the input id is by construction correct after this normalization. The other five document variants (Replace, Delete, Transfer, Purchase, UpdatePrice) have empty `validate_structure` no-op stubs. The new comment at lines 157-160 acknowledges this is defense-in-depth, and the document branch in `validate_basic_structure/v0/mod.rs:97-129` is batch-level only (nonce bounds + duplicate IDs) — it does not iterate per-transition `validate_structure()` like the token branch at line 132+. Token builders therefore deliver real client-side structural errors while document builders do not, despite the framing of pre-sign validation parity. Either drop the SDK-side id normalization so user-supplied invalid ids surface a structured pre-sign error, or land per-document `validate_structure` content for the remaining variants in a follow-up.

Comment on lines +93 to +101
pub fn errors_to_consensus_protocol_error(mut self) -> Option<ProtocolError> {
match self.errors.len() {
0 => None,
1 => Some(ProtocolError::ConsensusError(Box::new(
self.errors.pop().expect("len == 1"),
))),
_ => Some(ProtocolError::ConsensusErrors(self.errors)),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Length-guarded .expect() remains in load-bearing consensus-error conversion helper

errors_to_consensus_protocol_error matches on self.errors.len() and only enters the 1 arm when there is exactly one element, so self.errors.pop().expect("len == 1") is unreachable today. This helper is now load-bearing for the singular/plural ProtocolError mapping used by validate_base_structure_pre_sign and the five from_document.rs constructors (Delete, Replace, Transfer, Purchase, UpdatePrice). Project guidance discourages .expect() in production paths even when the value is mathematically guaranteed by the surrounding logic. A tidier shape uses swap_remove(0) or destructures via (len, self.errors) so the helper is mechanically panic-free.

💡 Suggested change
Suggested change
pub fn errors_to_consensus_protocol_error(mut self) -> Option<ProtocolError> {
match self.errors.len() {
0 => None,
1 => Some(ProtocolError::ConsensusError(Box::new(
self.errors.pop().expect("len == 1"),
))),
_ => Some(ProtocolError::ConsensusErrors(self.errors)),
}
}
pub fn errors_to_consensus_protocol_error(self) -> Option<ProtocolError> {
match (self.errors.len(), self.errors) {
(0, _) => None,
(1, mut errors) => Some(ProtocolError::ConsensusError(Box::new(errors.swap_remove(0)))),
(_, errors) => Some(ProtocolError::ConsensusErrors(errors)),
}
}

source: ['claude-general', 'codex-general', 'codex-rust-quality']

Comment on lines +187 to +193
#[cfg(test)]
mod validation_tests {
#[tokio::test]
async fn validate_base_structure_error_case() {
super::super::tests::assert_token_burn_validate_base_structure_error().await;
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: 11 token-builder files retain identically-named validation_tests wrapper modules

Each of burn.rs, claim.rs, config_update.rs, destroy.rs, emergency_action.rs, freeze.rs, mint.rs, purchase.rs, set_price.rs, transfer.rs, and unfreeze.rs (verified — exactly 11 files) ends with a #[cfg(test)] mod validation_tests whose single #[tokio::test] is named validate_base_structure_error_case, all delegating to helpers in tokens/builders/tests.rs. Every wrapper test shares the same leaf name, so CI failure logs and IDE jump-to-definition need the module path to disambiguate, and the extra hop dilutes any #[track_caller] on the helpers. The analogous wrappers were already dropped from the document-builder side earlier in this PR series. Drop the per-builder modules and let helpers in tokens/builders/tests.rs carry #[tokio::test] directly with distinct names (e.g. token_burn_validate_base_structure_error).

source: ['claude-general', 'codex-general', 'codex-rust-quality']

Comment on lines +233 to +255
fn with_active_consensus_errors<R>(
error_ptr: *const DashSDKError,
f: impl FnOnce(&[ConsensusErrorEntry]) -> R,
) -> Option<R> {
if error_ptr.is_null() {
return None;
}
// Snapshot the current message pointer for identity verification *before*
// taking the lock to keep the unsafe deref scope small. This dereference
// is sound under the documented contract (caller passes a live pointer
// returned by the SDK that has not yet been freed); a stale dangling
// pointer is UB at this point and unavoidable.
let current_message = unsafe { (*error_ptr).message } as usize;
let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
let entry = guard.get(&(error_ptr as usize))?;
if entry.message_ptr != current_message {
// Pointer key matched but the message field doesn't match the value
// we recorded at boxing time — almost certainly a recycled heap
// allocation now occupied by a different error. Treat as no sidecar.
return None;
}
Some(f(entry.entries.as_slice()))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Active sidecar identity still relies on raw heap-pointer reuse plus message-pointer integrity check

with_active_consensus_errors keys on error_ptr as usize with a secondary entry.message_ptr integrity check (lines 248-252) — pinned by active_sidecar_rejects_message_pointer_mismatch. The remaining failure mode requires caller contract violation: thread A calls dash_sdk_error_free(P) (which removes the active entry and frees both the box and the message CString); a subsequent unrelated FFI call has its Box::into_raw reuse heap address P, and box_dashsdk_error records a fresh ActiveSidecarEntry { message_ptr: M' }. If A queries through the dangling alias (post-free UB at the caller per the documented move-only contract at module rustdoc 18-43) and the new error's (*P).message happens to equal the recorded M' (allocator may reuse the message slot too for short messages), the integrity check passes for the new occupant and A receives the unrelated error's structured details rather than None. Security impact is low: payload is consensus-error metadata only (numeric code, kind, name, human-readable message) — no secrets. A non-recyclable cookie (AtomicU64::fetch_add(1)) threaded through ActiveSidecarEntry and a private trailing field on DashSDKError would close this without breaking the documented C ABI for callers that copy by struct value, turning the safety invariant from documentation into a mechanically-enforced boundary.

source: ['claude-general', 'claude-security-auditor', 'claude-ffi-engineer', 'codex-general', 'codex-rust-quality']

Comment thread packages/rs-sdk-ffi/src/error.rs Outdated
Comment on lines +240 to +254
// Snapshot the current message pointer for identity verification *before*
// taking the lock to keep the unsafe deref scope small. This dereference
// is sound under the documented contract (caller passes a live pointer
// returned by the SDK that has not yet been freed); a stale dangling
// pointer is UB at this point and unavoidable.
let current_message = unsafe { (*error_ptr).message } as usize;
let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
let entry = guard.get(&(error_ptr as usize))?;
if entry.message_ptr != current_message {
// Pointer key matched but the message field doesn't match the value
// we recorded at boxing time — almost certainly a recycled heap
// allocation now occupied by a different error. Treat as no sidecar.
return None;
}
Some(f(entry.entries.as_slice()))
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: (*error_ptr).message snapshot taken outside sidecar mutex lacks inline cross-reference to the free path

with_active_consensus_errors reads current_message = unsafe { (*error_ptr).message } as usize (245) before acquiring ACTIVE_CONSENSUS_ERRORS.lock() (246). The mutex protects the sidecar map only; it does not synchronize with dash_sdk_error_free, which removes the active entry and runs Box::from_raw without holding the active lock for the box reclamation. The deref soundness is correctly rationalized in the inline SAFETY comment (240-244): the documented synchronous-move-only contract makes a concurrent free post-free UB at the caller. Residual concern is forward-fragility: a future maintainer reordering the snapshot or adding a guarded refresh path could mistakenly assume the lock provides synchronization with concurrent free. A two-line cross-reference noting that the lock does NOT synchronize with dash_sdk_error_free, and that integrity is provided by (a) caller contract and (b) the message-pointer integrity check, would lock in the invariant for future readers.

source: ['claude-ffi-engineer']

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Review follow-up for #3133 (review) pushed in 1f4c357173:

  • Swift structured consensus details: kept public throwing wrappers source-compatible by continuing to throw scalar SDKError; documented that structured details must be read from the FFI pointer before dash_sdk_error_free or explicitly wrapped in SDKDetailedError; deprecated scalar SDKError.consensusErrors; removed example-app calls that implied scalar SDKError retained details.
  • Document create pre-sign validation: verified the SDK builder still normalizes create IDs by design; clarified the comment that the create-id check is defense-in-depth for this path while non-create transition-local checks are exercised in the corresponding DPP from_document constructors.

Validation:

  • git diff --check
  • cargo test -p dash-sdk document_create_builder_sign_replaces_incorrect_document_id --lib
  • swift test in packages/swift-sdk/SwiftTests still blocks because packages/swift-sdk/DashSDKFFI.xcframework is not a binary artifact in this worktree.

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR is consensus-safe at HEAD 1f4c357 with no blocking issues. The previously-flagged regressions (cfg-gated call sites, feature-flag transitivity, error truncation, mutex poisoning, wasm-sdk Debug-stringification) are all resolved on this head. Remaining items are non-blocking: structured Swift consensus details are unreachable from every public throwing wrapper (an intentional source-compat trade-off, but the PR description still describes a sidecar that no longer exists), a length-guarded .expect() in a now-load-bearing helper, an unreachable document-create pre-sign id check through the SDK builder, 11 identically-named token-builder test wrappers, and the active-sidecar pointer-reuse failure mode that remains documented rather than mechanically enforced.

Reviewed commit: 1f4c357

🟡 2 suggestion(s) | 💬 5 nitpick(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/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 498-517: Public Swift throwing wrappers discard structured FFI consensus details before freeing the error pointer
  `consumeDashSDKError(_:)` (SDK.swift:512-517) maps the FFI error to scalar `SDKError`, calls `finalizeConsumedDashSDKError` with hard-coded `consensusErrors: nil` (line 514), and frees the pointer via `dash_sdk_error_free`. `finalizeConsumedDashSDKError` itself discards the array (`_ = consensusErrors; return error` at lines 498-504), and `SDKError.consensusErrors` is `@available(*, deprecated)` returning `nil` (lines 449-456). Every public throwing wrapper migrated in this PR — `SDK.init`, `loadKnownContracts`, `getStatus`, the address wrappers, and the ~24 sites in `FFI/StateTransitionExtensions.swift` routed through `consumeDashSDKError`/`consumeDashSDKErrorOrInternal` — therefore throws scalar `SDKError` only. The new C ABI structured surface (`dash_sdk_error_consensus_error_count`/`_at`) and Swift readers (`fromDashSDKErrorWithConsensusErrors`, `consensusErrors(fromDashSDKError:)`) are wired and tested but unreachable through the normal Swift API: by the time a caller catches `SDKError`, the original heap pointer is already freed. The doc-comments at lines 440-456 and 506-511 explicitly document this as an intentional source-compatibility trade-off, but the PR description still claims a 'bounded best-effort sidecar keyed by SDK error code plus message' that no longer exists in code. Either capture `consensusErrors(fromDashSDKError:)` inside `consumeDashSDKError` and surface it through a non-source-breaking carrier (e.g. an optional payload attached to `SDKError` cases), or accept the asymmetry and update the PR description plus CHANGELOG so consumers know to drop down to FFI for structured details.

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 156-167: Document-create pre-sign id check is unreachable through the public SDK builder
  `DocumentCreateTransitionBuilder::sign` overwrites `document.id` via `Document::generate_document_id_v0(...)` (lines 162-167) immediately before invoking `BatchTransition::new_document_creation_transition_from_document`. The only document-side per-transition pre-sign check the constructor's `validate_base_structure_pre_sign` hook can run for Create is the entropy-derived ID equality test in `DocumentCreateTransitionStructureValidationV0::validate_structure_v0`, which by construction cannot fail through this SDK path. The other five document variants (Replace, Delete, Transfer, Purchase, UpdatePrice) have empty per-transition validators and the document branch in `validate_base_structure_v0_internal` is batch-level only (nonce bounds + duplicate IDs + max-transitions). With single-transition batches and `Sdk::get_identity_contract_nonce` masking the high nonce bits, no document-branch base-structure error is reachable through public SDK document builders today. Token builders, by contrast, surface real client-side structural errors. The new comment at lines 157-161 honestly acknowledges this is defense-in-depth, but the PR title implies parity that does not yet exist. Either drop the SDK-side id normalization so user-supplied invalid ids surface a structured pre-sign error, or land per-document `validate_structure` content for Replace/Delete/Transfer/Purchase/UpdatePrice in a follow-up. The dedicated dispatch fields already added in `DocumentsBatchTransitionValidationVersions` suggest the latter direction is planned.

Comment on lines +498 to +517
static func finalizeConsumedDashSDKError(
_ error: SDKError,
consensusErrors: [SDKConsensusError]?
) -> SDKError {
_ = consensusErrors
return error
}

/// Frees the owned FFI error pointer after mapping it to a Swift error.
///
/// Always returns the mapped `SDKError`, preserving the public thrown runtime
/// type for existing `catch let sdkError as SDKError` handlers. Structured
/// consensus details, if needed, must be read from the original pointer with
/// `fromDashSDKErrorWithConsensusErrors(_:)` before it is freed.
static func consumeDashSDKError(_ error: UnsafeMutablePointer<DashSDKError>) -> SDKError {
let mapped = fromDashSDKError(UnsafePointer(error))
let finalized = finalizeConsumedDashSDKError(mapped, consensusErrors: nil)
dash_sdk_error_free(error)
return finalized
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Public Swift throwing wrappers discard structured FFI consensus details before freeing the error pointer

consumeDashSDKError(_:) (SDK.swift:512-517) maps the FFI error to scalar SDKError, calls finalizeConsumedDashSDKError with hard-coded consensusErrors: nil (line 514), and frees the pointer via dash_sdk_error_free. finalizeConsumedDashSDKError itself discards the array (_ = consensusErrors; return error at lines 498-504), and SDKError.consensusErrors is @available(*, deprecated) returning nil (lines 449-456). Every public throwing wrapper migrated in this PR — SDK.init, loadKnownContracts, getStatus, the address wrappers, and the ~24 sites in FFI/StateTransitionExtensions.swift routed through consumeDashSDKError/consumeDashSDKErrorOrInternal — therefore throws scalar SDKError only. The new C ABI structured surface (dash_sdk_error_consensus_error_count/_at) and Swift readers (fromDashSDKErrorWithConsensusErrors, consensusErrors(fromDashSDKError:)) are wired and tested but unreachable through the normal Swift API: by the time a caller catches SDKError, the original heap pointer is already freed. The doc-comments at lines 440-456 and 506-511 explicitly document this as an intentional source-compatibility trade-off, but the PR description still claims a 'bounded best-effort sidecar keyed by SDK error code plus message' that no longer exists in code. Either capture consensusErrors(fromDashSDKError:) inside consumeDashSDKError and surface it through a non-source-breaking carrier (e.g. an optional payload attached to SDKError cases), or accept the asymmetry and update the PR description plus CHANGELOG so consumers know to drop down to FFI for structured details.

source: ['claude-general', 'claude-ffi-engineer', 'codex-general', 'codex-ffi-engineer']

🤖 Fix this 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/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 498-517: Public Swift throwing wrappers discard structured FFI consensus details before freeing the error pointer
  `consumeDashSDKError(_:)` (SDK.swift:512-517) maps the FFI error to scalar `SDKError`, calls `finalizeConsumedDashSDKError` with hard-coded `consensusErrors: nil` (line 514), and frees the pointer via `dash_sdk_error_free`. `finalizeConsumedDashSDKError` itself discards the array (`_ = consensusErrors; return error` at lines 498-504), and `SDKError.consensusErrors` is `@available(*, deprecated)` returning `nil` (lines 449-456). Every public throwing wrapper migrated in this PR — `SDK.init`, `loadKnownContracts`, `getStatus`, the address wrappers, and the ~24 sites in `FFI/StateTransitionExtensions.swift` routed through `consumeDashSDKError`/`consumeDashSDKErrorOrInternal` — therefore throws scalar `SDKError` only. The new C ABI structured surface (`dash_sdk_error_consensus_error_count`/`_at`) and Swift readers (`fromDashSDKErrorWithConsensusErrors`, `consensusErrors(fromDashSDKError:)`) are wired and tested but unreachable through the normal Swift API: by the time a caller catches `SDKError`, the original heap pointer is already freed. The doc-comments at lines 440-456 and 506-511 explicitly document this as an intentional source-compatibility trade-off, but the PR description still claims a 'bounded best-effort sidecar keyed by SDK error code plus message' that no longer exists in code. Either capture `consensusErrors(fromDashSDKError:)` inside `consumeDashSDKError` and surface it through a non-source-breaking carrier (e.g. an optional payload attached to `SDKError` cases), or accept the asymmetry and update the PR description plus CHANGELOG so consumers know to drop down to FFI for structured details.

Comment on lines +156 to +167
let mut document = self.document.clone();
// The public create builder always normalizes the document id here before
// calling the DPP constructor, so the constructor's create-id pre-sign
// validation is defense-in-depth for this SDK path rather than a
// user-reachable builder failure. Non-create transition-local checks are
// still exercised in the corresponding DPP `from_document` constructors.
document.set_id(Document::generate_document_id_v0(
self.data_contract.id_ref(),
&document.owner_id(),
&self.document_type_name,
self.document_state_transition_entropy.as_slice(),
));
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Document-create pre-sign id check is unreachable through the public SDK builder

DocumentCreateTransitionBuilder::sign overwrites document.id via Document::generate_document_id_v0(...) (lines 162-167) immediately before invoking BatchTransition::new_document_creation_transition_from_document. The only document-side per-transition pre-sign check the constructor's validate_base_structure_pre_sign hook can run for Create is the entropy-derived ID equality test in DocumentCreateTransitionStructureValidationV0::validate_structure_v0, which by construction cannot fail through this SDK path. The other five document variants (Replace, Delete, Transfer, Purchase, UpdatePrice) have empty per-transition validators and the document branch in validate_base_structure_v0_internal is batch-level only (nonce bounds + duplicate IDs + max-transitions). With single-transition batches and Sdk::get_identity_contract_nonce masking the high nonce bits, no document-branch base-structure error is reachable through public SDK document builders today. Token builders, by contrast, surface real client-side structural errors. The new comment at lines 157-161 honestly acknowledges this is defense-in-depth, but the PR title implies parity that does not yet exist. Either drop the SDK-side id normalization so user-supplied invalid ids surface a structured pre-sign error, or land per-document validate_structure content for Replace/Delete/Transfer/Purchase/UpdatePrice in a follow-up. The dedicated dispatch fields already added in DocumentsBatchTransitionValidationVersions suggest the latter direction is planned.

source: ['claude-general', 'claude-rust-quality', 'codex-general']

🤖 Fix this 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-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 156-167: Document-create pre-sign id check is unreachable through the public SDK builder
  `DocumentCreateTransitionBuilder::sign` overwrites `document.id` via `Document::generate_document_id_v0(...)` (lines 162-167) immediately before invoking `BatchTransition::new_document_creation_transition_from_document`. The only document-side per-transition pre-sign check the constructor's `validate_base_structure_pre_sign` hook can run for Create is the entropy-derived ID equality test in `DocumentCreateTransitionStructureValidationV0::validate_structure_v0`, which by construction cannot fail through this SDK path. The other five document variants (Replace, Delete, Transfer, Purchase, UpdatePrice) have empty per-transition validators and the document branch in `validate_base_structure_v0_internal` is batch-level only (nonce bounds + duplicate IDs + max-transitions). With single-transition batches and `Sdk::get_identity_contract_nonce` masking the high nonce bits, no document-branch base-structure error is reachable through public SDK document builders today. Token builders, by contrast, surface real client-side structural errors. The new comment at lines 157-161 honestly acknowledges this is defense-in-depth, but the PR title implies parity that does not yet exist. Either drop the SDK-side id normalization so user-supplied invalid ids surface a structured pre-sign error, or land per-document `validate_structure` content for Replace/Delete/Transfer/Purchase/UpdatePrice in a follow-up. The dedicated dispatch fields already added in `DocumentsBatchTransitionValidationVersions` suggest the latter direction is planned.

Comment on lines +93 to +101
pub fn errors_to_consensus_protocol_error(mut self) -> Option<ProtocolError> {
match self.errors.len() {
0 => None,
1 => Some(ProtocolError::ConsensusError(Box::new(
self.errors.pop().expect("len == 1"),
))),
_ => Some(ProtocolError::ConsensusErrors(self.errors)),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Length-guarded .expect() in load-bearing consensus-error conversion helper

errors_to_consensus_protocol_error matches on self.errors.len() and only enters the 1 arm when there is exactly one element, so self.errors.pop().expect("len == 1") is unreachable today. This helper is now load-bearing for the singular/plural ProtocolError mapping used by the validate_base_structure_pre_sign dispatcher and all six from_document.rs constructor sites. Project guidance discourages .expect() in production paths even when the value is mathematically guaranteed by surrounding logic. A panic-free shape uses swap_remove(0) after destructuring on (len, errors) so future maintainers cannot accidentally turn this into a real panic.

💡 Suggested change
Suggested change
pub fn errors_to_consensus_protocol_error(mut self) -> Option<ProtocolError> {
match self.errors.len() {
0 => None,
1 => Some(ProtocolError::ConsensusError(Box::new(
self.errors.pop().expect("len == 1"),
))),
_ => Some(ProtocolError::ConsensusErrors(self.errors)),
}
}
pub fn errors_to_consensus_protocol_error(self) -> Option<ProtocolError> {
match (self.errors.len(), self.errors) {
(0, _) => None,
(1, mut errors) => Some(ProtocolError::ConsensusError(Box::new(errors.swap_remove(0)))),
(_, errors) => Some(ProtocolError::ConsensusErrors(errors)),
}
}

source: ['claude-general', 'claude-rust-quality', 'codex-general', 'codex-rust-quality']

Comment on lines +187 to +193
#[cfg(test)]
mod validation_tests {
#[tokio::test]
async fn validate_base_structure_error_case() {
super::super::tests::assert_token_burn_validate_base_structure_error().await;
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: 11 token-builder files retain identically-named validation_tests wrapper modules

Each of burn.rs, claim.rs, config_update.rs, destroy.rs, emergency_action.rs, freeze.rs, mint.rs, purchase.rs, set_price.rs, transfer.rs, and unfreeze.rs (verified — exactly 11 files via grep) ends with a #[cfg(test)] mod validation_tests whose single #[tokio::test] is named validate_base_structure_error_case, all delegating to super::super::tests::assert_token_*_validate_base_structure_error() helpers in tokens/builders/tests.rs. Every wrapper test shares the same leaf name, so CI failure logs and IDE jump-to-definition rely on the module path to disambiguate, and the extra hop dilutes any #[track_caller] on the helpers. The analogous wrappers were already dropped from the document-builder side earlier in this PR series, sharpening the asymmetry. Drop the per-builder modules and let helpers in tokens/builders/tests.rs carry #[tokio::test] directly with distinct names (e.g. token_burn_validate_base_structure_error).

source: ['claude-general', 'claude-rust-quality', 'codex-general', 'codex-rust-quality']

Comment on lines +233 to +255
fn with_active_consensus_errors<R>(
error_ptr: *const DashSDKError,
f: impl FnOnce(&[ConsensusErrorEntry]) -> R,
) -> Option<R> {
if error_ptr.is_null() {
return None;
}
// Snapshot the current message pointer for identity verification *before*
// taking the lock to keep the unsafe deref scope small. This dereference
// is sound under the documented contract (caller passes a live pointer
// returned by the SDK that has not yet been freed); a stale dangling
// pointer is UB at this point and unavoidable.
let current_message = unsafe { (*error_ptr).message } as usize;
let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
let entry = guard.get(&(error_ptr as usize))?;
if entry.message_ptr != current_message {
// Pointer key matched but the message field doesn't match the value
// we recorded at boxing time — almost certainly a recycled heap
// allocation now occupied by a different error. Treat as no sidecar.
return None;
}
Some(f(entry.entries.as_slice()))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Active sidecar identity still relies on raw heap-pointer reuse plus message-pointer integrity check

with_active_consensus_errors keys on error_ptr as usize with a secondary entry.message_ptr integrity check (lines 248-252), pinned by active_sidecar_rejects_message_pointer_mismatch. The remaining failure mode requires a caller contract violation: thread A calls dash_sdk_error_free(P) (which removes the active entry and frees both the box and the message CString); a subsequent unrelated FFI call has its Box::into_raw reuse heap address P, and box_dashsdk_error records a fresh ActiveSidecarEntry { message_ptr: M' }. If A queries through the dangling alias and the new error's (*P).message happens to equal the recorded M' (allocator may reuse short-CString slots too), the integrity check passes for the new occupant and A receives the unrelated error's structured details rather than None. Querying through a freed alias is post-free UB at the caller per the documented synchronous-move-only contract; the residual exposure is that the structured-details API amplifies a single-allocation use-after-free into cross-error metadata attribution. Payload is consensus-error metadata only (numeric code, kind, name, message) — no secrets. A non-recyclable cookie (AtomicU64::fetch_add(1)) threaded through ActiveSidecarEntry and a private trailing field on DashSDKError (invisible to C clients via #[repr(C)] field ordering) would close this without breaking the documented C ABI, turning the safety invariant from documentation into a mechanically-enforced boundary.

source: ['claude-general', 'claude-security-auditor', 'claude-rust-quality', 'claude-ffi-engineer', 'codex-general', 'codex-rust-quality', 'codex-ffi-engineer']

Comment on lines +257 to +284
/// Box a [`DashSDKError`] for return across the FFI boundary, promoting any
/// pending consensus-error sidecar entries (keyed by the error's `message`
/// pointer) to the active sidecar (keyed by the heap error pointer).
///
/// Sidecar-capable errors — those built via the `From<FFIError>` impl from
/// `FFIError::SDKError(dash_sdk::Error::Protocol(_))` — MUST go through this
/// helper (directly or via [`DashSDKResult::error`] / the [`ffi_result!`]
/// macro) so the pending sidecar is reachable through the pointer the caller
/// actually receives. Hand-crafted [`DashSDKError::new`] errors that have no
/// pending sidecar entry are outside this contract; boxing them with bare
/// `Box::into_raw` is sound (it just produces an error with no structured
/// details), though routing them through this helper is also fine and is the
/// recommended default to keep return paths uniform.
pub fn box_dashsdk_error(error: DashSDKError) -> *mut DashSDKError {
let message_ptr = error.message;
let raw = Box::into_raw(Box::new(error));
if let Some(entries) = take_pending_consensus_errors(message_ptr) {
let mut map = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
map.insert(
raw as usize,
ActiveSidecarEntry {
message_ptr: message_ptr as usize,
entries,
},
);
}
raw
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Bare Box::into_raw(Box::new(DashSDKError::new(...))) remains at ~24 hand-crafted FFI sites

The module rustdoc and box_dashsdk_error's own doc explicitly carve out hand-crafted DashSDKError::new values built without From<FFIError>: they may be boxed via bare Box::into_raw because they have no pending sidecar entry to lose. ~24 FFI sites still construct error pointers via bare Box::into_raw(Box::new(DashSDKError::new(...))) (across protocol_version/queries/*, contested_resource/queries/*, system/queries/*, document/util.rs, etc.). All are sound today since none start from From<FFIError>. The new Drop impl on DashSDKError plus routing of DashSDKResult::error and the data-contract fetch result through box_dashsdk_error close the most common contract-violation paths. Residual forward fragility: a contributor refactoring any of these branches to start from a dash_sdk::Error::Protocol(_) value via From<FFIError> and bare-boxing it (instead of using box_dashsdk_error / ffi_result!) will silently produce a DashSDKError whose pending sidecar is keyed under the message pointer, never promoted, and only reclaimed by Drop — which never runs because Box::into_raw suppresses Drop. Narrowest structural fix: have From<FFIError> itself heap-box and return *mut DashSDKError, eliminating the pending phase from the type system rather than the docstring.

source: ['claude-general']

Comment thread packages/rs-sdk-ffi/src/error.rs Outdated
Comment on lines +240 to +254
// Snapshot the current message pointer for identity verification *before*
// taking the lock to keep the unsafe deref scope small. This dereference
// is sound under the documented contract (caller passes a live pointer
// returned by the SDK that has not yet been freed); a stale dangling
// pointer is UB at this point and unavoidable.
let current_message = unsafe { (*error_ptr).message } as usize;
let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
let entry = guard.get(&(error_ptr as usize))?;
if entry.message_ptr != current_message {
// Pointer key matched but the message field doesn't match the value
// we recorded at boxing time — almost certainly a recycled heap
// allocation now occupied by a different error. Treat as no sidecar.
return None;
}
Some(f(entry.entries.as_slice()))
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: (*error_ptr).message snapshot taken outside sidecar mutex; lock does not synchronize with dash_sdk_error_free

with_active_consensus_errors reads current_message = unsafe { (*error_ptr).message } as usize (line 245) before acquiring ACTIVE_CONSENSUS_ERRORS.lock() (line 246). The mutex protects only the sidecar map; it does not synchronize with dash_sdk_error_free, which removes the active entry and runs Box::from_raw without holding the active lock for the box reclamation. Soundness today is rationalized by the inline SAFETY comment plus the documented synchronous-move-only contract that makes any concurrent free post-free UB at the caller. Forward-fragility risk: a future maintainer reordering the snapshot or adding a guarded refresh path could mistakenly assume the lock provides synchronization with concurrent free. Worth a brief inline cross-reference noting that the lock does NOT synchronize with dash_sdk_error_free and that integrity is provided by (a) the move-only contract and (b) the message-pointer integrity check.

source: ['claude-ffi-engineer']

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Review follow-up for #3133 (review):

  • Swift FFI consensus details: verified current code intentionally keeps public throwing wrappers source-compatible as scalar SDKError. I updated the PR description to remove the stale claim about a bounded best-effort (code, message) sidecar and to state clearly that structured consensus details are only available while the original DashSDKError* is alive via SDKError.fromDashSDKErrorWithConsensusErrors(_:) / consensusErrors(fromDashSDKError:), or via explicit SDKDetailedError wrapping.
  • Document create validation: verified DocumentCreateTransitionBuilder::sign() intentionally normalizes the document id before invoking the DPP constructor, so the create-id check is defense-in-depth rather than user-reachable through this builder. I tightened the PR description to say document builders get batch-level DPP checks only and that broader per-document pre-sign parity remains follow-up work.

No code changes were needed for this pass; the changelog already documents the Swift asymmetry and source-compatibility trade-off.

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR extends DPP pre-sign batch base-structure validation to SDK builders, adds per-transition structure validators, threads a new ProtocolError::ConsensusErrors plural variant through wasm/FFI, and adds a pointer-keyed consensus-error sidecar with a new Drop impl on DashSDKError. The blocking issue is a double-free regression: the new impl Drop for DashSDKError is incompatible with existing transfer.rs/withdraw.rs tests that manually reclaim error.message via CString::from_raw and then Box::from_raw the heap error. Notable verifier corrections: Codex's dash-spv-ffi removal finding is a false positive (those changes came from upstream PR #3644 and are not part of PR #3133), and the ProtocolError variant-reordering concern is moot since ProtocolError is not consensus-serialized.

Reviewed commit: db0e002

🔴 1 blocking | 🟡 6 suggestion(s) | 💬 3 nitpick(s)

1 additional finding

💬 nitpick: errors_to_consensus_protocol_error has a gratuitous TData: Clone bound

packages/rs-dpp/src/validation/validation_result/mod.rs (lines 82-101)

The new helper lives in impl<TData: Clone> ConsensusValidationResult<TData>, but it only consumes self.errors and returns a ProtocolError. The TData type parameter is never used inside the method, so the Clone bound is gratuitous and excludes callers whose TData is not Clone. Either drop the bound on the impl block or, better, define this method on SimpleConsensusValidationResult only — that's the type used at every current call site (delete/purchase/replace/transfer/update-price from_document.rs). The current placement misleads readers into thinking the data field is involved in the conversion.

🤖 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-sdk-ffi/src/error.rs`:
- [BLOCKING] lines 319-337: New Drop for DashSDKError double-frees error.message in existing transfer/withdraw tests
  This PR introduces `impl Drop for DashSDKError` at error.rs:319-337 that unconditionally calls `CString::from_raw(self.message)` whenever the message pointer is non-null. Several pre-existing tests in this crate still take ownership of `error.message` manually and then drop the boxed error:

- `packages/rs-sdk-ffi/src/identity/transfer.rs:329-346` (null_identity_handle_returns_error)
- `packages/rs-sdk-ffi/src/identity/transfer.rs:376-383` (null_sdk_handle_returns_error)
- `packages/rs-sdk-ffi/src/identity/transfer.rs:411-419` (null_signer_handle_returns_error)
- `packages/rs-sdk-ffi/src/identity/transfer.rs:448-456` (null_to_identity_id_returns_error)
- `packages/rs-sdk-ffi/src/identity/withdraw.rs:287-296` and 326-334

Each site does:
```rust
let error = unsafe { &*result.error };
// ...
let _ = CString::from_raw(error.message as *mut _); // frees the message
let _ = Box::from_raw(result.error);                // Drop runs and frees again

Because error is a shared borrow into the still-boxed value, the manual reclaim does not null result.error->message. When Box::from_raw then runs DashSDKError::drop, the non-null self.message is freed a second time. The macOS allocator currently tolerates this, but it is UB and will fail under Miri/ASan or on Linux glibc. The PR description's listed test command only runs sdk_protocol_consensus, so this regression was not caught.

Fix: drop the manual CString::from_raw(error.message ...) calls in these tests and free strictly through dash_sdk_error_free(result.error) (or rely on the new Drop via Box::from_raw). The new Drop already owns the message lifecycle.

  • [SUGGESTION] lines 304-337: Document that DashSDKError.message is now Drop-owned and audit remaining manual reclaims
    Adding Drop to DashSDKError is a semantic change for every Rust user of the struct. Before this PR, message: *mut c_char was caller-owned and reclaimed manually via CString::from_raw; after this PR, it is owned by DashSDKError. The doc comment on Drop covers the boxed→free interaction but does not call out the incompatibility with prior manual-reclaim patterns. Add a # Compatibility note on both the Drop impl and on DashSDKError itself stating that message is now Drop-owned and must not be reclaimed externally, and grep the crate for remaining CString::from_raw(error.message ...) callsites — those are now double-frees and should be removed as part of this PR (see blocking finding for the test sites that are affected).
  • [SUGGESTION] lines 233-255: with_active_consensus_errors dereferences (*error_ptr).message outside the sidecar lock
    with_active_consensus_errors reads (*error_ptr).message at error.rs:245 before acquiring the ACTIVE_CONSENSUS_ERRORS mutex. The doc comment acknowledges that a stale pointer here is UB and unavoidable under the documented contract, but the deref happens unconditionally on every call — including the common 'no sidecar' case — and widens the consequence of any caller misuse (e.g. a buggy bilingual Swift/JS binding that frees on a different thread) from 'stale data' to UB.

Swap the order: acquire the lock first, look up the entry by pointer, then read (*error_ptr).message only when an entry exists. The map check is the integrity gate for whether the pointer is still owned by us, so doing it first costs nothing on the hit path and avoids the deref entirely on the miss path. Consider also wrapping dash_sdk_error_consensus_error_count / _at in std::panic::catch_unwind so misuse cannot unwind across extern "C".

  • [SUGGESTION] lines 209-215: PENDING_CONSENSUS_ERRORS can leak entries if a From error is built but never boxed or dropped
    register_pending_consensus_errors inserts into a process-global Lazy<Mutex<HashMap>> keyed on the message pointer. Cleanup happens either via box_dashsdk_error (promotion to ACTIVE) or via DashSDKError::drop (removal of PENDING). Both require Drop or boxing to run. Any code path that constructs DashSDKError::from(FFIError::SDKError(Error::Protocol(_))) and then mem::forgets the value, or stores it in a struct whose destructor is bypassed, leaves the PENDING entry alive indefinitely. The next allocator reuse of the same *mut c_char for a different error will then mis-attribute its consensus details.

Current callers all box via DashSDKResult::error or ffi_result!, so this is latent. Two options: (a) move sidecar registration inside box_dashsdk_error so promotion to ACTIVE is the only path that ever inserts entries, or (b) document the in-crate mem::forget contract more loudly at the module top. The module's current docs focus on the FFI-caller contract but not on the in-crate construction contract.

  • [SUGGESTION] lines 382-415: classify_sdk_error fallback now maps non-DAPI transport failures to InternalError
    classify_sdk_error only maps DapiClientError and a small set of variants to NetworkError/Timeout/NotFound; everything else falls through to InternalError. Pre-PR, the substring-matching classifier would route errors whose Display contained 'I/O error' or 'connection' into NetworkError, which could include ContextProviderError (trusted setup / trusted context fetches) and CoreClientError (Dash Core RPC).

The new structural classifier is clearly better than the prior substring matching with the hardcoded 'Failed to fetch balances' prefix, but it does shift the public C/Swift error code for those failure modes from NetworkError(3) to InternalError(99). Existing Swift/JS callers that retry, special-case, or surface connectivity issues based on NetworkError will silently stop doing so for these variants. Either add explicit arms for ContextProviderError and CoreClientError (mapping them to NetworkError where appropriate), or call out the deliberate classification change in the PR description so consumers can update their handling. This is a behavioral change in the exported API, not a pure cleanup.

In packages/rs-sdk/src/platform/documents/transitions/create.rs:

  • [SUGGESTION] lines 156-167: SDK create builder hardcodes generate_document_id_v0 and silently overrides caller-supplied IDs
    DocumentCreateTransitionBuilder::sign now clones self.document and unconditionally overwrites document.id with Document::generate_document_id_v0(...) before calling the DPP constructor. Two concerns:
  1. The call pins this SDK path to the v0 ID derivation regardless of any future DocumentCreateTransition method/version bump. Only v0 exists today, but this cuts across the repo's PlatformVersion dispatch pattern. Route through a versioned helper (or pass platform_version and dispatch on it) so a future v1 derivation does not silently diverge here.

  2. The rewrite is silent. Any caller who intentionally set document.id (e.g. integration tests asserting determinism, or higher-level code wanting to surface the constructor's InvalidDocumentTransitionIdError) will have their value replaced with no warning. Consider asserting document.id() == generated_id || document.id() == Identifier::default() and returning an explicit error on mismatch, so the constructor's defense-in-depth check the inline comment refers to actually remains reachable.

In packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v0_methods.rs:

  • [SUGGESTION] lines 331-344: Self-purchase check duplicated verbatim between BatchTransitionV0 and BatchTransitionV1 constructors
    The self-purchase guard (document.owner_id() == new_owner_idInvalidDocumentTransitionActionError) is implemented identically in BatchTransitionV0::new_document_purchase_transition_from_document (v0/v0_methods.rs:322-333) and BatchTransitionV1::new_document_purchase_transition_from_document (v1/v0_methods.rs:331-344), with the same literal format string and the same fully-qualified path. The PR also added a per-transition validate_structure hook for purchase, with a comment noting that the self-purchase check stays in the constructors because the hook doesn't have the buyer id.

Extend the purchase validate_structure signature to accept the buyer id so the check lives once next to the other purchase structure checks — or move it inside DocumentPurchaseTransition::from_document, which already has the document and document_type. Single-sourcing this check (a) prevents v0/v1 messages drifting silently, (b) keeps the per-version constructors focused on the transition shape, and (c) means a future drive-abci validator change only needs to be mirrored in one place.

</details>

Comment on lines +319 to +337
impl Drop for DashSDKError {
fn drop(&mut self) {
if !self.message.is_null() {
// Drain any still-pending sidecar entry keyed on this message
// pointer. After a successful `box_dashsdk_error` promotion this
// is a no-op (the entry has already been moved to the active
// map). When the value is dropped without boxing, this prevents
// a leak that would later mis-attribute details to a recycled
// message allocation.
let _ = take_pending_consensus_errors(self.message);
// SAFETY: `message` was allocated via `CString::into_raw` in
// `DashSDKError::new`; reclaim the allocation exactly once.
unsafe {
let _ = CString::from_raw(self.message);
}
self.message = std::ptr::null_mut();
}
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: New Drop for DashSDKError double-frees error.message in existing transfer/withdraw tests

This PR introduces impl Drop for DashSDKError at error.rs:319-337 that unconditionally calls CString::from_raw(self.message) whenever the message pointer is non-null. Several pre-existing tests in this crate still take ownership of error.message manually and then drop the boxed error:

  • packages/rs-sdk-ffi/src/identity/transfer.rs:329-346 (null_identity_handle_returns_error)
  • packages/rs-sdk-ffi/src/identity/transfer.rs:376-383 (null_sdk_handle_returns_error)
  • packages/rs-sdk-ffi/src/identity/transfer.rs:411-419 (null_signer_handle_returns_error)
  • packages/rs-sdk-ffi/src/identity/transfer.rs:448-456 (null_to_identity_id_returns_error)
  • packages/rs-sdk-ffi/src/identity/withdraw.rs:287-296 and 326-334

Each site does:

let error = unsafe { &*result.error };
// ...
let _ = CString::from_raw(error.message as *mut _); // frees the message
let _ = Box::from_raw(result.error);                // Drop runs and frees again

Because error is a shared borrow into the still-boxed value, the manual reclaim does not null result.error->message. When Box::from_raw then runs DashSDKError::drop, the non-null self.message is freed a second time. The macOS allocator currently tolerates this, but it is UB and will fail under Miri/ASan or on Linux glibc. The PR description's listed test command only runs sdk_protocol_consensus, so this regression was not caught.

Fix: drop the manual CString::from_raw(error.message ...) calls in these tests and free strictly through dash_sdk_error_free(result.error) (or rely on the new Drop via Box::from_raw). The new Drop already owns the message lifecycle.

source: ['claude', 'codex']

🤖 Fix this 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-sdk-ffi/src/error.rs`:
- [BLOCKING] lines 319-337: New Drop for DashSDKError double-frees error.message in existing transfer/withdraw tests
  This PR introduces `impl Drop for DashSDKError` at error.rs:319-337 that unconditionally calls `CString::from_raw(self.message)` whenever the message pointer is non-null. Several pre-existing tests in this crate still take ownership of `error.message` manually and then drop the boxed error:

- `packages/rs-sdk-ffi/src/identity/transfer.rs:329-346` (null_identity_handle_returns_error)
- `packages/rs-sdk-ffi/src/identity/transfer.rs:376-383` (null_sdk_handle_returns_error)
- `packages/rs-sdk-ffi/src/identity/transfer.rs:411-419` (null_signer_handle_returns_error)
- `packages/rs-sdk-ffi/src/identity/transfer.rs:448-456` (null_to_identity_id_returns_error)
- `packages/rs-sdk-ffi/src/identity/withdraw.rs:287-296` and 326-334

Each site does:
```rust
let error = unsafe { &*result.error };
// ...
let _ = CString::from_raw(error.message as *mut _); // frees the message
let _ = Box::from_raw(result.error);                // Drop runs and frees again

Because error is a shared borrow into the still-boxed value, the manual reclaim does not null result.error->message. When Box::from_raw then runs DashSDKError::drop, the non-null self.message is freed a second time. The macOS allocator currently tolerates this, but it is UB and will fail under Miri/ASan or on Linux glibc. The PR description's listed test command only runs sdk_protocol_consensus, so this regression was not caught.

Fix: drop the manual CString::from_raw(error.message ...) calls in these tests and free strictly through dash_sdk_error_free(result.error) (or rely on the new Drop via Box::from_raw). The new Drop already owns the message lifecycle.

</details>

Comment on lines 304 to +337
}
}

/// Reclaim the owned `message` `CString` and drop any pending sidecar entry
/// keyed on the message pointer. This makes it safe to `drop` a
/// `DashSDKError` value built via `From<FFIError>` without ever boxing it
/// (e.g. test helpers, error-conversion paths that fail before reaching
/// [`box_dashsdk_error`]) — both the message allocation and the pending
/// sidecar entry are reclaimed instead of leaking.
///
/// `box_dashsdk_error` moves the error into a `Box` and uses `Box::into_raw`,
/// which suppresses Drop until [`dash_sdk_error_free`] runs `Box::from_raw`,
/// so successful boxing → free paths still drop exactly once. The active
/// sidecar (keyed on the heap pointer) is removed by `dash_sdk_error_free`
/// before the Drop runs.
impl Drop for DashSDKError {
fn drop(&mut self) {
if !self.message.is_null() {
// Drain any still-pending sidecar entry keyed on this message
// pointer. After a successful `box_dashsdk_error` promotion this
// is a no-op (the entry has already been moved to the active
// map). When the value is dropped without boxing, this prevents
// a leak that would later mis-attribute details to a recycled
// message allocation.
let _ = take_pending_consensus_errors(self.message);
// SAFETY: `message` was allocated via `CString::into_raw` in
// `DashSDKError::new`; reclaim the allocation exactly once.
unsafe {
let _ = CString::from_raw(self.message);
}
self.message = std::ptr::null_mut();
}
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Document that DashSDKError.message is now Drop-owned and audit remaining manual reclaims

Adding Drop to DashSDKError is a semantic change for every Rust user of the struct. Before this PR, message: *mut c_char was caller-owned and reclaimed manually via CString::from_raw; after this PR, it is owned by DashSDKError. The doc comment on Drop covers the boxed→free interaction but does not call out the incompatibility with prior manual-reclaim patterns. Add a # Compatibility note on both the Drop impl and on DashSDKError itself stating that message is now Drop-owned and must not be reclaimed externally, and grep the crate for remaining CString::from_raw(error.message ...) callsites — those are now double-frees and should be removed as part of this PR (see blocking finding for the test sites that are affected).

source: ['claude']

🤖 Fix this 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-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 304-337: Document that DashSDKError.message is now Drop-owned and audit remaining manual reclaims
  Adding `Drop` to `DashSDKError` is a semantic change for every Rust user of the struct. Before this PR, `message: *mut c_char` was caller-owned and reclaimed manually via `CString::from_raw`; after this PR, it is owned by `DashSDKError`. The doc comment on Drop covers the boxed→free interaction but does not call out the incompatibility with prior manual-reclaim patterns. Add a `# Compatibility` note on both the `Drop` impl and on `DashSDKError` itself stating that `message` is now Drop-owned and must not be reclaimed externally, and grep the crate for remaining `CString::from_raw(error.message ...)` callsites — those are now double-frees and should be removed as part of this PR (see blocking finding for the test sites that are affected).

Comment on lines +233 to +255
fn with_active_consensus_errors<R>(
error_ptr: *const DashSDKError,
f: impl FnOnce(&[ConsensusErrorEntry]) -> R,
) -> Option<R> {
if error_ptr.is_null() {
return None;
}
// Snapshot the current message pointer for identity verification *before*
// taking the lock to keep the unsafe deref scope small. This dereference
// is sound under the documented contract (caller passes a live pointer
// returned by the SDK that has not yet been freed); a stale dangling
// pointer is UB at this point and unavoidable.
let current_message = unsafe { (*error_ptr).message } as usize;
let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
let entry = guard.get(&(error_ptr as usize))?;
if entry.message_ptr != current_message {
// Pointer key matched but the message field doesn't match the value
// we recorded at boxing time — almost certainly a recycled heap
// allocation now occupied by a different error. Treat as no sidecar.
return None;
}
Some(f(entry.entries.as_slice()))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*🟡 Suggestion: with_active_consensus_errors dereferences (error_ptr).message outside the sidecar lock

with_active_consensus_errors reads (*error_ptr).message at error.rs:245 before acquiring the ACTIVE_CONSENSUS_ERRORS mutex. The doc comment acknowledges that a stale pointer here is UB and unavoidable under the documented contract, but the deref happens unconditionally on every call — including the common 'no sidecar' case — and widens the consequence of any caller misuse (e.g. a buggy bilingual Swift/JS binding that frees on a different thread) from 'stale data' to UB.

Swap the order: acquire the lock first, look up the entry by pointer, then read (*error_ptr).message only when an entry exists. The map check is the integrity gate for whether the pointer is still owned by us, so doing it first costs nothing on the hit path and avoids the deref entirely on the miss path. Consider also wrapping dash_sdk_error_consensus_error_count / _at in std::panic::catch_unwind so misuse cannot unwind across extern "C".

source: ['claude']

🤖 Fix this 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-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 233-255: with_active_consensus_errors dereferences (*error_ptr).message outside the sidecar lock
  `with_active_consensus_errors` reads `(*error_ptr).message` at error.rs:245 before acquiring the `ACTIVE_CONSENSUS_ERRORS` mutex. The doc comment acknowledges that a stale pointer here is UB and unavoidable under the documented contract, but the deref happens unconditionally on every call — including the common 'no sidecar' case — and widens the consequence of any caller misuse (e.g. a buggy bilingual Swift/JS binding that frees on a different thread) from 'stale data' to UB.

Swap the order: acquire the lock first, look up the entry by pointer, then read `(*error_ptr).message` only when an entry exists. The map check is the integrity gate for whether the pointer is still owned by us, so doing it first costs nothing on the hit path and avoids the deref entirely on the miss path. Consider also wrapping `dash_sdk_error_consensus_error_count` / `_at` in `std::panic::catch_unwind` so misuse cannot unwind across `extern "C"`.

Comment on lines +209 to +215
fn register_pending_consensus_errors(message_ptr: *mut c_char, errors: Vec<ConsensusErrorEntry>) {
if message_ptr.is_null() || errors.is_empty() {
return;
}
let mut map = lock_recover(&PENDING_CONSENSUS_ERRORS);
map.insert(message_ptr as usize, errors);
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: PENDING_CONSENSUS_ERRORS can leak entries if a From error is built but never boxed or dropped

register_pending_consensus_errors inserts into a process-global Lazy<Mutex<HashMap>> keyed on the message pointer. Cleanup happens either via box_dashsdk_error (promotion to ACTIVE) or via DashSDKError::drop (removal of PENDING). Both require Drop or boxing to run. Any code path that constructs DashSDKError::from(FFIError::SDKError(Error::Protocol(_))) and then mem::forgets the value, or stores it in a struct whose destructor is bypassed, leaves the PENDING entry alive indefinitely. The next allocator reuse of the same *mut c_char for a different error will then mis-attribute its consensus details.

Current callers all box via DashSDKResult::error or ffi_result!, so this is latent. Two options: (a) move sidecar registration inside box_dashsdk_error so promotion to ACTIVE is the only path that ever inserts entries, or (b) document the in-crate mem::forget contract more loudly at the module top. The module's current docs focus on the FFI-caller contract but not on the in-crate construction contract.

source: ['claude']

🤖 Fix this 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-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 209-215: PENDING_CONSENSUS_ERRORS can leak entries if a From<FFIError> error is built but never boxed or dropped
  `register_pending_consensus_errors` inserts into a process-global `Lazy<Mutex<HashMap>>` keyed on the message pointer. Cleanup happens either via `box_dashsdk_error` (promotion to ACTIVE) or via `DashSDKError::drop` (removal of PENDING). Both require Drop or boxing to run. Any code path that constructs `DashSDKError::from(FFIError::SDKError(Error::Protocol(_)))` and then `mem::forget`s the value, or stores it in a struct whose destructor is bypassed, leaves the PENDING entry alive indefinitely. The next allocator reuse of the same `*mut c_char` for a different error will then mis-attribute its consensus details.

Current callers all box via `DashSDKResult::error` or `ffi_result!`, so this is latent. Two options: (a) move sidecar registration inside `box_dashsdk_error` so promotion to ACTIVE is the only path that ever inserts entries, or (b) document the in-crate `mem::forget` contract more loudly at the module top. The module's current docs focus on the FFI-caller contract but not on the in-crate construction contract.

Comment on lines +382 to +415
fn classify_sdk_error(sdk_err: &dash_sdk::Error) -> (DashSDKErrorCode, String) {
match sdk_err {
// Non-consensus protocol errors still surface as ProtocolError; their
// consensus sibling is handled by the caller before this point.
dash_sdk::Error::Protocol(_) => (DashSDKErrorCode::ProtocolError, sdk_err.to_string()),
dash_sdk::Error::TimeoutReached(_, _) => (DashSDKErrorCode::Timeout, sdk_err.to_string()),
// No-address / exhausted-addresses paths get the explicit operator
// hint message.
dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses)
| dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(_))
| dash_sdk::Error::NoAvailableAddressesToRetry(_) => (
DashSDKErrorCode::NetworkError,
"Cannot connect to network: No DAPI addresses configured. The SDK needs masternode quorum information to connect to the network.".to_string(),
),
dash_sdk::Error::DapiClientError(_) => (
DashSDKErrorCode::NetworkError,
format!("DAPI error: {}", sdk_err),
),
dash_sdk::Error::MissingDependency(_, _)
| dash_sdk::Error::TotalCreditsNotFound
| dash_sdk::Error::EpochNotFound
| dash_sdk::Error::IdentityNonceNotFound(_) => {
(DashSDKErrorCode::NotFound, sdk_err.to_string())
}
// Neutral fallback: we could not classify this SDK error as
// network/timeout/protocol/not-found, so report it as an internal
// SDK error rather than misattributing it to a specific operation
// (e.g. "Failed to fetch balances").
_ => (
DashSDKErrorCode::InternalError,
format!("SDK error: {}", sdk_err),
),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: classify_sdk_error fallback now maps non-DAPI transport failures to InternalError

classify_sdk_error only maps DapiClientError and a small set of variants to NetworkError/Timeout/NotFound; everything else falls through to InternalError. Pre-PR, the substring-matching classifier would route errors whose Display contained 'I/O error' or 'connection' into NetworkError, which could include ContextProviderError (trusted setup / trusted context fetches) and CoreClientError (Dash Core RPC).

The new structural classifier is clearly better than the prior substring matching with the hardcoded 'Failed to fetch balances' prefix, but it does shift the public C/Swift error code for those failure modes from NetworkError(3) to InternalError(99). Existing Swift/JS callers that retry, special-case, or surface connectivity issues based on NetworkError will silently stop doing so for these variants. Either add explicit arms for ContextProviderError and CoreClientError (mapping them to NetworkError where appropriate), or call out the deliberate classification change in the PR description so consumers can update their handling. This is a behavioral change in the exported API, not a pure cleanup.

source: ['codex']

🤖 Fix this 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-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 382-415: classify_sdk_error fallback now maps non-DAPI transport failures to InternalError
  `classify_sdk_error` only maps `DapiClientError` and a small set of variants to `NetworkError`/`Timeout`/`NotFound`; everything else falls through to `InternalError`. Pre-PR, the substring-matching classifier would route errors whose Display contained 'I/O error' or 'connection' into `NetworkError`, which could include `ContextProviderError` (trusted setup / trusted context fetches) and `CoreClientError` (Dash Core RPC).

The new structural classifier is clearly better than the prior substring matching with the hardcoded 'Failed to fetch balances' prefix, but it does shift the public C/Swift error code for those failure modes from `NetworkError(3)` to `InternalError(99)`. Existing Swift/JS callers that retry, special-case, or surface connectivity issues based on `NetworkError` will silently stop doing so for these variants. Either add explicit arms for `ContextProviderError` and `CoreClientError` (mapping them to `NetworkError` where appropriate), or call out the deliberate classification change in the PR description so consumers can update their handling. This is a behavioral change in the exported API, not a pure cleanup.

Comment on lines +156 to +167
let mut document = self.document.clone();
// The public create builder always normalizes the document id here before
// calling the DPP constructor, so the constructor's create-id pre-sign
// validation is defense-in-depth for this SDK path rather than a
// user-reachable builder failure. Non-create transition-local checks are
// still exercised in the corresponding DPP `from_document` constructors.
document.set_id(Document::generate_document_id_v0(
self.data_contract.id_ref(),
&document.owner_id(),
&self.document_type_name,
self.document_state_transition_entropy.as_slice(),
));
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: SDK create builder hardcodes generate_document_id_v0 and silently overrides caller-supplied IDs

DocumentCreateTransitionBuilder::sign now clones self.document and unconditionally overwrites document.id with Document::generate_document_id_v0(...) before calling the DPP constructor. Two concerns:

  1. The call pins this SDK path to the v0 ID derivation regardless of any future DocumentCreateTransition method/version bump. Only v0 exists today, but this cuts across the repo's PlatformVersion dispatch pattern. Route through a versioned helper (or pass platform_version and dispatch on it) so a future v1 derivation does not silently diverge here.

  2. The rewrite is silent. Any caller who intentionally set document.id (e.g. integration tests asserting determinism, or higher-level code wanting to surface the constructor's InvalidDocumentTransitionIdError) will have their value replaced with no warning. Consider asserting document.id() == generated_id || document.id() == Identifier::default() and returning an explicit error on mismatch, so the constructor's defense-in-depth check the inline comment refers to actually remains reachable.

source: ['claude', 'codex']

🤖 Fix this 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-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 156-167: SDK create builder hardcodes generate_document_id_v0 and silently overrides caller-supplied IDs
  `DocumentCreateTransitionBuilder::sign` now clones `self.document` and unconditionally overwrites `document.id` with `Document::generate_document_id_v0(...)` before calling the DPP constructor. Two concerns:

1. The call pins this SDK path to the v0 ID derivation regardless of any future `DocumentCreateTransition` method/version bump. Only v0 exists today, but this cuts across the repo's PlatformVersion dispatch pattern. Route through a versioned helper (or pass `platform_version` and dispatch on it) so a future v1 derivation does not silently diverge here.

2. The rewrite is silent. Any caller who intentionally set `document.id` (e.g. integration tests asserting determinism, or higher-level code wanting to surface the constructor's `InvalidDocumentTransitionIdError`) will have their value replaced with no warning. Consider asserting `document.id() == generated_id || document.id() == Identifier::default()` and returning an explicit error on mismatch, so the constructor's defense-in-depth check the inline comment refers to actually remains reachable.

Comment on lines +331 to +344
// Mirrors the drive-abci action validator self-purchase check.
// The document's owner is the seller; `new_owner_id` is the buyer.
// Surfacing this here means the SDK builder fails before signing
// when the same identity tries to buy from itself.
if document.owner_id() == new_owner_id {
return Err(ProtocolError::ConsensusError(Box::new(
crate::consensus::basic::document::InvalidDocumentTransitionActionError::new(
format!(
"on document type: {} identity trying to purchase a document that is already owned by the purchaser",
document_type.name()
),
)
.into(),
)));
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Self-purchase check duplicated verbatim between BatchTransitionV0 and BatchTransitionV1 constructors

The self-purchase guard (document.owner_id() == new_owner_idInvalidDocumentTransitionActionError) is implemented identically in BatchTransitionV0::new_document_purchase_transition_from_document (v0/v0_methods.rs:322-333) and BatchTransitionV1::new_document_purchase_transition_from_document (v1/v0_methods.rs:331-344), with the same literal format string and the same fully-qualified path. The PR also added a per-transition validate_structure hook for purchase, with a comment noting that the self-purchase check stays in the constructors because the hook doesn't have the buyer id.

Extend the purchase validate_structure signature to accept the buyer id so the check lives once next to the other purchase structure checks — or move it inside DocumentPurchaseTransition::from_document, which already has the document and document_type. Single-sourcing this check (a) prevents v0/v1 messages drifting silently, (b) keeps the per-version constructors focused on the transition shape, and (c) means a future drive-abci validator change only needs to be mirrored in one place.

source: ['claude']

🤖 Fix this 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/state_transition/state_transitions/document/batch_transition/v1/v0_methods.rs`:
- [SUGGESTION] lines 331-344: Self-purchase check duplicated verbatim between BatchTransitionV0 and BatchTransitionV1 constructors
  The self-purchase guard (`document.owner_id() == new_owner_id` → `InvalidDocumentTransitionActionError`) is implemented identically in `BatchTransitionV0::new_document_purchase_transition_from_document` (v0/v0_methods.rs:322-333) and `BatchTransitionV1::new_document_purchase_transition_from_document` (v1/v0_methods.rs:331-344), with the same literal format string and the same fully-qualified path. The PR also added a per-transition `validate_structure` hook for purchase, with a comment noting that the self-purchase check stays in the constructors because the hook doesn't have the buyer id.

Extend the purchase `validate_structure` signature to accept the buyer id so the check lives once next to the other purchase structure checks — or move it inside `DocumentPurchaseTransition::from_document`, which already has the document and document_type. Single-sourcing this check (a) prevents v0/v1 messages drifting silently, (b) keeps the per-version constructors focused on the transition shape, and (c) means a future drive-abci validator change only needs to be mirrored in one place.

Comment on lines +132 to +136
ConsensusError(Box<ConsensusError>),

#[error("Multiple consensus errors: {}", join_consensus_errors(.0))]
ConsensusErrors(Vec<ConsensusError>),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: ConsensusErrors plural variant parallels singular ConsensusError without a canonicalization rule

errors_to_consensus_protocol_error hand-routes 0/1/>1 errors to None / ConsensusError / ConsensusErrors, and the wasm/FFI layers then re-split on the same shape. Downstream consumers (e.g. wasm_dpp::errors::from::from_dpp_err, wasm_dpp::errors::protocol_error::from_protocol_error) now must handle both ConsensusError(_) and ConsensusErrors(vec![one]) to be defensive. Either explicitly document that ConsensusErrors is never length 1 (canonicalization invariant maintained at every construction site) or collapse to a single ConsensusErrors variant and always use it. The current split creates two places where a future contributor must remember to handle both variants or risk masking the plural case as 'unexpected'.

source: ['claude']

Comment on lines 190 to 196
if !consensus_result.is_valid() {
return Ok(consensus_result);
if accumulate_token_structure_errors {
result.merge(consensus_result);
} else {
return Ok(consensus_result);
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Server-side early return discards previously accumulated batch errors (pre-existing, entrenched)

When accumulate_token_structure_errors == false (the server path) and a token's validate_structure returns invalid, the function returns Ok(consensus_result) directly. Any errors already pushed into the outer result accumulator for the batch — nonce-out-of-bounds, duplicate-id, mismatched token-id, and any prior token's nonce/id errors — are dropped. Behavior predates this PR (and is consensus-equivalent: all nodes still reject the batch with the same final error set), but the new accumulate=true path demonstrates the alternative shape and this refactor entrenches the asymmetry. Worth at minimum a code comment flagging that the early-return is intentional for the server path, and ideally merging accumulated errors before returning for parity with the SDK path.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR at HEAD 370ec0e is consensus-safe with no blocking issues; the prior double-free regression, deref-outside-lock, self-purchase duplication, silent ID override, and classify_sdk_error misrouting findings are all resolved. Residual items are all non-blocking suggestions/nitpicks: the Swift throwing wrappers discard structured FFI consensus details before freeing the pointer (documented as intentional), a length-guarded .expect() with a gratuitous Clone bound in the load-bearing errors_to_consensus_protocol_error helper, 11 identically-named token-builder validation_tests wrappers, the active-sidecar pointer-reuse failure mode, an unused _new_owner_id parameter on DocumentPurchaseTransitionV0::from_document, and a wasm-dpp shape inconsistency that throws a bare Array for plural consensus errors. Two more theoretical concerns (PENDING_CONSENSUS_ERRORS retention if Drop is bypassed) were dropped as too speculative.

Reviewed commit: 370ec0e

🟡 1 suggestion(s) | 💬 4 nitpick(s)

3 additional findings

💬 nitpick: `DocumentPurchaseTransitionV0::from_document` takes an unused `_new_owner_id` parameter

packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/v0/from_document.rs (lines 13-22)

The self-purchase guard correctly moved up into DocumentPurchaseTransition::from_document (single source of truth), but the dispatcher still threads new_owner_id through to DocumentPurchaseTransitionV0::from_document, where it is bound as _new_owner_id at line 16 and never used. Since DocumentPurchaseTransitionV0::from_document is pub(crate) and the only caller is the v0 dispatch arm at document_purchase_transition/from_document.rs:48, the parameter is pure plumbing that obscures the v0 API. A future maintainer reading v0 has to chase up to the dispatcher to learn why the buyer id is being passed in. Either drop the parameter from v0's signature (and at the call site) since the canonical check is now in the dispatcher, or replace the underscore prefix with a // TODO: explaining why v0 takes the buyer id ahead of consuming it.

💬 nitpick: New extern "C" entry points lack `catch_unwind`; panics could unwind across the FFI boundary

packages/rs-sdk-ffi/src/error.rs (lines 521-639)

dash_sdk_error_free, dash_sdk_error_consensus_error_count, dash_sdk_error_consensus_error_at, and dash_sdk_consensus_error_free are #[no_mangle] pub unsafe extern "C" entry points but do not wrap their bodies in std::panic::catch_unwind. dash_sdk_error_consensus_error_at issues three CString::new(...) / Box::new(...) allocations (lines 589-614) inside a closure that holds the sidecar mutex via with_active_consensus_errors. Current code paths almost never panic, but any future refactor introducing a panic (e.g. format! in kind/name resolution, an unwrap-prone change, allocator OOM panic in CString) would unwind across extern "C" — UB under the Rust ABI contract. The mutex itself is recovered via lock_recover (lines 74-78), so the lock is panic-safe. A blanket catch_unwind(AssertUnwindSafe(|| { ... })) around each entry-point body, returning a benign sentinel (0 for count, null_mut() for _at, no-op for the frees) on Err, future-proofs the boundary at trivial cost.

💬 nitpick: `consensus_error_kind_name` / `_variant_name` duplicated verbatim between rs-sdk-ffi and wasm-sdk

packages/wasm-sdk/src/error.rs (lines 100-130)

packages/wasm-sdk/src/error.rs defines consensus_error_kind_name and consensus_error_variant_name as exact copies of the same helpers in packages/rs-sdk-ffi/src/error.rs:450-477; the wasm-sdk doc comment even calls out the mirror. Both rely on strum::IntoStaticStr derives on the inner consensus enums, so adding a new ConsensusError variant tomorrow requires touching both crates to extend the outer-enum match (inner-enum arms pick up the new variant automatically). Hoisting these into dpp::consensus itself (next to the ConsensusError enum) would let both crates import the same function and remove the silent drift risk. Today this is style-grade duplication, but once one side gains a sixth ConsensusError variant arm, the other will silently emit the wrong kind/name until someone notices.

🤖 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-dpp/src/errors/from.rs`:
- [SUGGESTION] lines 15-21: wasm-dpp throws a bare JavaScript Array for plural consensus errors instead of an error-shaped value
  `from_dpp_err` maps `ProtocolError::ConsensusError` to `from_consensus_error(...)` but maps `ProtocolError::ConsensusErrors` to `Array::from_iter(...).into()` (line 20). Now that the new `errors_to_consensus_protocol_error` helper structurally surfaces multiple consensus errors via `ProtocolError::ConsensusErrors`, JavaScript/TypeScript callers receive two incompatible throw shapes for the same protocol-error family: singular failures look like the existing consensus error object, while plural failures become a raw JS `Array` with no `.message`, no stable `name`, and no `instanceof Error` behavior. JS `catch` blocks written against the existing error-shaped surface will break or silently degrade on plural failures. Keep a consistent thrown shape (e.g. an error object with `message` plus a `consensusErrors` array) rather than switching to a bare `Array` only for the plural case.

Comment thread packages/wasm-dpp/src/errors/from.rs Outdated
Comment on lines +16 to +21
match pe {
// TODO(versioning): restore this
ProtocolError::ConsensusError(consensus_error) => from_consensus_error(*consensus_error),
ProtocolError::ConsensusErrors(consensus_errors) => {
Array::from_iter(consensus_errors.into_iter().map(from_consensus_error)).into()
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: wasm-dpp throws a bare JavaScript Array for plural consensus errors instead of an error-shaped value

from_dpp_err maps ProtocolError::ConsensusError to from_consensus_error(...) but maps ProtocolError::ConsensusErrors to Array::from_iter(...).into() (line 20). Now that the new errors_to_consensus_protocol_error helper structurally surfaces multiple consensus errors via ProtocolError::ConsensusErrors, JavaScript/TypeScript callers receive two incompatible throw shapes for the same protocol-error family: singular failures look like the existing consensus error object, while plural failures become a raw JS Array with no .message, no stable name, and no instanceof Error behavior. JS catch blocks written against the existing error-shaped surface will break or silently degrade on plural failures. Keep a consistent thrown shape (e.g. an error object with message plus a consensusErrors array) rather than switching to a bare Array only for the plural case.

source: ['codex']

🤖 Fix this 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-dpp/src/errors/from.rs`:
- [SUGGESTION] lines 15-21: wasm-dpp throws a bare JavaScript Array for plural consensus errors instead of an error-shaped value
  `from_dpp_err` maps `ProtocolError::ConsensusError` to `from_consensus_error(...)` but maps `ProtocolError::ConsensusErrors` to `Array::from_iter(...).into()` (line 20). Now that the new `errors_to_consensus_protocol_error` helper structurally surfaces multiple consensus errors via `ProtocolError::ConsensusErrors`, JavaScript/TypeScript callers receive two incompatible throw shapes for the same protocol-error family: singular failures look like the existing consensus error object, while plural failures become a raw JS `Array` with no `.message`, no stable `name`, and no `instanceof Error` behavior. JS `catch` blocks written against the existing error-shaped surface will break or silently degrade on plural failures. Keep a consistent thrown shape (e.g. an error object with `message` plus a `consensusErrors` array) rather than switching to a bare `Array` only for the plural case.

Comment on lines 190 to 196
if !consensus_result.is_valid() {
return Ok(consensus_result);
if accumulate_token_structure_errors {
result.merge(consensus_result);
} else {
return Ok(consensus_result);
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Server-side token validation drops accumulated batch-level errors on first invalid token structure

validate_base_structure_v0_internal accumulates nonce errors, duplicate-transition errors, and prior token nonce/id errors into result (lines 95-149), but when accumulate_token_structure_errors == false the first invalid token's validate_structure(...) returns Ok(consensus_result) directly at line 194 — discarding everything previously accumulated in result. The new test validate_base_structure_v0_keeps_token_structure_early_return_behavior explicitly pins this behavior, so the truncation is intentional and matches the prior server contract; flagging only because the pre-sign path now explicitly preserves multiplicity via ProtocolError::ConsensusErrors and the two modes diverge sharply. If the server contract is meant to stay one-error-per-call, a brief comment at the early-return clarifying that batch-level accumulation is discarded by design would prevent future readers from treating it as a bug. If both modes should be symmetric, replace the bare return Ok(consensus_result) with result.merge(consensus_result); return Ok(result);.

source: ['codex']

@thepastaclaw thepastaclaw force-pushed the feat/sdk-validate-all-transitions branch from 370ec0e to be00490 Compare May 17, 2026 19:57
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR adds welcome client-side pre-sign validation, a new ProtocolError::ConsensusErrors variant, and a structured FFI/WASM consensus-error sidecar. One blocking issue: the wasm-dpp change flips the JS-side error type from Array to Error without a version bump, breaking existing JS callers. Several quality suggestions around FFI consistency, SemVer hygiene on a public Rust enum, and shared-state coupling between consensus-critical and SDK-only validation paths. 7 lower-value nitpicks were dropped to stay within budget.

Reviewed commit: be00490

🔴 1 blocking | 🟡 9 suggestion(s)

2 additional findings

🟡 suggestion: Identity transfer/withdraw still stringify SDK errors, bypassing the new consensus-error sidecar

packages/rs-sdk-ffi/src/identity/transfer.rs (lines 247-251)

The new sidecar promotes structured ProtocolError::ConsensusError(s) into DashSDKConsensusError entries only when the inner error reaches From<FFIError> for DashSDKError as FFIError::SDKError(dash_sdk::Error::Protocol(_)). This PR updated every token transition path (burn.rs, claim.rs, mint.rs, etc.) to use .map_err(FFIError::SDKError)?, but dash_sdk_identity_transfer_credits (transfer.rs:247) and dash_sdk_identity_withdraw (withdraw.rs:213-227) still wrap as FFIError::InternalError(format!("…: {}", e)). That collapses the dash_sdk::Error variant to a string, so format_protocol_consensus_error is never called, no sidecar entry is registered, and Swift/C callers see InternalError with no structured detail — inconsistent with the equivalent token operations. Switch both error mappings to FFIError::SDKError(e) (keep the existing log line) so identity-credit operations expose the same structured consensus details as tokens.

🟡 suggestion: Boolean flag couples consensus-critical and SDK-only paths in one validator

packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs (lines 39-49)

validate_base_structure_v0_internal takes accumulate_token_structure_errors: bool and is called with false from the consensus-critical server entry point and true from the SDK pre-sign helper. The two modes diverge in a single subtle place — token-structure errors early-return when false and merge into result when true — and that early-return arm also silently discards any document-level errors already accumulated in result. The flag is consensus-critical and the compiler cannot enforce that the server path never receives true. Extract the shared traversal into a helper that yields per-transition results and let the two public entry points post-process independently, so future edits cannot accidentally cross modes.

🤖 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-dpp/src/errors/protocol_error.rs`:
- [BLOCKING] lines 7-31: `from_consensus_errors` flips the thrown JS type from Array to Error — unversioned breaking change
  Before this change, the consensus-errors JS binding handed back an Array of consensus error objects, which propagated up through `from_protocol_error` / `from_dpp_err` into wasm-bindgen throw sites such as `StateTransitionFactoryWasm::state_transition_wasm_from_factory_result` and `StateTransitionFacadeWasm::create_from_object` / `create_from_buffer`. The new helper now returns a `JsError` with a `consensusErrors` property. Any existing JS/TS caller that does `Array.isArray(e)`, indexes `e[0]`, reads `e.length`, or iterates the caught value will silently break — even though the Rust-side `ProtocolError::ConsensusError(s)` distinction did not exist in the previous binding. This is an externally visible WASM contract change without a version bump. Either restore an Array-shaped throw (with `consensusErrors`/`name` reflected onto the array if richer detail is needed) or land this behind a major version bump with release notes.

In `packages/rs-dpp/src/errors/protocol_error.rs`:
- [SUGGESTION] lines 131-135: Adding `ProtocolError::ConsensusErrors` is a breaking public-enum change
  `ProtocolError` is a public enum without `#[non_exhaustive]`, so introducing the new `ConsensusErrors(Vec<ConsensusError>)` variant breaks any downstream crate that matches `ProtocolError` exhaustively. Internal callers were updated, but external consumers (rs-sdk users, third-party SDK wrappers) will fail to compile on first pickup. Either add `#[non_exhaustive]` going forward and call this out as a planned breaking release, or represent aggregated consensus failures differently (e.g., reuse the existing singular variant with a wrapper error type) so the public enum surface does not widen.

In `packages/rs-sdk-ffi/src/identity/transfer.rs`:
- [SUGGESTION] lines 247-251: Identity transfer/withdraw still stringify SDK errors, bypassing the new consensus-error sidecar
  The new sidecar promotes structured `ProtocolError::ConsensusError(s)` into `DashSDKConsensusError` entries only when the inner error reaches `From<FFIError> for DashSDKError` as `FFIError::SDKError(dash_sdk::Error::Protocol(_))`. This PR updated every token transition path (`burn.rs`, `claim.rs`, `mint.rs`, etc.) to use `.map_err(FFIError::SDKError)?`, but `dash_sdk_identity_transfer_credits` (transfer.rs:247) and `dash_sdk_identity_withdraw` (withdraw.rs:213-227) still wrap as `FFIError::InternalError(format!("…: {}", e))`. That collapses the `dash_sdk::Error` variant to a string, so `format_protocol_consensus_error` is never called, no sidecar entry is registered, and Swift/C callers see `InternalError` with no structured detail — inconsistent with the equivalent token operations. Switch both error mappings to `FFIError::SDKError(e)` (keep the existing log line) so identity-credit operations expose the same structured consensus details as tokens.

In `packages/rs-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 548-584: Exported FFI helpers dereference `DashSDKError*` before sidecar lookup, defeating the stale-pointer mitigation
  The module comment on `with_active_consensus_errors` advertises that a sidecar miss means no read from the caller-provided pointer at all — that is the whole point of keying on the heap pointer and re-checking `message_ptr`. But both exported C functions (`dash_sdk_error_consensus_error_count` and `dash_sdk_error_consensus_error_at`) read `(*error).code` before calling `with_active_consensus_errors`. A Swift/C caller that accidentally hands back a freed `DashSDKError*` therefore still triggers an unchecked dereference, which is precisely what the mitigation claims to avoid. Move the sidecar-map lookup ahead of the `code` check (the map lookup already gates on pointer identity and message-pointer match) so the helpers genuinely refuse to dereference unknown pointers.
- [SUGGESTION] lines 219-252: `ACTIVE_CONSENSUS_ERRORS` grows unbounded if FFI callers leak errors
  `ACTIVE_CONSENSUS_ERRORS` is a process-global map keyed by the heap `*mut DashSDKError` pointer; entries are only released by `dash_sdk_error_free` (or `dash_sdk_result_free`, which delegates). Any FFI caller that drops a returned `DashSDKError` without calling free leaks both the box and its sidecar entry permanently — unlike the existing message-string leak, the sidecar accumulates across the process lifetime and pins each entry's `Vec<ConsensusErrorEntry>`. The pointer-identity-with-message-recheck design is sound; the concern is operational. Document this growth characteristic in the module-level safety docs, add a test-only sidecar-len inspector for hosts, or implement a soft cap with eviction before this lands in long-running embedders (mobile/desktop wallets).

In `packages/wasm-sdk/src/error.rs`:
- [SUGGESTION] lines 268-281: `WasmDppError → WasmSdkError` conversion drops structured consensus errors
  `impl From<WasmDppError> for WasmSdkError` only calls `err.to_string()` and returns `consensus_errors: Vec::new()`. The upstream `WasmDppError::From<ProtocolError>` (in `wasm-dpp2/src/error.rs`) is similarly stringly-typed, so any error path going `ProtocolError → WasmDppError → WasmSdkError` loses the structured detail this PR is trying to preserve. The direct `ProtocolError → WasmSdkError` path now preserves them (good), but the wasm-dpp2 boundary remains a structure-loss point. Either thread the `Vec<ConsensusError>` payload through `WasmDppError` and propagate it here, or document the gap so callers know not to route consensus errors through `WasmDppError`.

In `packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs`:
- [SUGGESTION] lines 39-49: Boolean flag couples consensus-critical and SDK-only paths in one validator
  `validate_base_structure_v0_internal` takes `accumulate_token_structure_errors: bool` and is called with `false` from the consensus-critical server entry point and `true` from the SDK pre-sign helper. The two modes diverge in a single subtle place — token-structure errors early-return when `false` and merge into `result` when `true` — and that early-return arm also silently discards any document-level errors already accumulated in `result`. The flag is consensus-critical and the compiler cannot enforce that the server path never receives `true`. Extract the shared traversal into a helper that yields per-transition results and let the two public entry points post-process independently, so future edits cannot accidentally cross modes.

In `packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/from_document.rs`:
- [SUGGESTION] lines 29-37: Self-purchase guard sits above the version dispatch
  The new `if document.owner_id() == new_owner_id { return Err(...) }` guard runs unconditionally before the `match feature_version` dispatch. Every other consensus-relevant check in this PR is placed inside the `0 =>` arm so future feature versions can refine behavior. Hoisting this rule above the match couples it across all current and future versions of `DocumentPurchaseTransition`; a future v1 cannot opt out or refine without touching the dispatcher. Either move the check into the `0 =>` arm (and any future arms), or add an explicit comment stating the rule is intentionally version-independent.

In `packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/mod.rs`:
- [SUGGESTION] lines 68-89: Pre-sign helper dispatches on the same version field as the consensus validator
  `validate_base_structure_pre_sign` reads the same `validation.validate_base_structure` `FeatureVersion` consulted by the server-side `validate_base_structure`. If a future protocol upgrade introduces a v1 server-side behavior that the SDK pre-sign path should not mirror (or should mirror differently), both arms will need synchronized care, with no compile-time forcing function. The new per-document validators each get their own dedicated version field (e.g. `document_create_transition_structure_validation`) precisely to avoid this entanglement — add a separate `validate_base_structure_pre_sign` version field so pre-sign can evolve independently of the consensus-critical validator.
- [SUGGESTION] lines 91-118: Pre-sign validator silently skips structure for hand-constructed non-Create transitions
  `validate_base_structure_pre_sign` only re-runs `validate_structure` for `DocumentTransition::Create`; the `Delete | Replace | Transfer | Purchase | UpdatePrice` arm returns `None` because those are checked inside the corresponding `from_document` constructor. That assumption holds for callers going through `BatchTransition::new_document_*_from_document`, but any caller (tests, custom SDK extensions, future code) that constructs a `DocumentDeleteTransition`/`Replace`/`Transfer`/etc. directly and stuffs it into a batch will get a clean pre-sign pass even though the structural check was never run, while a real submission is still rejected server-side. Either iterate the non-Create variants and call their `validate_structure` here (these are cheap contract-local checks), or document the constructor-only contract more loudly so callers don't accidentally rely on the pre-sign hook for hand-built transitions.

Comment on lines +7 to +31
pub(crate) fn from_consensus_errors(consensus_errors: Vec<ConsensusError>) -> JsValue {
let consensus_errors_array =
Array::from_iter(consensus_errors.into_iter().map(from_consensus_error));
let message = match consensus_errors_array.length() {
0 => "ProtocolError contained no consensus errors".to_string(),
1 => "ProtocolError contained 1 consensus error".to_string(),
count => format!("ProtocolError contained {count} consensus errors"),
};

let error = JsError::new(&message);
let error_value = JsValue::from(error);

let _ = Reflect::set(
&error_value,
&JsValue::from_str("name"),
&JsValue::from_str("ConsensusErrors"),
);
let _ = Reflect::set(
&error_value,
&JsValue::from_str("consensusErrors"),
&consensus_errors_array.into(),
);

error_value
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: from_consensus_errors flips the thrown JS type from Array to Error — unversioned breaking change

Before this change, the consensus-errors JS binding handed back an Array of consensus error objects, which propagated up through from_protocol_error / from_dpp_err into wasm-bindgen throw sites such as StateTransitionFactoryWasm::state_transition_wasm_from_factory_result and StateTransitionFacadeWasm::create_from_object / create_from_buffer. The new helper now returns a JsError with a consensusErrors property. Any existing JS/TS caller that does Array.isArray(e), indexes e[0], reads e.length, or iterates the caught value will silently break — even though the Rust-side ProtocolError::ConsensusError(s) distinction did not exist in the previous binding. This is an externally visible WASM contract change without a version bump. Either restore an Array-shaped throw (with consensusErrors/name reflected onto the array if richer detail is needed) or land this behind a major version bump with release notes.

source: ['codex']

🤖 Fix this 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-dpp/src/errors/protocol_error.rs`:
- [BLOCKING] lines 7-31: `from_consensus_errors` flips the thrown JS type from Array to Error — unversioned breaking change
  Before this change, the consensus-errors JS binding handed back an Array of consensus error objects, which propagated up through `from_protocol_error` / `from_dpp_err` into wasm-bindgen throw sites such as `StateTransitionFactoryWasm::state_transition_wasm_from_factory_result` and `StateTransitionFacadeWasm::create_from_object` / `create_from_buffer`. The new helper now returns a `JsError` with a `consensusErrors` property. Any existing JS/TS caller that does `Array.isArray(e)`, indexes `e[0]`, reads `e.length`, or iterates the caught value will silently break — even though the Rust-side `ProtocolError::ConsensusError(s)` distinction did not exist in the previous binding. This is an externally visible WASM contract change without a version bump. Either restore an Array-shaped throw (with `consensusErrors`/`name` reflected onto the array if richer detail is needed) or land this behind a major version bump with release notes.

Comment on lines +131 to +135
#[error(transparent)]
ConsensusError(Box<ConsensusError>),

#[error("Multiple consensus errors: {}", join_consensus_errors(.0))]
ConsensusErrors(Vec<ConsensusError>),
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Adding ProtocolError::ConsensusErrors is a breaking public-enum change

ProtocolError is a public enum without #[non_exhaustive], so introducing the new ConsensusErrors(Vec<ConsensusError>) variant breaks any downstream crate that matches ProtocolError exhaustively. Internal callers were updated, but external consumers (rs-sdk users, third-party SDK wrappers) will fail to compile on first pickup. Either add #[non_exhaustive] going forward and call this out as a planned breaking release, or represent aggregated consensus failures differently (e.g., reuse the existing singular variant with a wrapper error type) so the public enum surface does not widen.

source: ['codex']

🤖 Fix this 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/errors/protocol_error.rs`:
- [SUGGESTION] lines 131-135: Adding `ProtocolError::ConsensusErrors` is a breaking public-enum change
  `ProtocolError` is a public enum without `#[non_exhaustive]`, so introducing the new `ConsensusErrors(Vec<ConsensusError>)` variant breaks any downstream crate that matches `ProtocolError` exhaustively. Internal callers were updated, but external consumers (rs-sdk users, third-party SDK wrappers) will fail to compile on first pickup. Either add `#[non_exhaustive]` going forward and call this out as a planned breaking release, or represent aggregated consensus failures differently (e.g., reuse the existing singular variant with a wrapper error type) so the public enum surface does not widen.

Comment on lines +548 to +584
pub unsafe extern "C" fn dash_sdk_error_consensus_error_count(error: *const DashSDKError) -> usize {
if error.is_null() {
return 0;
}
if (*error).code != DashSDKErrorCode::ProtocolError {
return 0;
}
with_active_consensus_errors(error, |entries| entries.len()).unwrap_or(0)
}

/// Returns a newly-allocated [`DashSDKConsensusError`] for the consensus error
/// at `index`, or null if `error` is null, is not a `ProtocolError`, has no
/// structured details, `index` is out of range, or memory allocation fails.
///
/// The returned pointer is owned by the caller and must be freed with
/// [`dash_sdk_consensus_error_free`].
///
/// # Safety
/// - `error` must either be null or a pointer previously returned by this SDK
/// that has not yet been freed.
/// - Must be called synchronously, before [`dash_sdk_error_free`], on the
/// same `DashSDKError` value the SDK returned (not a copy/alias). See the
/// module-level move-only sidecar contract.
#[no_mangle]
pub unsafe extern "C" fn dash_sdk_error_consensus_error_at(
error: *const DashSDKError,
index: usize,
) -> *mut DashSDKConsensusError {
if error.is_null() {
return std::ptr::null_mut();
}
if (*error).code != DashSDKErrorCode::ProtocolError {
return std::ptr::null_mut();
}

let entry =
with_active_consensus_errors(error, |entries| entries.get(index).cloned()).flatten();
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Exported FFI helpers dereference DashSDKError* before sidecar lookup, defeating the stale-pointer mitigation

The module comment on with_active_consensus_errors advertises that a sidecar miss means no read from the caller-provided pointer at all — that is the whole point of keying on the heap pointer and re-checking message_ptr. But both exported C functions (dash_sdk_error_consensus_error_count and dash_sdk_error_consensus_error_at) read (*error).code before calling with_active_consensus_errors. A Swift/C caller that accidentally hands back a freed DashSDKError* therefore still triggers an unchecked dereference, which is precisely what the mitigation claims to avoid. Move the sidecar-map lookup ahead of the code check (the map lookup already gates on pointer identity and message-pointer match) so the helpers genuinely refuse to dereference unknown pointers.

source: ['codex']

🤖 Fix this 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-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 548-584: Exported FFI helpers dereference `DashSDKError*` before sidecar lookup, defeating the stale-pointer mitigation
  The module comment on `with_active_consensus_errors` advertises that a sidecar miss means no read from the caller-provided pointer at all — that is the whole point of keying on the heap pointer and re-checking `message_ptr`. But both exported C functions (`dash_sdk_error_consensus_error_count` and `dash_sdk_error_consensus_error_at`) read `(*error).code` before calling `with_active_consensus_errors`. A Swift/C caller that accidentally hands back a freed `DashSDKError*` therefore still triggers an unchecked dereference, which is precisely what the mitigation claims to avoid. Move the sidecar-map lookup ahead of the `code` check (the map lookup already gates on pointer identity and message-pointer match) so the helpers genuinely refuse to dereference unknown pointers.

Comment on lines 280 to 281
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: WasmDppError → WasmSdkError conversion drops structured consensus errors

impl From<WasmDppError> for WasmSdkError only calls err.to_string() and returns consensus_errors: Vec::new(). The upstream WasmDppError::From<ProtocolError> (in wasm-dpp2/src/error.rs) is similarly stringly-typed, so any error path going ProtocolError → WasmDppError → WasmSdkError loses the structured detail this PR is trying to preserve. The direct ProtocolError → WasmSdkError path now preserves them (good), but the wasm-dpp2 boundary remains a structure-loss point. Either thread the Vec<ConsensusError> payload through WasmDppError and propagate it here, or document the gap so callers know not to route consensus errors through WasmDppError.

source: ['claude']

🤖 Fix this 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/error.rs`:
- [SUGGESTION] lines 268-281: `WasmDppError → WasmSdkError` conversion drops structured consensus errors
  `impl From<WasmDppError> for WasmSdkError` only calls `err.to_string()` and returns `consensus_errors: Vec::new()`. The upstream `WasmDppError::From<ProtocolError>` (in `wasm-dpp2/src/error.rs`) is similarly stringly-typed, so any error path going `ProtocolError → WasmDppError → WasmSdkError` loses the structured detail this PR is trying to preserve. The direct `ProtocolError → WasmSdkError` path now preserves them (good), but the wasm-dpp2 boundary remains a structure-loss point. Either thread the `Vec<ConsensusError>` payload through `WasmDppError` and propagate it here, or document the gap so callers know not to route consensus errors through `WasmDppError`.

Comment on lines +29 to +37
if document.owner_id() == new_owner_id {
return Err(ProtocolError::ConsensusError(Box::new(
InvalidDocumentTransitionActionError::new(format!(
"on document type: {} identity trying to purchase a document that is already owned by the purchaser",
document_type.name()
))
.into(),
)));
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Self-purchase guard sits above the version dispatch

The new if document.owner_id() == new_owner_id { return Err(...) } guard runs unconditionally before the match feature_version dispatch. Every other consensus-relevant check in this PR is placed inside the 0 => arm so future feature versions can refine behavior. Hoisting this rule above the match couples it across all current and future versions of DocumentPurchaseTransition; a future v1 cannot opt out or refine without touching the dispatcher. Either move the check into the 0 => arm (and any future arms), or add an explicit comment stating the rule is intentionally version-independent.

source: ['claude']

🤖 Fix this 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/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/from_document.rs`:
- [SUGGESTION] lines 29-37: Self-purchase guard sits above the version dispatch
  The new `if document.owner_id() == new_owner_id { return Err(...) }` guard runs unconditionally before the `match feature_version` dispatch. Every other consensus-relevant check in this PR is placed inside the `0 =>` arm so future feature versions can refine behavior. Hoisting this rule above the match couples it across all current and future versions of `DocumentPurchaseTransition`; a future v1 cannot opt out or refine without touching the dispatcher. Either move the check into the `0 =>` arm (and any future arms), or add an explicit comment stating the rule is intentionally version-independent.

Comment on lines +219 to +252
/// Active sidecar map keyed by the heap `*mut DashSDKError` pointer that is
/// handed back across the FFI boundary. Populated by [`box_dashsdk_error`];
/// freed by [`dash_sdk_error_free`]. Keying by the boxed `DashSDKError`
/// pointer means a copied-by-value `DashSDKError` (which has a different
/// pointer identity) cannot accidentally resolve another error's sidecar
/// entry — even if its `message` raw pointer happens to coincide due to
/// allocator reuse. Each entry also carries the original `message` pointer
/// so post-free pointer reuse for a different error is detected on lookup.
static ACTIVE_CONSENSUS_ERRORS: Lazy<Mutex<HashMap<usize, ActiveSidecarEntry>>> =
Lazy::new(|| Mutex::new(HashMap::new()));

fn register_pending_consensus_errors(message_ptr: *mut c_char, errors: Vec<ConsensusErrorEntry>) {
if message_ptr.is_null() || errors.is_empty() {
return;
}
let mut map = lock_recover(&PENDING_CONSENSUS_ERRORS);
map.insert(message_ptr as usize, errors);
}

fn take_pending_consensus_errors(message_ptr: *mut c_char) -> Option<Vec<ConsensusErrorEntry>> {
if message_ptr.is_null() {
return None;
}
let mut map = lock_recover(&PENDING_CONSENSUS_ERRORS);
map.remove(&(message_ptr as usize))
}

fn take_active_consensus_errors(error_ptr: *mut DashSDKError) {
if error_ptr.is_null() {
return;
}
let mut map = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
map.remove(&(error_ptr as usize));
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: ACTIVE_CONSENSUS_ERRORS grows unbounded if FFI callers leak errors

ACTIVE_CONSENSUS_ERRORS is a process-global map keyed by the heap *mut DashSDKError pointer; entries are only released by dash_sdk_error_free (or dash_sdk_result_free, which delegates). Any FFI caller that drops a returned DashSDKError without calling free leaks both the box and its sidecar entry permanently — unlike the existing message-string leak, the sidecar accumulates across the process lifetime and pins each entry's Vec<ConsensusErrorEntry>. The pointer-identity-with-message-recheck design is sound; the concern is operational. Document this growth characteristic in the module-level safety docs, add a test-only sidecar-len inspector for hosts, or implement a soft cap with eviction before this lands in long-running embedders (mobile/desktop wallets).

source: ['claude']

🤖 Fix this 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-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 219-252: `ACTIVE_CONSENSUS_ERRORS` grows unbounded if FFI callers leak errors
  `ACTIVE_CONSENSUS_ERRORS` is a process-global map keyed by the heap `*mut DashSDKError` pointer; entries are only released by `dash_sdk_error_free` (or `dash_sdk_result_free`, which delegates). Any FFI caller that drops a returned `DashSDKError` without calling free leaks both the box and its sidecar entry permanently — unlike the existing message-string leak, the sidecar accumulates across the process lifetime and pins each entry's `Vec<ConsensusErrorEntry>`. The pointer-identity-with-message-recheck design is sound; the concern is operational. Document this growth characteristic in the module-level safety docs, add a test-only sidecar-len inspector for hosts, or implement a soft cap with eviction before this lands in long-running embedders (mobile/desktop wallets).

Comment on lines +68 to +89
pub(crate) fn validate_base_structure_pre_sign(
&self,
platform_version: &PlatformVersion,
) -> Result<(), ProtocolError> {
let mut result = match platform_version
.dpp
.state_transitions
.documents
.documents_batch_transition
.validation
.validate_base_structure
{
0 => self.validate_base_structure_pre_sign_v0(platform_version)?,
version => {
return Err(ProtocolError::UnknownVersionMismatch {
method: "DocumentsBatchTransition::validate_base_structure_pre_sign"
.to_string(),
known_versions: vec![0],
received: version,
})
}
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Pre-sign helper dispatches on the same version field as the consensus validator

validate_base_structure_pre_sign reads the same validation.validate_base_structure FeatureVersion consulted by the server-side validate_base_structure. If a future protocol upgrade introduces a v1 server-side behavior that the SDK pre-sign path should not mirror (or should mirror differently), both arms will need synchronized care, with no compile-time forcing function. The new per-document validators each get their own dedicated version field (e.g. document_create_transition_structure_validation) precisely to avoid this entanglement — add a separate validate_base_structure_pre_sign version field so pre-sign can evolve independently of the consensus-critical validator.

source: ['claude']

🤖 Fix this 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/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/mod.rs`:
- [SUGGESTION] lines 68-89: Pre-sign helper dispatches on the same version field as the consensus validator
  `validate_base_structure_pre_sign` reads the same `validation.validate_base_structure` `FeatureVersion` consulted by the server-side `validate_base_structure`. If a future protocol upgrade introduces a v1 server-side behavior that the SDK pre-sign path should not mirror (or should mirror differently), both arms will need synchronized care, with no compile-time forcing function. The new per-document validators each get their own dedicated version field (e.g. `document_create_transition_structure_validation`) precisely to avoid this entanglement — add a separate `validate_base_structure_pre_sign` version field so pre-sign can evolve independently of the consensus-critical validator.

Comment on lines +91 to +118
for batch_transition in self.transitions_iter() {
let crate::state_transition::batch_transition::batched_transition::BatchedTransitionRef::Document(
document_transition,
) = batch_transition else {
continue;
};

let transition_result = match document_transition {
crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::Create(
create_transition,
) => Some(create_transition.validate_structure(
self.owner_id(),
platform_version,
)?),
crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::Delete(_)
| crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::Replace(_)
| crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::Transfer(_)
| crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::Purchase(_)
| crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::UpdatePrice(_) => None,
};

if let Some(transition_result) = transition_result {
if !transition_result.is_valid() {
result.merge(transition_result);
}
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Pre-sign validator silently skips structure for hand-constructed non-Create transitions

validate_base_structure_pre_sign only re-runs validate_structure for DocumentTransition::Create; the Delete | Replace | Transfer | Purchase | UpdatePrice arm returns None because those are checked inside the corresponding from_document constructor. That assumption holds for callers going through BatchTransition::new_document_*_from_document, but any caller (tests, custom SDK extensions, future code) that constructs a DocumentDeleteTransition/Replace/Transfer/etc. directly and stuffs it into a batch will get a clean pre-sign pass even though the structural check was never run, while a real submission is still rejected server-side. Either iterate the non-Create variants and call their validate_structure here (these are cheap contract-local checks), or document the constructor-only contract more loudly so callers don't accidentally rely on the pre-sign hook for hand-built transitions.

source: ['claude']

🤖 Fix this 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/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/mod.rs`:
- [SUGGESTION] lines 91-118: Pre-sign validator silently skips structure for hand-constructed non-Create transitions
  `validate_base_structure_pre_sign` only re-runs `validate_structure` for `DocumentTransition::Create`; the `Delete | Replace | Transfer | Purchase | UpdatePrice` arm returns `None` because those are checked inside the corresponding `from_document` constructor. That assumption holds for callers going through `BatchTransition::new_document_*_from_document`, but any caller (tests, custom SDK extensions, future code) that constructs a `DocumentDeleteTransition`/`Replace`/`Transfer`/etc. directly and stuffs it into a batch will get a clean pre-sign pass even though the structural check was never run, while a real submission is still rejected server-side. Either iterate the non-Create variants and call their `validate_structure` here (these are cheap contract-local checks), or document the constructor-only contract more loudly so callers don't accidentally rely on the pre-sign hook for hand-built transitions.

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Verified all agent findings against the worktree at 990c07d. No blocking issues identified. Strongest items: a real circular-reference shape in the wasm-dpp Array-throw that breaks JSON.stringify, a #[non_exhaustive] on ProtocolError that is breaking for external Rust crates but only noted in the CHANGELOG, a wildcard arm in classify_sdk_error that misclassifies retryable StaleNode/Cancelled as InternalError, and a version-dispatch drift in the SDK's generated_document_id. Several findings dropped as either pre-existing behavior or low-impact defensive-coding notes.

Reviewed commit: 990c07d

🟡 5 suggestion(s) | 💬 4 nitpick(s)

1 additional finding

💬 nitpick: `DocumentPurchaseTransitionV0::from_document` takes `_new_owner_id` that v0 never reads

packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/v0/from_document.rs (lines 13-23)

v0 now accepts _new_owner_id: Identifier purely so the wrapper DocumentPurchaseTransition::from_document can dispatch with a uniform signature, even though the self-purchase check that consumes the value lives entirely in the wrapper. This pins every future DocumentPurchaseTransitionV{N}::from_document to a parameter it may not need and adds #[allow(clippy::too_many_arguments)] on top of an already-long signature. Prefer keeping new_owner_id at the wrapper layer only and adding it to per-version from_document signatures when (and only when) a given version actually needs it.

🤖 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-dpp/src/errors/protocol_error.rs`:
- [SUGGESTION] lines 7-38: Thrown JS Array carries a self-referential `consensusErrors` property
  `error_value` is built from `consensus_errors_array.clone()` (a wasm-bindgen wrapper clone — both handles point to the same underlying JS Array), and `Reflect::set(&error_value, "consensusErrors", &consensus_errors_array.into())` then assigns that same Array as a property of itself. The thrown value therefore satisfies `e.consensusErrors === e`, which:

- Throws `TypeError: Converting circular structure to JSON` in `JSON.stringify` — a path most logging/transport libraries (Sentry-style transports, generic loggers) hit by default.
- Breaks `structuredClone` / `postMessage` of the error value.
- Causes infinite recursion in any `Object.entries(e)` / generic pretty-printer that follows `consensusErrors`.

Either drop the redundant `consensusErrors` set (the array itself already satisfies `Array.isArray(e)`, `e[i]`, `e.length`) or have `consensusErrors` reference a sibling copy (e.g. a second `Array::from_iter(...)`) so it is not `=== error_value`.

In `packages/rs-dpp/src/errors/protocol_error.rs`:
- [SUGGESTION] lines 46-48: `#[non_exhaustive]` on public `ProtocolError` is a downstream-visible break not noted in PR description
  `ProtocolError` is re-exported from `dpp` and pattern-matched in many crates outside this workspace. Adding `#[non_exhaustive]` forces all exhaustive matches in *other* crates (no `_ =>` arm) to fail with E0004. The CHANGELOG entry calls this out; the PR description currently says "Breaking Changes: None". Align the PR description with the CHANGELOG so downstream Rust SDK consumers know a wildcard arm is needed, and prefer appending future variants (the new `ConsensusErrors` was inserted mid-enum, which is safe today only because `ProtocolError` is not platform-serialized).

In `packages/rs-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 412-453: Wildcard fallback misclassifies retryable `StaleNode`/`Cancelled` SDK errors as `InternalError`
  `dash_sdk::Error::StaleNode(_)` (rs-sdk/src/error.rs:109, documented as a retry-another-server condition — see `is_retryable`-style match at line 270) and `dash_sdk::Error::Cancelled(_)` (line 105) now fall through the trailing `_ =>` arm and surface to FFI as `DashSDKErrorCode::InternalError`. This is a behavior regression for embedders that route retry/UX policy off `DashSDKErrorCode`. Even setting that aside, the wildcard removes the maintenance signal Rust would otherwise give here: if `dash_sdk::Error` grows new variants, the compiler will not force this FFI mapping to be re-reviewed. Map `StaleNode` (and `Cancelled` if its retry semantics warrant) to an appropriate code (e.g. `NetworkError`/`Timeout`) and prefer naming the remaining variants explicitly rather than a wildcard.

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 126-149: `generated_document_id` dispatches on `document_structure_version` instead of the create-transition validation version
  This PR adds `generated_document_id` to the SDK builder, reimplementing DPP's versioned create-ID rule. The dispatch is keyed on `platform_version.dpp.document_versions.document_structure_version`, but the authoritative pre-sign validator for create-transition structure that this PR introduces is on the separate `documents_batch_transition.validation.document_create_transition_structure_validation` axis. The SDK builder can therefore silently drift from DPP if create-transition ID derivation evolves without a matching bump to the document structure version — exactly the consensus-adjacent maintenance trap the version-dispatch pattern is meant to prevent. Either (a) key this match on the same `document_create_transition_structure_validation` field DPP uses, or (b) call into a DPP-exposed helper instead of duplicating the rule.

In `packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 452-471: Swift `consumeDashSDKError` discards structured consensus details before free, leaving Swift without JS-parity error info
  `consumeDashSDKError` reads the scalar fields, calls `finalizeConsumedDashSDKError(mapped, consensusErrors: nil)` (the parameter is unused — `_ = consensusErrors`), and then frees the FFI error. Most error paths in `StateTransitionExtensions.swift` switched to this helper, so the new `dash_sdk_error_consensus_error_count`/`_at` sidecar — the whole point of this PR for the Swift surface — is never read for callers that catch `SDKError`. The frozen `SDKError` ABI is intentional and `SDKDetailedError` is the documented opt-in, but the practical effect is that wasm-sdk and rs-sdk-ffi consumers see structured `consensusErrors` while Swift consumers do not unless each call site is rewritten. Consider having `consumeDashSDKError` read details before free and route them into a thrown `SDKDetailedError` when present, while still returning `SDKError` so existing `catch let e as SDKError` handlers keep working.

Comment on lines +7 to +38
pub(crate) fn from_consensus_errors(consensus_errors: Vec<ConsensusError>) -> JsValue {
let consensus_errors_array =
Array::from_iter(consensus_errors.into_iter().map(from_consensus_error));
let message = match consensus_errors_array.length() {
0 => "ProtocolError contained no consensus errors".to_string(),
1 => "ProtocolError contained 1 consensus error".to_string(),
count => format!("ProtocolError contained {count} consensus errors"),
};

// Preserve the pre-existing wasm-dpp contract: plural consensus errors are
// thrown as an Array so JS callers can keep using Array.isArray(e), e[0],
// e.length, and iteration. Attach the richer properties to the same array
// rather than switching the thrown value to Error-shaped.
let error_value = JsValue::from(consensus_errors_array.clone());
let _ = Reflect::set(
&error_value,
&JsValue::from_str("name"),
&JsValue::from_str("ConsensusErrors"),
);
let _ = Reflect::set(
&error_value,
&JsValue::from_str("message"),
&JsValue::from_str(&message),
);
let _ = Reflect::set(
&error_value,
&JsValue::from_str("consensusErrors"),
&consensus_errors_array.into(),
);

error_value
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Thrown JS Array carries a self-referential consensusErrors property

error_value is built from consensus_errors_array.clone() (a wasm-bindgen wrapper clone — both handles point to the same underlying JS Array), and Reflect::set(&error_value, "consensusErrors", &consensus_errors_array.into()) then assigns that same Array as a property of itself. The thrown value therefore satisfies e.consensusErrors === e, which:

  • Throws TypeError: Converting circular structure to JSON in JSON.stringify — a path most logging/transport libraries (Sentry-style transports, generic loggers) hit by default.
  • Breaks structuredClone / postMessage of the error value.
  • Causes infinite recursion in any Object.entries(e) / generic pretty-printer that follows consensusErrors.

Either drop the redundant consensusErrors set (the array itself already satisfies Array.isArray(e), e[i], e.length) or have consensusErrors reference a sibling copy (e.g. a second Array::from_iter(...)) so it is not === error_value.

source: ['claude']

🤖 Fix this 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-dpp/src/errors/protocol_error.rs`:
- [SUGGESTION] lines 7-38: Thrown JS Array carries a self-referential `consensusErrors` property
  `error_value` is built from `consensus_errors_array.clone()` (a wasm-bindgen wrapper clone — both handles point to the same underlying JS Array), and `Reflect::set(&error_value, "consensusErrors", &consensus_errors_array.into())` then assigns that same Array as a property of itself. The thrown value therefore satisfies `e.consensusErrors === e`, which:

- Throws `TypeError: Converting circular structure to JSON` in `JSON.stringify` — a path most logging/transport libraries (Sentry-style transports, generic loggers) hit by default.
- Breaks `structuredClone` / `postMessage` of the error value.
- Causes infinite recursion in any `Object.entries(e)` / generic pretty-printer that follows `consensusErrors`.

Either drop the redundant `consensusErrors` set (the array itself already satisfies `Array.isArray(e)`, `e[i]`, `e.length`) or have `consensusErrors` reference a sibling copy (e.g. a second `Array::from_iter(...)`) so it is not `=== error_value`.

Comment on lines 46 to 48
#[allow(clippy::large_enum_variant)]
#[non_exhaustive]
#[derive(Error, Debug)]
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: #[non_exhaustive] on public ProtocolError is a downstream-visible break not noted in PR description

ProtocolError is re-exported from dpp and pattern-matched in many crates outside this workspace. Adding #[non_exhaustive] forces all exhaustive matches in other crates (no _ => arm) to fail with E0004. The CHANGELOG entry calls this out; the PR description currently says "Breaking Changes: None". Align the PR description with the CHANGELOG so downstream Rust SDK consumers know a wildcard arm is needed, and prefer appending future variants (the new ConsensusErrors was inserted mid-enum, which is safe today only because ProtocolError is not platform-serialized).

source: ['claude']

🤖 Fix this 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/errors/protocol_error.rs`:
- [SUGGESTION] lines 46-48: `#[non_exhaustive]` on public `ProtocolError` is a downstream-visible break not noted in PR description
  `ProtocolError` is re-exported from `dpp` and pattern-matched in many crates outside this workspace. Adding `#[non_exhaustive]` forces all exhaustive matches in *other* crates (no `_ =>` arm) to fail with E0004. The CHANGELOG entry calls this out; the PR description currently says "Breaking Changes: None". Align the PR description with the CHANGELOG so downstream Rust SDK consumers know a wildcard arm is needed, and prefer appending future variants (the new `ConsensusErrors` was inserted mid-enum, which is safe today only because `ProtocolError` is not platform-serialized).

Comment on lines +412 to +453
fn classify_sdk_error(sdk_err: &dash_sdk::Error) -> (DashSDKErrorCode, String) {
match sdk_err {
// Non-consensus protocol errors still surface as ProtocolError; their
// consensus sibling is handled by the caller before this point.
dash_sdk::Error::Protocol(_) => (DashSDKErrorCode::ProtocolError, sdk_err.to_string()),
dash_sdk::Error::TimeoutReached(_, _) => (DashSDKErrorCode::Timeout, sdk_err.to_string()),
// No-address / exhausted-addresses paths get the explicit operator
// hint message.
dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses)
| dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(_))
| dash_sdk::Error::NoAvailableAddressesToRetry(_) => (
DashSDKErrorCode::NetworkError,
"Cannot connect to network: No DAPI addresses configured. The SDK needs masternode quorum information to connect to the network.".to_string(),
),
dash_sdk::Error::DapiClientError(_) => (
DashSDKErrorCode::NetworkError,
format!("DAPI error: {}", sdk_err),
),
dash_sdk::Error::ContextProviderError(_) => (
DashSDKErrorCode::NetworkError,
format!("Context provider error: {}", sdk_err),
),
dash_sdk::Error::CoreClientError(_) => (
DashSDKErrorCode::NetworkError,
format!("Core client error: {}", sdk_err),
),
dash_sdk::Error::MissingDependency(_, _)
| dash_sdk::Error::TotalCreditsNotFound
| dash_sdk::Error::EpochNotFound
| dash_sdk::Error::IdentityNonceNotFound(_) => {
(DashSDKErrorCode::NotFound, sdk_err.to_string())
}
// Neutral fallback: we could not classify this SDK error as
// network/timeout/protocol/not-found, so report it as an internal
// SDK error rather than misattributing it to a specific operation
// (e.g. "Failed to fetch balances").
_ => (
DashSDKErrorCode::InternalError,
format!("SDK error: {}", sdk_err),
),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Wildcard fallback misclassifies retryable StaleNode/Cancelled SDK errors as InternalError

dash_sdk::Error::StaleNode(_) (rs-sdk/src/error.rs:109, documented as a retry-another-server condition — see is_retryable-style match at line 270) and dash_sdk::Error::Cancelled(_) (line 105) now fall through the trailing _ => arm and surface to FFI as DashSDKErrorCode::InternalError. This is a behavior regression for embedders that route retry/UX policy off DashSDKErrorCode. Even setting that aside, the wildcard removes the maintenance signal Rust would otherwise give here: if dash_sdk::Error grows new variants, the compiler will not force this FFI mapping to be re-reviewed. Map StaleNode (and Cancelled if its retry semantics warrant) to an appropriate code (e.g. NetworkError/Timeout) and prefer naming the remaining variants explicitly rather than a wildcard.

source: ['codex']

🤖 Fix this 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-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 412-453: Wildcard fallback misclassifies retryable `StaleNode`/`Cancelled` SDK errors as `InternalError`
  `dash_sdk::Error::StaleNode(_)` (rs-sdk/src/error.rs:109, documented as a retry-another-server condition — see `is_retryable`-style match at line 270) and `dash_sdk::Error::Cancelled(_)` (line 105) now fall through the trailing `_ =>` arm and surface to FFI as `DashSDKErrorCode::InternalError`. This is a behavior regression for embedders that route retry/UX policy off `DashSDKErrorCode`. Even setting that aside, the wildcard removes the maintenance signal Rust would otherwise give here: if `dash_sdk::Error` grows new variants, the compiler will not force this FFI mapping to be re-reviewed. Map `StaleNode` (and `Cancelled` if its retry semantics warrant) to an appropriate code (e.g. `NetworkError`/`Timeout`) and prefer naming the remaining variants explicitly rather than a wildcard.

Comment on lines +126 to +149
fn generated_document_id(
&self,
platform_version: &PlatformVersion,
) -> Result<Identifier, Error> {
match platform_version
.dpp
.document_versions
.document_structure_version
{
0 => Ok(Document::generate_document_id_v0(
self.data_contract.id_ref(),
&self.document.owner_id(),
&self.document_type_name,
self.document_state_transition_entropy.as_slice(),
)),
version => Err(Error::Protocol(
dpp::ProtocolError::UnknownVersionMismatch {
method: "DocumentCreateTransitionBuilder::generated_document_id".to_string(),
known_versions: vec![0],
received: version,
},
)),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: generated_document_id dispatches on document_structure_version instead of the create-transition validation version

This PR adds generated_document_id to the SDK builder, reimplementing DPP's versioned create-ID rule. The dispatch is keyed on platform_version.dpp.document_versions.document_structure_version, but the authoritative pre-sign validator for create-transition structure that this PR introduces is on the separate documents_batch_transition.validation.document_create_transition_structure_validation axis. The SDK builder can therefore silently drift from DPP if create-transition ID derivation evolves without a matching bump to the document structure version — exactly the consensus-adjacent maintenance trap the version-dispatch pattern is meant to prevent. Either (a) key this match on the same document_create_transition_structure_validation field DPP uses, or (b) call into a DPP-exposed helper instead of duplicating the rule.

source: ['codex']

🤖 Fix this 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-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 126-149: `generated_document_id` dispatches on `document_structure_version` instead of the create-transition validation version
  This PR adds `generated_document_id` to the SDK builder, reimplementing DPP's versioned create-ID rule. The dispatch is keyed on `platform_version.dpp.document_versions.document_structure_version`, but the authoritative pre-sign validator for create-transition structure that this PR introduces is on the separate `documents_batch_transition.validation.document_create_transition_structure_validation` axis. The SDK builder can therefore silently drift from DPP if create-transition ID derivation evolves without a matching bump to the document structure version — exactly the consensus-adjacent maintenance trap the version-dispatch pattern is meant to prevent. Either (a) key this match on the same `document_create_transition_structure_validation` field DPP uses, or (b) call into a DPP-exposed helper instead of duplicating the rule.

Comment on lines +452 to +471
static func finalizeConsumedDashSDKError(
_ error: SDKError,
consensusErrors: [SDKConsensusError]?
) -> SDKError {
_ = consensusErrors
return error
}

/// Frees the owned FFI error pointer after mapping it to a Swift error.
///
/// Always returns the mapped `SDKError`, preserving the public thrown runtime
/// type for existing `catch let sdkError as SDKError` handlers. Structured
/// consensus details, if needed, must be read from the original pointer with
/// `fromDashSDKErrorWithConsensusErrors(_:)` before it is freed.
static func consumeDashSDKError(_ error: UnsafeMutablePointer<DashSDKError>) -> SDKError {
let mapped = fromDashSDKError(UnsafePointer(error))
let finalized = finalizeConsumedDashSDKError(mapped, consensusErrors: nil)
dash_sdk_error_free(error)
return finalized
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Swift consumeDashSDKError discards structured consensus details before free, leaving Swift without JS-parity error info

consumeDashSDKError reads the scalar fields, calls finalizeConsumedDashSDKError(mapped, consensusErrors: nil) (the parameter is unused — _ = consensusErrors), and then frees the FFI error. Most error paths in StateTransitionExtensions.swift switched to this helper, so the new dash_sdk_error_consensus_error_count/_at sidecar — the whole point of this PR for the Swift surface — is never read for callers that catch SDKError. The frozen SDKError ABI is intentional and SDKDetailedError is the documented opt-in, but the practical effect is that wasm-sdk and rs-sdk-ffi consumers see structured consensusErrors while Swift consumers do not unless each call site is rewritten. Consider having consumeDashSDKError read details before free and route them into a thrown SDKDetailedError when present, while still returning SDKError so existing catch let e as SDKError handlers keep working.

source: ['claude']

🤖 Fix this 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/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 452-471: Swift `consumeDashSDKError` discards structured consensus details before free, leaving Swift without JS-parity error info
  `consumeDashSDKError` reads the scalar fields, calls `finalizeConsumedDashSDKError(mapped, consensusErrors: nil)` (the parameter is unused — `_ = consensusErrors`), and then frees the FFI error. Most error paths in `StateTransitionExtensions.swift` switched to this helper, so the new `dash_sdk_error_consensus_error_count`/`_at` sidecar — the whole point of this PR for the Swift surface — is never read for callers that catch `SDKError`. The frozen `SDKError` ABI is intentional and `SDKDetailedError` is the documented opt-in, but the practical effect is that wasm-sdk and rs-sdk-ffi consumers see structured `consensusErrors` while Swift consumers do not unless each call site is rewritten. Consider having `consumeDashSDKError` read details before free and route them into a thrown `SDKDetailedError` when present, while still returning `SDKError` so existing `catch let e as SDKError` handlers keep working.

Comment on lines +259 to +281
fn with_active_consensus_errors<R>(
error_ptr: *const DashSDKError,
f: impl FnOnce(&[ConsensusErrorEntry]) -> R,
) -> Option<R> {
if error_ptr.is_null() {
return None;
}
let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
let entry = guard.get(&(error_ptr as usize))?;
// Only dereference the FFI pointer after an active sidecar entry exists
// for that exact heap `DashSDKError` pointer. A miss is treated as "no
// details" and must not read from the caller-provided pointer at all.
// Once an entry exists, re-check the current `message` field against the
// value captured at boxing time to reject recycled heap allocations.
let current_message = unsafe { (*error_ptr).message } as usize;
if entry.message_ptr != current_message {
// Pointer key matched but the message field doesn't match the value
// we recorded at boxing time — almost certainly a recycled heap
// allocation now occupied by a different error. Treat as no sidecar.
return None;
}
Some(f(entry.entries.as_slice()))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Sidecar lookup dereferences caller pointer; document the free-order invariant at the deref site

with_active_consensus_errors dereferences (*error_ptr).message once an ACTIVE_CONSENSUS_ERRORS key match is found. Soundness depends on dash_sdk_error_free always removing the entry from the active map before Box::from_raw(error) runs (which it does, under the same mutex). If a future change reorders dash_sdk_error_free to free first, or moves the take outside the mutex, line 273 becomes a use-after-free across the FFI boundary. Add a debug_assert! or a colocated comment at the deref site stating that this read is sound only because every free path removes the active-sidecar entry under the same mutex before the box is reclaimed.

source: ['claude']

Comment on lines +29 to +40
// Self-purchase is intentionally version-independent: every current
// and future purchase transition version must reject transferring a
// document to its existing owner before constructing the transition.
if document.owner_id() == new_owner_id {
return Err(ProtocolError::ConsensusError(Box::new(
InvalidDocumentTransitionActionError::new(format!(
"on document type: {} identity trying to purchase a document that is already owned by the purchaser",
document_type.name()
))
.into(),
)));
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Self-purchase guard runs before the version-dispatch match

The if document.owner_id() == new_owner_id check fires unconditionally before the match feature_version dispatch. The inline comment states this is intentional ("every current and future purchase transition version must reject…"), but it (a) diverges from the dispatch-first convention used elsewhere in DPP — a future protocol version that legitimately allowed buyer == seller (e.g. an escrow-style self-buyback) would silently fail at the SDK boundary even when the consensus rule changed, and (b) means a self-purchase with an unsupported feature_version returns the self-purchase error instead of UnknownVersionMismatch. Either push the guard into each per-version from_document so future versions can opt-out via PlatformVersion, or strengthen the doc comment on DocumentPurchaseTransition to spell out the permanence assumption.

source: ['claude']

Comment on lines +132 to +137
#[error(transparent)]
ConsensusError(Box<ConsensusError>),

#[error("Multiple consensus errors: {}", join_consensus_errors(.0))]
ConsensusErrors(Vec<ConsensusError>),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Existing ProtocolError::ConsensusError matches will not handle a single-element ConsensusErrors

Adding ProtocolError::ConsensusErrors(Vec<_>) is wire-safe (ProtocolError is not Platform-serialized). However, several call sites pattern-match ProtocolError::ConsensusError(err) to decide whether a verification result represents a recoverable failure (e.g. packages/wasm-dpp/src/data_contract/state_transition/data_contract_create_transition/mod.rs returns Ok(false) only for a singular ConsensusError -> SignatureError::InvalidStateTransitionSignatureError). If any future producer routes a signature failure through the plural variant, those branches will be skipped. Either collapse single-element ConsensusErrors to ConsensusError at construction, or audit and extend those match sites to also handle a one-element ConsensusErrors carrying a SignatureError.

source: ['claude']

@thepastaclaw thepastaclaw force-pushed the feat/sdk-validate-all-transitions branch from 990c07d to 7de3008 Compare May 17, 2026 23:13
@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Review follow-up for #3133 (review) pushed in 7de3008c2224399149d4dacb5fde564d9a1754e2.

Handled the current findings by:

  • avoiding the self-referential wasm-dpp consensusErrors array property;
  • keeping ProtocolError::ConsensusErrors appended in the enum and updating the PR breaking-changes note for #[non_exhaustive];
  • structurally mapping rs-sdk-ffi Cancelled/StaleNode and removing the SDK-error wildcard fallback;
  • keying SDK document-create ID generation on the DPP create-transition validation version;
  • having Swift error consumption read consensus sidecar details before free and surface SDKDetailedError when details are present.

Validation run:

  • cargo fmt --all -- --check
  • git diff --check
  • cargo test -p rs-sdk-ffi maps_to -- --nocapture
  • cargo test -p dash-sdk document_create_builder_sign_uses_create_transition_validation_version_for_id_generation -- --nocapture
  • cargo check -p wasm-dpp (passes with existing data-contracts warning)
  • cargo clippy -p rs-sdk-ffi --lib --all-targets -- -D warnings
  • cargo clippy -p dash-sdk --lib --all-targets -- -D warnings
  • cargo test -p dpp --lib validation::validation_result::tests::errors_to_consensus_protocol_error
  • xcrun swift-format lint --strict packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift

Swift test/build remains locally blocked because packages/swift-sdk/DashSDKFFI.xcframework does not contain a binary artifact in this worktree.

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR #3133 extends pre-sign batch base-structure validation across SDK document/token builders and threads structured consensus errors through the FFI/WASM/Swift surface. Implementation is sound and well-tested; no consensus-critical paths are altered. Remaining issues are quality/architecture concerns: a sidecar-leak in shared FFI test helpers, a Swift API-contract change in consumeDashSDKError, a misaligned version field in the SDK create builder, an unused new_owner_id plumbed into V0, and several documentation/ownership notes worth surfacing. No blockers; review action is COMMENT.

Reviewed commit: 7de3008

🟡 9 suggestion(s) | 💬 1 nitpick(s)

3 additional findings

🟡 suggestion: parse_string_result bypasses dash_sdk_error_free and leaks active sidecar entries

packages/rs-sdk-ffi/tests/integration_tests/ffi_utils.rs (lines 146-154)

parse_string_result reclaims result.error with Box::from_raw directly. After this PR, DashSDKResult::error(...) routes through box_dashsdk_error, which promotes the pending consensus-error sidecar entry into ACTIVE_CONSENSUS_ERRORS keyed on the heap *mut DashSDKError. The new Drop for DashSDKError only drains the pending sidecar (by message pointer); nothing removes the active entry. The canonical path dash_sdk_error_free calls take_active_consensus_errors before Box::from_raw — bypassing it leaks one ActiveSidecarEntry per ProtocolError::ConsensusError(_) | ConsensusErrors(_) for the rest of the process. This is exactly the failure mode the new module-level docs warn against, and because this helper is shared by every FFI integration test, long-running test runs that exercise erroring queries will accumulate entries silently.

💡 Suggested change
pub unsafe fn parse_string_result(result: DashSDKResult) -> Result<Option<String>, String> {
    if !result.error.is_null() {
        let code = (*result.error).code as i32;
        let message = from_c_string((*result.error).message).unwrap_or_default();
        crate::error::dash_sdk_error_free(result.error);
        return Err(format!("Error code {}: {}", code, message));
    }
🟡 suggestion: `_new_owner_id` is plumbed into V0 but never read

packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/v0/from_document.rs (lines 13-22)

DocumentPurchaseTransitionV0::from_document accepts _new_owner_id: Identifier and silences clippy with #[allow(clippy::too_many_arguments)], but the value is unused — the self-purchase check correctly lives in the version-independent dispatcher. Two cleaner options: (a) drop the parameter from V0 entirely and only thread new_owner_id into future versions that need it, or (b) keep it but remove the leading underscore and add a doc-comment stating V0 ignores it by design. As written, a future maintainer reading V0 sees no signal that the parameter is forced from outside and may copy the pattern (or remove the parameter and break the dispatcher signature).

💬 nitpick: Test reclaims FFI error with Box::from_raw instead of dash_sdk_error_free

packages/rs-sdk-ffi/src/token/queries/calculate_token_id.rs (lines 145-148)

rejects_null_contract_id reclaims result.error with Box::from_raw rather than crate::error::dash_sdk_error_free. For the specific null-pointer input under test no consensus sidecar is registered, so today this leaks nothing. The concern is purely that this is a copy-paste hazard: identical bypass code applied to any FFI error path that goes through box_dashsdk_error (any FFI fn returning protocol consensus errors via the new DashSDKResult::error(...)) will leak the ACTIVE_CONSENSUS_ERRORS entry. Route all tests through dash_sdk_error_free to keep cleanup uniform with the public ABI contract documented at the top of error.rs.

💡 Suggested change
        unsafe {
            // Free via the canonical FFI cleanup path so any active sidecar
            // entry registered by `box_dashsdk_error` is also released.
            crate::error::dash_sdk_error_free(result.error);
        }
🤖 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-sdk-ffi/tests/integration_tests/ffi_utils.rs`:
- [SUGGESTION] lines 146-154: parse_string_result bypasses dash_sdk_error_free and leaks active sidecar entries
  `parse_string_result` reclaims `result.error` with `Box::from_raw` directly. After this PR, `DashSDKResult::error(...)` routes through `box_dashsdk_error`, which promotes the pending consensus-error sidecar entry into `ACTIVE_CONSENSUS_ERRORS` keyed on the heap `*mut DashSDKError`. The new `Drop for DashSDKError` only drains the *pending* sidecar (by `message` pointer); nothing removes the *active* entry. The canonical path `dash_sdk_error_free` calls `take_active_consensus_errors` before `Box::from_raw` — bypassing it leaks one `ActiveSidecarEntry` per `ProtocolError::ConsensusError(_) | ConsensusErrors(_)` for the rest of the process. This is exactly the failure mode the new module-level docs warn against, and because this helper is shared by every FFI integration test, long-running test runs that exercise erroring queries will accumulate entries silently.

In `packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 452-474: consumeDashSDKError now throws SDKDetailedError instead of SDKError for protocol consensus failures
  `consumeDashSDKError` is the shared sink for every public Swift wrapper that consumes a Rust `DashSDKError*` (17+ call sites across SDK.swift alone, plus Addresses, StateTransitionExtensions, etc.). Before this PR it always mapped to a scalar `SDKError`. Now, whenever a sidecar consensus entry is present, `finalizeConsumedDashSDKError` returns `SDKDetailedError` — a different dynamic type that is still thrown via the same untyped `throws`. Existing Swift callers using `catch let e as SDKError` or `switch error { case .protocolError(...) }` will silently stop matching for any consensus-bearing failure. If the goal is to surface details, prefer extending `SDKError.protocolError` (e.g. attaching `consensusErrors` to the existing case), or document `SDKDetailedError` as a subtype convention and update wrappers to throw the scalar form when callers haven't opted in. Routing all protocol consensus errors through a *different* error type without a deprecation path is an avoidable interop break.

In `packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/v0/from_document.rs`:
- [SUGGESTION] lines 13-22: `_new_owner_id` is plumbed into V0 but never read
  `DocumentPurchaseTransitionV0::from_document` accepts `_new_owner_id: Identifier` and silences clippy with `#[allow(clippy::too_many_arguments)]`, but the value is unused — the self-purchase check correctly lives in the version-independent dispatcher. Two cleaner options: (a) drop the parameter from V0 entirely and only thread `new_owner_id` into future versions that need it, or (b) keep it but remove the leading underscore and add a doc-comment stating V0 ignores it by design. As written, a future maintainer reading V0 sees no signal that the parameter is forced from outside and may copy the pattern (or remove the parameter and break the dispatcher signature).

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 126-152: generated_document_id dispatches on a different version field than the DPP constructor
  `generated_document_id` reads `platform_version.dpp.state_transitions.documents.documents_batch_transition.validation.document_create_transition_structure_validation`, but the actual DPP constructor (`DocumentCreateTransition::from_document` via `BatchTransitionV0::new_document_creation_transition_from_document` at v0_methods.rs:107-116) dispatches on `state_transition_serialization_versions.document_create_state_transition` and honors `StateTransitionCreationOptions.method_feature_version`. Today every version is `0`, so the test suite passes. The moment those two namespaces diverge — or a caller opts into a non-default `method_feature_version` — the SDK will precompute or reject an ID using one algorithm while DPP serializes the transition using another, producing an `InvalidDocumentTransitionIdError` (or worse, a silently-mismatched ID) on a perfectly valid call. The version dispatch in the builder should honor the same `method_feature_version`/serialization-version source the constructor uses.
- [SUGGESTION] lines 173-200: sign() awaits a nonce fetch before running the new deterministic local ID validation
  The new ID-mismatch check only depends on in-memory state (document, contract ID, type name, entropy, platform version) — it is fully local and deterministic. However `sign()` still awaits `sdk.get_identity_contract_nonce(...)` *before* checking whether the supplied document ID is default or correctly matches the generated one. For invalid input this incurs an unnecessary round-trip (or mock call), and prevents the builder from failing fast offline — which undercuts the stated goal of surfacing constructor-backed validation client-side before signing. Move the ID resolution above the nonce fetch.
- [SUGGESTION] lines 154-200: Builder contract change: non-default document IDs now must equal the generated ID
  `sign()` previously always overwrote `document.id()`. It now overwrites only when the supplied ID equals `Identifier::default()` (all-zeros) and otherwise rejects with `InvalidDocumentTransitionIdError`. This is a meaningful API contract shift, but (1) the `sign()` rustdoc does not mention the new precondition, (2) the public `document: Document` field on the builder gives no hint that its `id` is treated as a sentinel, and (3) `Identifier::default()` is a structurally valid 32-byte identifier — it cannot distinguish 'unset' from 'legitimately-all-zeros'. A typed sentinel (`Option<Identifier>` or a `with_document_id(...)` setter) would express this contract cleanly. At minimum, update the `sign()` doc-comment and the struct field to call out that callers must supply either `Identifier::default()` or the correctly-derived ID so users hitting `InvalidDocumentTransitionIdError` after this PR can understand the new requirement.

In `packages/rs-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 259-281: with_active_consensus_errors dereferences the FFI pointer while holding the global Mutex
  `with_active_consensus_errors` now takes `ACTIVE_CONSENSUS_ERRORS` *before* dereferencing `error_ptr`. Two side effects worth weighing: (1) if a caller violates the contract and passes a dangling/invalid pointer that happens to collide with a still-live key, the deref panic/abort poisons the global Mutex — every subsequent error-detail query then traverses the `lock_recover` poisoned-path. (2) The message-pointer cross-check no longer defends against intra-process slot reuse: if `Box::into_raw` returns the same heap slot to a new error, the active map is rewritten with that new error's `message_ptr` *before* the stale caller queries, and `current_message == entry.message_ptr` succeeds — handing the stale caller the new error's sidecar. The previous snapshot-then-lock ordering at least allowed comparing against a stale message field for the same allocation. Either document these two trade-offs on the function, or restore snapshot-then-lock with null as the only fast-out before the deref.
- [SUGGESTION] lines 349-367: New Drop impl on DashSDKError can double-free for external callers still doing manual message reclamation
  `impl Drop for DashSDKError` now reclaims `message` via `CString::from_raw(self.message)`. All intra-tree call sites that previously did `Box::from_raw(error); CString::from_raw(error.message)` (Swift wrappers, FFI tests) have been migrated to `dash_sdk_error_free` / `consumeDashSDKError`, so they don't double-free. However, any out-of-tree C/Swift consumer relying on the prior pattern will double-free on the very first error after upgrading. The module-level docs note this contract change, but `DashSDKError` is a frozen `repr(C)` struct exposed in the public header — external integrators reading only the header have no way to know they must now route freeing exclusively through `dash_sdk_error_free`. Add a CHANGELOG entry that explicitly calls out the ownership shift (currently the CHANGELOG mentions the new APIs but not this contract change) so downstream integrators audit their cleanup code.

In `packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/from_document.rs`:
- [SUGGESTION] lines 17-40: Public DocumentPurchaseTransition::from_document signature change not documented as breaking
  `DocumentPurchaseTransition::from_document` now requires a new `new_owner_id: Identifier` argument inserted between `document_type` and `price`, and the new self-purchase check returns `InvalidDocumentTransitionActionError` at construction time (a shift from 'caught only at consensus validation' to 'caught pre-sign'). The PR's Breaking Changes section only mentions `ProtocolError` becoming `#[non_exhaustive]` and the new `ConsensusErrors` variant. Because `from_document` is a public function in `dpp`, any external Rust consumer that constructs a purchase transition outside the `BatchTransition::new_*` helpers will fail to compile, and any code that relied on consensus-time rejection of self-purchases will now see errors earlier in the pipeline. Add both to the Breaking Changes list so downstream rs-dpp consumers can plan their migration.

Comment on lines +452 to +474
static func finalizeConsumedDashSDKError(
_ error: SDKError,
consensusErrors: [SDKConsensusError]?
) -> Error {
guard let consensusErrors, !consensusErrors.isEmpty else {
return error
}

return SDKDetailedError(sdkError: error, consensusErrors: consensusErrors)
}

/// Frees the owned FFI error pointer after mapping it to a Swift error.
///
/// Returns the mapped scalar `SDKError` when no structured consensus details
/// exist. When sidecar-backed consensus details are present, returns
/// `SDKDetailedError` so those details survive past `dash_sdk_error_free`.
static func consumeDashSDKError(_ error: UnsafeMutablePointer<DashSDKError>) -> Error {
let pointer = UnsafePointer(error)
let mapped = fromDashSDKError(pointer)
let consensusErrors = consensusErrors(fromDashSDKError: pointer)
let finalized = finalizeConsumedDashSDKError(mapped, consensusErrors: consensusErrors)
dash_sdk_error_free(error)
return finalized
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: consumeDashSDKError now throws SDKDetailedError instead of SDKError for protocol consensus failures

consumeDashSDKError is the shared sink for every public Swift wrapper that consumes a Rust DashSDKError* (17+ call sites across SDK.swift alone, plus Addresses, StateTransitionExtensions, etc.). Before this PR it always mapped to a scalar SDKError. Now, whenever a sidecar consensus entry is present, finalizeConsumedDashSDKError returns SDKDetailedError — a different dynamic type that is still thrown via the same untyped throws. Existing Swift callers using catch let e as SDKError or switch error { case .protocolError(...) } will silently stop matching for any consensus-bearing failure. If the goal is to surface details, prefer extending SDKError.protocolError (e.g. attaching consensusErrors to the existing case), or document SDKDetailedError as a subtype convention and update wrappers to throw the scalar form when callers haven't opted in. Routing all protocol consensus errors through a different error type without a deprecation path is an avoidable interop break.

source: ['codex']

🤖 Fix this 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/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 452-474: consumeDashSDKError now throws SDKDetailedError instead of SDKError for protocol consensus failures
  `consumeDashSDKError` is the shared sink for every public Swift wrapper that consumes a Rust `DashSDKError*` (17+ call sites across SDK.swift alone, plus Addresses, StateTransitionExtensions, etc.). Before this PR it always mapped to a scalar `SDKError`. Now, whenever a sidecar consensus entry is present, `finalizeConsumedDashSDKError` returns `SDKDetailedError` — a different dynamic type that is still thrown via the same untyped `throws`. Existing Swift callers using `catch let e as SDKError` or `switch error { case .protocolError(...) }` will silently stop matching for any consensus-bearing failure. If the goal is to surface details, prefer extending `SDKError.protocolError` (e.g. attaching `consensusErrors` to the existing case), or document `SDKDetailedError` as a subtype convention and update wrappers to throw the scalar form when callers haven't opted in. Routing all protocol consensus errors through a *different* error type without a deprecation path is an avoidable interop break.

Comment on lines +126 to +152
fn generated_document_id(
&self,
platform_version: &PlatformVersion,
) -> Result<Identifier, Error> {
match platform_version
.dpp
.state_transitions
.documents
.documents_batch_transition
.validation
.document_create_transition_structure_validation
{
0 => Ok(Document::generate_document_id_v0(
self.data_contract.id_ref(),
&self.document.owner_id(),
&self.document_type_name,
self.document_state_transition_entropy.as_slice(),
)),
version => Err(Error::Protocol(
dpp::ProtocolError::UnknownVersionMismatch {
method: "DocumentCreateTransitionBuilder::generated_document_id".to_string(),
known_versions: vec![0],
received: version,
},
)),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: generated_document_id dispatches on a different version field than the DPP constructor

generated_document_id reads platform_version.dpp.state_transitions.documents.documents_batch_transition.validation.document_create_transition_structure_validation, but the actual DPP constructor (DocumentCreateTransition::from_document via BatchTransitionV0::new_document_creation_transition_from_document at v0_methods.rs:107-116) dispatches on state_transition_serialization_versions.document_create_state_transition and honors StateTransitionCreationOptions.method_feature_version. Today every version is 0, so the test suite passes. The moment those two namespaces diverge — or a caller opts into a non-default method_feature_version — the SDK will precompute or reject an ID using one algorithm while DPP serializes the transition using another, producing an InvalidDocumentTransitionIdError (or worse, a silently-mismatched ID) on a perfectly valid call. The version dispatch in the builder should honor the same method_feature_version/serialization-version source the constructor uses.

source: ['codex']

🤖 Fix this 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-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 126-152: generated_document_id dispatches on a different version field than the DPP constructor
  `generated_document_id` reads `platform_version.dpp.state_transitions.documents.documents_batch_transition.validation.document_create_transition_structure_validation`, but the actual DPP constructor (`DocumentCreateTransition::from_document` via `BatchTransitionV0::new_document_creation_transition_from_document` at v0_methods.rs:107-116) dispatches on `state_transition_serialization_versions.document_create_state_transition` and honors `StateTransitionCreationOptions.method_feature_version`. Today every version is `0`, so the test suite passes. The moment those two namespaces diverge — or a caller opts into a non-default `method_feature_version` — the SDK will precompute or reject an ID using one algorithm while DPP serializes the transition using another, producing an `InvalidDocumentTransitionIdError` (or worse, a silently-mismatched ID) on a perfectly valid call. The version dispatch in the builder should honor the same `method_feature_version`/serialization-version source the constructor uses.

Comment on lines +259 to +281
fn with_active_consensus_errors<R>(
error_ptr: *const DashSDKError,
f: impl FnOnce(&[ConsensusErrorEntry]) -> R,
) -> Option<R> {
if error_ptr.is_null() {
return None;
}
let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
let entry = guard.get(&(error_ptr as usize))?;
// Only dereference the FFI pointer after an active sidecar entry exists
// for that exact heap `DashSDKError` pointer. A miss is treated as "no
// details" and must not read from the caller-provided pointer at all.
// Once an entry exists, re-check the current `message` field against the
// value captured at boxing time to reject recycled heap allocations.
let current_message = unsafe { (*error_ptr).message } as usize;
if entry.message_ptr != current_message {
// Pointer key matched but the message field doesn't match the value
// we recorded at boxing time — almost certainly a recycled heap
// allocation now occupied by a different error. Treat as no sidecar.
return None;
}
Some(f(entry.entries.as_slice()))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: with_active_consensus_errors dereferences the FFI pointer while holding the global Mutex

with_active_consensus_errors now takes ACTIVE_CONSENSUS_ERRORS before dereferencing error_ptr. Two side effects worth weighing: (1) if a caller violates the contract and passes a dangling/invalid pointer that happens to collide with a still-live key, the deref panic/abort poisons the global Mutex — every subsequent error-detail query then traverses the lock_recover poisoned-path. (2) The message-pointer cross-check no longer defends against intra-process slot reuse: if Box::into_raw returns the same heap slot to a new error, the active map is rewritten with that new error's message_ptr before the stale caller queries, and current_message == entry.message_ptr succeeds — handing the stale caller the new error's sidecar. The previous snapshot-then-lock ordering at least allowed comparing against a stale message field for the same allocation. Either document these two trade-offs on the function, or restore snapshot-then-lock with null as the only fast-out before the deref.

source: ['claude']

🤖 Fix this 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-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 259-281: with_active_consensus_errors dereferences the FFI pointer while holding the global Mutex
  `with_active_consensus_errors` now takes `ACTIVE_CONSENSUS_ERRORS` *before* dereferencing `error_ptr`. Two side effects worth weighing: (1) if a caller violates the contract and passes a dangling/invalid pointer that happens to collide with a still-live key, the deref panic/abort poisons the global Mutex — every subsequent error-detail query then traverses the `lock_recover` poisoned-path. (2) The message-pointer cross-check no longer defends against intra-process slot reuse: if `Box::into_raw` returns the same heap slot to a new error, the active map is rewritten with that new error's `message_ptr` *before* the stale caller queries, and `current_message == entry.message_ptr` succeeds — handing the stale caller the new error's sidecar. The previous snapshot-then-lock ordering at least allowed comparing against a stale message field for the same allocation. Either document these two trade-offs on the function, or restore snapshot-then-lock with null as the only fast-out before the deref.

Comment on lines 184 to +200
.document_type_for_name(&self.document_type_name)
.map_err(|e| Error::Protocol(e.into()))?;

let generated_id = self.generated_document_id(platform_version)?;
let mut document = self.document.clone();
let current_id = document.id();

if current_id == Identifier::default() {
document.set_id(generated_id);
} else if current_id != generated_id {
return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
Box::new(ConsensusError::from(InvalidDocumentTransitionIdError::new(
generated_id,
current_id,
))),
)));
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: sign() awaits a nonce fetch before running the new deterministic local ID validation

The new ID-mismatch check only depends on in-memory state (document, contract ID, type name, entropy, platform version) — it is fully local and deterministic. However sign() still awaits sdk.get_identity_contract_nonce(...) before checking whether the supplied document ID is default or correctly matches the generated one. For invalid input this incurs an unnecessary round-trip (or mock call), and prevents the builder from failing fast offline — which undercuts the stated goal of surfacing constructor-backed validation client-side before signing. Move the ID resolution above the nonce fetch.

💡 Suggested change
Suggested change
.document_type_for_name(&self.document_type_name)
.map_err(|e| Error::Protocol(e.into()))?;
let generated_id = self.generated_document_id(platform_version)?;
let mut document = self.document.clone();
let current_id = document.id();
if current_id == Identifier::default() {
document.set_id(generated_id);
} else if current_id != generated_id {
return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
Box::new(ConsensusError::from(InvalidDocumentTransitionIdError::new(
generated_id,
current_id,
))),
)));
}
let generated_id = self.generated_document_id(platform_version)?;
let mut document = self.document.clone();
let current_id = document.id();
if current_id == Identifier::default() {
document.set_id(generated_id);
} else if current_id != generated_id {
return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
Box::new(ConsensusError::from(InvalidDocumentTransitionIdError::new(
generated_id,
current_id,
))),
)));
}
let identity_contract_nonce = sdk
.get_identity_contract_nonce(
self.document.owner_id(),
self.data_contract.id(),
true,
self.settings,
)
.await?;
let document_type = self
.data_contract
.document_type_for_name(&self.document_type_name)
.map_err(|e| Error::Protocol(e.into()))?;

source: ['codex']

🤖 Fix this 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-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 173-200: sign() awaits a nonce fetch before running the new deterministic local ID validation
  The new ID-mismatch check only depends on in-memory state (document, contract ID, type name, entropy, platform version) — it is fully local and deterministic. However `sign()` still awaits `sdk.get_identity_contract_nonce(...)` *before* checking whether the supplied document ID is default or correctly matches the generated one. For invalid input this incurs an unnecessary round-trip (or mock call), and prevents the builder from failing fast offline — which undercuts the stated goal of surfacing constructor-backed validation client-side before signing. Move the ID resolution above the nonce fetch.

Comment on lines 154 to +200
@@ -153,8 +184,23 @@ impl DocumentCreateTransitionBuilder {
.document_type_for_name(&self.document_type_name)
.map_err(|e| Error::Protocol(e.into()))?;

let generated_id = self.generated_document_id(platform_version)?;
let mut document = self.document.clone();
let current_id = document.id();

if current_id == Identifier::default() {
document.set_id(generated_id);
} else if current_id != generated_id {
return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(
Box::new(ConsensusError::from(InvalidDocumentTransitionIdError::new(
generated_id,
current_id,
))),
)));
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Builder contract change: non-default document IDs now must equal the generated ID

sign() previously always overwrote document.id(). It now overwrites only when the supplied ID equals Identifier::default() (all-zeros) and otherwise rejects with InvalidDocumentTransitionIdError. This is a meaningful API contract shift, but (1) the sign() rustdoc does not mention the new precondition, (2) the public document: Document field on the builder gives no hint that its id is treated as a sentinel, and (3) Identifier::default() is a structurally valid 32-byte identifier — it cannot distinguish 'unset' from 'legitimately-all-zeros'. A typed sentinel (Option<Identifier> or a with_document_id(...) setter) would express this contract cleanly. At minimum, update the sign() doc-comment and the struct field to call out that callers must supply either Identifier::default() or the correctly-derived ID so users hitting InvalidDocumentTransitionIdError after this PR can understand the new requirement.

source: ['claude']

🤖 Fix this 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-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 154-200: Builder contract change: non-default document IDs now must equal the generated ID
  `sign()` previously always overwrote `document.id()`. It now overwrites only when the supplied ID equals `Identifier::default()` (all-zeros) and otherwise rejects with `InvalidDocumentTransitionIdError`. This is a meaningful API contract shift, but (1) the `sign()` rustdoc does not mention the new precondition, (2) the public `document: Document` field on the builder gives no hint that its `id` is treated as a sentinel, and (3) `Identifier::default()` is a structurally valid 32-byte identifier — it cannot distinguish 'unset' from 'legitimately-all-zeros'. A typed sentinel (`Option<Identifier>` or a `with_document_id(...)` setter) would express this contract cleanly. At minimum, update the `sign()` doc-comment and the struct field to call out that callers must supply either `Identifier::default()` or the correctly-derived ID so users hitting `InvalidDocumentTransitionIdError` after this PR can understand the new requirement.

Comment on lines +349 to +367
impl Drop for DashSDKError {
fn drop(&mut self) {
if !self.message.is_null() {
// Drain any still-pending sidecar entry keyed on this message
// pointer. After a successful `box_dashsdk_error` promotion this
// is a no-op (the entry has already been moved to the active
// map). When the value is dropped without boxing, this prevents
// a leak that would later mis-attribute details to a recycled
// message allocation.
let _ = take_pending_consensus_errors(self.message);
// SAFETY: `message` was allocated via `CString::into_raw` in
// `DashSDKError::new`; reclaim the allocation exactly once.
unsafe {
let _ = CString::from_raw(self.message);
}
self.message = std::ptr::null_mut();
}
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: New Drop impl on DashSDKError can double-free for external callers still doing manual message reclamation

impl Drop for DashSDKError now reclaims message via CString::from_raw(self.message). All intra-tree call sites that previously did Box::from_raw(error); CString::from_raw(error.message) (Swift wrappers, FFI tests) have been migrated to dash_sdk_error_free / consumeDashSDKError, so they don't double-free. However, any out-of-tree C/Swift consumer relying on the prior pattern will double-free on the very first error after upgrading. The module-level docs note this contract change, but DashSDKError is a frozen repr(C) struct exposed in the public header — external integrators reading only the header have no way to know they must now route freeing exclusively through dash_sdk_error_free. Add a CHANGELOG entry that explicitly calls out the ownership shift (currently the CHANGELOG mentions the new APIs but not this contract change) so downstream integrators audit their cleanup code.

source: ['claude']

🤖 Fix this 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-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 349-367: New Drop impl on DashSDKError can double-free for external callers still doing manual message reclamation
  `impl Drop for DashSDKError` now reclaims `message` via `CString::from_raw(self.message)`. All intra-tree call sites that previously did `Box::from_raw(error); CString::from_raw(error.message)` (Swift wrappers, FFI tests) have been migrated to `dash_sdk_error_free` / `consumeDashSDKError`, so they don't double-free. However, any out-of-tree C/Swift consumer relying on the prior pattern will double-free on the very first error after upgrading. The module-level docs note this contract change, but `DashSDKError` is a frozen `repr(C)` struct exposed in the public header — external integrators reading only the header have no way to know they must now route freeing exclusively through `dash_sdk_error_free`. Add a CHANGELOG entry that explicitly calls out the ownership shift (currently the CHANGELOG mentions the new APIs but not this contract change) so downstream integrators audit their cleanup code.

Comment on lines 26 to +40
feature_version: Option<FeatureVersion>,
base_feature_version: Option<FeatureVersion>,
) -> Result<Self, ProtocolError> {
// Self-purchase is intentionally version-independent: every current
// and future purchase transition version must reject transferring a
// document to its existing owner before constructing the transition.
if document.owner_id() == new_owner_id {
return Err(ProtocolError::ConsensusError(Box::new(
InvalidDocumentTransitionActionError::new(format!(
"on document type: {} identity trying to purchase a document that is already owned by the purchaser",
document_type.name()
))
.into(),
)));
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Public DocumentPurchaseTransition::from_document signature change not documented as breaking

DocumentPurchaseTransition::from_document now requires a new new_owner_id: Identifier argument inserted between document_type and price, and the new self-purchase check returns InvalidDocumentTransitionActionError at construction time (a shift from 'caught only at consensus validation' to 'caught pre-sign'). The PR's Breaking Changes section only mentions ProtocolError becoming #[non_exhaustive] and the new ConsensusErrors variant. Because from_document is a public function in dpp, any external Rust consumer that constructs a purchase transition outside the BatchTransition::new_* helpers will fail to compile, and any code that relied on consensus-time rejection of self-purchases will now see errors earlier in the pipeline. Add both to the Breaking Changes list so downstream rs-dpp consumers can plan their migration.

source: ['claude']

🤖 Fix this 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/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/from_document.rs`:
- [SUGGESTION] lines 17-40: Public DocumentPurchaseTransition::from_document signature change not documented as breaking
  `DocumentPurchaseTransition::from_document` now requires a new `new_owner_id: Identifier` argument inserted between `document_type` and `price`, and the new self-purchase check returns `InvalidDocumentTransitionActionError` at construction time (a shift from 'caught only at consensus validation' to 'caught pre-sign'). The PR's Breaking Changes section only mentions `ProtocolError` becoming `#[non_exhaustive]` and the new `ConsensusErrors` variant. Because `from_document` is a public function in `dpp`, any external Rust consumer that constructs a purchase transition outside the `BatchTransition::new_*` helpers will fail to compile, and any code that relied on consensus-time rejection of self-purchases will now see errors earlier in the pipeline. Add both to the Breaking Changes list so downstream rs-dpp consumers can plan their migration.

@thepastaclaw thepastaclaw force-pushed the feat/sdk-validate-all-transitions branch from 7de3008 to 5d7f08a Compare May 18, 2026 01:01
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR is consensus-safe at HEAD 5d7f08a. All previously-flagged blocking issues from prior iterations are resolved. Two convergent suggestion-level findings remain about the new structured-consensus FFI plumbing: the sidecar lookup now dereferences the caller pointer before the map lookup (weakening the documented miss-path safety), and every public Swift throwing wrapper pays an FFI round-trip for structured details that are then discarded by design.

Reviewed commit: 5d7f08a

🟡 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/rs-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 259-280: with_active_consensus_errors dereferences (*error_ptr).message before the sidecar lookup, widening the post-free UB blast radius on the miss path
  This commit reordered `with_active_consensus_errors` so `let current_message = unsafe { (*error_ptr).message } as usize;` (line 266) now runs before `lock_recover(&ACTIVE_CONSENSUS_ERRORS)` (line 267) and before `guard.get(&(error_ptr as usize))?` (line 268). Previously the dereference happened only after the entry lookup succeeded, so a stale pointer that was not in the sidecar map was tolerated without a deref. The new ordering unconditionally dereferences `error_ptr->message` for every non-null pointer — including the common miss path (non-protocol errors such as `NotFound` or `NetworkError`, which never register sidecar entries, plus any caller that aliases a freed error). The inline comment cites a mutex-hold-time trade-off, but the held work is a single `HashMap::get` of a small map keyed on `usize`; the trade-off favors hold time over caller-pointer safety. Per the documented synchronous-move-only contract this is still sound when callers comply, but the previous lookup-first ordering caught precisely the contract-violation case (use-after-free across FFI) that is hardest to debug. Either restore the lookup-first ordering (cheap and strictly safer on the miss path), or extend the SAFETY comment to call out that every non-null pointer is now dereferenced regardless of whether a sidecar entry exists.

In `packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 452-474: consumeDashSDKError performs an FFI consensus-errors lookup whose result is unconditionally discarded by finalizeConsumedDashSDKError
  `consumeDashSDKError(_:)` at SDK.swift:467-474 calls `consensusErrors(fromDashSDKError: pointer)` (line 470) on every error, then passes the `[SDKConsensusError]?` to `finalizeConsumedDashSDKError(mapped, consensusErrors:)`, whose body at SDK.swift:452-458 discards the parameter via `_ = consensusErrors` and returns the scalar `mapped`. The behavior is deliberate — the docstring at SDK.swift:394-410 documents that scalar `SDKError` does not retain structured details — but the cost is paid anyway: every protocol error with N consensus entries causes N round-trips into `dash_sdk_error_consensus_error_at` (Rust allocates 3 C-strings per entry via `CString::into_raw`) followed by N round-trips into `dash_sdk_consensus_error_free` to reclaim them, plus mutex acquisition and Array allocation in `consensusErrors(fromDashSDKError:)`. All public throwing wrappers in this PR funnel through this helper, so every protocol-error throw across `SDK.swift`, `Addresses.swift`, `PlatformQueryExtensions.swift`, and `StateTransitionExtensions.swift` pays this cost. A related consequence: high-level Swift callers only ever receive scalar `SDKError`; they never see the original pointer and therefore cannot call `fromDashSDKErrorWithConsensusErrors(_:)` or `consensusErrors(fromDashSDKError:)` themselves — the new Rust→Swift structured channel is unreachable through the public wrappers. Either drop the lookup call here and simplify `finalizeConsumedDashSDKError` to a pass-through (structured details remain available via the dedicated public APIs callers must opt into before invoking the wrapper), or actually surface the payload (e.g. throw `SDKDetailedError` from the wrappers that want it). The current shape is the worst of both worlds: pays for the structured lookup on every error, then silently discards it.

Comment on lines +259 to +280
fn with_active_consensus_errors<R>(
error_ptr: *const DashSDKError,
f: impl FnOnce(&[ConsensusErrorEntry]) -> R,
) -> Option<R> {
if error_ptr.is_null() {
return None;
}
let current_message = unsafe { (*error_ptr).message } as usize;
let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
let entry = guard.get(&(error_ptr as usize))?;
// Trade-off: read the current `message` pointer first, then confirm the
// active sidecar entry still matches it under lock. This keeps mutex hold
// time free of caller-memory dereferences while still requiring both the
// boxed error pointer and captured `message` pointer to match.
if entry.message_ptr != current_message {
// Pointer key matched but the message field doesn't match the value
// we recorded at boxing time — almost certainly a recycled heap
// allocation now occupied by a different error. Treat as no sidecar.
return None;
}
Some(f(entry.entries.as_slice()))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*🟡 Suggestion: with_active_consensus_errors dereferences (error_ptr).message before the sidecar lookup, widening the post-free UB blast radius on the miss path

This commit reordered with_active_consensus_errors so let current_message = unsafe { (*error_ptr).message } as usize; (line 266) now runs before lock_recover(&ACTIVE_CONSENSUS_ERRORS) (line 267) and before guard.get(&(error_ptr as usize))? (line 268). Previously the dereference happened only after the entry lookup succeeded, so a stale pointer that was not in the sidecar map was tolerated without a deref. The new ordering unconditionally dereferences error_ptr->message for every non-null pointer — including the common miss path (non-protocol errors such as NotFound or NetworkError, which never register sidecar entries, plus any caller that aliases a freed error). The inline comment cites a mutex-hold-time trade-off, but the held work is a single HashMap::get of a small map keyed on usize; the trade-off favors hold time over caller-pointer safety. Per the documented synchronous-move-only contract this is still sound when callers comply, but the previous lookup-first ordering caught precisely the contract-violation case (use-after-free across FFI) that is hardest to debug. Either restore the lookup-first ordering (cheap and strictly safer on the miss path), or extend the SAFETY comment to call out that every non-null pointer is now dereferenced regardless of whether a sidecar entry exists.

💡 Suggested change
Suggested change
fn with_active_consensus_errors<R>(
error_ptr: *const DashSDKError,
f: impl FnOnce(&[ConsensusErrorEntry]) -> R,
) -> Option<R> {
if error_ptr.is_null() {
return None;
}
let current_message = unsafe { (*error_ptr).message } as usize;
let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
let entry = guard.get(&(error_ptr as usize))?;
// Trade-off: read the current `message` pointer first, then confirm the
// active sidecar entry still matches it under lock. This keeps mutex hold
// time free of caller-memory dereferences while still requiring both the
// boxed error pointer and captured `message` pointer to match.
if entry.message_ptr != current_message {
// Pointer key matched but the message field doesn't match the value
// we recorded at boxing time — almost certainly a recycled heap
// allocation now occupied by a different error. Treat as no sidecar.
return None;
}
Some(f(entry.entries.as_slice()))
}
fn with_active_consensus_errors<R>(
error_ptr: *const DashSDKError,
f: impl FnOnce(&[ConsensusErrorEntry]) -> R,
) -> Option<R> {
if error_ptr.is_null() {
return None;
}
let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS);
let entry = guard.get(&(error_ptr as usize))?;
// Confirm the active sidecar entry still matches the current `message`
// pointer. Reading `message` only after the pointer-key match means a
// stale pointer that does not have a sidecar entry is never dereferenced.
let current_message = unsafe { (*error_ptr).message } as usize;
if entry.message_ptr != current_message {
return None;
}
Some(f(entry.entries.as_slice()))
}

source: ['claude', 'codex']

🤖 Fix this 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-sdk-ffi/src/error.rs`:
- [SUGGESTION] lines 259-280: with_active_consensus_errors dereferences (*error_ptr).message before the sidecar lookup, widening the post-free UB blast radius on the miss path
  This commit reordered `with_active_consensus_errors` so `let current_message = unsafe { (*error_ptr).message } as usize;` (line 266) now runs before `lock_recover(&ACTIVE_CONSENSUS_ERRORS)` (line 267) and before `guard.get(&(error_ptr as usize))?` (line 268). Previously the dereference happened only after the entry lookup succeeded, so a stale pointer that was not in the sidecar map was tolerated without a deref. The new ordering unconditionally dereferences `error_ptr->message` for every non-null pointer — including the common miss path (non-protocol errors such as `NotFound` or `NetworkError`, which never register sidecar entries, plus any caller that aliases a freed error). The inline comment cites a mutex-hold-time trade-off, but the held work is a single `HashMap::get` of a small map keyed on `usize`; the trade-off favors hold time over caller-pointer safety. Per the documented synchronous-move-only contract this is still sound when callers comply, but the previous lookup-first ordering caught precisely the contract-violation case (use-after-free across FFI) that is hardest to debug. Either restore the lookup-first ordering (cheap and strictly safer on the miss path), or extend the SAFETY comment to call out that every non-null pointer is now dereferenced regardless of whether a sidecar entry exists.

Comment on lines +452 to +474
static func finalizeConsumedDashSDKError(
_ error: SDKError,
consensusErrors: [SDKConsensusError]?
) -> SDKError {
_ = consensusErrors
return error
}

/// Frees the owned FFI error pointer after mapping it to a Swift error.
///
/// Public throwing wrappers intentionally collapse the FFI error to scalar
/// `SDKError` for source compatibility. Callers that need structured
/// consensus details must inspect the pointer with
/// `consensusErrors(fromDashSDKError:)` or
/// `fromDashSDKErrorWithConsensusErrors(_:)` before this free runs.
static func consumeDashSDKError(_ error: UnsafeMutablePointer<DashSDKError>) -> SDKError {
let pointer = UnsafePointer(error)
let mapped = fromDashSDKError(pointer)
let consensusErrors = consensusErrors(fromDashSDKError: pointer)
let finalized = finalizeConsumedDashSDKError(mapped, consensusErrors: consensusErrors)
dash_sdk_error_free(error)
return finalized
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: consumeDashSDKError performs an FFI consensus-errors lookup whose result is unconditionally discarded by finalizeConsumedDashSDKError

consumeDashSDKError(_:) at SDK.swift:467-474 calls consensusErrors(fromDashSDKError: pointer) (line 470) on every error, then passes the [SDKConsensusError]? to finalizeConsumedDashSDKError(mapped, consensusErrors:), whose body at SDK.swift:452-458 discards the parameter via _ = consensusErrors and returns the scalar mapped. The behavior is deliberate — the docstring at SDK.swift:394-410 documents that scalar SDKError does not retain structured details — but the cost is paid anyway: every protocol error with N consensus entries causes N round-trips into dash_sdk_error_consensus_error_at (Rust allocates 3 C-strings per entry via CString::into_raw) followed by N round-trips into dash_sdk_consensus_error_free to reclaim them, plus mutex acquisition and Array allocation in consensusErrors(fromDashSDKError:). All public throwing wrappers in this PR funnel through this helper, so every protocol-error throw across SDK.swift, Addresses.swift, PlatformQueryExtensions.swift, and StateTransitionExtensions.swift pays this cost. A related consequence: high-level Swift callers only ever receive scalar SDKError; they never see the original pointer and therefore cannot call fromDashSDKErrorWithConsensusErrors(_:) or consensusErrors(fromDashSDKError:) themselves — the new Rust→Swift structured channel is unreachable through the public wrappers. Either drop the lookup call here and simplify finalizeConsumedDashSDKError to a pass-through (structured details remain available via the dedicated public APIs callers must opt into before invoking the wrapper), or actually surface the payload (e.g. throw SDKDetailedError from the wrappers that want it). The current shape is the worst of both worlds: pays for the structured lookup on every error, then silently discards it.

source: ['claude', 'codex']

🤖 Fix this 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/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 452-474: consumeDashSDKError performs an FFI consensus-errors lookup whose result is unconditionally discarded by finalizeConsumedDashSDKError
  `consumeDashSDKError(_:)` at SDK.swift:467-474 calls `consensusErrors(fromDashSDKError: pointer)` (line 470) on every error, then passes the `[SDKConsensusError]?` to `finalizeConsumedDashSDKError(mapped, consensusErrors:)`, whose body at SDK.swift:452-458 discards the parameter via `_ = consensusErrors` and returns the scalar `mapped`. The behavior is deliberate — the docstring at SDK.swift:394-410 documents that scalar `SDKError` does not retain structured details — but the cost is paid anyway: every protocol error with N consensus entries causes N round-trips into `dash_sdk_error_consensus_error_at` (Rust allocates 3 C-strings per entry via `CString::into_raw`) followed by N round-trips into `dash_sdk_consensus_error_free` to reclaim them, plus mutex acquisition and Array allocation in `consensusErrors(fromDashSDKError:)`. All public throwing wrappers in this PR funnel through this helper, so every protocol-error throw across `SDK.swift`, `Addresses.swift`, `PlatformQueryExtensions.swift`, and `StateTransitionExtensions.swift` pays this cost. A related consequence: high-level Swift callers only ever receive scalar `SDKError`; they never see the original pointer and therefore cannot call `fromDashSDKErrorWithConsensusErrors(_:)` or `consensusErrors(fromDashSDKError:)` themselves — the new Rust→Swift structured channel is unreachable through the public wrappers. Either drop the lookup call here and simplify `finalizeConsumedDashSDKError` to a pass-through (structured details remain available via the dedicated public APIs callers must opt into before invoking the wrapper), or actually surface the payload (e.g. throw `SDKDetailedError` from the wrappers that want it). The current shape is the worst of both worlds: pays for the structured lookup on every error, then silently discards it.

@thepastaclaw thepastaclaw force-pushed the feat/sdk-validate-all-transitions branch from 5d7f08a to 3dc15c9 Compare May 18, 2026 02:14
@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Addressed the latest automated review follow-up on 5d7f08a1 and pushed amended head 3dc15c9bdd6701d975f48baa1d0c9609760dc487.

  • Restored lookup-first ordering in with_active_consensus_errors: the active sidecar map is checked before dereferencing DashSDKError*, so miss paths for unrelated/stale non-null pointers no longer read (*error_ptr).message.
  • Removed the eager Swift consensus-error lookup from SDKError.consumeDashSDKError(_:); public wrapper consumption now maps/frees as scalar SDKError, while structured details remain available through the explicit pointer-based helpers callers must use before freeing.

Checks run:

  • cargo fmt --all -- --check
  • cargo test -p rs-sdk-ffi consensus_error --lib
  • git diff --check
  • cargo clippy -p rs-sdk-ffi --lib --all-targets -- -D warnings
  • swift build --disable-sandbox in packages/swift-sdk (blocked locally because DashSDKFFI.xcframework is missing/not a binary artifact)

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Verified at HEAD 3dc15c9. No blocking issues remain — prior consensus, FFI ownership, and feature-flag concerns are resolved. Four convergent nitpicks remain across the validation helper, FFI sidecar identity, wasm-dpp redundant array exposure, and 11 identically-named token-builder test wrappers. All are quality/maintainability improvements, none are correctness defects at this SHA.

Reviewed commit: 3dc15c9

🟡 1 suggestion(s) | 💬 2 nitpick(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/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 129-160: `generated_document_id` couples state-transition serialization version with the id-generation algorithm
  `DocumentCreateTransitionBuilder::generated_document_id` dispatches on `state_transition_creation_options.method_feature_version` (the *serialization* version of `DocumentCreateStateTransition`) and only returns an id for `0`; any other value short-circuits with `UnknownVersionMismatch`. The corresponding DPP pre-sign validator instead dispatches on the independent `dpp.state_transitions.documents.documents_batch_transition.validation.document_create_transition_structure_validation` field, and at v0 hardcodes `Document::generate_document_id_v0`. The two dispatch tables move on different cadences — the id algorithm is keyed off the DPP validation version, not the serialization version. The day someone bumps state-transition serialization to v1 without changing the id-derivation algorithm or the DPP validation dispatcher, this SDK helper will spuriously reject valid create builds (and the user-supplied-id mismatch arm at lines 196-203 becomes unreachable, since the SDK errors before getting that far). Either read the same field the DPP validator reads, or always call `Document::generate_document_id_v0` directly until a second id-derivation function actually exists — and comment that the SDK and DPP id checks must move together.

Comment on lines +140 to +148
pub fn errors_to_consensus_protocol_error(mut self) -> Option<ProtocolError> {
match self.errors.len() {
0 => None,
1 => Some(ProtocolError::ConsensusError(Box::new(
self.errors.pop().expect("len == 1"),
))),
_ => Some(ProtocolError::ConsensusErrors(self.errors)),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Length-guarded .expect() in load-bearing errors_to_consensus_protocol_error

errors_to_consensus_protocol_error matches on self.errors.len() and only enters the 1 arm when there is exactly one element, so self.errors.pop().expect("len == 1") is unreachable. This helper is now the canonical bridge from accumulated validation results into ProtocolError::{ConsensusError, ConsensusErrors}, used by validate_base_structure_pre_sign and the five document from_document constructors (Delete/Replace/Transfer/Purchase/UpdatePrice). Project guidance discourages .expect() in production paths even when the value is mathematically guaranteed. Consuming self.errors once via a combined pattern match removes the latent panic site from this hot conversion seam without behavior change.

💡 Suggested change
Suggested change
pub fn errors_to_consensus_protocol_error(mut self) -> Option<ProtocolError> {
match self.errors.len() {
0 => None,
1 => Some(ProtocolError::ConsensusError(Box::new(
self.errors.pop().expect("len == 1"),
))),
_ => Some(ProtocolError::ConsensusErrors(self.errors)),
}
}
pub fn errors_to_consensus_protocol_error(self) -> Option<ProtocolError> {
match (self.errors.len(), self.errors) {
(0, _) => None,
(1, mut errors) => Some(ProtocolError::ConsensusError(Box::new(errors.swap_remove(0)))),
(_, errors) => Some(ProtocolError::ConsensusErrors(errors)),
}
}

source: ['claude', 'codex']

Comment on lines +129 to +160
fn generated_document_id(
&self,
platform_version: &PlatformVersion,
) -> Result<Identifier, Error> {
let feature_version = self
.state_transition_creation_options
.and_then(|options| options.method_feature_version)
.unwrap_or(
platform_version
.dpp
.state_transition_serialization_versions
.document_create_state_transition
.bounds
.default_current_version,
);

match feature_version {
0 => Ok(Document::generate_document_id_v0(
self.data_contract.id_ref(),
&self.document.owner_id(),
&self.document_type_name,
self.document_state_transition_entropy.as_slice(),
)),
version => Err(Error::Protocol(
dpp::ProtocolError::UnknownVersionMismatch {
method: "DocumentCreateTransitionBuilder::generated_document_id".to_string(),
known_versions: vec![0],
received: version,
},
)),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: generated_document_id couples state-transition serialization version with the id-generation algorithm

DocumentCreateTransitionBuilder::generated_document_id dispatches on state_transition_creation_options.method_feature_version (the serialization version of DocumentCreateStateTransition) and only returns an id for 0; any other value short-circuits with UnknownVersionMismatch. The corresponding DPP pre-sign validator instead dispatches on the independent dpp.state_transitions.documents.documents_batch_transition.validation.document_create_transition_structure_validation field, and at v0 hardcodes Document::generate_document_id_v0. The two dispatch tables move on different cadences — the id algorithm is keyed off the DPP validation version, not the serialization version. The day someone bumps state-transition serialization to v1 without changing the id-derivation algorithm or the DPP validation dispatcher, this SDK helper will spuriously reject valid create builds (and the user-supplied-id mismatch arm at lines 196-203 becomes unreachable, since the SDK errors before getting that far). Either read the same field the DPP validator reads, or always call Document::generate_document_id_v0 directly until a second id-derivation function actually exists — and comment that the SDK and DPP id checks must move together.

source: ['claude']

🤖 Fix this 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-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 129-160: `generated_document_id` couples state-transition serialization version with the id-generation algorithm
  `DocumentCreateTransitionBuilder::generated_document_id` dispatches on `state_transition_creation_options.method_feature_version` (the *serialization* version of `DocumentCreateStateTransition`) and only returns an id for `0`; any other value short-circuits with `UnknownVersionMismatch`. The corresponding DPP pre-sign validator instead dispatches on the independent `dpp.state_transitions.documents.documents_batch_transition.validation.document_create_transition_structure_validation` field, and at v0 hardcodes `Document::generate_document_id_v0`. The two dispatch tables move on different cadences — the id algorithm is keyed off the DPP validation version, not the serialization version. The day someone bumps state-transition serialization to v1 without changing the id-derivation algorithm or the DPP validation dispatcher, this SDK helper will spuriously reject valid create builds (and the user-supplied-id mismatch arm at lines 196-203 becomes unreachable, since the SDK errors before getting that far). Either read the same field the DPP validator reads, or always call `Document::generate_document_id_v0` directly until a second id-derivation function actually exists — and comment that the SDK and DPP id checks must move together.

Comment on lines +7 to +40
pub(crate) fn from_consensus_errors(consensus_errors: Vec<ConsensusError>) -> JsValue {
let consensus_errors_array =
Array::from_iter(consensus_errors.into_iter().map(from_consensus_error));
let consensus_errors_property =
consensus_errors_array.slice(0, consensus_errors_array.length());
let message = match consensus_errors_array.length() {
0 => "ProtocolError contained no consensus errors".to_string(),
1 => "ProtocolError contained 1 consensus error".to_string(),
count => format!("ProtocolError contained {count} consensus errors"),
};

// Preserve the pre-existing wasm-dpp contract: plural consensus errors are
// thrown as an Array so JS callers can keep using Array.isArray(e), e[0],
// e.length, and iteration. Attach the richer properties to the same array
// rather than switching the thrown value to Error-shaped.
let error_value = JsValue::from(consensus_errors_array.clone());
let _ = Reflect::set(
&error_value,
&JsValue::from_str("name"),
&JsValue::from_str("ConsensusErrors"),
);
let _ = Reflect::set(
&error_value,
&JsValue::from_str("message"),
&JsValue::from_str(&message),
);
let _ = Reflect::set(
&error_value,
&JsValue::from_str("consensusErrors"),
&consensus_errors_property.into(),
);

error_value
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: from_consensus_errors allocates a redundant array snapshot and exposes the same data twice

from_consensus_errors builds consensus_errors_array, then immediately slice(0, .length())s it into consensus_errors_property, then returns the original array as the JsValue and attaches the sliced copy as a consensusErrors property on that same array. Consumers therefore see both e[0]/e.length/for..of e AND e.consensusErrors[0] referring to logically identical data through two different array allocations. Either self-reference consensusErrors to the same array (e.g. Reflect::set(&error_value, "consensusErrors", &error_value)), or drop the consensusErrors property entirely since existing array-shaped iteration already works. The slice copy doubles wrapper allocation per consensus-error batch with no observable benefit and risks confusing callers about which view is canonical.

source: ['claude']

@thepastaclaw thepastaclaw force-pushed the feat/sdk-validate-all-transitions branch from 3dc15c9 to 11fb8c4 Compare May 18, 2026 03:37
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

I verified the saved findings against the checked-out 11fb8c4e8232fc5264ac848d952a2a245c307490 source. Most flagged items are either explicitly documented compatibility tradeoffs or concern unsupported FFI misuse outside the stated ownership contract, so they should not be posted as PR findings. One real public-surface gap remains: wasm-dpp2 now preserves plural consensus errors internally but still does not expose them to JavaScript callers.

Reviewed commit: 11fb8c4

🟡 1 suggestion(s)

🟡 suggestion: `wasm-dpp2` preserves plural consensus errors internally but never exposes them on its JS API

packages/wasm-dpp2/src/error.rs (lines 26-149)

WasmDppError now stores consensus_errors: Vec<ConsensusError>, and From<ProtocolError> populates that field for both ConsensusError and ConsensusErrors. However, the only accessor for that data is the Rust-only pub fn consensus_errors(&self) -> &[ConsensusError], while the #[wasm_bindgen] surface only exports kind, name, message, and code. The result is that direct @dashevo/wasm-dpp2 consumers still lose the structured plural validation details that this PR now preserves internally, and only wasm-sdk can recover them by reading the Rust-only getter before re-wrapping the error. If the goal of this change is to preserve structured consensus failures across the wasm boundary, wasm-dpp2 needs its own JS-visible consensusErrors getter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants