-
Notifications
You must be signed in to change notification settings - Fork 54
fix: paid/unpaid classification for invalid batch transitions #3616
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
Merged
QuantumExplorer
merged 27 commits into
v3.1-dev
from
fix/issue-2867-validating-state-transition-for-free
May 15, 2026
Merged
Changes from all commits
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 2511248
fix(drive-abci): close paid-with-empty-action gap in batch transformer
shumkov 46cc656
refactor(dpp,drive-abci): rename strict aggregators to canonical; leg…
shumkov ce5c375
chore: deprecate _or_empty_vec aggregators, simplify None construction
shumkov fdecf17
fix(drive-abci): bump nonce of failed transitions in best-effort batches
shumkov 4c7aa62
Revert "fix(drive-abci): bump nonce of failed transitions in best-eff…
shumkov 99cc0ab
refactor(dpp,drive-abci): version flatten/merge_many instead of the b…
shumkov 9a7d350
refactor(dpp): organise validation_result aggregator versions per cod…
shumkov c37622f
test(dpp): co-locate validation_result aggregator tests with their ve…
shumkov 1d0a02b
refactor(drive-abci): tighten bump-emission framing in batch transfor…
shumkov f9d0ee1
style(drive-abci): cargo fmt fetch_documents.rs flatten call
shumkov aa47a82
style(drive-abci): cargo fmt for #2867 bump-emission cleanup
shumkov be4a538
fix(drive-abci): version-gate per-transition bump emission, add v11 p…
shumkov c402712
Merge branch 'v3.1-dev' into fix/issue-2867-validating-state-transiti…
QuantumExplorer 24437ef
Merge remote-tracking branch 'origin/v3.1-dev' into fix/issue-2867-bu…
shumkov 583bd6d
test(drive-abci): expect UnpaidConsensusError for single-tx purchase …
shumkov d353bc9
Merge remote-tracking branch 'origin/fix/issue-2867-bump-nonce-on-fai…
shumkov 964df5b
test(drive-abci): pair purchase-failure tests for v11/v12 protocol co…
shumkov 8ab48a0
fix(drive-abci): preserve v11 bump on Replace's missing-target-docume…
shumkov 869d257
docs(drive-abci): correct nft.rs helper doc — v11 lands Paid via lega…
shumkov 50addd7
docs(platform-version): drop stale PR #3608 reference from max_transi…
shumkov ed0c66a
docs(drive-abci): explain why batch transformer keeps the _v0 suffix …
shumkov 7fb8495
docs(dpp): document Some(empty_vec) collapse hazard in flatten_v1
shumkov 046ccef
test(drive-abci): pin tokens-always-pay invariant for batch transform…
shumkov 11c87bc
docs(drive-abci): address QE review comments on transformer/v0 doc ac…
shumkov 8d7cad6
docs(drive-abci): trim Replace inline-bump comment per QE review
shumkov fd92ebd
docs(drive-abci): fix mislabeled inline comment in set_price_on_not_o…
shumkov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
2 changes: 2 additions & 0 deletions
2
packages/rs-dpp/src/validation/validation_result/flatten/mod.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| pub(super) mod v0; | ||
| pub(super) mod v1; |
75 changes: 75 additions & 0 deletions
75
packages/rs-dpp/src/validation/validation_result/flatten/v0/mod.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| //! v0 of [`ConsensusValidationResult::flatten`]. | ||
| //! | ||
| //! Legacy semantics: always returns `data: Some(Vec<...>)`, including | ||
| //! `Some(empty_vec)` when no input contributed any data. | ||
| //! | ||
| //! Preserved for `PROTOCOL_VERSION_11` and below — the | ||
| //! `Some(empty_vec)`-on-no-data behavior is part of the existing chain | ||
| //! history, and changing it would be a consensus-breaking change for | ||
| //! already-finalized blocks. New code should let the facade dispatch to v1. | ||
| //! | ||
| //! See issue #2867 for context. | ||
| //! | ||
| //! [`ConsensusValidationResult::flatten`]: crate::validation::ConsensusValidationResult::flatten | ||
|
|
||
| use crate::validation::ValidationResult; | ||
| use std::fmt::Debug; | ||
|
|
||
| pub(in crate::validation::validation_result) fn flatten_v0<TData, E, I>( | ||
| items: I, | ||
| ) -> ValidationResult<Vec<TData>, E> | ||
| where | ||
| TData: Clone, | ||
| E: Debug, | ||
| I: IntoIterator<Item = 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); | ||
| } | ||
| }); | ||
| ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn merges_data_and_errors() { | ||
| 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 = flatten_v0(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 empty_input_returns_some_empty() { | ||
| // Legacy v11 behavior: Some(empty_vec), not None. | ||
| let flat: ValidationResult<Vec<i32>, String> = | ||
| flatten_v0(std::iter::empty::<ValidationResult<Vec<i32>, String>>()); | ||
| assert_eq!(flat.data, Some(vec![])); | ||
| assert!(flat.errors.is_empty()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn all_inputs_no_data_returns_some_empty() { | ||
| 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 = flatten_v0(vec![r1, r2]); | ||
| assert_eq!(flat.data, Some(vec![])); | ||
| assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); | ||
| } | ||
| } |
121 changes: 121 additions & 0 deletions
121
packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| //! v1 of [`ConsensusValidationResult::flatten`]. | ||
| //! | ||
| //! Canonical semantics: returns `data: None` when no input contributed any | ||
| //! data (i.e. every input was either `data: None` or `data: Some(empty_vec)`), | ||
| //! and `data: Some(merged_vec)` when at least one input contributed | ||
| //! non-empty data. | ||
| //! | ||
| //! This honors the invariant `data.is_none() ⇔ no work done`, which | ||
| //! downstream code (e.g. `process_validation_result_v0:241`) relies on to | ||
| //! choose between `PaidConsensusError` and `UnpaidConsensusError`. | ||
| //! | ||
| //! # Caller-intent ambiguity | ||
| //! | ||
| //! `flatten_v1` keys on `aggregate_data.is_empty()` to decide between | ||
| //! `data: None` and `data: Some(_)`. This collapses two distinct | ||
| //! caller-side intents into the same output: | ||
| //! | ||
| //! * **Truly no work**: every input had `data: None`. | ||
| //! * **Validated but produced no output**: every input had | ||
| //! `data: Some(empty_vec)`. | ||
| //! | ||
| //! v1 cannot distinguish those two cases at the aggregate level — both | ||
| //! end up as `data: None` and are routed to `UnpaidConsensusError` | ||
| //! downstream. For the documents-batch path under PROTOCOL_VERSION_12 this | ||
| //! is safe: every per-transition handler emits at least one action on | ||
| //! success and a bump action on failure, so no caller produces | ||
| //! `Some(empty_vec)`. A future caller that needs "validated, but no | ||
| //! actions to apply" must signal that with at least one non-empty entry, | ||
| //! not with `Some(empty_vec)`. | ||
| //! | ||
| //! See issue #2867 for context. | ||
| //! | ||
| //! [`ConsensusValidationResult::flatten`]: crate::validation::ConsensusValidationResult::flatten | ||
|
|
||
| use crate::validation::ValidationResult; | ||
| use std::fmt::Debug; | ||
|
|
||
| pub(in crate::validation::validation_result) fn flatten_v1<TData, E, I>( | ||
| items: I, | ||
| ) -> ValidationResult<Vec<TData>, E> | ||
| where | ||
| TData: Clone, | ||
| E: Debug, | ||
| I: IntoIterator<Item = 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::new_with_errors(aggregate_errors) | ||
| } else { | ||
| ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) | ||
| } | ||
| } | ||
|
shumkov marked this conversation as resolved.
|
||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn 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 = flatten_v1(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 empty_input_returns_none() { | ||
| let flat: ValidationResult<Vec<i32>, String> = | ||
| flatten_v1(std::iter::empty::<ValidationResult<Vec<i32>, String>>()); | ||
| assert_eq!(flat.data, None); | ||
| assert!(flat.errors.is_empty()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn all_inputs_no_data_returns_none() { | ||
| // Downstream code (process_validation_result_v0:241) keys on | ||
| // data.is_none() to route to UnpaidConsensusError. | ||
| 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 = flatten_v1(vec![r1, r2]); | ||
| assert!(flat.data.is_none()); | ||
| assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); | ||
| } | ||
|
|
||
| #[test] | ||
| fn some_empty_some_non_empty_returns_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 = flatten_v1(vec![r1, r2]); | ||
| assert_eq!(flat.data, Some(vec![42])); | ||
| assert!(flat.errors.is_empty()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn all_some_empty_returns_none() { | ||
| // All inputs had data:Some(empty_vec). The aggregate Vec is empty → data:None. | ||
| let r1: ValidationResult<Vec<i32>, String> = ValidationResult::new_with_data(vec![]); | ||
| let r2: ValidationResult<Vec<i32>, String> = ValidationResult::new_with_data(vec![]); | ||
|
|
||
| let flat = flatten_v1(vec![r1, r2]); | ||
| assert!(flat.data.is_none()); | ||
| assert!(flat.errors.is_empty()); | ||
| } | ||
| } | ||
2 changes: 2 additions & 0 deletions
2
packages/rs-dpp/src/validation/validation_result/merge_many/mod.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| pub(super) mod v0; | ||
| pub(super) mod v1; |
71 changes: 71 additions & 0 deletions
71
packages/rs-dpp/src/validation/validation_result/merge_many/v0/mod.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| //! v0 of [`ValidationResult::merge_many`]. | ||
| //! | ||
| //! Legacy semantics: always returns `data: Some(Vec<...>)`, including | ||
| //! `Some(empty_vec)` when no input had `data: Some(_)`. | ||
| //! | ||
| //! Preserved for `PROTOCOL_VERSION_11` and below — the | ||
| //! `Some(empty_vec)`-on-no-data behavior is part of the existing chain | ||
| //! history, and changing it would be a consensus-breaking change for | ||
| //! already-finalized blocks. New code should let the facade dispatch to v1. | ||
| //! | ||
| //! See issue #2867 for context. | ||
| //! | ||
| //! [`ValidationResult::merge_many`]: crate::validation::ValidationResult::merge_many | ||
|
|
||
| use crate::validation::ValidationResult; | ||
| use std::fmt::Debug; | ||
|
|
||
| pub(in crate::validation::validation_result) fn merge_many_v0<TData, E, I>( | ||
| items: I, | ||
| ) -> ValidationResult<Vec<TData>, E> | ||
| where | ||
| TData: Clone, | ||
| E: Debug, | ||
| I: IntoIterator<Item = ValidationResult<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); | ||
| } | ||
| }); | ||
| ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn collects_data_into_vec() { | ||
| 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 = merge_many_v0(vec![r1, r2, r3]); | ||
| assert_eq!(merged.data, Some(vec![1, 2])); | ||
| assert_eq!(merged.errors, vec!["e".to_string()]); | ||
| } | ||
|
|
||
| #[test] | ||
| fn empty_input_returns_some_empty() { | ||
| // Legacy v11 behavior: Some(empty_vec), not None. | ||
| let merged: ValidationResult<Vec<i32>, String> = | ||
| merge_many_v0(std::iter::empty::<ValidationResult<i32, String>>()); | ||
| assert_eq!(merged.data, Some(vec![])); | ||
| assert!(merged.errors.is_empty()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn 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 = merge_many_v0(vec![r1, r2]); | ||
| assert_eq!(merged.data, Some(vec![])); | ||
| assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); | ||
| } | ||
| } |
95 changes: 95 additions & 0 deletions
95
packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| //! v1 of [`ValidationResult::merge_many`]. | ||
| //! | ||
| //! Canonical semantics: returns `data: None` when no input had | ||
| //! `data: Some(_)`, and `data: Some(Vec<TData>)` when at least one input | ||
| //! contributed data. | ||
| //! | ||
| //! This honors the invariant `data.is_none() ⇔ no work done`, which | ||
| //! downstream code (e.g. `process_validation_result_v0:241`) relies on to | ||
| //! choose between `PaidConsensusError` and `UnpaidConsensusError`. | ||
| //! | ||
| //! # Caller-intent ambiguity | ||
| //! | ||
| //! `merge_many_v1` keys on `aggregate_data.is_empty()` to decide between | ||
| //! `data: None` and `data: Some(_)`. Every `Some(_)` input contributes one | ||
| //! element to `aggregate_data`, so the only way to get `data: None` is to | ||
| //! have zero inputs with `data: Some(_)`. There is no `Some(empty_vec)` | ||
| //! input shape at this layer (the per-item `data` is `TData`, not | ||
| //! `Vec<TData>`), so the collapse hazard described for `flatten_v1` | ||
| //! doesn't apply here. The dispatcher facade ([`ValidationResult::merge_many`]) | ||
| //! shares the limitation note for symmetry. | ||
| //! | ||
| //! See issue #2867 for context. | ||
| //! | ||
| //! [`ValidationResult::merge_many`]: crate::validation::ValidationResult::merge_many | ||
|
|
||
| use crate::validation::ValidationResult; | ||
| use std::fmt::Debug; | ||
|
|
||
| pub(in crate::validation::validation_result) fn merge_many_v1<TData, E, I>( | ||
| items: I, | ||
| ) -> ValidationResult<Vec<TData>, E> | ||
| where | ||
| TData: Clone, | ||
| E: Debug, | ||
| I: IntoIterator<Item = ValidationResult<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::new_with_errors(aggregate_errors) | ||
| } else { | ||
| ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn 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 = merge_many_v1(vec![r1, r2, r3]); | ||
| assert_eq!(merged.data, Some(vec![1, 2])); | ||
| assert_eq!(merged.errors, vec!["e".to_string()]); | ||
| } | ||
|
|
||
| #[test] | ||
| fn empty_input_returns_none() { | ||
| let merged: ValidationResult<Vec<i32>, String> = | ||
| merge_many_v1(std::iter::empty::<ValidationResult<i32, String>>()); | ||
| assert!(merged.data.is_none()); | ||
| assert!(merged.errors.is_empty()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn all_inputs_no_data_returns_none() { | ||
| 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 = merge_many_v1(vec![r1, r2]); | ||
| assert!(merged.data.is_none()); | ||
| assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); | ||
| } | ||
|
|
||
| #[test] | ||
| fn 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 = merge_many_v1(vec![r1, r2]); | ||
| assert_eq!(merged.data, Some(vec![7])); | ||
| assert_eq!(merged.errors, vec!["e1".to_string()]); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
🟡 Suggestion: flatten_v1 collapses Some(empty_vec) into data:None — same footgun class this PR is closing
flatten_v1keys onaggregate_data.is_empty()to decide betweendata:None(→ UnpaidConsensusError) anddata:Some(_)(→ PaidConsensusError). This collapses two distinct caller intents into one output: (1) every input haddata:None(truly no work) and (2) every input hadSome(empty_vec)(work performed, no actions emitted). Today's batch transformer never producesSome(empty_vec)under v12 because every per-transition handler emits at least one action or a bump action — but that invariant is maintained by convention across many call sites. A future per-transition path (new TokenOperationType, new DocumentTransition variant, or a refactor) that returnsConsensusValidationResult::new_with_data(vec![])silently slides back intodata:Noneaggregate → tx erased from block, free validation work, nonce never advances — exactly the #2867 shape this PR is closing.A stronger formulation would track
any_some_input: boolseparately fromaggregate_datasoSome(empty_vec)inputs surface asSome(empty_vec)(or as an internal error), while pureNoneinputs continue to collapse toNone. The PR's ownall_some_empty_returns_nonetest demonstrates the collapse — at minimum, add a defensivedebug_assert!or doc-level invariant on per-item shape at each call site.source: ['claude']
🤖 Fix this with AI agents