-
Notifications
You must be signed in to change notification settings - Fork 55
feat(sdk): add client-side validation to state transition construction methods #3096
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v4.1-dev
Are you sure you want to change the base?
Changes from all commits
075ce04
e280098
d8e6e3f
a16f8f8
34f1d12
5b900d4
ef4d031
34300af
7afae73
d5ee6aa
ecc09ee
e0c901d
49628bc
fa5e31b
27d3473
cec64fb
b8ac508
7ea360b
d1c8ba0
0fc4ce4
04c9f42
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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}")] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -131,6 +143,9 @@ pub enum ProtocolError { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[error(transparent)] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ConsensusError(Box<ConsensusError>), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[error("Multiple consensus errors: {0:?}")] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ConsensusErrors(Vec<ConsensusError>), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[error(transparent)] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Document(Box<DocumentError>), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 Nitpick: The
Suggested change
source: ['claude'] |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+362
to
+375
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 Nitpick:
Separately, source: ['claude']
Comment on lines
+362
to
+375
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 Nitpick:
source: ['claude']
Comment on lines
+362
to
+375
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: The new impl maps The same hazard exists in the JS-facing wrappers: source: ['claude'] 🤖 Fix this with AI agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| impl From<DocumentError> for ProtocolError { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fn from(e: DocumentError) -> Self { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ProtocolError::Document(Box::new(e)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: The three new tests source: ['claude'] 🤖 Fix this with AI agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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) { | ||
|
|
@@ -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( | ||
|
|
@@ -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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 Nitpick: The same body (with the same 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)] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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::{ | ||
|
|
@@ -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!( | ||
|
|
@@ -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, | ||
|
|
@@ -57,16 +62,52 @@ impl AddressCreditWithdrawalTransitionMethodsV0 for AddressCreditWithdrawalTrans | |
| input_witnesses: Vec::new(), | ||
| }; | ||
|
Comment on lines
62
to
63
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Unnecessary BTreeMap clone of
source: ['claude'] 🤖 Fix this with AI agents |
||
|
|
||
| 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 Nitpick: Address-based constructors call
source: ['claude']
Comment on lines
+76
to
+85
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
source: ['claude'] 🤖 Fix this with AI agents |
||
|
|
||
| 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
source: ['claude'] 🤖 Fix this with AI agents
Comment on lines
+105
to
+109
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: The same six-line block — call source: ['claude'] 🤖 Fix this with AI agents
Comment on lines
+91
to
+109
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 Nitpick: Post-signing
source: ['claude']
Comment on lines
+91
to
+109
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
source: ['claude-rust-quality'] |
||
|
|
||
|
Comment on lines
95
to
+110
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 Nitpick: Post-signing The signing loop is source: ['claude'] |
||
| tracing::debug!("try_from_inputs_with_signer: Successfully created transition"); | ||
| Ok(address_credit_withdrawal_transition.into()) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💬 Nitpick:
ConsensusErrorsDisplay usesDebugformatting for its payload#[error("Multiple consensus errors: {0:?}")]formats the innerVec<ConsensusError>with Rust'sDebug, producing output likeMultiple consensus errors: [BasicError(...), ...]with internal enum syntax. Because the wasm-sdkFrom<ProtocolError>boundary stringifies viato_string()(see related finding), this Debug output is exactly what JS consumers see inWasmSdkError.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']