Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5cdc306
fix(drive-abci): bump nonce on failed batch document transitions
shumkov May 7, 2026
2511248
fix(drive-abci): close paid-with-empty-action gap in batch transformer
shumkov May 7, 2026
46cc656
refactor(dpp,drive-abci): rename strict aggregators to canonical; leg…
shumkov May 7, 2026
ce5c375
chore: deprecate _or_empty_vec aggregators, simplify None construction
shumkov May 7, 2026
fdecf17
fix(drive-abci): bump nonce of failed transitions in best-effort batches
shumkov May 7, 2026
4c7aa62
Revert "fix(drive-abci): bump nonce of failed transitions in best-eff…
shumkov May 7, 2026
99cc0ab
refactor(dpp,drive-abci): version flatten/merge_many instead of the b…
shumkov May 7, 2026
9a7d350
refactor(dpp): organise validation_result aggregator versions per cod…
shumkov May 7, 2026
c37622f
test(dpp): co-locate validation_result aggregator tests with their ve…
shumkov May 7, 2026
1d0a02b
refactor(drive-abci): tighten bump-emission framing in batch transfor…
shumkov May 7, 2026
f9d0ee1
style(drive-abci): cargo fmt fetch_documents.rs flatten call
shumkov May 7, 2026
aa47a82
style(drive-abci): cargo fmt for #2867 bump-emission cleanup
shumkov May 7, 2026
be4a538
fix(drive-abci): version-gate per-transition bump emission, add v11 p…
shumkov May 7, 2026
c402712
Merge branch 'v3.1-dev' into fix/issue-2867-validating-state-transiti…
QuantumExplorer May 8, 2026
24437ef
Merge remote-tracking branch 'origin/v3.1-dev' into fix/issue-2867-bu…
shumkov May 8, 2026
583bd6d
test(drive-abci): expect UnpaidConsensusError for single-tx purchase …
shumkov May 8, 2026
d353bc9
Merge remote-tracking branch 'origin/fix/issue-2867-bump-nonce-on-fai…
shumkov May 8, 2026
964df5b
test(drive-abci): pair purchase-failure tests for v11/v12 protocol co…
shumkov May 8, 2026
8ab48a0
fix(drive-abci): preserve v11 bump on Replace's missing-target-docume…
shumkov May 11, 2026
869d257
docs(drive-abci): correct nft.rs helper doc — v11 lands Paid via lega…
shumkov May 11, 2026
50addd7
docs(platform-version): drop stale PR #3608 reference from max_transi…
shumkov May 11, 2026
ed0c66a
docs(drive-abci): explain why batch transformer keeps the _v0 suffix …
shumkov May 11, 2026
7fb8495
docs(dpp): document Some(empty_vec) collapse hazard in flatten_v1
shumkov May 11, 2026
046ccef
test(drive-abci): pin tokens-always-pay invariant for batch transform…
shumkov May 11, 2026
11c87bc
docs(drive-abci): address QE review comments on transformer/v0 doc ac…
shumkov May 11, 2026
8d7cad6
docs(drive-abci): trim Replace inline-bump comment per QE review
shumkov May 12, 2026
fd92ebd
docs(drive-abci): fix mislabeled inline comment in set_price_on_not_o…
shumkov May 12, 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
245 changes: 243 additions & 2 deletions packages/rs-dpp/src/validation/validation_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ impl<T: Clone, E: Debug> Default for ValidationResult<T, E> {
}

impl<TData: Clone, E: Debug> ValidationResult<Vec<TData>, E> {
/// **Deprecated.** Always returns `data: Some(Vec<...>)` — even if no
/// input contributed any data — which violates the implicit contract
/// `data.is_none() ⇔ no work done` that downstream `process_validation_result`
/// keys on. See issue #2867 (the empty-action / "validating state
/// transition for free" bug). Use [`flatten_strict`] instead, which
/// returns `data: None` when no input contributed data.
///
/// Preserved for `PROTOCOL_VERSION_11` and below — changing this
/// function's behavior would be a consensus-breaking change for the
/// existing chain history.
///
/// [`flatten_strict`]: ValidationResult::flatten_strict
#[deprecated(
since = "3.1.0",
note = "use flatten_strict; flatten always returns Some(empty_vec) which violates the data-is-None ⇔ no-work invariant — see issue #2867"
)]
pub fn flatten<I: IntoIterator<Item = ValidationResult<Vec<TData>, E>>>(
items: I,
) -> ValidationResult<Vec<TData>, E> {
Expand All @@ -48,9 +64,58 @@ impl<TData: Clone, E: Debug> ValidationResult<Vec<TData>, E> {
});
ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors)
}

/// Strict variant of [`flatten`]: returns `data: None` when no input
/// contributed any data (i.e. every input was either `data: None` or
/// `data: Some(empty_vec)`), and only returns `data: Some(...)` when
/// the aggregate Vec is non-empty.
///
/// This restores the invariant that `data.is_none() ⇔ no work done`,
/// which downstream code (e.g.
/// `process_validation_result_v0:241`) relies on to choose between
/// `PaidConsensusError` and `UnpaidConsensusError`. Used by
/// `PROTOCOL_VERSION_12`+ to close the issue #2867 "validating state
/// transition for free" gap.
///
/// [`flatten`]: ValidationResult::flatten
pub fn flatten_strict<I: IntoIterator<Item = ValidationResult<Vec<TData>, E>>>(
items: I,
) -> ValidationResult<Vec<TData>, E> {
let mut aggregate_errors = vec![];
let mut aggregate_data = vec![];
items.into_iter().for_each(|single_validation_result| {
let ValidationResult { mut errors, data } = single_validation_result;
aggregate_errors.append(&mut errors);
if let Some(mut data) = data {
aggregate_data.append(&mut data);
}
});
if aggregate_data.is_empty() {
ValidationResult {
errors: aggregate_errors,
data: None,
}
} else {
ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors)
}
}
}

impl<TData: Clone, E: Debug> ValidationResult<TData, E> {
/// **Deprecated.** Always returns `data: Some(Vec<...>)` — even if no
/// input contributed any data — which violates the implicit contract
/// `data.is_none() ⇔ no work done`. See issue #2867. Use
/// [`merge_many_strict`] instead.
///
/// Preserved for `PROTOCOL_VERSION_11` and below — changing this
/// function's behavior would be a consensus-breaking change for the
/// existing chain history.
///
/// [`merge_many_strict`]: ValidationResult::merge_many_strict
#[deprecated(
since = "3.1.0",
note = "use merge_many_strict; merge_many always returns Some(empty_vec) which violates the data-is-None ⇔ no-work invariant — see issue #2867"
)]
pub fn merge_many<I: IntoIterator<Item = ValidationResult<TData, E>>>(
items: I,
) -> ValidationResult<Vec<TData>, E> {
Expand All @@ -65,6 +130,37 @@ impl<TData: Clone, E: Debug> ValidationResult<TData, E> {
});
ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors)
}

/// Strict variant of [`merge_many`]: returns `data: None` when no
/// input had `Some(data)`, and only returns `data: Some(Vec<...>)`
/// when at least one input contributed data.
///
/// This restores the `data.is_none() ⇔ no work done` invariant — see
/// issue #2867. Used by `PROTOCOL_VERSION_12`+ to close the
/// "validating state transition for free" gap.
///
/// [`merge_many`]: ValidationResult::merge_many
pub fn merge_many_strict<I: IntoIterator<Item = ValidationResult<TData, E>>>(
items: I,
) -> ValidationResult<Vec<TData>, E> {
let mut aggregate_errors = vec![];
let mut aggregate_data = vec![];
items.into_iter().for_each(|single_validation_result| {
let ValidationResult { mut errors, data } = single_validation_result;
aggregate_errors.append(&mut errors);
if let Some(data) = data {
aggregate_data.push(data);
}
});
if aggregate_data.is_empty() {
ValidationResult {
errors: aggregate_errors,
data: None,
}
} else {
ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors)
}
}
}

impl<E: Debug> SimpleValidationResult<E> {
Expand Down Expand Up @@ -539,9 +635,12 @@ mod tests {
assert_eq!(result.errors, vec!["bad".to_string()]);
}

// -- flatten() --
// -- flatten() (deprecated) --
// These pin the historical buggy behavior preserved for
// PROTOCOL_VERSION_11 and below — issue #2867.

#[test]
#[allow(deprecated)]
fn test_flatten_merges_data_and_errors() {
let r1: ValidationResult<Vec<i32>, String> = ValidationResult::new_with_data(vec![1, 2]);
let r2: ValidationResult<Vec<i32>, String> =
Expand All @@ -555,16 +654,36 @@ mod tests {
}

#[test]
#[allow(deprecated)]
fn test_flatten_empty_input() {
let flat: ValidationResult<Vec<i32>, String> =
ValidationResult::flatten(std::iter::empty());
// Issue #2867 root cause: flatten produces Some(empty_vec) here,
// not None. Downstream code that checks `data.is_none()` is fooled
// into treating "no data" as "has data".
assert_eq!(flat.data, Some(vec![]));
assert!(flat.errors.is_empty());
}

// -- merge_many() --
#[test]
#[allow(deprecated)]
fn test_flatten_all_inputs_no_data_returns_some_empty() {
// Pins the buggy v11 behavior: all inputs have data:None, but
// flatten still produces data:Some(vec![]).
let r1: ValidationResult<Vec<i32>, String> =
ValidationResult::new_with_error("e1".to_string());
let r2: ValidationResult<Vec<i32>, String> =
ValidationResult::new_with_error("e2".to_string());

let flat = ValidationResult::flatten(vec![r1, r2]);
assert_eq!(flat.data, Some(vec![]));
assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]);
}

// -- merge_many() (deprecated) --

#[test]
#[allow(deprecated)]
fn test_merge_many_collects_data_into_vec() {
let r1: ValidationResult<i32, String> = ValidationResult::new_with_data(1);
let r2: ValidationResult<i32, String> = ValidationResult::new_with_data(2);
Expand All @@ -576,13 +695,135 @@ mod tests {
}

#[test]
#[allow(deprecated)]
fn test_merge_many_empty_input() {
let merged: ValidationResult<Vec<i32>, String> =
ValidationResult::merge_many(std::iter::empty::<ValidationResult<i32, String>>());
// Same buggy shape: Some(empty_vec) instead of None.
assert_eq!(merged.data, Some(vec![]));
assert!(merged.errors.is_empty());
}

#[test]
#[allow(deprecated)]
fn test_merge_many_all_inputs_no_data_returns_some_empty() {
let r1: ValidationResult<i32, String> = ValidationResult::new_with_error("e1".to_string());
let r2: ValidationResult<i32, String> = ValidationResult::new_with_error("e2".to_string());

let merged = ValidationResult::merge_many(vec![r1, r2]);
assert_eq!(merged.data, Some(vec![]));
assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]);
}

// -- flatten_strict() (issue #2867 fix) --
// PROTOCOL_VERSION_12+ uses these. They restore the
// `data.is_none() ⇔ no work done` invariant.

#[test]
fn test_flatten_strict_merges_non_empty_data() {
let r1: ValidationResult<Vec<i32>, String> = ValidationResult::new_with_data(vec![1, 2]);
let r2: ValidationResult<Vec<i32>, String> =
ValidationResult::new_with_data_and_errors(vec![3], vec!["e".to_string()]);
let r3: ValidationResult<Vec<i32>, String> =
ValidationResult::new_with_error("e2".to_string());

let flat = ValidationResult::flatten_strict(vec![r1, r2, r3]);
assert_eq!(flat.data, Some(vec![1, 2, 3]));
assert_eq!(flat.errors, vec!["e".to_string(), "e2".to_string()]);
}

#[test]
fn test_flatten_strict_empty_input_returns_none_data() {
let flat: ValidationResult<Vec<i32>, String> =
ValidationResult::flatten_strict(std::iter::empty());
assert_eq!(flat.data, None);
assert!(flat.errors.is_empty());
}

#[test]
fn test_flatten_strict_all_inputs_no_data_returns_none() {
// The whole point of strict: when no input contributed data,
// return data:None — not Some(empty_vec). Downstream code
// (process_validation_result_v0:241) keys on data.is_none().
let r1: ValidationResult<Vec<i32>, String> =
ValidationResult::new_with_error("e1".to_string());
let r2: ValidationResult<Vec<i32>, String> =
ValidationResult::new_with_error("e2".to_string());

let flat = ValidationResult::flatten_strict(vec![r1, r2]);
assert!(flat.data.is_none());
assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]);
}

#[test]
fn test_flatten_strict_some_empty_some_non_empty_returns_some() {
// Mixed input: one had data:Some(empty_vec), another had
// Some(non_empty). The aggregate is non-empty, so data:Some(...).
let r1: ValidationResult<Vec<i32>, String> = ValidationResult::new_with_data(vec![]);
let r2: ValidationResult<Vec<i32>, String> = ValidationResult::new_with_data(vec![42]);

let flat = ValidationResult::flatten_strict(vec![r1, r2]);
assert_eq!(flat.data, Some(vec![42]));
assert!(flat.errors.is_empty());
}

#[test]
fn test_flatten_strict_all_some_empty_returns_none() {
// All inputs had data:Some(empty_vec). The aggregate Vec is
// empty → data:None per the strict contract.
let r1: ValidationResult<Vec<i32>, String> = ValidationResult::new_with_data(vec![]);
let r2: ValidationResult<Vec<i32>, String> = ValidationResult::new_with_data(vec![]);

let flat = ValidationResult::flatten_strict(vec![r1, r2]);
assert!(flat.data.is_none());
assert!(flat.errors.is_empty());
}

// -- merge_many_strict() (issue #2867 fix) --

#[test]
fn test_merge_many_strict_collects_non_empty_data() {
let r1: ValidationResult<i32, String> = ValidationResult::new_with_data(1);
let r2: ValidationResult<i32, String> = ValidationResult::new_with_data(2);
let r3: ValidationResult<i32, String> = ValidationResult::new_with_error("e".to_string());

let merged = ValidationResult::merge_many_strict(vec![r1, r2, r3]);
assert_eq!(merged.data, Some(vec![1, 2]));
assert_eq!(merged.errors, vec!["e".to_string()]);
}

#[test]
fn test_merge_many_strict_empty_input_returns_none_data() {
let merged: ValidationResult<Vec<i32>, String> = ValidationResult::merge_many_strict(
std::iter::empty::<ValidationResult<i32, String>>(),
);
assert!(merged.data.is_none());
assert!(merged.errors.is_empty());
}

#[test]
fn test_merge_many_strict_all_inputs_no_data_returns_none() {
// The bug-fixing case: all per-transition results returned
// errors-only with no action. Strict aggregator surfaces this
// as data:None so the downstream paid/unpaid switch picks unpaid.
let r1: ValidationResult<i32, String> = ValidationResult::new_with_error("e1".to_string());
let r2: ValidationResult<i32, String> = ValidationResult::new_with_error("e2".to_string());

let merged = ValidationResult::merge_many_strict(vec![r1, r2]);
assert!(merged.data.is_none());
assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]);
}

#[test]
fn test_merge_many_strict_some_data_returns_some() {
let r1: ValidationResult<i32, String> = ValidationResult::new_with_error("e1".to_string());
let r2: ValidationResult<i32, String> = ValidationResult::new_with_data(7);

let merged = ValidationResult::merge_many_strict(vec![r1, r2]);
assert_eq!(merged.data, Some(vec![7]));
assert_eq!(merged.errors, vec!["e1".to_string()]);
}

// -- merge_many_errors() --

#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use crate::rpc::core::CoreRPCLike;
use crate::execution::validation::state_transition::batch::advanced_structure::v0::DocumentsBatchStateTransitionStructureValidationV0;
use crate::execution::validation::state_transition::batch::identity_contract_nonce::v0::DocumentsBatchStateTransitionIdentityContractNonceV0;
use crate::execution::validation::state_transition::batch::state::v0::DocumentsBatchStateTransitionStateValidationV0;
use crate::execution::validation::state_transition::batch::transformer::v1::BatchTransitionActionTransformerV1;
use crate::execution::validation::state_transition::processor::advanced_structure_with_state::StateTransitionStructureKnownInStateValidationV0;
use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0;
use crate::execution::validation::state_transition::processor::identity_nonces::StateTransitionIdentityNonceValidationV0;
Expand Down Expand Up @@ -75,9 +76,10 @@ impl StateTransitionActionTransformer for BatchTransition {
.transform_into_action
{
0 => self.transform_into_action_v0(&platform.into(), block_info, validation_mode, tx),
1 => self.transform_into_action_v1(&platform.into(), block_info, validation_mode, tx),
version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch {
method: "documents batch transition: transform_into_action".to_string(),
known_versions: vec![0],
known_versions: vec![0, 1],
received: version,
})),
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ pub(crate) fn fetch_documents_for_transitions(
})
.collect::<Result<Vec<ConsensusValidationResult<Vec<Document>>>, Error>>()?;

// The deprecated non-strict aggregator is fine here: the only caller
// checks `is_valid()` (errors), not `data.is_some()`, so the
// `Some(empty_vec)` vs `None` distinction is invisible to it. See
// issue #2867 for context on the strict aggregators.
#[allow(deprecated)]
let validation_result = ConsensusValidationResult::flatten(validation_results_of_documents);

Ok(validation_result)
Expand Down
Loading
Loading