Skip to content

fix(dpp): reject empty token pricing schedules to prevent a direct-purchase chain halt#3865

Merged
shumkov merged 2 commits into
v3.1-devfrom
claude/hopeful-sinoussi-89c75c
Jun 12, 2026
Merged

fix(dpp): reject empty token pricing schedules to prevent a direct-purchase chain halt#3865
shumkov merged 2 commits into
v3.1-devfrom
claude/hopeful-sinoussi-89c75c

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jun 12, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

A chain-halt (liveness) bug in the token direct-purchase path.

An empty SetPrices(BTreeMap::new()) pricing schedule is a representable, storable value:
TokenPricingSchedule::SetPrices wraps a BTreeMap<TokenAmount, Credits>, and neither the
set-price structure validation nor the set-price state validation rejected an empty map.
A token's pricing authority could therefore set price = Some(SetPrices(empty map)) via a
TokenSetPriceForDirectPurchase transition that passed all validation, and the empty schedule
flowed unchanged into GroveDB.

Afterwards, any identity submitting a TokenDirectPurchase for that token detonated a panic.
In the transformer's None arm, the original code built the error via
set_prices.keys().next().expect("Map is not empty"), which panics when the map is empty.

This panic is a deterministic, quorum-wide chain halt — confirmed by tracing the call stack:

  • The transformer runs during deterministic block execution on every validator:
    process_proposalrun_block_proposalprocess_raw_state_transitions
    process_state_transitiontransform_into_action → the token transformer.
  • Per-state-transition Errs are caught into an InternalError execution result (chain
    continues) at process_raw_state_transitions/v0/mod.rs, but panics are not — that catch is
    .unwrap_or_else(...) on a Result, which a panic unwinds straight through.
  • There is no catch_unwind anywhere on the ABCI path (workspace-wide).
  • The global panic hook (install_panic_hook in main.rs) calls cancel.cancel(), shutting the
    node down on any panic.

Result: every validator panics on the same block during process_proposal → every node shuts
down → chain halt.

What was done?

Defense in depth across the produce and consume sides:

  1. Reject empty SetPrices at set-price structure validation with a new
    TokenPricingScheduleEmptyError consensus error (basic code 10461). Emptiness is a pure
    property of the transition's price field — no state needed — and structure validation
    short-circuits the batch before the transition is ever applied, so an empty schedule can never
    reach state.
  2. Transformer guard (already present in the base branch): the None arm matches on
    set_prices.keys().next() and returns TokenNotForDirectSale for an empty map instead of
    .expect()-ing. This PR keeps it as the consume-side guard and adds a regression test for it.

Wiring for the new error: new error struct under errors/consensus/basic/token/, re-exported in
that module, added to the BasicError enum (tail-appended to preserve bincode positional
discriminants), assigned error code 10461 in codes.rs, and bridged in the wasm-dpp
exhaustive consensus-error match.

How Has This Been Tested?

New regression tests:

  • should_return_error_when_set_prices_is_empty — set-price structure validation rejects an empty
    SetPrices with TokenPricingScheduleEmptyError (plus should_pass_with_non_empty_set_prices
    and should_pass_with_single_price for the happy paths).
  • set_prices_empty_map_does_not_panic_and_is_treated_as_not_for_sale — the transformer's
    None-arm logic resolves an empty map to the not-for-sale branch without panicking.

Verification run locally:

  • cargo test -p dpp --all-features (new validation tests pass)
  • cargo test -p drive --lib set_prices_empty_map_does_not_panic (transformer regression passes)
  • cargo check -p dpp --all-features --tests
  • cargo check -p wasm-dpp --target wasm32-unknown-unknown
  • cargo fmt --all

Breaking Changes

Consensus behavior changes: a TokenSetPriceForDirectPurchase transition carrying an empty
SetPrices map is now rejected at structure validation (new consensus error code 10461),
whereas it was previously accepted. This is a consensus-relevant hardening landing pre-release
(4.0.0-rc.2) — the protocol version is not finalized/deployed, so there is no released network
to fork against and no migration concern. The new BasicError variant is appended at the enum
tail to preserve bincode positional encoding of existing variants.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Validation now rejects empty token pricing schedules and returns a clear error instead of allowing invalid configs.
    • Prevented a panic when processing direct-purchase transitions with empty pricing schedules; these now fail gracefully.
    • Error mapping updated so the new error is surfaced consistently across native and WASM layers.
  • Tests
    • Added regression and unit tests covering empty and non-empty pricing schedules and direct-purchase edge cases.

…rchase chain halt

An empty `SetPrices(BTreeMap::new())` pricing schedule was a representable,
storable value that passed both structure and state validation for
`TokenSetPriceForDirectPurchase`. Once stored, any `TokenDirectPurchase` for that
token reached the direct-purchase transformer's `None` arm, where the original code
did `set_prices.keys().next().expect("Map is not empty")` — which panics on an empty
map.

That transformer runs during deterministic block execution on every validator
(process_proposal -> run_block_proposal -> process_raw_state_transitions ->
transform_into_action). Per-state-transition `Err`s are caught into an
`InternalError` execution result and the chain continues, but panics are not caught:
there is no `catch_unwind` anywhere on the path, and the global panic hook
(main.rs install_panic_hook) calls `cancel.cancel()`, shutting the node down. Because
block execution is deterministic, every validator panics on the same block, taking the
whole quorum down — a chain halt.

Fix (defense in depth):
- Reject an empty `SetPrices` map at set-price structure validation with a new
  `TokenPricingScheduleEmptyError` consensus error (basic code 10461), so such a
  schedule can never be written to state. Structure validation short-circuits the
  batch before the transition is applied.
- The transformer already handles an empty schedule gracefully (matches on
  `keys().next()` and returns `TokenNotForDirectSale` instead of `.expect()`); kept as
  the consume-side guard.

Adds regression tests for both layers: structure validation rejects an empty
`SetPrices`, and the transformer's `None`-arm logic resolves an empty map to
not-for-sale without panicking. Wires the new error through `BasicError` (tail-
appended for bincode positional encoding), error codes, and the wasm-dpp bindings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thepastaclaw

thepastaclaw commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 9eb600d)

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05243739-6223-456d-bb37-b7731e274335

📥 Commits

Reviewing files that changed from the base of the PR and between b286cdc and 9eb600d.

📒 Files selected for processing (1)
  • packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_direct_purchase_transition_action/v0/transformer.rs

📝 Walkthrough

Walkthrough

This PR defines a new TokenPricingScheduleEmptyError, rejects empty TokenPricingSchedule::SetPrices during structure validation, extracts a SetPrices resolution helper in the transformer (preventing panics), adds unit tests, and maps the new error into WASM error conversion.

Changes

Token pricing schedule empty error validation

Layer / File(s) Summary
Token pricing schedule empty error type definition and registration
packages/rs-dpp/src/errors/consensus/basic/token/token_pricing_schedule_empty_error.rs, packages/rs-dpp/src/errors/consensus/basic/token/mod.rs, packages/rs-dpp/src/errors/consensus/basic/basic_error.rs, packages/rs-dpp/src/errors/consensus/codes.rs
TokenPricingScheduleEmptyError struct stores token_id, derives serialization and error traits, implements From to ConsensusError, is registered as a BasicError variant, re-exported in token mod, and assigned code 10461.
Direct-purchase pricing structure validation
packages/rs-dpp/src/state_transition/.../token_set_price_for_direct_purchase_transition/validate_structure/v0/mod.rs
Validation now rejects empty TokenPricingSchedule::SetPrices by returning TokenPricingScheduleEmptyError. Test utilities were extended to accept an optional pricing schedule and new tests verify SinglePrice and non-empty SetPrices succeed while empty maps fail.
Transformer helper, tests, and WASM error handling
packages/rs-drive/src/.../token_direct_purchase_transition_action/v0/transformer.rs, packages/wasm-dpp/src/errors/consensus/consensus_error.rs
Extracts resolve_set_prices_direct_purchase_price helper to compute required totals and handle empty maps (preventing panics); transformer delegates to it and surfaces returned ConsensusError; adds unit tests including a regression for empty SetPrices; WASM error mapping adds conversion for the new BasicError variant.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • dashpay/platform#3856: Both PRs address the empty-SetPrices direct-purchase edge case and related transformer/validation fixes.

Suggested labels

bug

Suggested reviewers

  • thepastaclaw

Poem

🐰 I sniffed an empty pricing row, a gap so thin and bare,
A tiny error sprouted up to catch it with a care.
No panics now in transformer land, tests hop in a ring,
The bunny cheers the safety net — let proper errors sing! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: rejecting empty token pricing schedules to prevent a chain halt caused by panics in direct-purchase processing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/hopeful-sinoussi-89c75c

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_direct_purchase_transition_action/v0/transformer.rs (1)

563-591: 🏗️ Heavy lift

Exercise the transformer path, not just BTreeMap behavior.

This test proves that an empty map yields None, but it never invokes TokenDirectPurchaseTransitionActionV0::try_from_borrowed_token_direct_purchase_transition_with_contract_lookup or asserts the emitted TokenNotForDirectSale error. A future regression inside the transformer's None arm would still leave this test green. Please route the check through the real transformer flow, or extract the SetPrices resolution into a pure helper and test that directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_direct_purchase_transition_action/v0/transformer.rs`
around lines 563 - 591, The test only inspects BTreeMap behavior instead of
exercising the transformer path; change the test to call
TokenDirectPurchaseTransitionActionV0::try_from_borrowed_token_direct_purchase_transition_with_contract_lookup
(or, alternatively, extract the set_prices resolution into a pure helper like
resolve_set_prices_tier and test that) so the transformer's None branch is
executed; construct a minimal BorrowedTokenDirectPurchaseTransition (with an
empty SetPrices schedule and token_count=5) or call the new helper, run the
transformer, and assert it returns the TokenNotForDirectSale/appropriate
consensus error rather than just checking BTreeMap::keys/next behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_direct_purchase_transition_action/v0/transformer.rs`:
- Around line 563-591: The test only inspects BTreeMap behavior instead of
exercising the transformer path; change the test to call
TokenDirectPurchaseTransitionActionV0::try_from_borrowed_token_direct_purchase_transition_with_contract_lookup
(or, alternatively, extract the set_prices resolution into a pure helper like
resolve_set_prices_tier and test that) so the transformer's None branch is
executed; construct a minimal BorrowedTokenDirectPurchaseTransition (with an
empty SetPrices schedule and token_count=5) or call the new helper, run the
transformer, and assert it returns the TokenNotForDirectSale/appropriate
consensus error rather than just checking BTreeMap::keys/next behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 596428bc-a427-4418-97d0-66885ea484e9

📥 Commits

Reviewing files that changed from the base of the PR and between 1ba1ca5 and b286cdc.

📒 Files selected for processing (7)
  • packages/rs-dpp/src/errors/consensus/basic/basic_error.rs
  • packages/rs-dpp/src/errors/consensus/basic/token/mod.rs
  • packages/rs-dpp/src/errors/consensus/basic/token/token_pricing_schedule_empty_error.rs
  • packages/rs-dpp/src/errors/consensus/codes.rs
  • packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_set_price_for_direct_purchase_transition/validate_structure/v0/mod.rs
  • packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_direct_purchase_transition_action/v0/transformer.rs
  • packages/wasm-dpp/src/errors/consensus/consensus_error.rs

…testable helper

Addresses review feedback (CodeRabbit): the empty-schedule regression test only
inspected `BTreeMap` behavior, so a future regression in the transformer's `None`
arm — e.g. reintroducing the chain-halting `.expect()` — would have left it green.

Extract the `SetPrices` tier-resolution logic (tier lookup, overflow, under-minimum,
price-too-low, and the empty-schedule -> `TokenNotForDirectSale` case) out of the
transformer into a pure `resolve_set_prices_direct_purchase_price` helper. The
transformer now routes through it with identical observable behavior (on `Err` it
bumps the identity-contract nonce and surfaces the same consensus error as before).

Replace the BTreeMap-replica regression test with five tests that drive the real
helper across every branch, so a regression inside the `None` arm now fails a test
instead of silently passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Targeted, well-scoped fix for the direct-purchase chain-halt: empty SetPrices is now rejected at DPP structure validation with a new TokenPricingScheduleEmptyError (code 10461), appended at the tail of BasicError so bincode discriminants are preserved, and bridged through the wasm-dpp exhaustive converter. Plumbing, ordering, and gating are correct. The only in-scope concern is that the new transformer-side 'regression' test does not call the transformer — it asserts standard BTreeMap semantics on an empty map, so a reintroduction of the original .expect("Map is not empty") would not be caught.

🟡 1 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_direct_purchase_transition_action/v0/transformer.rs`:
- [SUGGESTION] packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_direct_purchase_transition_action/v0/transformer.rs:573-591: Regression test does not exercise the transformer — it tests `BTreeMap`, not the fixed code path
  `set_prices_empty_map_does_not_panic_and_is_treated_as_not_for_sale` is documented as the regression test for the chain-halt bug, but it never invokes `try_from_borrowed_token_direct_purchase_transition_with_contract_lookup` (or any transformer entry point). It only asserts that `BTreeMap::range(..=k).next_back()` and `BTreeMap::keys().next()` return `None` on an empty map — properties of the standard library, not of the transformer. If a later refactor restored the original `*set_prices.keys().next().expect("Map is not empty")` on the `None` arm, this test would still pass green. Drive the transformer directly with a `TokenDirectPurchaseTransition` whose token state carries `Some(TokenPricingSchedule::SetPrices(BTreeMap::new()))` and assert the result is `BumpIdentityDataContractNonce` + `TokenNotForDirectSale` rather than a panic; that fails closed if the `.expect()` is ever reintroduced. Not blocking because the new structure-validation guard prevents an empty schedule from being stored on the happy path, but the consume-side defense-in-depth narrative in the PR description currently isn't backed by a real test of the consume-side branch.

@QuantumExplorer

Copy link
Copy Markdown
Member Author

@coderabbitai addressed in 9eb600d. Extracted the SetPrices resolution out of the transformer into a pure resolve_set_prices_direct_purchase_price helper and routed the transformer through it (behavior unchanged — on Err it bumps the nonce and surfaces the same consensus error). Replaced the BTreeMap-replica test with five tests that drive the real helper across every branch: empty → TokenNotForDirectSale, below-minimum → TokenAmountUnderMinimumSaleAmount, matched tier → required total, underpayment → TokenDirectPurchaseUserPriceTooLow, and overflow → OverflowError. A regression in the None arm now fails a test instead of passing silently.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@QuantumExplorer That's a great improvement — extracting the logic into a pure resolve_set_prices_direct_purchase_price helper makes it independently testable, and the five-branch coverage (empty, below-minimum, matched tier, underpayment, overflow) is exactly what this kind of tiered-pricing logic needs. The fixed None-arm regression catching a real failure path rather than silently passing is a nice bonus.

·.¸¸.·´¯·.¸¸.-> 🐇 <-.¸¸.·´¯`·.¸¸.·´¯)


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@shumkov shumkov merged commit ac1cd63 into v3.1-dev Jun 12, 2026
5 checks passed
@shumkov shumkov deleted the claude/hopeful-sinoussi-89c75c branch June 12, 2026 07:54

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Cumulative re-review at head 9eb600d. The single prior finding — that the regression test only asserted BTreeMap stdlib semantics rather than exercising the fixed transformer code path — is FIXED. The latest delta (refactor commit 9eb600d) extracts the SetPrices resolution logic into a pure helper resolve_set_prices_direct_purchase_price (transformer.rs:254-308) that the transformer's SetPrices arm now invokes (transformer.rs:199-224). The replacement test resolve_set_prices_empty_map_returns_not_for_sale_without_panicking (transformer.rs:571-587) calls that helper directly with an empty BTreeMap and asserts Err(StateError::TokenNotForDirectSale), so reintroducing .expect("Map is not empty") in the helper's None arm would now fail the test instead of leaving it green. Four additional tests cover below-minimum, matched-tier, underpayment, and overflow branches through the helper. No new in-scope defects identified in the latest delta; the structure-validation guard, tail-appended BasicError variant (code 10461), and wasm-dpp bridge remain correct from the prior pass.

@github-actions

Copy link
Copy Markdown
Contributor

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "d36fa20867de18b9277d8ca73e9400720e296473fc3db0edeb1d3718820395b8"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

QuantumExplorer added a commit that referenced this pull request Jun 12, 2026
…tch #3865)

Per maintainer decision, drop the version-gating and add the byte-array
encoding-stability check directly to validate_update_v0, consistent with the
sibling chain-halt fix #3865 which added its rejection rule to
validate_structure_v0 in place. The check runs before validate_schema so it
cannot be bypassed by a JSON-schema-compatible widening.

- revert validate_update_v1, the dispatcher arm, and the
  DPP_VALIDATION_VERSIONS_V3 feature-version bump
- check + tests now live in validate_update/v0 (tests still exercise the public
  validate_update dispatcher); drops the pre-activation gating test
- e2e error assertion keeps assert_matches! on the decode-failure variant

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.20%. Comparing base (f4ed60f) to head (9eb600d).
⚠️ Report is 19 commits behind head on v3.1-dev.

Additional details and impacted files
@@             Coverage Diff              @@
##           v3.1-dev    #3865      +/-   ##
============================================
+ Coverage     70.73%   71.20%   +0.47%     
============================================
  Files            20       20              
  Lines          2788     2837      +49     
============================================
+ Hits           1972     2020      +48     
- Misses          816      817       +1     
Components Coverage Δ
dpp ∅ <ø> (∅)
drive ∅ <ø> (∅)
drive-abci ∅ <ø> (∅)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value ∅ <ø> (∅)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier ∅ <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants