Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
075ce04
fix(dpp): validate public key security levels client-side in Identity…
thepastaclaw Feb 17, 2026
e280098
fix(dpp): add client-side key validation to identity create transitions
thepastaclaw Feb 17, 2026
d8e6e3f
fix(dpp): remove underscore prefix from now-used platform_version par…
thepastaclaw Feb 17, 2026
a16f8f8
chore: remove underscore prefix from now-used platform_version params
thepastaclaw Feb 17, 2026
34f1d12
feat(sdk): add client-side validate_structure() to remaining state tr…
thepastaclaw Feb 18, 2026
5b900d4
test(dpp,drive-abci): fix test fixtures for client-side validate_stru…
thepastaclaw Feb 21, 2026
ef4d031
test(drive-abci): adapt address transition tests to client-side valid…
thepastaclaw Feb 21, 2026
34300af
style(drive-abci): apply cargo fmt
thepastaclaw Feb 21, 2026
7afae73
fix(tests): adapt strategy tests for client-side validation
thepastaclaw Feb 21, 2026
d5ee6aa
style: apply cargo fmt to strategy.rs
thepastaclaw Feb 21, 2026
ecc09ee
fix(tests): finish rebased validation helpers
thepastaclaw Apr 26, 2026
e0c901d
fix(sdk): address validation review follow-ups
thepastaclaw Apr 28, 2026
49628bc
fix(tests): align address balance tracking
thepastaclaw May 10, 2026
fa5e31b
fix(sdk): validate signed identity transitions client-side
thepastaclaw May 10, 2026
27d3473
fix(sdk): address validation review edge cases
thepastaclaw May 12, 2026
cec64fb
fix(sdk): address remaining validation review follow-ups
thepastaclaw May 12, 2026
b8ac508
fix(sdk): align client validation review follow-ups
thepastaclaw May 13, 2026
7ea360b
fix(sdk): address validation review follow-ups
thepastaclaw May 13, 2026
d1c8ba0
fix(sdk): surface first consensus validation message
thepastaclaw May 14, 2026
0fc4ce4
test(dpp): cover bad identity key PoP signatures
thepastaclaw May 14, 2026
04c9f42
fix(sdk): address validation review blockers
thepastaclaw Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/rs-dpp/src/errors/protocol_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ pub enum ProtocolError {
/// requested core height
received: FeatureVersion,
},

/// The method is not active at the current DPP version (the version field
/// is `None`). Mirrors drive-abci's `ExecutionError::VersionNotActive` so
/// the SDK can express the same condition without relying on an
/// `UnknownVersionMismatch` sentinel value.
#[error("{method} not active for dpp version")]
VersionNotActive {
/// method
method: String,
/// the versions of this method that exist (even if currently inactive)
known_versions: Vec<FeatureVersion>,
},
#[error("current platform version not initialized")]
CurrentProtocolVersionNotInitialized,
#[error("unknown version error {0}")]
Expand Down Expand Up @@ -131,6 +143,9 @@ pub enum ProtocolError {
#[error(transparent)]
ConsensusError(Box<ConsensusError>),

#[error("Multiple consensus errors: {0:?}")]
ConsensusErrors(Vec<ConsensusError>),
Comment on lines +146 to +147

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 Display uses Debug formatting for its payload

#[error("Multiple consensus errors: {0:?}")] formats the inner Vec<ConsensusError> with Rust's Debug, producing output like Multiple consensus errors: [BasicError(...), ...] with internal enum syntax. Because the wasm-sdk From<ProtocolError> boundary stringifies via to_string() (see related finding), this Debug output is exactly what JS consumers see in WasmSdkError.message, and Rust-internal logs are also less useful than the per-error Display impls would produce. Prefer iterating and joining each error's Display: "Multiple consensus errors: [{}]" with a manually-joined {} list.

source: ['claude']


#[error(transparent)]
Document(Box<DocumentError>),

Expand Down Expand Up @@ -344,6 +359,21 @@ impl From<ConsensusError> for ProtocolError {
}
}

impl From<Vec<ConsensusError>> for ProtocolError {
fn from(mut errors: Vec<ConsensusError>) -> Self {
debug_assert!(
!errors.is_empty(),
"ProtocolError::from(Vec<ConsensusError>) expects a non-empty error list"
);

match errors.len() {
0 => ProtocolError::ConsensusErrors(errors),
1 => ProtocolError::ConsensusError(Box::new(errors.remove(0))),
_ => ProtocolError::ConsensusErrors(errors),
}
}
Comment on lines +362 to +374

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<Vec<ConsensusError>> enforces its non-empty contract only in debug builds

The pub From<Vec<ConsensusError>> impl uses debug_assert! for the non-empty invariant and then matches 0 => ProtocolError::ConsensusErrors(errors) on line 370. In release builds an accidentally-empty vector silently yields ProtocolError::ConsensusErrors(vec![]), whose Display renders as "Multiple consensus errors: []". The PR's new consensus_errors_as_protocol_error helper guards current call sites correctly, but the pub impl remains a foot-gun any future .into() bypasses. Either replace the 0-arm with unreachable! (fail-fast in release), or downgrade the impl to pub(crate) so the type system enforces what debug_assert! only enforces in debug builds.

Suggested change
impl From<Vec<ConsensusError>> for ProtocolError {
fn from(mut errors: Vec<ConsensusError>) -> Self {
debug_assert!(
!errors.is_empty(),
"ProtocolError::from(Vec<ConsensusError>) expects a non-empty error list"
);
match errors.len() {
0 => ProtocolError::ConsensusErrors(errors),
1 => ProtocolError::ConsensusError(Box::new(errors.remove(0))),
_ => ProtocolError::ConsensusErrors(errors),
}
}
fn from(mut errors: Vec<ConsensusError>) -> Self {
debug_assert!(
!errors.is_empty(),
"ProtocolError::from(Vec<ConsensusError>) expects a non-empty error list"
);
match errors.len() {
0 => unreachable!(
"ProtocolError::from(Vec<ConsensusError>) expects a non-empty error list — use consensus_errors_as_protocol_error to guard"
),
1 => ProtocolError::ConsensusError(Box::new(errors.remove(0))),
_ => ProtocolError::ConsensusErrors(errors),
}
}

source: ['claude']

}
Comment on lines +362 to +375

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<Vec<ConsensusError>> silently converts empty input to a Generic string

From<Vec<ConsensusError>> returns ProtocolError::Generic("empty consensus error list") for an empty vec. The only in-tree caller short-circuits before reaching this branch, so it's unreachable today, but as an impl From<Vec<ConsensusError>> it invites future misuse where an empty validation result silently degrades to a 'Generic' error string with no structured context. Consider returning Option<ProtocolError> from the conversion (or scoping the impl to pub(crate) if not needed by external crates) so the contract is explicit.

Separately, ProtocolError is not annotated #[non_exhaustive], so adding ConsensusErrors is technically a SemVer-minor break for any out-of-tree exhaustive match (e.g. wasm-dpp shims, downstream SDKs). Worth confirming no external consumer pattern-matches the enum exhaustively.

source: ['claude']

Comment on lines +362 to +375

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<Vec<ConsensusError>> silently fabricates a stringly-typed error on empty input

From<Vec<ConsensusError>> for ProtocolError maps an empty vec to ProtocolError::Generic("empty consensus error list".to_string()). The intended caller guards against this, but the From impl is public and any future misuse will produce an opaque message with no programmer-error signal. Prefer debug_assert!(!errors.is_empty()) + unreachable!(), or make the conversion pub(crate) so the surface is fenced in.

source: ['claude']

Comment on lines +362 to +375

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: From<Vec<ConsensusError>> for ProtocolError empty-vec arm produces misleading Generic error

The new impl maps errors.len() == 0 to ProtocolError::Generic("empty consensus error list".to_string()). The only intended caller (consensus_errors_as_protocol_error) gates on non-empty, but From is implicitly reachable anywhere a Vec<ConsensusError> is .into()'d into a ProtocolError. Future paths that accidentally hand it an empty vec will surface as an opaque Generic error (no ConsensusError arm in wasm-sdk lights up, JS consumers see a sentinel string) instead of a successful no-op.

The same hazard exists in the JS-facing wrappers: wasm_sdk::error::protocol_with_consensus_errors emits "0 consensus errors" with details.errors=[], and wasm_dpp::errors::protocol_error::consensus_errors_to_js_error emits ConsensusErrors with errors: [] and message "Multiple consensus errors". All three would benefit from an explicit non-empty contract — either TryFrom returning a Result, a debug_assert!, or a dedicated EmptyConsensusErrorList variant.

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 344-352: `From<Vec<ConsensusError>> for ProtocolError` empty-vec arm produces misleading `Generic` error
  The new impl maps `errors.len() == 0` to `ProtocolError::Generic("empty consensus error list".to_string())`. The only intended caller (`consensus_errors_as_protocol_error`) gates on non-empty, but `From` is implicitly reachable anywhere a `Vec<ConsensusError>` is `.into()`'d into a `ProtocolError`. Future paths that accidentally hand it an empty vec will surface as an opaque `Generic` error (no `ConsensusError` arm in `wasm-sdk` lights up, JS consumers see a sentinel string) instead of a successful no-op.

The same hazard exists in the JS-facing wrappers: `wasm_sdk::error::protocol_with_consensus_errors` emits `"0 consensus errors"` with `details.errors=[]`, and `wasm_dpp::errors::protocol_error::consensus_errors_to_js_error` emits `ConsensusErrors` with `errors: []` and message `"Multiple consensus errors"`. All three would benefit from an explicit non-empty contract — either `TryFrom` returning a `Result`, a `debug_assert!`, or a dedicated `EmptyConsensusErrorList` variant.


impl From<DocumentError> for ProtocolError {
fn from(e: DocumentError) -> Self {
ProtocolError::Document(Box::new(e))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ use dashcore::transaction::special_transaction::TransactionPayload;
use dashcore::{InstantLock, OutPoint, Transaction, TxIn, TxOut};
use platform_value::{BinaryData, Value};

#[cfg(feature = "validation")]
#[cfg(any(feature = "validation", feature = "state-transition-validation"))]
use crate::identity::state_transition::asset_lock_proof::instant::methods;
#[cfg(feature = "validation")]
#[cfg(any(feature = "validation", feature = "state-transition-validation"))]
use platform_version::version::PlatformVersion;
use serde::de::Error as DeError;
use serde::ser::Error as SerError;
Expand All @@ -23,7 +23,7 @@ use crate::prelude::Identifier;
#[cfg(feature = "cbor")]
use crate::util::cbor_value::CborCanonicalMap;
use crate::util::hash::hash_double;
#[cfg(feature = "validation")]
#[cfg(any(feature = "validation", feature = "state-transition-validation"))]
use crate::validation::SimpleConsensusValidationResult;
use crate::ProtocolError;

Expand Down Expand Up @@ -176,7 +176,7 @@ impl InstantAssetLockProof {
}

/// Validate Instant Asset Lock Proof structure
#[cfg(feature = "validation")]
#[cfg(any(feature = "validation", feature = "state-transition-validation"))]
pub fn validate_structure(
&self,
platform_version: &PlatformVersion,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ use bincode::{Decode, Encode};

pub use instant::*;
use platform_value::Value;
#[cfg(feature = "validation")]
#[cfg(any(feature = "validation", feature = "state-transition-validation"))]
use platform_version::version::PlatformVersion;
use serde::de::Error;

use crate::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof;
use crate::prelude::Identifier;
#[cfg(feature = "validation")]
#[cfg(any(feature = "validation", feature = "state-transition-validation"))]
use crate::validation::SimpleConsensusValidationResult;
use crate::{ProtocolError, SerdeParsingError};

Expand Down Expand Up @@ -176,7 +176,7 @@ impl AssetLockProof {
}

/// Validate the structure of the asset lock proof
#[cfg(feature = "validation")]
#[cfg(any(feature = "validation", feature = "state-transition-validation"))]
pub fn validate_structure(
&self,
platform_version: &PlatformVersion,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,81 @@ pub struct AddressCreditWithdrawalTransitionV0 {
#[platform_signable(exclude_from_sig_hash)]
pub input_witnesses: Vec<AddressWitness>,
}

#[cfg(all(test, feature = "state-transition-signing"))]
mod signing_tests {
use super::*;
use crate::address_funds::AddressFundsFeeStrategyStep;
use crate::consensus::basic::BasicError;
use crate::consensus::ConsensusError;
use crate::identity::signer::Signer;
use crate::state_transition::address_credit_withdrawal_transition::methods::AddressCreditWithdrawalTransitionMethodsV0;
use async_trait::async_trait;
use platform_value::BinaryData;
use platform_version::version::PlatformVersion;

#[derive(Debug)]
struct UnreachableAddressSigner;

#[async_trait]
impl Signer<PlatformAddress> for UnreachableAddressSigner {
async fn sign(
&self,
_key: &PlatformAddress,
_data: &[u8],
) -> Result<BinaryData, ProtocolError> {
panic!("sign should not run when protocol gating rejects the constructor")
}

async fn sign_create_witness(
&self,
_key: &PlatformAddress,
_data: &[u8],
) -> Result<AddressWitness, ProtocolError> {
panic!(
"sign_create_witness should not run when protocol gating rejects the constructor"
)
}

fn can_sign_with(&self, _key: &PlatformAddress) -> bool {
false
}
}

#[tokio::test]
async fn constructor_returns_not_active_before_structure_validation() {
let mut low_version = PlatformVersion::get(1)
.expect("platform version 1 exists")
.clone();
low_version
.drive_abci
.validation_and_processing
.state_transitions
.address_credit_withdrawal
.basic_structure = None;
let mut inputs = BTreeMap::new();
inputs.insert(PlatformAddress::P2pkh([1u8; 20]), (1, 1));

let result = AddressCreditWithdrawalTransitionV0::try_from_inputs_with_signer(
inputs,
None,
vec![AddressFundsFeeStrategyStep::DeductFromInput(99)],
1,
crate::withdrawal::Pooling::Never,
CoreScript::default(),
&UnreachableAddressSigner,
0,
&low_version,
)
.await;

assert!(matches!(
result,
Err(ProtocolError::ConsensusError(boxed))
if matches!(
*boxed,
ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(_))
)
));
}
Comment on lines +94 to +129

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: not_active_before_structure_validation tests assert an unreachable error path

The three new tests constructor_returns_not_active_before_structure_validation (this file and address_funding_from_asset_lock_transition/v0/mod.rs) and try_from_identity_returns_not_active_before_structure_validation (identity_credit_transfer_to_addresses_transition/v0/mod.rs) all call PlatformVersion::get(1) and assert ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(_)). Verified against packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs (and v2–v8): address_credit_withdrawal.basic_structure, address_funds_from_asset_lock.basic_structure, and identity_credit_transfer_to_addresses_state_transition.basic_structure are all Some(0). With Some(0), address_funds_constructor_dispatch_error returns None (see state_transition_structure_validation.rs:104-105), so the constructor proceeds to validate_structure_without_input_witnesses, which then fails with a different BasicError (e.g. FeeStrategyIndexOutOfBoundsError for the DeductFromInput(99) setup, OutputBelowMinimumError for the (addr, 1) to-addresses test). These tests are also gated by #[cfg(all(test, feature = "state-transition-signing"))] while rs-dpp's default features are only ["state-transitions"], so they may be silently skipped in default test runs. Either point them at a PlatformVersion where the dispatched field is actually None, or change the assertion to match the error the code actually produces.

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/address_funds/address_credit_withdrawal_transition/v0/mod.rs`:
- [BLOCKING] lines 82-109: `not_active_before_structure_validation` tests assert an unreachable error path
  The three new tests `constructor_returns_not_active_before_structure_validation` (this file and `address_funding_from_asset_lock_transition/v0/mod.rs`) and `try_from_identity_returns_not_active_before_structure_validation` (`identity_credit_transfer_to_addresses_transition/v0/mod.rs`) all call `PlatformVersion::get(1)` and assert `ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(_))`. Verified against `packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs` (and v2–v8): `address_credit_withdrawal.basic_structure`, `address_funds_from_asset_lock.basic_structure`, and `identity_credit_transfer_to_addresses_state_transition.basic_structure` are all `Some(0)`. With `Some(0)`, `address_funds_constructor_dispatch_error` returns `None` (see `state_transition_structure_validation.rs:104-105`), so the constructor proceeds to `validate_structure_without_input_witnesses`, which then fails with a different `BasicError` (e.g. `FeeStrategyIndexOutOfBoundsError` for the `DeductFromInput(99)` setup, `OutputBelowMinimumError` for the `(addr, 1)` to-addresses test). These tests are also gated by `#[cfg(all(test, feature = "state-transition-signing"))]` while rs-dpp's default features are only `["state-transitions"]`, so they may be silently skipped in default test runs. Either point them at a `PlatformVersion` where the dispatched field is actually `None`, or change the assertion to match the error the code actually produces.

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ use crate::withdrawal::Pooling;
use platform_version::version::PlatformVersion;
use std::collections::HashSet;

impl StateTransitionStructureValidation for AddressCreditWithdrawalTransitionV0 {
fn validate_structure(
impl AddressCreditWithdrawalTransitionV0 {
/// Validates all structural properties of the transition except for the
/// `input_witnesses` count. This is intended for client-side pre-signing
/// validation, where witnesses are not yet present.
pub fn validate_structure_without_input_witnesses(
&self,
platform_version: &PlatformVersion,
) -> SimpleConsensusValidationResult {
Expand All @@ -44,17 +47,6 @@ impl StateTransitionStructureValidation for AddressCreditWithdrawalTransitionV0
);
}

// Validate input witnesses count matches inputs count
if self.inputs.len() != self.input_witnesses.len() {
return SimpleConsensusValidationResult::new_with_error(
BasicError::InputWitnessCountMismatchError(InputWitnessCountMismatchError::new(
self.inputs.len().min(u16::MAX as usize) as u16,
self.input_witnesses.len().min(u16::MAX as usize) as u16,
))
.into(),
);
}

// Validate output address is not also an input address
if let Some((output_address, _)) = &self.output {
if self.inputs.contains_key(output_address) {
Expand Down Expand Up @@ -202,15 +194,14 @@ impl StateTransitionStructureValidation for AddressCreditWithdrawalTransitionV0
.inputs
.values()
.try_fold(0u64, |acc, (_, amount)| acc.checked_add(*amount));
if input_sum.is_none() {
let Some(input_sum) = input_sum else {
return SimpleConsensusValidationResult::new_with_error(
BasicError::OverflowError(OverflowError::new("Input sum overflow".to_string()))
.into(),
);
}
};

// Validate that input_sum > output_amount (withdrawal amount must be positive)
let input_sum = input_sum.unwrap(); // Safe: checked above
let output_amount = self.output.as_ref().map_or(0, |(_, amount)| *amount);
if input_sum <= output_amount {
return SimpleConsensusValidationResult::new_with_error(
Expand Down Expand Up @@ -240,6 +231,35 @@ impl StateTransitionStructureValidation for AddressCreditWithdrawalTransitionV0

SimpleConsensusValidationResult::new()
}

/// Validates that the number of `input_witnesses` matches the number of
/// inputs. Intended to be invoked after signing, once witnesses have been
/// produced by the signer.
pub fn validate_input_witnesses_count(&self) -> SimpleConsensusValidationResult {
if self.inputs.len() != self.input_witnesses.len() {
return SimpleConsensusValidationResult::new_with_error(
BasicError::InputWitnessCountMismatchError(InputWitnessCountMismatchError::new(
self.inputs.len().min(u16::MAX as usize) as u16,
self.input_witnesses.len().min(u16::MAX as usize) as u16,
))
.into(),
);
}
SimpleConsensusValidationResult::new()
}
}

impl StateTransitionStructureValidation for AddressCreditWithdrawalTransitionV0 {
Comment on lines +235 to +252

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: validate_input_witnesses_count body duplicated verbatim across five transitions

The same body (with the same min(u16::MAX as usize) as u16 casts and InputWitnessCountMismatchError construction) appears in address_credit_withdrawal_transition, address_funding_from_asset_lock_transition, address_funds_transfer_transition, identity_create_from_addresses_transition, and identity_topup_from_addresses_transition. A small free helper, e.g. fn validate_witness_count(inputs_len: usize, witnesses_len: usize) -> SimpleConsensusValidationResult, would centralize the cast logic and prevent drift if the error is later extended.

source: ['claude']

fn validate_structure(
&self,
platform_version: &PlatformVersion,
) -> SimpleConsensusValidationResult {
let result = self.validate_structure_without_input_witnesses(platform_version);
if !result.is_valid() {
return result;
}
self.validate_input_witnesses_count()
}
}

#[cfg(test)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ use crate::serialization::Signable;
use crate::state_transition::address_credit_withdrawal_transition::methods::AddressCreditWithdrawalTransitionMethodsV0;
use crate::state_transition::address_credit_withdrawal_transition::v0::AddressCreditWithdrawalTransitionV0;
#[cfg(feature = "state-transition-signing")]
use crate::state_transition::{
address_funds_constructor_dispatch_error, consensus_errors_as_protocol_error,
verify_address_witnesses, StateTransitionType,
};
#[cfg(feature = "state-transition-signing")]
use crate::withdrawal::Pooling;
#[cfg(feature = "state-transition-signing")]
use crate::{
Expand All @@ -35,7 +40,7 @@ impl AddressCreditWithdrawalTransitionMethodsV0 for AddressCreditWithdrawalTrans
output_script: CoreScript,
signer: &S,
user_fee_increase: UserFeeIncrease,
_platform_version: &PlatformVersion,
platform_version: &PlatformVersion,
) -> Result<StateTransition, ProtocolError> {
tracing::debug!("try_from_inputs_with_signer: Started");
tracing::debug!(
Expand All @@ -47,7 +52,7 @@ impl AddressCreditWithdrawalTransitionMethodsV0 for AddressCreditWithdrawalTrans

// Create the unsigned transition
let mut address_credit_withdrawal_transition = AddressCreditWithdrawalTransitionV0 {
inputs: inputs.clone(),
inputs,
output,
fee_strategy,
core_fee_per_byte,
Expand All @@ -57,16 +62,52 @@ impl AddressCreditWithdrawalTransitionMethodsV0 for AddressCreditWithdrawalTrans
input_witnesses: Vec::new(),
};
Comment on lines 62 to 63

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: Unnecessary BTreeMap clone of inputs in address-based constructors

inputs is taken by value, but the constructor clones the entire BTreeMap to populate the struct (line 52) and then iterates the original at line 76 for inputs.keys() / inputs.len(). The clone walks every node and allocates fresh BTree storage on every transition build. Moving inputs into the struct and reading from address_credit_withdrawal_transition.inputs.keys() (and .len() for the witness Vec::with_capacity) eliminates the clone. The same pattern recurs in address_funds_transfer_transition/v0/v0_methods.rs:43, address_funding_from_asset_lock_transition/v0/v0_methods.rs:45, identity_topup_from_addresses_transition/v0/v0_methods.rs:39, and identity_create_from_addresses_transition/v0/v0_methods.rs:60 (which also keeps the original inputs around to iterate again for witnesses at line 141).

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/address_funds/address_credit_withdrawal_transition/v0/v0_methods.rs`:
- [SUGGESTION] lines 50-60: Unnecessary BTreeMap clone of `inputs` in address-based constructors
  `inputs` is taken by value, but the constructor clones the entire `BTreeMap` to populate the struct (line 52) and then iterates the original at line 76 for `inputs.keys()` / `inputs.len()`. The clone walks every node and allocates fresh BTree storage on every transition build. Moving `inputs` into the struct and reading from `address_credit_withdrawal_transition.inputs.keys()` (and `.len()` for the witness `Vec::with_capacity`) eliminates the clone. The same pattern recurs in `address_funds_transfer_transition/v0/v0_methods.rs:43`, `address_funding_from_asset_lock_transition/v0/v0_methods.rs:45`, `identity_topup_from_addresses_transition/v0/v0_methods.rs:39`, and `identity_create_from_addresses_transition/v0/v0_methods.rs:60` (which also keeps the original `inputs` around to iterate again for witnesses at line 141).


if let Some(error) = address_funds_constructor_dispatch_error(
StateTransitionType::AddressCreditWithdrawal,
platform_version,
) {
return Err(error);
}

// Pre-signing structure check: validate everything except the witness
// count, so structural errors fail fast before performing any async
// signer work.
//
// LOCKSTEP: this call is hard-coded to the v0 basic-structure check.
// If a future v1 basic-structure is introduced for this transition,
// both the drive-abci server dispatcher AND this SDK constructor must
// be updated together (e.g. by routing through a versioned
// `validate_basic_structure` wrapper as IdentityUpdate does).
let pre_validation_result = address_credit_withdrawal_transition
.validate_structure_without_input_witnesses(platform_version);
if let Some(error) = consensus_errors_as_protocol_error(pre_validation_result) {
return Err(error);
}
Comment on lines 63 to +85

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: Address-based constructors call validate_structure_without_input_witnesses unversioned, while IdentityUpdate now dispatches through PlatformVersion

IdentityUpdateTransitionV0::try_from_identity_with_signer was updated in this PR to go through validate_basic_structure(platform_version), which reads platform_version.drive_abci.validation_and_processing.state_transitions.identity_update_state_transition.basic_structure and only then dispatches to validate_basic_structure_v0. The address-based and credit-transfer constructors call validate_structure_without_input_witnesses directly — an unversioned, v0-hardcoded path. If any of these transitions later gains a v1 basic-structure check (relaxed limits, new error variants, etc.), the server dispatcher will move to v1 while every SDK constructor stays on v0, producing transitions the client thinks are valid that the network rejects — or vice versa. Either mirror the IdentityUpdate pattern across the other transitions, or add a comment at each call site noting that adding v1 must update both the server dispatcher and every SDK constructor in lockstep.

source: ['claude']

Comment on lines +76 to +85

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: Six SDK constructors hard-code the v0 basic-structure check, guarded only by a single test

IdentityUpdateTransitionV0 was migrated to dispatch through a validate_basic_structure wrapper backed by a DPP-owned OptionalFeatureVersion. The other constructors touched by this PR (AddressCreditWithdrawalTransitionV0, AddressFundsTransferTransitionV0, AddressFundingFromAssetLockTransitionV0, IdentityCreateFromAddressesTransitionV0, IdentityCreditTransferToAddressesTransitionV0, IdentityTopUpFromAddressesTransitionV0, plus IdentityCreateTransitionV0 and IdentityCreditWithdrawalTransitionV1) call their versioned methods directly with only LOCKSTEP comments and the sdk_constructors_hardcoded_v0_dispatch_still_matches_latest_platform_version test as a guardrail. The wrapper pattern used by IdentityUpdate makes drift structurally impossible (one match on a field both client and server read) rather than test-enforced. Not blocking, but consider migrating these constructors to the same pattern so version dispatch is structural.

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/address_funds/address_credit_withdrawal_transition/v0/v0_methods.rs`:
- [SUGGESTION] lines 66-75: Six SDK constructors hard-code the v0 basic-structure check, guarded only by a single test
  `IdentityUpdateTransitionV0` was migrated to dispatch through a `validate_basic_structure` wrapper backed by a DPP-owned `OptionalFeatureVersion`. The other constructors touched by this PR (`AddressCreditWithdrawalTransitionV0`, `AddressFundsTransferTransitionV0`, `AddressFundingFromAssetLockTransitionV0`, `IdentityCreateFromAddressesTransitionV0`, `IdentityCreditTransferToAddressesTransitionV0`, `IdentityTopUpFromAddressesTransitionV0`, plus `IdentityCreateTransitionV0` and `IdentityCreditWithdrawalTransitionV1`) call their versioned methods directly with only `LOCKSTEP` comments and the `sdk_constructors_hardcoded_v0_dispatch_still_matches_latest_platform_version` test as a guardrail. The wrapper pattern used by `IdentityUpdate` makes drift structurally impossible (one `match` on a field both client and server read) rather than test-enforced. Not blocking, but consider migrating these constructors to the same pattern so version dispatch is structural.


let state_transition: StateTransition = address_credit_withdrawal_transition.clone().into();

let signable_bytes = state_transition.signable_bytes()?;

let mut input_witnesses: Vec<AddressWitness> = Vec::with_capacity(inputs.len());
for address in inputs.keys() {
let mut input_witnesses: Vec<AddressWitness> =
Vec::with_capacity(address_credit_withdrawal_transition.inputs.len());
for address in address_credit_withdrawal_transition.inputs.keys() {
input_witnesses.push(signer.sign_create_witness(address, &signable_bytes).await?);
}
verify_address_witnesses(
address_credit_withdrawal_transition.inputs.keys(),
&input_witnesses,
&signable_bytes,
)?;
address_credit_withdrawal_transition.input_witnesses = input_witnesses;

// After signing, only the witness count needs (re-)validation; the rest
// of the structure was already verified above.
let validation_result =
address_credit_withdrawal_transition.validate_input_witnesses_count();
if let Some(error) = consensus_errors_as_protocol_error(validation_result) {
return Err(error);
}
Comment on lines 95 to +109

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: Structure validation runs after async signing — invalid transitions still incur N signer round-trips

validate_structure() is called only after the loop that awaits signer.sign_create_witness(...) for every input. None of the structural checks (max inputs, fee strategy, output script, input/output amounts) actually depend on the witnesses; only the witness-count check does, and it is tautologically satisfied here because the loop produces exactly inputs.len() witnesses. The same pattern exists in address_funds_transfer_transition/v0/v0_methods.rs, address_funding_from_asset_lock_transition/v0/v0_methods.rs, identity_create_from_addresses_transition/v0/v0_methods.rs, and identity_topup_from_addresses_transition/v0/v0_methods.rs. When Signer is backed by a hardware wallet, secure enclave, or remote KMS — the SDK's primary use case — every input requires a user prompt or network round-trip. The whole point of this PR is to fail fast client-side before broadcast; failing fast after N HSM confirmations defeats much of the benefit. Add a pre-signing structure check (skipping only the witness-count assertion) in addition to the existing post-signing call.

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/address_funds/address_credit_withdrawal_transition/v0/v0_methods.rs`:
- [SUGGESTION] lines 66-78: Structure validation runs after async signing — invalid transitions still incur N signer round-trips
  `validate_structure()` is called only after the loop that awaits `signer.sign_create_witness(...)` for every input. None of the structural checks (max inputs, fee strategy, output script, input/output amounts) actually depend on the witnesses; only the witness-count check does, and it is tautologically satisfied here because the loop produces exactly `inputs.len()` witnesses. The same pattern exists in `address_funds_transfer_transition/v0/v0_methods.rs`, `address_funding_from_asset_lock_transition/v0/v0_methods.rs`, `identity_create_from_addresses_transition/v0/v0_methods.rs`, and `identity_topup_from_addresses_transition/v0/v0_methods.rs`. When `Signer` is backed by a hardware wallet, secure enclave, or remote KMS — the SDK's primary use case — every input requires a user prompt or network round-trip. The whole point of this PR is to fail fast client-side before broadcast; failing fast *after* N HSM confirmations defeats much of the benefit. Add a pre-signing structure check (skipping only the witness-count assertion) in addition to the existing post-signing call.

Comment on lines +105 to +109

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: errors.into_iter().next().unwrap() pattern duplicated across 7+ sites and discards all but the first error

The same six-line block — call validate_structure/validate_identity_public_keys_structure, check is_valid(), then validation_result.errors.into_iter().next().unwrap() and wrap in ProtocolError::ConsensusError(Box::new(...)) — appears in seven places across this PR (address_credit_withdrawal, address_funding_from_asset_lock, address_funds_transfer, identity_create_from_addresses (×2), identity_credit_transfer_to_addresses, identity_topup_from_addresses, identity_update). Two issues: (1) The unwrap() is technically safe today because ValidationResult::is_valid() is defined as errors.is_empty(), but it relies on a non-local invariant; a future refactor that lets a result be "invalid with no errors" would silently introduce a panic. (2) Discarding every error after the first is user-hostile — a transition with two structural problems requires two round-trips to discover both. A small pub(crate) fn first_error_as_protocol_error(result: SimpleConsensusValidationResult) -> Option<ProtocolError> (or a variant returning all errors) in state_transition/mod.rs would deduplicate the boilerplate and remove the unwrap.

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/address_funds/address_credit_withdrawal_transition/v0/v0_methods.rs`:
- [SUGGESTION] lines 73-78: `errors.into_iter().next().unwrap()` pattern duplicated across 7+ sites and discards all but the first error
  The same six-line block — call `validate_structure`/`validate_identity_public_keys_structure`, check `is_valid()`, then `validation_result.errors.into_iter().next().unwrap()` and wrap in `ProtocolError::ConsensusError(Box::new(...))` — appears in seven places across this PR (`address_credit_withdrawal`, `address_funding_from_asset_lock`, `address_funds_transfer`, `identity_create_from_addresses` (×2), `identity_credit_transfer_to_addresses`, `identity_topup_from_addresses`, `identity_update`). Two issues: (1) The `unwrap()` is technically safe today because `ValidationResult::is_valid()` is defined as `errors.is_empty()`, but it relies on a non-local invariant; a future refactor that lets a result be "invalid with no errors" would silently introduce a panic. (2) Discarding every error after the first is user-hostile — a transition with two structural problems requires two round-trips to discover both. A small `pub(crate) fn first_error_as_protocol_error(result: SimpleConsensusValidationResult) -> Option<ProtocolError>` (or a variant returning all errors) in `state_transition/mod.rs` would deduplicate the boilerplate and remove the unwrap.

Comment on lines +91 to +109

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: Post-signing validate_input_witnesses_count is unreachable on the success path

Vec::with_capacity(inputs.len()) is followed by a for address in inputs.keys() { input_witnesses.push(signer.sign_create_witness(...).await?) } loop, which on success pushes exactly inputs.len() witnesses and any signer error short-circuits via ? before the count check. The subsequent validate_input_witnesses_count therefore can never fire on the constructor's happy path. The same shape recurs in address_funds_transfer_transition/v0/v0_methods.rs, address_funding_from_asset_lock_transition/v0/v0_methods.rs, identity_topup_from_addresses_transition/v0/v0_methods.rs, and identity_create_from_addresses_transition/v0/v0_methods.rs. If the intent is a defensive invariant, a debug_assert_eq!(inputs.len(), input_witnesses.len()) communicates that more clearly than a runtime structural check returning a ProtocolError.

source: ['claude']

Comment on lines +91 to +109

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: Post-signing validate_input_witnesses_count is unreachable on the success path

input_witnesses is built with Vec::with_capacity(inputs.len()) and populated with one push per input key; the ? on the signer call short-circuits on error. On success the count check can never fire. The same pattern recurs in address_funds_transfer_transition, address_funding_from_asset_lock_transition, identity_topup_from_addresses_transition, and identity_create_from_addresses_transition. If the intent is a defensive invariant against future refactors, a debug_assert_eq!(inputs.len(), input_witnesses.len()) communicates that more clearly than a runtime ProtocolError-returning check.

source: ['claude-rust-quality']


Comment on lines 95 to +110

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: Post-signing validate_input_witnesses_count is unreachable on the success path

The signing loop is for address in inputs.keys() { input_witnesses.push(signer.sign_create_witness(...).await?) }, which on success pushes exactly inputs.len() witnesses; any signer error short-circuits earlier via ?. The subsequent validate_input_witnesses_count therefore always passes. Same redundancy in address_funds_transfer_transition, address_funding_from_asset_lock_transition, identity_topup_from_addresses_transition, and identity_create_from_addresses_transition. If the goal is to assert the invariant defensively, a debug_assert_eq!(inputs.len(), input_witnesses.len()) would communicate intent more clearly than a runtime structural check that surfaces a ProtocolError.

source: ['claude']

tracing::debug!("try_from_inputs_with_signer: Successfully created transition");
Ok(address_credit_withdrawal_transition.into())
}
Expand Down
Loading
Loading