Skip to content

feat(dpp)!: dashpay payment detection keys and stealth derivation - #4272

Draft
QuantumExplorer wants to merge 15 commits into
v4.2-devfrom
feat/dashpay-payment-addresses
Draft

feat(dpp)!: dashpay payment detection keys and stealth derivation#4272
QuantumExplorer wants to merge 15 commits into
v4.2-devfrom
feat/dashpay-payment-addresses

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 3, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Second slice of DIP-33 (dashpay/dips#188), stacked on #4380 (the public tips tier — review that first). This PR adds the notified tier's key material and math: identity payment detection keys and stealth one-time address derivation for the Core and Platform transparent rails.

What was done?

  • Key purposes PAYMENT_SCAN = 7 / PAYMENT_SPEND = 8 (protocol version 14): ECDSA_SECP256K1 only, exactly 33-byte compressed SEC1 points, non-signing, no contract bounds, not searchable, at most one active key per purpose per identity.
  • Validation: structure validation v1 (per-transition purpose/type/size checks, gated at PV14 via state-transition method versions v2) + the against-state uniqueness check in the identity-update path, with the same-transition rotation exception.
  • Consensus errors InvalidKeyPurposeKeyTypeError (10535) and TooManyPublicKeysOfPurposeError (10536), appended at the BasicError tail with discriminant-pinning tests.
  • Stealth math: platform-encryption::stealth (pure curve ops, KAT vectors matching DIP-33) and the platform-wallet module (DIP-9 feature 33' derivation; payer/receiver/spender roles; rail-domain-separated one-time destinations for Core P2PKH and Platform addresses; typed retryable errors).
  • Binding mirrors: wasm-dpp/wasm-dpp2 (incl. string aliases and strict numeric parsing), Swift KeyPurpose (with unknown-discriminant skip instead of coercion), Kotlin KeyPurpose.

How Has This Been Tested?

  • dpp: structure-validation tests (type, size incl. 65-byte uncompressed rejection, per-transition duplicates) and BasicError discriminant pins.
  • drive-abci: identity-update state tests — add-only conflict, same-transition rotation, disabled-historical-key replacement, scan/spend independence, PV13 rejection / PV14 acceptance through the full pipeline.
  • Workspace clippy (all-features) clean; Kotlin :sdk:compileDebugKotlin passes; Swift covered by CI.

Breaking Changes

Consensus-breaking at protocol version 14 (new key purposes accepted by structure validation). Pre-release; no migration concerns.

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

QuantumExplorer and others added 2 commits August 3, 2026 09:00
… cache

A read-only query thread fetches contracts from committed state with no
transaction and populates the global cache from what it read. If such a thread
reads a contract, is descheduled while block execution rewrites that contract,
and performs its insert only after the block cache is promoted to the global
cache, the unconditional insert clobbered the newer contract with the stale
one. Block execution would then serialize documents against a different
contract than a node whose cache was cold — the same consensus divergence the
v13 DPNS refresh closes, reopened through a millisecond-scale scheduling window
that exists on every contract rewrite, and that an attacker could widen at the
predictable activation height by flooding document queries.

Contract versions increase strictly monotonically (the data contract update
transition enforces new == old + 1, and the v13 DPNS rewrite goes 1 -> 2), so
an insert carrying a lower version than the cached entry is always a delayed
writer racing a newer copy in, never fresh information. The insert now skips in
that case, atomically per key via moka's compute API so the version comparison
and the write have no window between them. Same-version inserts still
overwrite, which the cache-hit fee-calculation path relies on.

This closes the race for the ordinary contract-update path as well, where the
batch finalization task evicts the superseded contract during block execution
and the same delayed query insert could land after promotion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements the transparent-rail (Core + Platform address) half of DIP-33:
profile payment address fields, the two payment key purposes, and the
client-side stealth one-time address derivation.

DashPay contract v2 (protocol version 14):
* profile gains optional corePaymentAddress (Base58 string) and
  platformPaymentAddress (21-byte DIP-18 storage form), positions 5-6.
* Mirrors the DPNS-v2-at-PV13 mechanics: schema/v2, v2 loader, a new
  system_data_contract_versions v3 (dashpay: 2), and a
  transition_to_version_14 first-block migration that re-applies the
  DashPay contract. Reuses the SystemDataContract::ALL cache refresh, so
  the migration inherits the monotonic data-contract-cache guard that
  protects the DPNS v13 activation.

Identity key purposes PAYMENT_SCAN (7) and PAYMENT_SPEND (8):
* ECDSA_SECP256K1, non-signing, no contract bounds, at most one active
  key of each purpose per identity. Not searchable (no per-purpose key
  reference tree), found by fetching the identity's keys.
* Gated at protocol version 14 via validate_identity_public_keys_structure
  v1 (in-transition rules) plus a validate_payment_key_uniqueness common
  state check wired into the identity update add path. Two new consensus
  errors: InvalidKeyPurposeKeyType (10535), TooManyPublicKeysOfPurpose
  (10536). Mirrored in wasm-dpp / wasm-dpp2.

Stealth derivation:
* platform-encryption::stealth — pure secp256k1 curve math (shared point,
  rail-domain-separated one-time tweak, one-time pub/secret keys) with
  DIP-33 known-answer test vectors.
* platform-wallet stealth module — DIP-9 feature 33' key derivation and
  Core P2PKH / Platform one-time destination construction (payer,
  receiver, spender).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added this to the v4.2.0 milestone Aug 3, 2026
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds DashPay v2 schemas, DIP-33 payment purposes and stealth payment derivation, payment-key validation, protocol v14 migration behavior, and monotonic contract-cache updates.

Changes

DashPay v2 and payment-purpose contracts

Layer / File(s) Summary
DashPay v2 schemas and loaders
packages/dashpay-contract/schema/v2/..., packages/dashpay-contract/src/...
Adds profile, contactInfo, and contactRequest schemas. Version 2 schema loading is supported.
Payment-purpose contracts and SDK conversions
packages/rs-dpp/src/identity/..., packages/rs-dpp/src/errors/..., packages/wasm-dpp/..., packages/wasm-dpp2/..., packages/kotlin-sdk/..., packages/swift-sdk/...
Adds PAYMENT_SCAN and PAYMENT_SPEND, validation errors, serialization mappings, and SDK representations.

DIP-33 stealth derivation

Layer / File(s) Summary
Stealth primitives and wallet APIs
packages/rs-platform-encryption/..., packages/rs-platform-wallet/...
Implements rail-specific shared points, tweaks, one-time destinations, destination recognition, and secret-key derivation for Core and Platform rails.

Payment-key validation

Layer / File(s) Summary
Identity key validation and uniqueness checks
packages/rs-dpp/src/state_transition/..., packages/rs-drive-abci/src/execution/validation/..., packages/rs-platform-version/...
Enforces payment-key type, encoding, security-level, cardinality, and active-key uniqueness rules.
Validation and transition coverage
packages/rs-drive-abci/src/execution/validation/...
Tests first-key creation, duplicate rejection, key rotation, disabled-key replacement, purpose independence, and pre-v14 rejection.

Protocol v14 and cache migration

Layer / File(s) Summary
Protocol versions and DashPay migration
packages/rs-platform-version/..., packages/rs-drive-abci/src/execution/platform_events/...
Protocol v14 selects the new method and system contract versions. The upgrade reapplies DashPay with payment-address fields.
Monotonic caching and expectations
packages/rs-drive/src/cache/..., packages/rs-drive/tests/..., packages/rs-drive-abci/src/.../tests/...
Higher-version contracts replace lower versions in the cache. Related cache, root-hash, document, and fee expectations are updated.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main changes: DashPay payment detection keys and stealth derivation.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dashpay-payment-addresses

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

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.55313% with 62 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.06%. Comparing base (2e0d766) to head (7fedd37).

Files with missing lines Patch % Lines
.../validate_identity_public_keys_structure/v1/mod.rs 92.17% 18 Missing ⚠️
packages/rs-platform-encryption/src/stealth.rs 77.21% 18 Missing ⚠️
.../state_transitions/identity_update/state/v0/mod.rs 66.66% 9 Missing ⚠️
..._costs/for_purpose_in_key_reference_tree/v0/mod.rs 0.00% 9 Missing ⚠️
...tion/common/validate_payment_key_uniqueness/mod.rs 82.75% 5 Missing ⚠️
...ods/validate_identity_public_keys_structure/mod.rs 66.66% 2 Missing ⚠️
...n/common/validate_payment_key_uniqueness/v0/mod.rs 97.72% 1 Missing ⚠️
Additional details and impacted files
@@                    Coverage Diff                    @@
##           feat/dashpay-tips-jar    #4272      +/-   ##
=========================================================
- Coverage                  87.63%   87.06%   -0.58%     
=========================================================
  Files                       2670     2645      -25     
  Lines                     339591   337926    -1665     
=========================================================
- Hits                      297604   294211    -3393     
- Misses                     41987    43715    +1728     
Components Coverage Δ
dpp 88.62% <91.52%> (-0.25%) ⬇️
drive 85.78% <0.00%> (-0.48%) ⬇️
drive-abci 87.89% <96.34%> (-1.78%) ⬇️
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.88% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 48.02% <ø> (ø)
🚀 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.

@QuantumExplorer QuantumExplorer changed the title feat(dpp)!: DashPay payment addresses and payment key purposes (DIP-33) feat(dpp)!: payment addresses and payment key purposes (DIP-33) Aug 3, 2026
@thepastaclaw

thepastaclaw commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Opus deferred (commit 79154a2)
Canonical validated blockers: 3

QuantumExplorer and others added 3 commits August 3, 2026 14:04
The PAYMENT_SCAN/PAYMENT_SPEND variants carry underscores, which trips
clippy's non_camel_case_types lint (single-word all-caps variants do not).
Matches the allow already on the rs-dpp Purpose enum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ress

key_wallet::Address has no pubkey_hash(); extract the P2PKH hash via
script_pubkey().p2pkh_public_key_hash_bytes() as elsewhere in the crate.
Only the test target used it, so lib check/clippy did not catch it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tests

The pattern was enforced at consensus (verified: documents carrying a
non-Base58 value were rejected with a JsonSchemaError), but it could not
prove the checksum or network byte, so clients had to fully validate the
address regardless. What it did do was make every random document
generator using FillIfNotRequired emit schema-invalid dashpay profiles,
breaking 16 unrelated document tests — a permanent tax on anyone
generating a profile fixture. The byteArray platformPaymentAddress field
broke nothing, which is the asymmetry that surfaced this.

Consensus now constrains the length only; validating the address proper
is explicitly a client responsibility (DIP-33 updated to match).

Rebaselined for the larger PV14 dashpay contract, all latest-version
only — historical protocol-version pins are untouched so chain replay
stays bit-for-bit reproducible:
* check_tx contract create/update processing fees
* deterministic root hash after contract insertion
* happy-path replace and delete fee baselines
* expected profile document string representations, which now include
  the two new optional fields

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer

Copy link
Copy Markdown
Member Author

CI fixes + one design change worth a look

Three CI rounds fixed. The third surfaced something I think is a genuine improvement, so flagging it explicitly rather than burying it in a commit.

Dropped the Base58 pattern from corePaymentAddress

The pattern was working — I verified documents carrying a non-Base58 value were rejected at consensus with a JsonSchemaError. But:

  • it could only check the character class, not the checksum or network byte, so clients had to fully validate the address regardless — the on-chain check bought a partial guarantee for a field the client must re-validate anyway;
  • it made every random-document generator using FillIfNotRequired emit schema-invalid dashpay profiles, breaking 16 unrelated document tests (creation, replacement, deletion) and taxing anyone who generates a profile fixture in future.

The tell is the asymmetry: platformPaymentAddress (a 21-byte byteArray) broke nothing, because any random 21 bytes is schema-valid. Consensus now constrains length only, and full validation is explicitly the client's job. dashpay/dips#188 updated to match.

If you'd rather keep stricter on-chain validation, the cleaner version is to store the Core address as 21 bytes (version byte + hash160) exactly like the platform one — symmetric across both rails, smaller, and random-fill-safe. That's a bigger DIP change so I didn't take it unilaterally; happy to switch.

Rebaselined PV14 expectations

The larger dashpay v2 contract shifts grovedb node layout, so several pinned values moved. All updates are latest-version only — every historical protocol-version pin is untouched, so chain replay stays bit-for-bit reproducible:

  • check_tx contract create/update processing fees
  • deterministic root hash after contract insertion
  • happy-path replace and delete fee baselines
  • expected profile document strings (now include the two new optional fields)

Verification

drive-abci 2630 passed / 0 failed · dpp 3884 / 0 · drive 3261+ / 0 · platform-wallet green · workspace cargo fmt --check and CI-style cargo clippy --workspace --all-features -D warnings both clean.

Earlier rounds: PR title needed a lowercase subject; the wasm PurposeWasm mirror enums needed #[allow(non_camel_case_types)] (underscored variants trip the lint where single-word all-caps don't); and a stealth test used pubkey_hash(), which key_wallet::Address doesn't have — that one only broke the test target, which cargo check/clippy don't build.

@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.

Actionable comments posted: 3

🧹 Nitpick comments (4)
packages/rs-platform-encryption/src/lib.rs (1)

13-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The intra-doc link points at a private module.

Line 26 declares mod stealth; without pub. An intra-doc link to a private item does not resolve in generated documentation, and rustdoc emits a private_intra_doc_links warning. Either make the module public or drop the link.

📝 Option A: make the module public
-mod stealth;
+pub mod stealth;
📝 Option B: keep the module private and remove the link
-//! - [`stealth`] — DIP-33 stealth one-time key derivation.
+//! - `stealth` — DIP-33 stealth one-time key derivation (re-exported at the crate root).
🤖 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-platform-encryption/src/lib.rs` at line 13, Resolve the broken
intra-doc link to stealth in the crate-level documentation by either making the
stealth module public at its declaration or removing the [`stealth`] entry from
the documentation list; preserve the intended visibility of the module and
ensure rustdoc no longer reports a private_intra_doc_links warning.
packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs (1)

161-172: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Each stealth entry point builds its own Secp256k1 context. The root cause is that the module creates the context instead of accepting one. Secp256k1::new() allocates and initializes the full pre-computation table, which costs far more than the one or two curve operations that follow it. recognize_one_time_destination runs once per candidate notification and per output index during wallet scanning, so this sits on a hot path. Add a secp: &Secp256k1<C> parameter to the three public functions, or use the crate-global secp256k1::SECP256K1.

  • packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs#L161-L172: remove let secp = Secp256k1::new(); from derive_one_time_destination and take the context from the caller.
  • packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs#L200-L200: remove let secp = Secp256k1::new(); from recognize_one_time_destination and take the context from the caller. This is the scanning path and benefits most.
  • packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs#L225-L225: remove let secp = Secp256k1::new(); from derive_one_time_secret_key. Only stealth_shared_point needs the context; one_time_secret_key performs scalar addition and needs none.
🤖 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-platform-wallet/src/wallet/identity/crypto/stealth.rs` around
lines 161 - 172, The stealth entry points independently initialize expensive
Secp256k1 contexts; update derive_one_time_destination,
recognize_one_time_destination, and derive_one_time_secret_key in
packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs at lines
161-172, 200-200, and 225-225 to reuse a caller-provided context or the
crate-global SECP256K1. Remove each Secp256k1::new() allocation, thread the
context through the affected calls, and ensure derive_one_time_secret_key passes
it only to stealth_shared_point while leaving one_time_secret_key context-free.
packages/rs-platform-encryption/src/stealth.rs (1)

121-155: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Consider making the modular reduction branch-free, or removing it.

ge_order returns on the first differing byte and reduce_mod_order takes a data-dependent branch. The input is SHA256 of the shared secret point, so the control flow depends on secret-derived data. The leak is weak because SHA256 is one-way, but a cryptographic primitive crate is the wrong place to accept a variable-time scalar path.

Two options:

  • Make the subtraction unconditional and select the result with a constant-time mask.
  • Drop the reduction. Scalar::from_be_bytes already rejects values at or above the order. Treat that rejection like the existing zero case and require a fresh ephemeral key. The probability of either event is about 2^-128.

Option 2 removes reduce_mod_order, ge_order, and CURVE_ORDER_BE entirely. Confirm first that the DIP-33 specification defines the tweak as a reduction and not as a rejection, because the two differ observably on out-of-range digests.

🤖 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-platform-encryption/src/stealth.rs` around lines 121 - 155,
Verify whether DIP-33 requires reducing the SHA256-derived tweak modulo the
curve order or rejecting out-of-range values. If rejection is permitted, remove
reduce_mod_order, ge_order, and CURVE_ORDER_BE, and treat Scalar::from_be_bytes
failure like the existing zero-tweak case by generating a fresh ephemeral key;
otherwise replace the data-dependent comparison and subtraction in those helpers
with constant-time unconditional subtraction and masked selection.
packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs (1)

59-69: 🚀 Performance & Scalability | 🔵 Trivial

Unbounded key fetch scales with total historical keys, not the current transition.

The AllKeys fetch has no limit. Total key count for an identity is unbounded over its lifetime, since max_public_keys_in_creation only caps keys added per transition, not the total accumulated over many updates. Consider monitoring identities with unusually large key counts, or revisit pagination if this becomes a hot path under high key churn.

🤖 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-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs`
around lines 59 - 69, Review the key lookup in the payment-key uniqueness
validation flow and avoid relying on an unbounded AllKeys fetch for identities
with large historical key counts. Update the logic around IdentityKeysRequest
and fetch_identity_keys to use an appropriate bounded or paginated strategy
while still checking all keys relevant to the current transition.
🤖 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.

Inline comments:
In `@packages/rs-dpp/src/errors/consensus/basic/basic_error.rs`:
- Around line 604-613: Move InvalidKeyPurposeKeyTypeError and
TooManyPublicKeysOfPurposeError out of their current position in BasicError and
append both variants after the existing tail variant
TokenPricingScheduleEmptyError, preserving their transparent error annotations
and the order of all existing variants.

In `@packages/rs-platform-encryption/src/stealth.rs`:
- Around line 167-175: Verify whether the expected DIP-33 vector constants and
outputs used by the known-answer test were copied from the published DIP-33
specification. If so, cite the exact DIP-33 section in the comment near B_SCAN,
B_SPEND, and R_EPHEMERAL; otherwise, reword the interop claim to describe the
test as a locally generated regression lock and note a follow-up to add
published vectors.

In `@packages/wasm-dpp2/src/enums/keys/purpose.rs`:
- Around line 83-84: Update the PurposeLike numeric conversion near the
PurposeWasm mappings to validate that the input is finite, integral, and within
the supported purpose range before casting to u8. Reject fractional values and
non-negative overflow values instead of allowing truncation or wrapping, while
preserving valid mappings such as 7 to PAYMENT_SCAN and 8 to PAYMENT_SPEND.

---

Nitpick comments:
In
`@packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs`:
- Around line 59-69: Review the key lookup in the payment-key uniqueness
validation flow and avoid relying on an unbounded AllKeys fetch for identities
with large historical key counts. Update the logic around IdentityKeysRequest
and fetch_identity_keys to use an appropriate bounded or paginated strategy
while still checking all keys relevant to the current transition.

In `@packages/rs-platform-encryption/src/lib.rs`:
- Line 13: Resolve the broken intra-doc link to stealth in the crate-level
documentation by either making the stealth module public at its declaration or
removing the [`stealth`] entry from the documentation list; preserve the
intended visibility of the module and ensure rustdoc no longer reports a
private_intra_doc_links warning.

In `@packages/rs-platform-encryption/src/stealth.rs`:
- Around line 121-155: Verify whether DIP-33 requires reducing the
SHA256-derived tweak modulo the curve order or rejecting out-of-range values. If
rejection is permitted, remove reduce_mod_order, ge_order, and CURVE_ORDER_BE,
and treat Scalar::from_be_bytes failure like the existing zero-tweak case by
generating a fresh ephemeral key; otherwise replace the data-dependent
comparison and subtraction in those helpers with constant-time unconditional
subtraction and masked selection.

In `@packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs`:
- Around line 161-172: The stealth entry points independently initialize
expensive Secp256k1 contexts; update derive_one_time_destination,
recognize_one_time_destination, and derive_one_time_secret_key in
packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs at lines
161-172, 200-200, and 225-225 to reuse a caller-provided context or the
crate-global SECP256K1. Remove each Secp256k1::new() allocation, thread the
context through the affected calls, and ensure derive_one_time_secret_key passes
it only to stealth_shared_point while leaving one_time_secret_key context-free.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c7e5d2fe-3388-47f1-b8fc-6fa3b5aab1c1

📥 Commits

Reviewing files that changed from the base of the PR and between 5d68612 and 6693d75.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (48)
  • packages/dashpay-contract/schema/v2/dashpay.schema.json
  • packages/dashpay-contract/src/lib.rs
  • packages/dashpay-contract/src/v2/mod.rs
  • packages/rs-dpp/src/errors/consensus/basic/basic_error.rs
  • packages/rs-dpp/src/errors/consensus/basic/identity/invalid_key_purpose_key_type_error.rs
  • packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs
  • packages/rs-dpp/src/errors/consensus/basic/identity/too_many_public_keys_of_purpose_error.rs
  • packages/rs-dpp/src/errors/consensus/codes.rs
  • packages/rs-dpp/src/identity/identity_public_key/purpose.rs
  • packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs
  • packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs
  • packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs
  • packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/mod.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs
  • packages/rs-drive/src/cache/data_contract.rs
  • packages/rs-drive/src/cache/system_contracts.rs
  • packages/rs-drive/src/drive/contract/refresh_cache/mod.rs
  • packages/rs-drive/src/drive/identity/estimation_costs/for_purpose_in_key_reference_tree/v0/mod.rs
  • packages/rs-drive/tests/deterministic_root_hash.rs
  • packages/rs-platform-encryption/Cargo.toml
  • packages/rs-platform-encryption/src/error.rs
  • packages/rs-platform-encryption/src/lib.rs
  • packages/rs-platform-encryption/src/stealth.rs
  • packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs
  • packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs
  • packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs
  • packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs
  • packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs
  • packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs
  • packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs
  • packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs
  • packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs
  • packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs
  • packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs
  • packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs
  • packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs
  • packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs
  • packages/rs-platform-version/src/version/v14.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs
  • packages/wasm-dpp/src/errors/consensus/consensus_error.rs
  • packages/wasm-dpp/src/identity/identity_public_key/purpose.rs
  • packages/wasm-dpp2/src/enums/keys/purpose.rs

Comment thread packages/rs-dpp/src/errors/consensus/basic/basic_error.rs
Comment thread packages/rs-platform-encryption/src/stealth.rs
Comment thread packages/wasm-dpp2/src/enums/keys/purpose.rs

@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.

Preliminary review — Codex only

The DIP-33 implementation is generally well-structured and version-dispatched, but three blocking issues remain: positional BasicError compatibility is broken, noncanonical 65-byte payment keys are accepted, and Swift rewrites the new key-purpose discriminants during persistence. Additional WASM and Kotlin boundary issues, retryable error flattening, and missing state-validation coverage should also be addressed.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking | 🟡 5 suggestion(s)

2 additional finding(s) omitted (not in diff).

🤖 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-dpp/src/errors/consensus/basic/basic_error.rs`:
- [BLOCKING] packages/rs-dpp/src/errors/consensus/basic/basic_error.rs:607-611: Append the new BasicError variants to preserve wire discriminants
  BasicError derives bincode Encode/Decode and PlatformSerialize/PlatformDeserialize, so its enum discriminants are positional. Inserting these variants before StateTransitionNotActiveError shifts every existing variant through TokenPricingScheduleEmptyError, causing previously encoded errors to decode as the wrong variant or fail. This directly violates the ordering invariant documented at the enum tail. Move both new variants after TokenPricingScheduleEmptyError without changing the order of any existing variant, and add a compatibility test that pins a pre-existing variant from after the current insertion point.

In `packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs`:
- [BLOCKING] packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs:148-161: Require compressed 33-byte encodings for payment keys
  This check constrains payment keys to ECDSA_SECP256K1 but does not constrain their encoding. DPP's ECDSA public-key parser explicitly accepts both 33-byte compressed and 65-byte uncompressed SEC1 keys, and the generic added-key signature verifier passes either representation to PublicKey::from_slice. A signed transition can therefore register a 65-byte PAYMENT_SCAN or PAYMENT_SPEND key even though DIP-33 specifies compressed SEC1 points and this validator's own comment requires the full compressed key. Besides producing noncanonical persisted state, compressed and uncompressed encodings of the same point also evade the in-transition duplicate check, which compares raw bytes. Reject payment-purpose keys unless data().len() is exactly 33 and add a regression test using a valid 65-byte key.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:1798-1804: Preserve payment-key purposes across the Swift FFI boundary
  Rust emits each DPP purpose as its raw u8 value, so the new PAYMENT_SCAN and PAYMENT_SPEND keys reach Swift as 7 and 8. Swift's KeyPurpose enum still ends at 6, and this fallback silently persists either new value as authentication. The cold-start restore path later sends that stored raw value back to Rust, changing the key's meaning, while ManagedIdentity.getPublicKeys() rejects the same values as unknown. Add paymentScan/paymentSpend cases with raw values 7 and 8, update exhaustive switches, and replace this semantic fallback with an unsupported-discriminant error rather than coercing an unknown purpose to authentication.

In `packages/wasm-dpp2/src/enums/keys/purpose.rs`:
- [SUGGESTION] packages/wasm-dpp2/src/enums/keys/purpose.rs:65-66: Accept the payment-purpose strings emitted by the WASM getter
  The IdentityPublicKey purpose getter emits PAYMENT_SCAN and PAYMENT_SPEND. The setter lowercases those strings to payment_scan and payment_spend, but this parser accepts only paymentscan and paymentspend. Consequently, assigning key.purpose = key.purpose or copying a key through its exposed properties fails only for the new variants. Accept the underscore forms as aliases so getter values round-trip through constructors and setters.
- [SUGGESTION] packages/wasm-dpp2/src/enums/keys/purpose.rs:74-84: Reject fractional numeric payment-purpose values
  PurposeLike's TypeScript union is not a runtime constraint. Casting an arbitrary JavaScript number directly to u8 truncates fractional values, so 7.5 is accepted as PAYMENT_SCAN and 8.5 as PAYMENT_SPEND. Validate that the number is finite, integral, and within the supported 0 through 8 range before casting. Rust's float-to-integer cast saturates rather than wraps, so positive overflow currently becomes 255 and is rejected; the newly introduced defect is specifically the acceptance of fractional values that truncate to 7 or 8.

In `packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs`:
- [SUGGESTION] packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs:37-84: Cover payment-key uniqueness against persisted identity state
  This helper implements the consensus-critical against-state half of the one-active-key invariant, including the state-sensitive rotation exception, but rs-drive-abci contains no PAYMENT_SCAN or PAYMENT_SPEND tests. Add tests proving that an add-only update conflicts with an existing active key, disabling that exact key while adding a replacement succeeds, disabled historical keys do not block replacement, and scan/spend purposes remain independent. A pipeline-level test should also pin rejection before protocol version 14 and acceptance at version 14.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt:27-35: Expose the new DPP key purposes in Kotlin
  The generic Kotlin identity registration and update codecs serialize KeyPurpose.ffiValue, but this mirror stops at OWNER=6. Kotlin callers therefore cannot construct PAYMENT_SCAN=7 or PAYMENT_SPEND=8 rows even though the Rust decoder now supports them. Add both enum cases and update the example application's exhaustive when expressions and purpose-selection logic so Android callers can use the protocol-version-14 feature.

In `packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs:171-229: Preserve retryable stealth crypto errors
  The lower crypto layer exposes ZeroStealthTweak as a recoverable condition that requires the payer to choose a fresh ephemeral key, but every stealth operation converts CryptoError into InvalidIdentityData(String). Callers cannot distinguish the retry condition without parsing display text. Add a typed PlatformWalletError source variant for platform_encryption::CryptoError, or a dedicated zero-tweak variant, and propagate it with ?. Errors constructing or deriving the DIP-9 path should likewise use the existing KeyDerivation category rather than InvalidIdentityData.

Comment on lines +607 to +611
#[error(transparent)]
InvalidKeyPurposeKeyTypeError(InvalidKeyPurposeKeyTypeError),

#[error(transparent)]
TooManyPublicKeysOfPurposeError(TooManyPublicKeysOfPurposeError),

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.

🔴 Blocking: Append the new BasicError variants to preserve wire discriminants

BasicError derives bincode Encode/Decode and PlatformSerialize/PlatformDeserialize, so its enum discriminants are positional. Inserting these variants before StateTransitionNotActiveError shifts every existing variant through TokenPricingScheduleEmptyError, causing previously encoded errors to decode as the wrong variant or fail. This directly violates the ordering invariant documented at the enum tail. Move both new variants after TokenPricingScheduleEmptyError without changing the order of any existing variant, and add a compatibility test that pins a pre-existing variant from after the current insertion point.

source: ['codex']

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.

Resolved in b2b51fbAppend the new BasicError variants to preserve wire discriminants no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +148 to +161
if let Some(invalid_type_key) = identity_public_keys_with_witness.iter().find(|key| {
Purpose::payment_purposes().contains(&key.purpose())
&& key.key_type() != KeyType::ECDSA_SECP256K1
}) {
return Ok(SimpleConsensusValidationResult::new_with_error(
BasicError::InvalidKeyPurposeKeyTypeError(InvalidKeyPurposeKeyTypeError::new(
invalid_type_key.id(),
invalid_type_key.purpose(),
invalid_type_key.key_type(),
vec![KeyType::ECDSA_SECP256K1],
))
.into(),
));
}

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.

🔴 Blocking: Require compressed 33-byte encodings for payment keys

This check constrains payment keys to ECDSA_SECP256K1 but does not constrain their encoding. DPP's ECDSA public-key parser explicitly accepts both 33-byte compressed and 65-byte uncompressed SEC1 keys, and the generic added-key signature verifier passes either representation to PublicKey::from_slice. A signed transition can therefore register a 65-byte PAYMENT_SCAN or PAYMENT_SPEND key even though DIP-33 specifies compressed SEC1 points and this validator's own comment requires the full compressed key. Besides producing noncanonical persisted state, compressed and uncompressed encodings of the same point also evade the in-transition duplicate check, which compares raw bytes. Reject payment-purpose keys unless data().len() is exactly 33 and add a regression test using a valid 65-byte key.

source: ['codex']

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.

Resolved in b2b51fbRequire compressed 33-byte encodings for payment keys no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +65 to +66
"paymentscan" => Ok(PurposeWasm::PAYMENT_SCAN),
"paymentspend" => Ok(PurposeWasm::PAYMENT_SPEND),

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.

🟡 Suggestion: Accept the payment-purpose strings emitted by the WASM getter

The IdentityPublicKey purpose getter emits PAYMENT_SCAN and PAYMENT_SPEND. The setter lowercases those strings to payment_scan and payment_spend, but this parser accepts only paymentscan and paymentspend. Consequently, assigning key.purpose = key.purpose or copying a key through its exposed properties fails only for the new variants. Accept the underscore forms as aliases so getter values round-trip through constructors and setters.

Suggested change
"paymentscan" => Ok(PurposeWasm::PAYMENT_SCAN),
"paymentspend" => Ok(PurposeWasm::PAYMENT_SPEND),
"paymentscan" | "payment_scan" => Ok(PurposeWasm::PAYMENT_SCAN),
"paymentspend" | "payment_spend" => Ok(PurposeWasm::PAYMENT_SPEND),

source: ['codex']

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.

Resolved in b2b51fbAccept the payment-purpose strings emitted by the WASM getter no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +37 to +84
pub(super) fn validate_payment_key_uniqueness_in_state_v0(
identity_id: Identifier,
public_keys_being_added: &[IdentityPublicKeyInCreation],
public_key_ids_to_disable: &[KeyID],
drive: &Drive,
_execution_context: &mut StateTransitionExecutionContext,
transaction: TransactionArg,
platform_version: &PlatformVersion,
) -> Result<SimpleConsensusValidationResult, Error> {
let payment_purposes_being_added: Vec<Purpose> = Purpose::payment_purposes()
.into_iter()
.filter(|purpose| {
public_keys_being_added
.iter()
.any(|key| key.purpose() == *purpose)
})
.collect();

if payment_purposes_being_added.is_empty() {
return Ok(SimpleConsensusValidationResult::new());
}

let identity_key_request = IdentityKeysRequest {
identity_id: identity_id.to_buffer(),
request_type: KeyRequestType::AllKeys,
limit: None,
offset: None,
};
let existing_keys = drive.fetch_identity_keys::<KeyIDIdentityPublicKeyPairBTreeMap>(
identity_key_request,
transaction,
platform_version,
)?;

for purpose in payment_purposes_being_added {
let conflicting_active_key_exists = existing_keys.values().any(|key| {
key.purpose() == purpose
&& key.disabled_at().is_none()
&& !public_key_ids_to_disable.contains(&key.id())
});
if conflicting_active_key_exists {
return Ok(SimpleConsensusValidationResult::new_with_error(
TooManyPublicKeysOfPurposeError::new(purpose, 1).into(),
));
}
}

Ok(SimpleConsensusValidationResult::new())

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.

🟡 Suggestion: Cover payment-key uniqueness against persisted identity state

This helper implements the consensus-critical against-state half of the one-active-key invariant, including the state-sensitive rotation exception, but rs-drive-abci contains no PAYMENT_SCAN or PAYMENT_SPEND tests. Add tests proving that an add-only update conflicts with an existing active key, disabling that exact key while adding a replacement succeeds, disabled historical keys do not block replacement, and scan/spend purposes remain independent. A pipeline-level test should also pin rejection before protocol version 14 and acceptance at version 14.

source: ['codex']

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.

Resolved in this update — Cover payment-key uniqueness against persisted identity state no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines 80 to +84
4 => Ok(PurposeWasm::SYSTEM),
5 => Ok(PurposeWasm::VOTING),
6 => Ok(PurposeWasm::OWNER),
7 => Ok(PurposeWasm::PAYMENT_SCAN),
8 => Ok(PurposeWasm::PAYMENT_SPEND),

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.

🟡 Suggestion: Reject fractional numeric payment-purpose values

PurposeLike's TypeScript union is not a runtime constraint. Casting an arbitrary JavaScript number directly to u8 truncates fractional values, so 7.5 is accepted as PAYMENT_SCAN and 8.5 as PAYMENT_SPEND. Validate that the number is finite, integral, and within the supported 0 through 8 range before casting. Rust's float-to-integer cast saturates rather than wraps, so positive overflow currently becomes 255 and is rejected; the newly introduced defect is specifically the acceptance of fractional values that truncate to 7 or 8.

source: ['coderabbit']

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.

Resolved in b2b51fbReject fractional numeric payment-purpose values no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +171 to +229
let shared = stealth_shared_point(&secp, ephemeral_secret, scan_public)
.map_err(|e| PlatformWalletError::InvalidIdentityData(e.to_string()))?;
let one_time_public = one_time_public_key(
&secp,
spend_public,
&shared,
&ephemeral_public,
rail.into(),
n,
)
.map_err(|e| PlatformWalletError::InvalidIdentityData(e.to_string()))?;
Ok((
destination_from_public_key(&one_time_public, rail, network),
ephemeral_public,
))
}

/// Receiver / watch service: re-derive the one-time destination for output `n`
/// given the payer's published `R`, the recipient's scan secret, and the
/// recipient's spend public key. Compare the result against the referenced
/// on-chain output. Requires no spend secret.
pub fn recognize_one_time_destination(
scan_secret: &SecretKey,
spend_public: &PublicKey,
ephemeral_public: &PublicKey,
rail: PaymentRail,
n: u32,
network: Network,
) -> Result<OneTimeDestination, PlatformWalletError> {
let secp = Secp256k1::new();
let shared = stealth_shared_point(&secp, scan_secret, ephemeral_public)
.map_err(|e| PlatformWalletError::InvalidIdentityData(e.to_string()))?;
let one_time_public = one_time_public_key(
&secp,
spend_public,
&shared,
ephemeral_public,
rail.into(),
n,
)
.map_err(|e| PlatformWalletError::InvalidIdentityData(e.to_string()))?;
Ok(destination_from_public_key(&one_time_public, rail, network))
}

/// Spender: derive the one-time secret key controlling output `n`, given the
/// payer's published `R`, the recipient's scan secret, and the recipient's
/// spend secret.
pub fn derive_one_time_secret_key(
scan_secret: &SecretKey,
spend_secret: &SecretKey,
ephemeral_public: &PublicKey,
rail: PaymentRail,
n: u32,
) -> Result<SecretKey, PlatformWalletError> {
let secp = Secp256k1::new();
let shared = stealth_shared_point(&secp, scan_secret, ephemeral_public)
.map_err(|e| PlatformWalletError::InvalidIdentityData(e.to_string()))?;
one_time_secret_key(spend_secret, &shared, ephemeral_public, rail.into(), n)
.map_err(|e| PlatformWalletError::InvalidIdentityData(e.to_string()))

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.

🟡 Suggestion: Preserve retryable stealth crypto errors

The lower crypto layer exposes ZeroStealthTweak as a recoverable condition that requires the payer to choose a fresh ephemeral key, but every stealth operation converts CryptoError into InvalidIdentityData(String). Callers cannot distinguish the retry condition without parsing display text. Add a typed PlatformWalletError source variant for platform_encryption::CryptoError, or a dedicated zero-tweak variant, and propagate it with ?. Errors constructing or deriving the DIP-9 path should likewise use the existing KeyDerivation category rather than InvalidIdentityData.

source: ['codex']

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.

Resolved in b2b51fbPreserve retryable stealth crypto errors no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

QuantumExplorer and others added 2 commits August 12, 2026 18:25
…nt keys, typed purposes across bindings

- move InvalidKeyPurposeKeyTypeError/TooManyPublicKeysOfPurposeError to the
  BasicError tail (positional bincode discriminants) and pin discriminants
  in tests
- require payment keys to be exactly 33-byte compressed SEC1 points (a
  65-byte uncompressed encoding of the same point also evaded the raw-bytes
  duplicate check)
- Swift: add paymentScan/paymentSpend to KeyPurpose; skip-and-warn on
  unknown discriminants instead of coercing to authentication
- Kotlin: add PAYMENT_SCAN/PAYMENT_SPEND to the KeyPurpose mirror
- wasm-dpp2: accept payment_scan/payment_spend string aliases; reject
  non-integral numeric purpose values
- platform-wallet: preserve retryable stealth crypto errors via a typed
  StealthCrypto variant; derivation errors use KeyDerivation
- drive-abci: state tests for payment-key uniqueness (conflict, rotation,
  disabled-key replacement, purpose independence, PV13 rejection)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same serialization as platformPaymentAddress: variant byte (0x00 P2PKH /
0x01 P2SH) + 20-byte HASH160. The Base58Check string bought nothing at
consensus (checksum unprovable there), while the byte form is exact-size
validated, smaller, symmetric with the platform field, and carries no
network byte — the network follows the Platform chain, so a mismatch is
unrepresentable. Matches the updated DIP-33 text.

Rebaselines: PV14 fees (check_tx, replace, delete) and deterministic
root hash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer

Copy link
Copy Markdown
Member Author

Addressed the review in b2b51fb + 07c5630.

Blockers

  • BasicError wire discriminants: both new variants moved to the enum tail; added tests pinning the encoded discriminants of StateTransitionNotActiveError (143) and TokenPricingScheduleEmptyError (172) plus the new tail positions (173/174), so any future mid-enum insertion fails CI.
  • 65-byte payment keys: structure validation v1 now requires data().len() == 33 for payment purposes (rejected as InvalidIdentityPublicKeyDataError), with a regression test that re-encodes a valid compressed key uncompressed.
  • Swift purpose coercion: KeyPurpose gains paymentScan = 7 / paymentSpend = 8; the persistence path no longer coerces unknown discriminants to .authentication — it skips the row with a warning (purpose, level, and type are all guarded), so a stored raw value can never change meaning on cold-start restore.

Suggestions

  • wasm-dpp2 parser accepts payment_scan/payment_spend (the lowercased getter output) and rejects non-finite/fractional/out-of-range numeric purposes instead of truncating.
  • Kotlin KeyPurpose mirror gains both purposes (+ example-app display arms).
  • platform-wallet stealth ops propagate platform_encryption::CryptoError through a typed StealthCrypto variant (retryable ZeroStealthTweak is now matchable); derivation-path errors use KeyDerivation.
  • drive-abci state tests for the one-active-payment-key invariant: add-only conflict, same-transition rotation, disabled-historical-key replacement, scan/spend independence, and PV13 rejection / PV14 acceptance through the full pipeline.

Also in this push (07c5630): corePaymentAddress is now the 21-byte storage form (variant byte + HASH160), symmetric with platformPaymentAddress, per the open question on this PR — DIP-33 updated to match in dashpay/dips#188. PV14 fee and root-hash baselines regenerated.

Verified locally: dpp 3893 tests, drive-abci full suite, drive, platform-wallet/encryption, dashpay-contract all green; workspace clippy (all-features) clean; Kotlin :sdk:compileDebugKotlin passes; Swift changes parse (xcframework build is CI's).

@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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 1798-1807: In the identity-key persistence flow around the
existing fetch/update logic, validate purpose, securityLevel, and keyType before
fetching or branching on an existing row. For supported discriminants, update
existing rows with the validated enum raw values as well as inserting new rows;
skip unsupported entries without coercion. Add coverage for payment purposes 7
and 8, including updates to already-persisted keys.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7abb592e-7465-4dfa-b1df-26f2869ec658

📥 Commits

Reviewing files that changed from the base of the PR and between 6693d75 and 07c5630.

📒 Files selected for processing (16)
  • packages/dashpay-contract/schema/v2/dashpay.schema.json
  • packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/AddIdentityKeyScreen.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt
  • packages/rs-dpp/src/address_funds/witness_verification_operations.rs
  • packages/rs-dpp/src/errors/consensus/basic/basic_error.rs
  • packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs
  • packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs
  • packages/rs-drive/tests/deterministic_root_hash.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/wasm-dpp2/src/enums/keys/purpose.rs
🚧 Files skipped from review as they are similar to previous changes (7)
  • packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs
  • packages/dashpay-contract/schema/v2/dashpay.schema.json
  • packages/rs-drive/tests/deterministic_root_hash.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs
  • packages/wasm-dpp2/src/enums/keys/purpose.rs
  • packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs

DashPay contract v2 (protocol version 14): the profile document gains two
optional public payment fields — corePaymentAddress (position 5) and
platformPaymentAddress (position 6), both the 21-byte storage form
(variant byte 0x00 P2PKH / 0x01 P2SH + 20-byte HASH160). Payments to
them are intentionally public and linkable to the profile (per DIP-33,
the public tier); no derivation-source publishing.

Follows the DPNS-v2 versioning pattern: schema/v2 +
system_data_contract_versions v3 (dashpay: 2) at PV14, with the
contract reload on the first block after the protocol change.

Rebaselines PV14 fees (check_tx, replace, delete), profile fixture
strings, and the deterministic root hash.

Extracted from the larger payment-addresses branch; payment detection
key purposes and stealth derivation follow in a separate PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer changed the title feat(dpp)!: payment addresses and payment key purposes (DIP-33) feat(dpp)!: dashpay payment detection keys and stealth derivation Aug 12, 2026
@QuantumExplorer
QuantumExplorer changed the base branch from v4.2-dev to feat/dashpay-tips-jar August 12, 2026 12:02
@QuantumExplorer

Copy link
Copy Markdown
Member Author

PR split: the public tips tier (dashpay contract v2 + corePaymentAddress/platformPaymentAddress profile fields, PV14 plumbing, cache guard, and all fee/root-hash rebaselines) has been extracted to #4380, and this PR is now stacked on it (base: feat/dashpay-tips-jar). This PR narrows to the notified tier: PAYMENT_SCAN/PAYMENT_SPEND key purposes with structure + state validation, the stealth derivation modules, and the language-binding mirrors. The merge from the new base also folds in ~9 days of v4.2-dev (the only interaction was the new drive_abci_validation_versions/v10.rs, which gained the validate_payment_key_uniqueness field). Review order: #4380 first, then this.

@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.

Preliminary review — Codex only

The payment-key model and stealth derivation are generally well structured, but three consensus blockers remain: creation paths accept contract-bounded payment keys that later fail internally, the uniqueness scan discards its query costs, and the new state rule modifies the existing identity-update v0 implementation. The Swift legacy C bridge also cannot decode the new purposes, and several public-API and activation-test improvements remain advisable.
Source: reviewer backends gpt-5.6-sol (general, security-auditor, rust-quality, ffi-engineer); final verifier backend gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking | 🟡 4 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 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-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs`:
- [BLOCKING] packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs:150-182: Reject contract bounds on payment keys during structure validation
  The payment-key checks enforce key type and compressed encoding but do not reject `contract_bounds`. Identity updates eventually call `validate_identity_public_keys_contract_bounds`, but the identity-create, identity-create-from-addresses, and identity-create-from-shielded-pool state paths do not run that validation. A creation transition can therefore pass consensus validation with a `PAYMENT_SCAN` or `PAYMENT_SPEND` key whose bounds are present. During identity insertion, `add_potential_contract_info_for_contract_bounded_key_v0` rejects every purpose other than `ENCRYPTION` or `DECRYPTION` with the internal `IdentityKeyBoundsError("purpose not available for key bounds")`. Reject payment-purpose keys with bounds in this shared structure validator using `InvalidKeyPurposeForContractBoundsError`, and add creation-path regression coverage so user-controlled input produces a deterministic consensus error instead of an execution failure.

In `packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs`:
- [BLOCKING] packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs:37-69: Meter the all-keys lookup used by payment-key uniqueness validation
  Every update adding a payment key performs an `AllKeys` query over the identity's complete key history. `fetch_identity_keys` builds GroveDB cost operations and then discards them, while this helper deliberately ignores `_execution_context`, so the potentially large read and deserialization work is never charged. Repeated payment-key rotations can therefore force validators to rescan a large identity cheaply. Use `fetch_identity_keys_with_costs`, pass the committed epoch, and add the resulting fee to the actual processor-owned context as `ValidationOperation::PrecalculatedOperation`. The identity-update implementation currently creates a separate local context while ignoring the outer context passed by the processor, so that context must also be threaded through; otherwise the operation will still be absent from the resulting `ExecutionEvent`.

In `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs`:
- [BLOCKING] packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs:124-139: Introduce a new identity-update state-validation version
  This inserts the payment-key uniqueness rule directly into `validate_state_v0`, while every Drive-ABCI validation table still selects identity-update state version 0. Consensus-critical version implementations must remain behaviorally immutable. Keep v0 unchanged, add a `state/v1` implementation containing the new rule and the correctly threaded execution context, extend the dispatcher to versions 0 and 1, and select version 1 only for protocol version 14. Tests should explicitly show that version 13 continues through v0 and version 14 uses v1.

In `packages/rs-sdk-ffi/src/identity/create_from_components.rs`:
- [SUGGESTION] packages/rs-sdk-ffi/src/identity/create_from_components.rs:111-125: Synchronize the legacy Rust C API with the new Swift purpose values
  Swift now exposes `paymentScan = 7` and `paymentSpend = 8`, and `identityToHandle` forwards those raw values through `DashSDKPublicKeyData.purpose`. This decoder only accepts purposes 0 through 6, so converting a `DPPIdentity` containing either new key into a native identity handle fails with `Invalid key purpose`; document and token operations that call `identityToHandle` consequently cannot use such identities. The standalone decoder in `packages/rs-sdk-ffi/src/identity/keys.rs:222-235` is also stale. Use the existing `Purpose::try_from` conversion, or explicitly accept 7 and 8 in both C entry points, so the Swift mirror and Rust boundary share the same discriminants.

In `packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs:51-56: Represent the closed payment key classes with an enum
  DIP-33 defines the scan, spend, and notification-out key classes, but the public derivation functions accept any `u32`. Values outside 0 through 2 still produce valid hardened BIP32 paths, so callers can derive nonstandard keys without receiving an error. Represent the role as a `PaymentKeyClass` enum and convert it to the hardened index internally. This makes invalid roles unrepresentable and gives callers an exhaustive API if another role is introduced later.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs:157-225: Reuse the secp256k1 context during destination scanning
  Each payer, receiver, and spender entry point constructs a fresh `Secp256k1` context. `recognize_one_time_destination` is intended to run repeatedly while scanning candidate notifications and output counters, making repeated context initialization unnecessary work on the hottest path. Accept a caller-owned context, provide a scanner object that owns one, or use the crate's shared context, then pass it through to the lower functions that already accept a borrowed context.

In `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs`:
- [SUGGESTION] packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs:2010-2229: Pin the activation test to protocol version 14
  The acceptance test uses `PlatformVersion::latest()`, so it will silently move to later protocol versions and stop protecting the version-14 table. Select `PlatformVersion::get(14)` explicitly. The version-13 test should also assert the expected paid `InvalidIdentityPublicKeySecurityLevelError` rather than merely checking that execution was unsuccessful; the current assertion passes for unrelated signature, nonce, serialization, balance, or internal failures and therefore does not prove the activation boundary.

Comment on lines +150 to +182
if let Some(invalid_type_key) = identity_public_keys_with_witness.iter().find(|key| {
Purpose::payment_purposes().contains(&key.purpose())
&& key.key_type() != KeyType::ECDSA_SECP256K1
}) {
return Ok(SimpleConsensusValidationResult::new_with_error(
BasicError::InvalidKeyPurposeKeyTypeError(InvalidKeyPurposeKeyTypeError::new(
invalid_type_key.id(),
invalid_type_key.purpose(),
invalid_type_key.key_type(),
vec![KeyType::ECDSA_SECP256K1],
))
.into(),
));
}

// DIP-33 publishes payment keys as compressed SEC1 points, but DPP's ECDSA
// parser also accepts 65-byte uncompressed keys — and the two encodings of
// the same point would evade the raw-bytes duplicate check above
if let Some(invalid_size_key) = identity_public_keys_with_witness.iter().find(|key| {
Purpose::payment_purposes().contains(&key.purpose()) && key.data().len() != 33
}) {
return Ok(SimpleConsensusValidationResult::new_with_error(
BasicError::InvalidIdentityPublicKeyDataError(
InvalidIdentityPublicKeyDataError::new(
invalid_size_key.id(),
PublicKeyValidationError::new(
"payment keys must be 33-byte compressed SEC1 points",
),
),
)
.into(),
));
}

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.

🔴 Blocking: Reject contract bounds on payment keys during structure validation

The payment-key checks enforce key type and compressed encoding but do not reject contract_bounds. Identity updates eventually call validate_identity_public_keys_contract_bounds, but the identity-create, identity-create-from-addresses, and identity-create-from-shielded-pool state paths do not run that validation. A creation transition can therefore pass consensus validation with a PAYMENT_SCAN or PAYMENT_SPEND key whose bounds are present. During identity insertion, add_potential_contract_info_for_contract_bounded_key_v0 rejects every purpose other than ENCRYPTION or DECRYPTION with the internal IdentityKeyBoundsError("purpose not available for key bounds"). Reject payment-purpose keys with bounds in this shared structure validator using InvalidKeyPurposeForContractBoundsError, and add creation-path regression coverage so user-controlled input produces a deterministic consensus error instead of an execution failure.

source: ['codex']

Comment on lines +37 to +69
pub(super) fn validate_payment_key_uniqueness_in_state_v0(
identity_id: Identifier,
public_keys_being_added: &[IdentityPublicKeyInCreation],
public_key_ids_to_disable: &[KeyID],
drive: &Drive,
_execution_context: &mut StateTransitionExecutionContext,
transaction: TransactionArg,
platform_version: &PlatformVersion,
) -> Result<SimpleConsensusValidationResult, Error> {
let payment_purposes_being_added: Vec<Purpose> = Purpose::payment_purposes()
.into_iter()
.filter(|purpose| {
public_keys_being_added
.iter()
.any(|key| key.purpose() == *purpose)
})
.collect();

if payment_purposes_being_added.is_empty() {
return Ok(SimpleConsensusValidationResult::new());
}

let identity_key_request = IdentityKeysRequest {
identity_id: identity_id.to_buffer(),
request_type: KeyRequestType::AllKeys,
limit: None,
offset: None,
};
let existing_keys = drive.fetch_identity_keys::<KeyIDIdentityPublicKeyPairBTreeMap>(
identity_key_request,
transaction,
platform_version,
)?;

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.

🔴 Blocking: Meter the all-keys lookup used by payment-key uniqueness validation

Every update adding a payment key performs an AllKeys query over the identity's complete key history. fetch_identity_keys builds GroveDB cost operations and then discards them, while this helper deliberately ignores _execution_context, so the potentially large read and deserialization work is never charged. Repeated payment-key rotations can therefore force validators to rescan a large identity cheaply. Use fetch_identity_keys_with_costs, pass the committed epoch, and add the resulting fee to the actual processor-owned context as ValidationOperation::PrecalculatedOperation. The identity-update implementation currently creates a separate local context while ignoring the outer context passed by the processor, so that context must also be threaded through; otherwise the operation will still be absent from the resulting ExecutionEvent.

source: ['codex']

Comment on lines +124 to +139
// DIP-33 payment keys: the identity may end up with at most one active
// key of each payment purpose. This must run even when no keys are
// being disabled (a pure add-only update), unlike the master key
// uniqueness check below.
validation_result.add_errors(
validate_payment_key_uniqueness_in_state(
self.identity_id(),
self.public_keys_to_add(),
self.public_key_ids_to_disable(),
drive,
&mut state_transition_execution_context,
tx,
platform_version,
)?
.errors,
);

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.

🔴 Blocking: Introduce a new identity-update state-validation version

This inserts the payment-key uniqueness rule directly into validate_state_v0, while every Drive-ABCI validation table still selects identity-update state version 0. Consensus-critical version implementations must remain behaviorally immutable. Keep v0 unchanged, add a state/v1 implementation containing the new rule and the correctly threaded execution context, extend the dispatcher to versions 0 and 1, and select version 1 only for protocol version 14. Tests should explicitly show that version 13 continues through v0 and version 14 uses v1.

source: ['codex']

Comment on lines +51 to +56
/// `key_class'` for the scan key.
pub const KEY_CLASS_SCAN: u32 = 0;
/// `key_class'` for the spend key.
pub const KEY_CLASS_SPEND: u32 = 1;
/// `key_class'` for the notification out-key.
pub const KEY_CLASS_NOTIF_OUT: u32 = 2;

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.

🟡 Suggestion: Represent the closed payment key classes with an enum

DIP-33 defines the scan, spend, and notification-out key classes, but the public derivation functions accept any u32. Values outside 0 through 2 still produce valid hardened BIP32 paths, so callers can derive nonstandard keys without receiving an error. Represent the role as a PaymentKeyClass enum and convert it to the hardened index internally. This makes invalid roles unrepresentable and gives callers an exhaustive API if another role is introduced later.

source: ['codex']

Comment on lines +157 to +225
pub fn derive_one_time_destination(
ephemeral_secret: &SecretKey,
scan_public: &PublicKey,
spend_public: &PublicKey,
rail: PaymentRail,
n: u32,
network: Network,
) -> Result<(OneTimeDestination, PublicKey), PlatformWalletError> {
let secp = Secp256k1::new();
let ephemeral_public = PublicKey::from_secret_key(&secp, ephemeral_secret);
let shared = stealth_shared_point(&secp, ephemeral_secret, scan_public)?;
let one_time_public = one_time_public_key(
&secp,
spend_public,
&shared,
&ephemeral_public,
rail.into(),
n,
)?;
Ok((
destination_from_public_key(&one_time_public, rail, network),
ephemeral_public,
))
}

/// Receiver / watch service: re-derive the one-time destination for output `n`
/// given the payer's published `R`, the recipient's scan secret, and the
/// recipient's spend public key. Compare the result against the referenced
/// on-chain output. Requires no spend secret.
pub fn recognize_one_time_destination(
scan_secret: &SecretKey,
spend_public: &PublicKey,
ephemeral_public: &PublicKey,
rail: PaymentRail,
n: u32,
network: Network,
) -> Result<OneTimeDestination, PlatformWalletError> {
let secp = Secp256k1::new();
let shared = stealth_shared_point(&secp, scan_secret, ephemeral_public)?;
let one_time_public = one_time_public_key(
&secp,
spend_public,
&shared,
ephemeral_public,
rail.into(),
n,
)?;
Ok(destination_from_public_key(&one_time_public, rail, network))
}

/// Spender: derive the one-time secret key controlling output `n`, given the
/// payer's published `R`, the recipient's scan secret, and the recipient's
/// spend secret.
pub fn derive_one_time_secret_key(
scan_secret: &SecretKey,
spend_secret: &SecretKey,
ephemeral_public: &PublicKey,
rail: PaymentRail,
n: u32,
) -> Result<SecretKey, PlatformWalletError> {
let secp = Secp256k1::new();
let shared = stealth_shared_point(&secp, scan_secret, ephemeral_public)?;
Ok(one_time_secret_key(
spend_secret,
&shared,
ephemeral_public,
rail.into(),
n,
)?)

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.

🟡 Suggestion: Reuse the secp256k1 context during destination scanning

Each payer, receiver, and spender entry point constructs a fresh Secp256k1 context. recognize_one_time_destination is intended to run repeatedly while scanning candidate notifications and output counters, making repeated context initialization unnecessary work on the hottest path. Accept a caller-owned context, provide a scanner object that owns one, or use the crate's shared context, then pass it through to the lower functions that already accept a borrowed context.

source: ['codex']

Comment on lines +2010 to +2229
let platform_version = PlatformVersion::latest();
let mut platform = TestPlatformBuilder::new()
.build_with_mock_rpc()
.set_genesis_state();
let (identity, signer, _, master_key) =
setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1));

let results = process_update(
&platform,
identity.id(),
&signer,
&master_key,
1,
1,
&[(10, Purpose::PAYMENT_SCAN, 4001)],
vec![],
platform_version,
)
.await;
assert_matches!(
results.as_slice(),
[StateTransitionExecutionResult::SuccessfulExecution { .. }]
);
}

#[tokio::test]
async fn test_adding_payment_key_conflicts_with_existing_active_key() {
let platform_version = PlatformVersion::latest();
let mut platform = TestPlatformBuilder::new()
.build_with_mock_rpc()
.set_genesis_state();
let (identity, signer, _, master_key) =
setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1));
install_payment_key(
&platform,
identity.id(),
10,
Purpose::PAYMENT_SCAN,
4002,
platform_version,
);

let results = process_update(
&platform,
identity.id(),
&signer,
&master_key,
1,
1,
&[(11, Purpose::PAYMENT_SCAN, 4003)],
vec![],
platform_version,
)
.await;
assert_matches!(
results.as_slice(),
[StateTransitionExecutionResult::PaidConsensusError {
error: ConsensusError::BasicError(BasicError::TooManyPublicKeysOfPurposeError(
_
)),
..
}]
);
}

#[tokio::test]
async fn test_rotating_payment_key_by_disabling_in_same_transition_is_valid() {
let platform_version = PlatformVersion::latest();
let mut platform = TestPlatformBuilder::new()
.build_with_mock_rpc()
.set_genesis_state();
let (identity, signer, _, master_key) =
setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1));
let old_key = install_payment_key(
&platform,
identity.id(),
10,
Purpose::PAYMENT_SCAN,
4004,
platform_version,
);

let results = process_update(
&platform,
identity.id(),
&signer,
&master_key,
1,
1,
&[(11, Purpose::PAYMENT_SCAN, 4005)],
vec![old_key.id()],
platform_version,
)
.await;
assert_matches!(
results.as_slice(),
[StateTransitionExecutionResult::SuccessfulExecution { .. }]
);
}

#[tokio::test]
async fn test_previously_disabled_payment_key_does_not_block_replacement() {
let platform_version = PlatformVersion::latest();
let mut platform = TestPlatformBuilder::new()
.build_with_mock_rpc()
.set_genesis_state();
let (identity, signer, _, master_key) =
setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1));
let old_key = install_payment_key(
&platform,
identity.id(),
10,
Purpose::PAYMENT_SCAN,
4006,
platform_version,
);

// first update: disable the old key only
let results = process_update(
&platform,
identity.id(),
&signer,
&master_key,
1,
1,
&[],
vec![old_key.id()],
platform_version,
)
.await;
assert_matches!(
results.as_slice(),
[StateTransitionExecutionResult::SuccessfulExecution { .. }]
);

// second update: the disabled historical key must not block adding
// a replacement
let results = process_update(
&platform,
identity.id(),
&signer,
&master_key,
2,
2,
&[(11, Purpose::PAYMENT_SCAN, 4007)],
vec![],
platform_version,
)
.await;
assert_matches!(
results.as_slice(),
[StateTransitionExecutionResult::SuccessfulExecution { .. }]
);
}

#[tokio::test]
async fn test_scan_and_spend_purposes_are_independent() {
let platform_version = PlatformVersion::latest();
let mut platform = TestPlatformBuilder::new()
.build_with_mock_rpc()
.set_genesis_state();
let (identity, signer, _, master_key) =
setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1));
install_payment_key(
&platform,
identity.id(),
10,
Purpose::PAYMENT_SCAN,
4008,
platform_version,
);

let results = process_update(
&platform,
identity.id(),
&signer,
&master_key,
1,
1,
&[(11, Purpose::PAYMENT_SPEND, 4009)],
vec![],
platform_version,
)
.await;
assert_matches!(
results.as_slice(),
[StateTransitionExecutionResult::SuccessfulExecution { .. }]
);
}

#[tokio::test]
async fn test_payment_keys_rejected_before_protocol_version_14() {
let platform_version = PlatformVersion::get(13).expect("expected version 13");
let mut platform = TestPlatformBuilder::new()
.with_initial_protocol_version(13)
.build_with_mock_rpc()
.set_genesis_state();
let (identity, signer, _, master_key) =
setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1));

let results = process_update(
&platform,
identity.id(),
&signer,
&master_key,
1,
1,
&[(10, Purpose::PAYMENT_SCAN, 4010)],
vec![],
platform_version,
)
.await;
assert!(
!matches!(
results.as_slice(),
[StateTransitionExecutionResult::SuccessfulExecution { .. }]
),
"payment keys must be rejected before protocol version 14, got: {:?}",
results
);

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.

🟡 Suggestion: Pin the activation test to protocol version 14

The acceptance test uses PlatformVersion::latest(), so it will silently move to later protocol versions and stop protecting the version-14 table. Select PlatformVersion::get(14) explicitly. The version-13 test should also assert the expected paid InvalidIdentityPublicKeySecurityLevelError rather than merely checking that execution was unsuccessful; the current assertion passes for unrelated signature, nonce, serialization, balance, or internal failures and therefore does not prove the activation boundary.

source: ['codex']

QuantumExplorer and others added 6 commits August 12, 2026 21:04
…er, cover the v13-v14 boundary

Address review: the 21-byte schema constraint alone accepted values whose
leading byte is not a supported address type. A new data trigger (bindings
list v2, PV14) rejects profile creates/replaces whose corePaymentAddress or
platformPaymentAddress does not start with 0x00 (P2PKH) or 0x01 (P2SH) —
unlike a Base58Check string, the storage form has no checksum, so this
check makes accepted values fully decodable.

- profile fixtures now carry valid type bytes (guarded to schema versions
  that have the fields, so protocol-version-11 variants are untouched)
- acceptance/rejection matrix test for both fields across leading bytes
- boundary test drives the v13→v14 upgrade through the production
  perform_events_on_first_block_of_protocol_change dispatcher with a
  stored v1 profile and warmed contract cache, asserting both fields
  absent before, present after, and the legacy profile still readable
- pre-upgrade assertion now also covers platformPaymentAddress
- rebaselines for the schema description change (check_tx fees, root hash)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…xed test name

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Base automatically changed from feat/dashpay-tips-jar to v4.2-dev August 12, 2026 16:15
@QuantumExplorer
QuantumExplorer marked this pull request as draft August 12, 2026 18:08
@QuantumExplorer

Copy link
Copy Markdown
Member Author

Parked (converted to draft), not abandoned.

The non-public payment tier has been re-scoped: the public tips tier (#4380, merged) plus shielded-pool payments cover the product need, so the notified/stealth tier this PR implements — PAYMENT_SCAN/PAYMENT_SPEND identity key purposes, their structure/state validation, and the stealth one-time-address derivation — has no consumer for now, and we don't want to ship dormant consensus surface.

The deciding argument: any payment-time Platform artifact (the payment notification that stealth discovery depends on) is timing-correlatable with the settlement transaction by a global observer, which defeats the unlinkability goal. The clean alternatives are either chain-native scanning (a BIP352-style DAPI tweak index — real new infrastructure) or simply routing private payments through the shielded pool, which already exists and hides the amount as well. We chose the pool.

Everything here remains reviewable and revivable: the pastaclaw round-1 findings are all addressed, the branch stays, and the published detection keys were deliberately made BIP352-shaped so a future scanning-based tier can adopt them unchanged. DIP-33 (dashpay/dips#188) is being redrafted to the reduced scope with this design recorded as considered-and-deferred.

@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.

Preliminary review — Codex only

The payment-purpose model and stealth derivation are generally coherent, but three consensus blockers remain: creation paths accept contract-bounded payment keys that later fail internally, the uniqueness scan is unmetered, and the new uniqueness behavior was added directly to identity-update state-validation v0. Four API, FFI, performance, and activation-test suggestions also remain valid; Opus review is deferred until the Codex blocker gate clears.
Source: reviewer backend gpt-5.6-sol; final verifier backend gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking | 🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

6 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 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-sdk-ffi/src/identity/create_from_components.rs`:
- [SUGGESTION] packages/rs-sdk-ffi/src/identity/create_from_components.rs:111-125: Synchronize the legacy Rust C API with the new Swift purpose values
  Swift now exposes `paymentScan = 7` and `paymentSpend = 8`, and `identityToHandle` forwards every public key's raw purpose through `DashSDKPublicKeyData`. This decoder still accepts only values 0 through 6, so converting a `DPPIdentity` containing a payment key into a native identity handle fails with `Invalid key purpose`. Document and token operations using this bridge consequently cannot use such identities. The separate decoder in `packages/rs-sdk-ffi/src/identity/keys.rs` is also stale. Use the canonical `Purpose::try_from` conversion, or explicitly accept discriminants 7 and 8 at both C entry points.

In `packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs`:
- [BLOCKING] packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs:150-182: Reject contract bounds on payment keys during structure validation
  (existing thread: https://github.com/dashpay/platform/pull/4272#discussion_r3766545358)
  The payment-purpose checks enforce key type and compressed encoding but do not reject `contract_bounds`, despite the PR defining payment keys as unbounded. Identity updates later call `validate_identity_public_keys_contract_bounds`, but identity-create, identity-create-from-addresses, and identity-create-from-shielded-pool do not perform that validation. Those creation transitions can therefore pass consensus validation with a bounded `PAYMENT_SCAN` or `PAYMENT_SPEND` key and reach Drive insertion, where `add_potential_contract_info_for_contract_bounded_key_v0` rejects every purpose other than encryption or decryption with the internal `IdentityKeyBoundsError("purpose not available for key bounds")`. Reject bounded payment keys here with `InvalidKeyPurposeForContractBoundsError`, and add creation-pipeline coverage so malformed user input produces a deterministic consensus error rather than an internal execution failure.

In `packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs`:
- [BLOCKING] packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs:37-69: Meter the all-keys lookup used by payment-key uniqueness validation
  (existing thread: https://github.com/dashpay/platform/pull/4272#discussion_r3766545363)
  Every update adding a payment key performs an unbounded `AllKeys` query over the identity's complete key history. `fetch_identity_keys` executes the GroveDB query and collects its low-level operations, but then discards those operations, while this helper explicitly ignores `_execution_context`. Repeated payment-key rotations can therefore force validators to read and deserialize a growing key history without charging for that work. Use `fetch_identity_keys_with_costs` with the last committed epoch and record its `FeeResult` as `ValidationOperation::PrecalculatedOperation`. The identity-update state implementation must also receive and use the processor-owned execution context instead of its private local context; otherwise the new charge will not be included in the resulting `ExecutionEvent`.

In `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs`:
- [BLOCKING] packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs:124-139: Introduce a new identity-update state-validation version
  (existing thread: https://github.com/dashpay/platform/pull/4272#discussion_r3766545366)
  The payment-key uniqueness rule was inserted directly into `validate_state_v0`. Both the protocol-13 and protocol-14 Drive-ABCI validation tables still select identity-update state version 0, so the PR changes an existing consensus implementation in place. Restore v0 to its prior behavior, add a `state/v1` implementation containing the uniqueness rule and correctly threaded execution context, extend the dispatcher to support versions 0 and 1, and select version 1 only in the protocol-14 validation table. Add dispatch coverage showing that protocol 13 retains v0 while protocol 14 activates v1.

In `packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs:51-123: Represent the closed payment key classes with an enum
  (existing thread: https://github.com/dashpay/platform/pull/4272#discussion_r3766545370)
  The module defines the three DIP-33 key classes—scan, spend, and notification out—as constants, but both public derivation APIs accept an unrestricted `u32`. Any other hardened value therefore produces a valid-looking path for a nonstandard key class instead of being rejected. Introduce a `PaymentKeyClass` enum and convert its variants to hardened indices internally. That makes invalid roles unrepresentable and gives callers exhaustive handling if another standardized class is added later.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs:157-225: Reuse the secp256k1 context during destination scanning
  (existing thread: https://github.com/dashpay/platform/pull/4272#discussion_r3766545373)
  Each payer, receiver, and spender entry point constructs and randomizes a fresh `Secp256k1` context. `recognize_one_time_destination` is intended to run repeatedly while scanning candidate notifications and output counters, so this repeatedly allocates and initializes a context on the hottest path. Accept a caller-owned context, provide a scanner that owns one, or use an available shared context, then pass it into the lower functions that already borrow a context.

In `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs`:
- [SUGGESTION] packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs:2008-2229: Pin the activation test to protocol version 14
  (existing thread: https://github.com/dashpay/platform/pull/4272#discussion_r3766545380)
  The protocol-14 acceptance tests use `PlatformVersion::latest()`, so they will silently move to later protocol versions and stop protecting the version-14 dispatch table. Select `PlatformVersion::get(14)` explicitly. The protocol-13 test also asserts only that execution was not successful; it should match the expected paid `InvalidIdentityPublicKeySecurityLevelError`, because unrelated signature, nonce, serialization, balance, or internal failures currently satisfy the assertion without proving the activation boundary.

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.

2 participants