Skip to content

feat(sdk)!: key limits, DIP-14 sub-feature derivation and decode-any-kind for DashPay Connect - #4833

Closed
PastaPastaPasta wants to merge 4 commits into
dashpay:v4.2-devfrom
PastaPastaPasta:feat/connect-v2-sdk-groundwork
Closed

PastaPastaPasta wants to merge 4 commits into
dashpay:v4.2-devfrom
PastaPastaPasta:feat/connect-v2-sdk-groundwork

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Sep 19, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

SDK groundwork for DashPay Connect v2 (the wallet-to-dApp login redesign). The wallet derives every key an app is allowed to hold, registers session keys with a budget and an expiry, and describes any state transition an app asks it to sign before the user approves it. This PR delivers the three dashpay/platform rows of that plan that the mobile wallets need before their own work can start; the Drive fix and the key-exchange system contract are separate PRs.

What was done?

1. Key limits through the FFI (platform-wallet, platform-wallet-ffi)

IdentityPubkeyFFI already carried has_total_budget / total_budget / has_expires_at / expires_at (#4811). This PR pins the path with tests: a row with either limit set decodes to IdentityPublicKey::V1, and IdentityUpdateTransition::try_from_identity_with_signer (which update_identity_with_signer calls) turns it into IdentityPublicKeyInCreation::V1 so the limits sit inside the signed bytes; a row with neither stays V0. The struct and update_identity_with_signer docs now state which protocol 14 rules are left to consensus rather than duplicated in the FFI (limits only on AUTHENTICATION keys below MASTER, non-zero budget, expiry after the registering block).

ParsedIdentityUpdatePublicKeyFFI gains the same four fields at the end, so a parsed identity update shows the limits of every key it adds; Swift IdentityPubkey (which already had totalBudget / expiresAt, and ContractBounds.contractGroup(id:)) reads them.

2. DIP-14 sub-feature derivation

New connect_key_derivation_path / derive_connect_keypair_from_master in platform-wallet, and the FFI entry dash_sdk_derive_connect_key_with_resolver next to dash_sdk_derive_identity_key_at_slot_with_resolver, derive from the resolver-backed seed at

m / 9' / coin' / 5' / <subFeature>' / 0' / <identityId>' / <leaf>' [ / <purpose>' ]
  • coin' follows the network as the existing identity paths do (5' mainnet, 1' otherwise).
  • subFeature' is 6' for the session authentication key (leaf = the connect request id, hash256(appEphemeralPubKey)) or 7' for the app encryption pair (leaf = the id of the data contract the key is bound to).
  • 0' is the DIP-13 key type slot (ECDSA secp256k1).
  • identityId' and leaf' are DIP-14 256-bit hardened children (ChildNumber::Hardened256), so nothing wallet-local (identity ordinal, key counter) is an input and two devices restored from one seed derive the same key without coordination.
  • purpose', present only on the encryption path, is one further hardened child holding the DPP purpose discriminant, 1' ENCRYPTION or 2' DECRYPTION, so an identity holds the DashPay-style pair per contract. The FFI takes purpose: u32 with 0 meaning no purpose level and refuses anything other than 0, 1, 2; the Swift and Kotlin wrappers take a two-case ConnectKeyPurpose (nil = no purpose level) so 0 is unrepresentable there.

This is the DIP-13 amendment in dashpay/dips#191 (sub-features 6' and 7' plus the DIP-9 table row). Vectors for the all-zero-entropy test mnemonic, identity 0x35*32, leaf 0x6B*32 are pinned in Rust and re-derived through the FFI by the Swift tests.

Surfaces: Swift ManagedPlatformWallet.deriveConnectKey(subFeature:identityId:leaf:purpose:network:storage:); Kotlin PlatformWalletManager.deriveConnectKey(walletId, subFeature, identityId, leaf, purpose) over a new JNI export IdentityNative.deriveConnectKeyWithResolver. Both resolve the mnemonic through the client-owned resolver for the call only; the Rust side zeroizes its copy. While adding withExtendedLifetime(resolver) around the new Swift FFI call, the same omission in the pre-existing deriveIdentityAuthKeyAtSlot was fixed too (ARC could otherwise release the resolver's passUnretained ctx mid-call).

3. Decode-any-kind parse_state_transition

platform_wallet_parse_state_transition previously accepted only an IdentityUpdate or a batch carrying exactly one TokenDirectPurchase and rejected everything else. It now decodes every StateTransition kind with the untrusted decoder and returns:

  • common fields for every kind: kind_name (StateTransition::name()), owner_id, is_signed, and serialized (the bytes that actually decoded, always tagged, so a client signs what it showed);
  • typed payloads for Batch (one row per batched transition: contract id, document type / document id or token id / position, action name, amount, recipient), IdentityUpdate (every added key with purpose, level, bounds and limits; every disabled key id), IdentityCreditTransfer (recipient, amount) and DataContractCreate / DataContractUpdate (contract id, owner, document type names);
  • PARSED_STATE_TRANSITION_KIND_OTHER with the common fields only for every other kind, so the sheet can show a structured dump instead of refusing.

Tagless framing is still accepted, but every framing is now tried and a payload that decodes under more than one is refused as ambiguous rather than described under whichever was tried first.

Surfaces: Swift parseStateTransition(_:) returns a ParsedStateTransition (kindName, ownerId, isSigned, serialized, kind: ParsedStateTransitionKind); Kotlin gains StateTransitionParser.parse(bytes) over a new JNI export TransactionsNative.parseStateTransition that packs the result into a big-endian blob (layout documented in rs-unified-sdk-jni/src/parse_state_transition.rs).

How Has This Been Tested?

Rust, on the three touched crates (platform-wallet, platform-wallet-ffi, rs-unified-sdk-jni):

  • cargo test -p platform-wallet-ffi -p platform-wallet -p rs-unified-sdk-jni --lib: 1823 passed, 0 failed. New tests cover: V1-vs-V0 key in creation for limited/unlimited rows (FFI and platform-wallet); path shape, determinism, pinned vectors and cross-input distinctness of the connect derivation; the FFI derive against the library vector, distinct leaves/purposes/sub-features, zeroizing free, resolver miss, null inputs, rejected purpose; every described kind parsing (mixed batch of document create + transfer + token transfer + direct purchase, identity update with a limited group-bound key, credit transfer, contract create and update), tagless framing restoring the tag, ambiguous framing refused, OTHER for a masternode vote, error-path frees, and the fixture bytes pinned for the client suites; JNI blob round-trips pinned as goldens.
  • cargo clippy -p platform-wallet-ffi -p platform-wallet -p rs-unified-sdk-jni --all-targets -- -D warnings: clean.
  • cargo fmt --all -- --check: clean.

Swift (packages/swift-sdk, macOS slice of DashSDKFFI.xcframework built with build_ios.sh --target mac --profile dev):

  • swift test --filter SwiftDashSDKTests: 612 tests, 0 failures (1 pre-existing skip). New ConnectKeyDerivationTests (3) re-derive the pinned vectors through the FFI and check distinctness and error paths; new ParseStateTransitionTests (7) decode one fixture of each kind. The fixtures under SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/ are serialized by the Rust test fixture_bytes_are_pinned_for_the_client_suites, which include_str!s the same files, so the two suites cannot disagree about the bytes.

Kotlin (packages/kotlin-sdk): implemented but not run locally. The sdk module is an Android library and this machine has no Android SDK (./gradlew :sdk:testDebugUnitTest stops at SDK location not found). The new StateTransitionParserTest (5 tests) is a host-JVM test that decodes the goldens under sdk/src/test/resources/golden/parsed_*_v1.bin, which the JNI tests include_bytes! and assert byte-for-byte, so the layout is verified from the Rust end here and the Kotlin end in CI's Kotlin job.

Breaking Changes

platform_wallet_parse_state_transition now describes every kind instead of refusing all but two, and its result struct changed shape:

  • Removed: PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE, ParsedTokenDirectPurchaseFFI, and the token_direct_purchase field of ParsedStateTransitionFFI. A direct purchase now arrives as a Batch row with action == "DirectPurchase", amount = total agreed price.
  • ParsedStateTransitionFFI gained kind_name, has_owner_id, owner_id, is_signed, serialized, serialized_len, batch, credit_transfer, data_contract; PARSED_STATE_TRANSITION_KIND_BATCH (2) replaces the removed constant's value.
  • ParsedIdentityUpdatePublicKeyFFI gained has_total_budget, total_budget, has_expires_at, expires_at (appended).
  • Swift: ManagedPlatformWallet.ParsedTokenPurchaseTransition and the two-case ParsedStateTransition enum are removed; parseStateTransition(_:) returns the new ParsedStateTransition struct. IdentityPubkey and ParsedIdentityUpdateTransition are now Equatable.
  • Rust (crate-internal): deserialize_transition_with_flexible_framing returns (StateTransition, Vec<u8>).

No wire format, consensus or data-contract change.

Checklist:

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

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added DashPay Connect key derivation for session authentication and app encryption, including optional encryption and decryption purposes.
    • Added state-transition parsing for identity updates, batches, credit transfers, data-contract changes, and unknown transition types.
    • Added structured transition details, including ownership, signing status, serialized data, document actions, token transfers, and contract metadata.
    • Added support for identity public-key usage limits and expiration metadata.
  • Bug Fixes

    • Improved handling and validation of malformed, ambiguous, truncated, and unsupported transition data.

PastaPastaPasta and others added 4 commits September 18, 2026 18:49
…ation

IdentityPubkeyFFI rows with has_total_budget / has_expires_at already became IdentityPublicKey::V1 through key_with_row_limits (dashpay#4811); this documents the consensus rules the FFI deliberately leaves to Platform (AUTHENTICATION below MASTER, non-zero budget, future expiry) and pins the path with tests: a limited row decodes to a V1 key and reaches IdentityUpdateTransition::try_from_identity_with_signer as IdentityPublicKeyInCreation::V1 with the limits inside the signed bytes, while an unlimited row stays V0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…feature paths

Adds connect_key_derivation_path / derive_connect_keypair_from_master, building m/9'/coin'/5'/<subFeature>'/0'/<identityId>'/<leaf>'[/<purpose>'] with DIP-14 256-bit hardened children for the identity id and the leaf, so nothing wallet-local (identity ordinal, key counter) is an input and two devices restored from one seed derive the same key. Sub-feature 6' is the session authentication key (leaf = request id); 7' is the app encryption pair (leaf = bound contract id, purpose' = 1 ENCRYPTION or 2 DECRYPTION). Registered by the DIP-13 amendment dashpay/dips#191.

The FFI entry dash_sdk_derive_connect_key_with_resolver follows dash_sdk_derive_identity_key_at_slot_with_resolver: the mnemonic is pulled through the client-owned resolver into a zeroized buffer for the call only, and the returned ConnectDerivedKeyFFI is plain data the paired _free zeroizes. Purpose values other than 0, 1 and 2 are refused. Fixed vectors (all-zero-entropy mnemonic, identity 0x35*32, leaf 0x6B*32) are pinned for both networks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…_state_transition

platform_wallet_parse_state_transition accepted only an IdentityUpdate or a batch carrying exactly one TokenDirectPurchase and refused everything else. DashPay Connect's sign request hands the wallet a complete unsigned transition of any kind, so the parser now decodes every StateTransition with the untrusted decoder and returns the kind name, owner id, whether it is signed, the tagged bytes that decoded, and a typed summary for Batch (one row per batched transition with contract id, document type, action, amount and recipient), IdentityUpdate (added keys with purpose, level, bounds and limits; disabled key ids), IdentityCreditTransfer and DataContractCreate/Update; other kinds report PARSED_STATE_TRANSITION_KIND_OTHER with the common fields.

Every framing is tried and a payload that decodes under more than one is refused as ambiguous instead of being described under the first that decoded. ParsedIdentityUpdatePublicKeyFFI gains has_total_budget/total_budget/has_expires_at/expires_at (appended). The serialized test fixtures are pinned under swift-sdk's test Fixtures so the Swift suite decodes the same bytes.

BREAKING CHANGE: PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE, ParsedTokenDirectPurchaseFFI and ParsedStateTransitionFFI.token_direct_purchase are removed; a direct purchase is a Batch row with action DirectPurchase.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g in Swift and Kotlin

Swift: ManagedPlatformWallet.deriveConnectKey(subFeature:identityId:leaf:purpose:network:storage:) over dash_sdk_derive_connect_key_with_resolver, with ConnectSubFeature (6 session authentication, 7 app encryption) and ConnectKeyPurpose (encryption, decryption; nil = no purpose level). parseStateTransition returns a ParsedStateTransition carrying kindName, ownerId, isSigned, the tagged serialized bytes and a ParsedStateTransitionKind (identityUpdate, batch, creditTransfer, dataContractCreate, dataContractUpdate, other); parsed IdentityPubkey rows surface totalBudget / expiresAt. Both the new FFI call and the pre-existing deriveIdentityAuthKeyAtSlot now pin the MnemonicResolver with withExtendedLifetime across the synchronous call.

Kotlin: PlatformWalletManager.deriveConnectKey over the new JNI export IdentityNative.deriveConnectKeyWithResolver; StateTransitionParser.parse over TransactionsNative.parseStateTransition, whose JNI side packs ParsedStateTransitionFFI into a big-endian blob (layout in rs-unified-sdk-jni/src/parse_state_transition.rs). The blobs of two fixtures are checked in as goldens that both the JNI tests (include_bytes!) and the host-JVM StateTransitionParserTest read.

BREAKING CHANGE: Swift ManagedPlatformWallet.ParsedTokenPurchaseTransition and the two-case ParsedStateTransition enum are removed; parseStateTransition returns the new struct.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: dashpay/platform/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 77a67b9c-758e-4bed-af73-b09b74c4bdc4

📥 Commits

Reviewing files that changed from the base of the PR and between 51183be and 749486f.

⛔ Files ignored due to path filters (3)
  • Cargo.lock is excluded by !**/*.lock
  • packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_identity_update_v1.bin is excluded by !**/*.bin
  • packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_token_transfer_batch_v1.bin is excluded by !**/*.bin
📒 Files selected for processing (25)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParserTest.kt
  • packages/rs-platform-wallet-ffi/src/derive_connect_key.rs
  • packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs
  • packages/rs-platform-wallet-ffi/src/identity_update.rs
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/rs-platform-wallet-ffi/src/parse_state_transition.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/update.rs
  • packages/rs-unified-sdk-jni/Cargo.toml
  • packages/rs-unified-sdk-jni/src/identity.rs
  • packages/rs-unified-sdk-jni/src/lib.rs
  • packages/rs-unified-sdk-jni/src/parse_state_transition.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer.hex
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_create.hex
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_update.hex
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/identity_update.hex
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/mixed_batch.hex
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The pull request adds Connect key derivation APIs across Rust, JNI, Kotlin, and Swift. It also expands state-transition parsing to cover more transition kinds, carries more identity-key metadata, and adds shared fixtures and tests for the new native and SDK parsing paths.

Changes

Wallet SDK and parsing updates

Layer / File(s) Summary
Connect key derivation flow
packages/rs-platform-wallet/src/wallet/identity/network/*, packages/rs-platform-wallet-ffi/src/derive_connect_key.rs, packages/rs-platform-wallet-ffi/src/lib.rs, packages/rs-unified-sdk-jni/src/identity.rs, packages/kotlin-sdk/sdk/src/main/kotlin/.../IdentityNative.kt, packages/kotlin-sdk/sdk/src/main/kotlin/.../PlatformWalletManager.kt, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift, packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift
Adds DIP-13 Connect key path construction and resolver-backed key derivation in Rust, exposes it through FFI and JNI, adds Kotlin and Swift SDK APIs, validates input sizes and purpose values, and adds fixed-vector and error-path tests.
Expanded native state-transition model
packages/rs-platform-wallet-ffi/src/parse_state_transition.rs, packages/rs-platform-wallet-ffi/src/identity_update.rs, packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs, packages/rs-platform-wallet/src/wallet/identity/network/update.rs
The Rust FFI parser now summarizes batch, credit-transfer, data-contract-create, data-contract-update, and other transition kinds, records common fields and tagged serialized bytes, exposes V1 public-key budget and expiry metadata, updates free/reset paths, and extends related docs and tests for limited keys.
JNI blob encoding and Kotlin parser
packages/rs-unified-sdk-jni/src/parse_state_transition.rs, packages/rs-unified-sdk-jni/src/lib.rs, packages/rs-unified-sdk-jni/Cargo.toml, packages/kotlin-sdk/sdk/src/main/kotlin/.../TransactionsNative.kt, packages/kotlin-sdk/sdk/src/main/kotlin/.../StateTransitionParser.kt, packages/kotlin-sdk/sdk/src/test/kotlin/.../StateTransitionParserTest.kt
Adds a JNI export that encodes parsed transition results into a packed blob, adds Kotlin parser types and blob decoding for the expanded transition set, and adds Kotlin golden-fixture tests and malformed-input checks.
Swift parsed state-transition and fixture coverage
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift, packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift, packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/*
Replaces the older two-case Swift parsed-transition model with a struct and kind enum that cover the expanded native output, preserves common fields and V1 key limit metadata, and adds fixture-backed tests for mixed batches, identity updates, credit transfers, and data-contract transitions.

Priority: ➖ Normal

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

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant KotlinSDK as PlatformWalletManager
  participant JNI as IdentityNative/JNI
  participant RustFFI as dash_sdk_derive_connect_key_with_resolver
  participant Resolver as MnemonicResolver
  participant Wallet as rs-platform-wallet
  App->>KotlinSDK: deriveConnectKey(...)
  KotlinSDK->>JNI: deriveConnectKeyWithResolver(...)
  JNI->>RustFFI: native derive call
  RustFFI->>Resolver: resolve mnemonic
  RustFFI->>Wallet: derive_connect_keypair_from_master(...)
  Wallet-->>RustFFI: derived keypair
  RustFFI-->>JNI: private/public bytes
  JNI-->>KotlinSDK: Array<ByteArray>
  KotlinSDK-->>App: Pair(privateKey, publicKey)
Loading
sequenceDiagram
  participant Client
  participant Native as TransactionsNative/JNI
  participant FFI as platform_wallet_parse_state_transition
  participant Parser as StateTransitionParser
  Client->>Native: parseStateTransition(bytes)
  Native->>FFI: parse transition bytes
  FFI-->>Native: ParsedStateTransitionFFI
  Native-->>Parser: packed blob
  Parser-->>Client: ParsedStateTransition
Loading

Merge Risk: ⚪ Minimal · up to 74948

No concrete merge-blocking defect remains in the reviewed changes.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.52% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 155 functions across 19 files. (6 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the three primary changes: key limits, DIP-14 sub-feature derivation, and generic DashPay Connect state-transition decoding. It is specific and clear.
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.
Full details: Docstring Coverage

Explanation

Docstring coverage is 44.52% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 155 functions across 19 files. (6 skipped: 6 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

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

❤️ Share

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

@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

PR Hygiene

State: waiting-bots · commit 749486f3701ec56a390b5df0a18cc14aabcd967d

  • Bot review threads remain unresolved

Self-review is an author attestation that you have read the diff:
/self-reviewed — covers everything pushed so far; post it again after a new push.

This check passes when the policy is satisfied; the repository decides whether merging requires it.

@github-actions github-actions Bot added this to the v4.2.0 milestone Sep 19, 2026
@thepastaclaw

thepastaclaw commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

✅ Final review complete — no blockers (commit 749486f) · triage: critical · Phase 2 only (queue backlog)

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

Final validation — Phase 2 only (queue backlog)

The expanded parser has six confirmed issues affecting approval details, framing, authentication metadata, and Kotlin integration. The 13 existing parser tests passed; temporary regression probes independently confirmed trailing-byte acceptance, false framing ambiguity, and incorrect witness-signature status, and the working tree was restored unchanged. These are client-side security and correctness issues rather than consensus violations, so they are classified as suggestions under the supplied severity policy.

🟡 6 suggestion(s)

Review provenance

Source: reviewer 1: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 2: gpt-6-astra (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6-astra (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6-astra (agent: phase2-reviewer, role: platform-versioning); reviewer 5: gpt-6-astra (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6-astra (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)

  • Triage: critical by gpt-6-astra (effort low) — This large, cross-language change directly modifies cryptographic key derivation and resolver-backed secret handling in derive_connect_keypair_from_master and packages/rs-platform-wallet-ffi/src/derive_connect_key.rs, while also changing signed key-limit representation and state-transition decoding used for signing approval.
  • Phase 1 reviewers: not run (skipped for throughput: 19 PRs queued, above the 10 limit)
  • Fresh verifier: gpt-6-astra — final-verifier; agent astra-verifier
  • Phase 2 reviewers: gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — platform-versioning (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 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-platform-wallet-ffi/src/parse_state_transition.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/parse_state_transition.rs:405-408: Expose the parameters of administrative token actions before approval
  The new describe-then-sign workflow returns approval summaries that omit authorization-changing parameters. EmergencyAction(Pause) and EmergencyAction(Resume) have identical exported descriptions; ConfigUpdate omits update_token_configuration_item, including changes to minting authority; SetPriceForDirectPurchase omits both the pricing schedule and whether purchases are disabled. The OTHER branch has the same problem: IdentityKeyLimitsUpdate exposes neither the target key nor its replacement budget/expiry, despite the documented promise of a structured fallback. All these parameters remain in serialized, which callers are instructed to sign. Export the operation-specific details or a DPP-generated structured representation through the bindings. Until those details are available, explicitly mark these results as incomplete and unsupported for approval rather than treating the summary as sufficient.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/parse_state_transition.rs:584-590: Expose the attacker-selected fee multiplier before signing
  The common result omits user_fee_increase even though serialized preserves it. Consequently, otherwise identical unsigned credit transfers with fee increases of 0 and 65535 produce the same approval fields. FeeResult::apply_user_fee_increase adds processing_fee * user_fee_increase / 100, so the latter can charge approximately 656.35 times the base processing fee when sufficient funds are available. This is newly relevant because the previous parser documented rebuilding operations through wallet APIs, whereas this PR instructs callers to sign the returned bytes. Expose the multiplier through the common FFI and mobile results so approval and fee estimation can account for it, or reject unsupported nonzero increases.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/parse_state_transition.rs:303-305: Require complete consumption before counting a framing as valid
  deserialize_from_bytes_untrusted discards the decoder's consumed-byte count, so success only establishes that a prefix decoded. This breaks both the new ambiguity check and the exact-byte inspection guarantee. An independent probe using the contract fixture with owner [0x21; 32] and nonce 44 confirmed that the valid tagged contract consumes all 2340 bytes, while prepending the IdentityUpdate tag decodes only 83 of 2341 bytes; the parser nevertheless rejects the contract as ambiguous. A second probe appended 23 bytes to the 73-byte credit-transfer fixture: parsing succeeded and exported all 96 bytes. Require complete consumption for every framing candidate using a bounded, count-preserving untrusted decoder. Keep existing consensus decoding semantics unchanged and add regressions for both cases.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/parse_state_transition.rs:588: Account for witness-based signatures in the common signing status
  StateTransition::signature() returns None for AddressFundsTransfer and several shielded kinds because their authentication is stored elsewhere, not because they are unsigned. An independent FFI probe containing a nonempty 65-byte P2PKH input-witness signature returned is_signed=false. The new common field therefore makes an incorrect signature-presence claim for kinds the parser now accepts. Inspect the applicable authentication fields, preferably through a DPP-owned accessor, or expose a status that distinguishes unsigned from unsupported/not-applicable authentication models. The documented Connect owner check excludes ownerless transitions from that signing flow, but it does not make this public decode-any-kind metadata accurate.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt:291-295: Decode token amounts as unsigned 64-bit values
  The JNI encoder writes row.amount as u64, but buf.long decodes its bits as a signed Long and ParsedBatchedTransition.Token exposes that value without conversion or a range check. Thus 9223372036854775808 is displayed as -9223372036854775808, and u64::MAX becomes -1, while serialized retains the original unsigned quantity. The existing Tokens API uses ULong for this protocol range. Preserve unsigned semantics in the public approval model and convert with toULong(), or explicitly reject unsupported values rather than silently presenting negative amounts. Add boundary coverage above Long.MAX_VALUE.
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt:183: Initialize the native library before standalone parsing
  StateTransitionParser.parse is a public, handle-free entry point, but neither it nor TransactionsNative loads the JNI library. If parsing is the first SDK operation, the native method cannot be resolved and throws UnsatisfiedLinkError; mapNativeErrors only catches DashSDKException. The existing standalone TransactionDecoder.decode calls Sdk.initialize() before its native invocation. Apply the same initialization here, keeping parseBlob native-free for its JVM tests.

Comment on lines +405 to +408
TokenTransition::Claim(_)
| TokenTransition::EmergencyAction(_)
| TokenTransition::ConfigUpdate(_)
| TokenTransition::SetPriceForDirectPurchase(_) => (None, None),

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: Expose the parameters of administrative token actions before approval

The new describe-then-sign workflow returns approval summaries that omit authorization-changing parameters. EmergencyAction(Pause) and EmergencyAction(Resume) have identical exported descriptions; ConfigUpdate omits update_token_configuration_item, including changes to minting authority; SetPriceForDirectPurchase omits both the pricing schedule and whether purchases are disabled. The OTHER branch has the same problem: IdentityKeyLimitsUpdate exposes neither the target key nor its replacement budget/expiry, despite the documented promise of a structured fallback. All these parameters remain in serialized, which callers are instructed to sign. Export the operation-specific details or a DPP-generated structured representation through the bindings. Until those details are available, explicitly mark these results as incomplete and unsupported for approval rather than treating the summary as sufficient.

source: gpt-6-astra (phase2-reviewer: general)

Comment on lines +584 to +590
out.kind = kind;
out.kind_name = kind_name;
out.has_owner_id = owner_id.is_some();
out.owner_id = owner_id.map(|id| id.to_buffer()).unwrap_or_default();
out.is_signed = transition.signature().is_some_and(|sig| !sig.is_empty());
out.serialized = serialized_ptr;
out.serialized_len = serialized_len;

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: Expose the attacker-selected fee multiplier before signing

The common result omits user_fee_increase even though serialized preserves it. Consequently, otherwise identical unsigned credit transfers with fee increases of 0 and 65535 produce the same approval fields. FeeResult::apply_user_fee_increase adds processing_fee * user_fee_increase / 100, so the latter can charge approximately 656.35 times the base processing fee when sufficient funds are available. This is newly relevant because the previous parser documented rebuilding operations through wallet APIs, whereas this PR instructs callers to sign the returned bytes. Expose the multiplier through the common FFI and mobile results so approval and fee estimation can account for it, or reject unsupported nonzero increases.

source: gpt-6-astra (phase2-reviewer: security-auditor)

Comment on lines 303 to 305
match StateTransition::deserialize_from_bytes_untrusted(payload) {
Ok(state_transition) => return Ok(state_transition),
Ok(state_transition) => decoded.push((state_transition, payload.to_vec(), label)),
Err(error) => failures.push(format!("{label}: {error}")),

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: Require complete consumption before counting a framing as valid

deserialize_from_bytes_untrusted discards the decoder's consumed-byte count, so success only establishes that a prefix decoded. This breaks both the new ambiguity check and the exact-byte inspection guarantee. An independent probe using the contract fixture with owner [0x21; 32] and nonce 44 confirmed that the valid tagged contract consumes all 2340 bytes, while prepending the IdentityUpdate tag decodes only 83 of 2341 bytes; the parser nevertheless rejects the contract as ambiguous. A second probe appended 23 bytes to the 73-byte credit-transfer fixture: parsing succeeded and exported all 96 bytes. Require complete consumption for every framing candidate using a bounded, count-preserving untrusted decoder. Keep existing consensus decoding semantics unchanged and add regressions for both cases.

source: gpt-6-astra (phase2-reviewer: rust-quality)

Comment on lines +291 to +295
val position = buf.short.toInt() and 0xFFFF
val tokenId = readId32(buf)
val amount = if (readBool(buf)) buf.long else null
val recipient = if (readBool(buf)) readId32(buf) else null
ParsedBatchedTransition.Token(dataContractId, tokenId, position, action, amount, recipient)

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: Decode token amounts as unsigned 64-bit values

The JNI encoder writes row.amount as u64, but buf.long decodes its bits as a signed Long and ParsedBatchedTransition.Token exposes that value without conversion or a range check. Thus 9223372036854775808 is displayed as -9223372036854775808, and u64::MAX becomes -1, while serialized retains the original unsigned quantity. The existing Tokens API uses ULong for this protocol range. Preserve unsigned semantics in the public approval model and convert with toULong(), or explicitly reject unsupported values rather than silently presenting negative amounts. Add boundary coverage above Long.MAX_VALUE.

source: gpt-6-astra (phase2-reviewer: general)

*/
fun parse(transitionBytes: ByteArray): ParsedStateTransition {
require(transitionBytes.isNotEmpty()) { "transitionBytes must not be empty" }
val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) }

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: Initialize the native library before standalone parsing

StateTransitionParser.parse is a public, handle-free entry point, but neither it nor TransactionsNative loads the JNI library. If parsing is the first SDK operation, the native method cannot be resolved and throws UnsatisfiedLinkError; mapNativeErrors only catches DashSDKException. The existing standalone TransactionDecoder.decode calls Sdk.initialize() before its native invocation. Apply the same initialization here, keeping parseBlob native-free for its JVM tests.

Suggested change
val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) }
org.dashfoundation.dashsdk.Sdk.initialize()
val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) }

source: gpt-6-astra (phase2-reviewer: general)

out.kind_name = kind_name;
out.has_owner_id = owner_id.is_some();
out.owner_id = owner_id.map(|id| id.to_buffer()).unwrap_or_default();
out.is_signed = transition.signature().is_some_and(|sig| !sig.is_empty());

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: Account for witness-based signatures in the common signing status

StateTransition::signature() returns None for AddressFundsTransfer and several shielded kinds because their authentication is stored elsewhere, not because they are unsigned. An independent FFI probe containing a nonempty 65-byte P2PKH input-witness signature returned is_signed=false. The new common field therefore makes an incorrect signature-presence claim for kinds the parser now accepts. Inspect the applicable authentication fields, preferably through a DPP-owned accessor, or expose a status that distinguishes unsigned from unsupported/not-applicable authentication models. The documented Connect owner check excludes ownerless transitions from that signing flow, but it does not make this public decode-any-kind metadata accurate.

source: gpt-6-astra (phase2-reviewer: general)

@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Superseded by #4844: same commits and head (749486f370), reopened from a dashpay/platform branch so the Rust workspace CI runs (fork pull requests skip it).


🤖 Posted autonomously by Claude on behalf of pasta.

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