Skip to content

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

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

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

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Member

Supersedes #4833 (same commits, same head 749486f370), reopened from a dashpay/platform branch so the Rust workspace CI runs; fork pull requests skip it.

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 (#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

Warning

Review limit reached

Next included review available in 10 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

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

Review profile: CHILL

Plan: Advanced

Run ID: f2031972-cbe7-4e31-97f2-cb6ee66f31b4

📥 Commits

Reviewing files that changed from the base of the PR and between 6def218 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

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 added this to the v4.2.0 milestone Sep 19, 2026
@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

PR Hygiene

State: waiting-bots · commit 749486f3701ec56a390b5df0a18cc14aabcd967d

  • Bot changes request remains outstanding
  • Bot review threads remain unresolved
  • Proceeded without coderabbitai: no review within the configured window

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.
/skip-bots — proceed without the bots that have not reported; anyone with write access may, and the report says who did.

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

@github-actions github-actions Bot added the bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. label Sep 19, 2026
@thepastaclaw

thepastaclaw commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

⛔ Final review complete — 4 blocking finding(s) (commit 749486f) · triage: critical

@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.64%. Comparing base (d09c15d) to head (749486f).
⚠️ Report is 28 commits behind head on v4.2-dev.

Additional details and impacted files
@@             Coverage Diff              @@
##           v4.2-dev    #4844      +/-   ##
============================================
- Coverage     76.96%   75.64%   -1.33%     
============================================
  Files          2963     2981      +18     
  Lines        429535   440194   +10659     
============================================
+ Hits         330609   332983    +2374     
- Misses        98926   107211    +8285     
Components Coverage Δ
dpp 73.23% <ø> (-0.94%) ⬇️
drive 76.55% <ø> (-1.75%) ⬇️
drive-abci 77.23% <ø> (-1.26%) ⬇️
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 86.09% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 25.08% <ø> (-2.37%) ⬇️
🚀 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.

@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 1 + Phase 2

Four blocking issues remain: partial decoding rejects valid transitions, standalone Kotlin parsing does not initialize JNI, and the approval data omits both fee multipliers and material operation details. The 13 existing parser tests and two JNI layout tests passed; four temporary verification probes independently confirmed the framing and projection defects and disproved the proposed zero-filled ambiguity fixture. The working tree is unchanged.

🔴 4 blocking | 🟡 2 suggestion(s)

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: architecture-layering); reviewer 3: muse-spark-1.3-contributor (agent: phase1-reviewer, role: ffi-engineer); reviewer 4: muse-spark-1.3-contributor (agent: phase1-reviewer, role: platform-versioning); reviewer 5: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 6: muse-spark-1.3-contributor (agent: phase1-reviewer, role: security-auditor); reviewer 7: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 8: gpt-6-astra (agent: phase2-reviewer, role: architecture-layering); reviewer 9: gpt-6-astra (agent: phase2-reviewer, role: ffi-engineer); reviewer 10: gpt-6-astra (agent: phase2-reviewer, role: platform-versioning); reviewer 11: gpt-6-astra (agent: phase2-reviewer, role: rust-quality); reviewer 12: 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) — The large, cross-language diff directly changes cryptographic key handling in connect_key_derivation_path and derive_connect_keypair_from_master in packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs and resolver-backed secret derivation in packages/rs-platform-wallet-ffi/src/derive_connect_key.rs, while also changing how key limits enter signed identity updates.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — architecture-layering (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — ffi-engineer (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — platform-versioning (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — security-auditor (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (lane failed), glm-5.3-flash (not used above high effort; tier asks max)
  • 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`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/parse_state_transition.rs:302-305: Require full-buffer decoding before counting framing candidates
  The generated implementation of deserialize_from_bytes_untrusted discards bincode's consumed-byte count, so this loop counts successful prefix decodes as competing framings. Independently reproducing the reported contract fixture confirmed that its normal tagged form consumes all 2,340 bytes, while prepending the IdentityUpdate tag consumes only 47 of 2,341 bytes. The exported parser nevertheless rejects the valid contract as ambiguous. Use the bounded untrusted bincode decoder that returns the consumed length and accept a candidate only when it consumes the entire buffer. Add a regression through the public parser covering this collision and trailing-byte rejection; genuine ambiguity should require multiple complete decodes.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/parse_state_transition.rs:584-590: Expose or constrain the fee multiplier before approving raw bytes
  The new contract tells callers to sign serialized instead of rebuilding the approved operation, but the common projection omits user_fee_increase. An independent probe confirmed that unsigned credit transfers with multipliers 0 and 65535 produce identical common and typed approval fields, differing only in opaque serialized bytes. Both pass the documented owner/signature checks. FeeResult::apply_user_fee_increase applies this percentage to processing fees, so the latter requests approximately 656.35 times the base processing fee without exposing that choice to the approval UI. Carry the multiplier through FFI, JNI, Swift, and Kotlin so callers can display or constrain it, or reject unsupported multipliers before returning an approvable result.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/parse_state_transition.rs:404-408: Provide complete inspection data for partially described operations
  The summaries discard material parameters while the new API documentation directs callers to approve and sign the returned bytes. An independent probe confirmed that ConfigUpdate granting manual minting to the wallet owner and granting it to another identity produce identical approval rows. The administrator's signature would authorize whichever hidden change was supplied. Other omissions include the emergency action, purchase-price schedule, claim distribution type, and direct-purchase token count previously exposed by the old parser. These operations remain typed Batch results, so an Other-only fallback would not protect them. Separately, Other exposes only common metadata and binary bytes: withdrawal destinations/amounts and key-limit changes cannot be rendered as the promised structured dump. Expose complete native-decoded details, either as typed fields or a structured fallback carried through both mobile wrappers. Until those details are available, explicitly identify incompletely described operations as unsuitable for summary-only approval rather than documenting them as ready to sign.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt`:
- [BLOCKING] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt:181-183: Initialize the native library before standalone parsing
  Neither StateTransitionParser nor TransactionsNative initializes the native library. Calling this public, handle-free utility as the first SDK operation therefore reaches an unresolved native method and throws UnsatisfiedLinkError. mapNativeErrors catches only DashSDKException, so it does not convert this failure. Initialize the SDK before entering JNI, as TransactionDecoder.decode already does, and test this entry point in a fresh process without constructing another SDK object first. The current parseBlob tests bypass library initialization entirely.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt:690: Scrub the derived private key when cancellation discards the result
  JNI copies the private scalar into a JVM byte array, after which Rust wipes only its own copies. teardownGate.op uses withContext(Dispatchers.IO), whose prompt cancellation can discard a completed derivation before handing the result back to the caller. The caller then never receives the array it is documented to wipe. Use the existing opWithCleanupOnCancellation helper to zero pair.first on a failed handoff. TeardownGateTest.cancelledOpScrubsACompletedSecretResultBeforeDiscardingIt already covers this exact lifecycle for secret-bearing results.

In `packages/rs-unified-sdk-jni/src/parse_state_transition.rs`:
- [SUGGESTION] packages/rs-unified-sdk-jni/src/parse_state_transition.rs:481-488: Pin the variable-length JNI layout branches in shared fixtures
  The shared Rust/Kotlin goldens cover a group-bound identity key and a token-only batch, but neither exercises the document-row branch nor SingleContractDocumentType key bounds. Both branches insert variable-length strings before subsequent fields, so field-order or length mismatches can escape compilation and the existing goldens. Add a mixed document/token batch and a document-type-bound key to the shared fixtures, then assert fields following the strings in both Rust and Kotlin. The Swift parser tests do not exercise this JNI blob encoding.
Out-of-scope follow-up suggestions (1)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • prePersistIdentityKeysForRegistration does not explicitly retain its resolver across FFI — The unchanged registration helper passes resolver.handle to dash_sdk_derive_and_persist_identity_keys without extending the resolver's lifetime. MnemonicResolver uses passUnretained(self), and its deinitializer destroys the native handle; the last Swift use is argument evaluation rather than completion of the native callback. This is a concrete lifetime hazard worth tracking separately, but it predates this PR and the new Connect path correctly uses withExtendedLifetime.
    • Follow-up: Track a separate fix that pins the registration resolver for the complete synchronous FFI call.

Comment on lines 302 to 305
for (payload, label) in &attempts {
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.

🔴 Blocking: Require full-buffer decoding before counting framing candidates

The generated implementation of deserialize_from_bytes_untrusted discards bincode's consumed-byte count, so this loop counts successful prefix decodes as competing framings. Independently reproducing the reported contract fixture confirmed that its normal tagged form consumes all 2,340 bytes, while prepending the IdentityUpdate tag consumes only 47 of 2,341 bytes. The exported parser nevertheless rejects the valid contract as ambiguous. Use the bounded untrusted bincode decoder that returns the consumed length and accept a candidate only when it consumes the entire buffer. Add a regression through the public parser covering this collision and trailing-byte rejection; genuine ambiguity should require multiple complete decodes.

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

Comment on lines +181 to +183
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.

🔴 Blocking: Initialize the native library before standalone parsing

Neither StateTransitionParser nor TransactionsNative initializes the native library. Calling this public, handle-free utility as the first SDK operation therefore reaches an unresolved native method and throws UnsatisfiedLinkError. mapNativeErrors catches only DashSDKException, so it does not convert this failure. Initialize the SDK before entering JNI, as TransactionDecoder.decode already does, and test this entry point in a fresh process without constructing another SDK object first. The current parseBlob tests bypass library initialization entirely.

Suggested change
fun parse(transitionBytes: ByteArray): ParsedStateTransition {
require(transitionBytes.isNotEmpty()) { "transitionBytes must not be empty" }
val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) }
fun parse(transitionBytes: ByteArray): ParsedStateTransition {
require(transitionBytes.isNotEmpty()) { "transitionBytes must not be empty" }
org.dashfoundation.dashsdk.Sdk.initialize()
val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) }

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.

🔴 Blocking: Expose or constrain the fee multiplier before approving raw bytes

The new contract tells callers to sign serialized instead of rebuilding the approved operation, but the common projection omits user_fee_increase. An independent probe confirmed that unsigned credit transfers with multipliers 0 and 65535 produce identical common and typed approval fields, differing only in opaque serialized bytes. Both pass the documented owner/signature checks. FeeResult::apply_user_fee_increase applies this percentage to processing fees, so the latter requests approximately 656.35 times the base processing fee without exposing that choice to the approval UI. Carry the multiplier through FFI, JNI, Swift, and Kotlin so callers can display or constrain it, or reject unsupported multipliers before returning an approvable result.

source: muse-spark-1.3-contributor (phase1-reviewer: general, architecture-layering, ffi-engineer, platform-versioning, rust-quality, security-auditor); gpt-6-astra (phase2-reviewer: security-auditor)

Comment on lines +404 to +408
TokenTransition::DirectPurchase(t) => (Some(t.total_agreed_price()), None),
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.

🔴 Blocking: Provide complete inspection data for partially described operations

The summaries discard material parameters while the new API documentation directs callers to approve and sign the returned bytes. An independent probe confirmed that ConfigUpdate granting manual minting to the wallet owner and granting it to another identity produce identical approval rows. The administrator's signature would authorize whichever hidden change was supplied. Other omissions include the emergency action, purchase-price schedule, claim distribution type, and direct-purchase token count previously exposed by the old parser. These operations remain typed Batch results, so an Other-only fallback would not protect them. Separately, Other exposes only common metadata and binary bytes: withdrawal destinations/amounts and key-limit changes cannot be rendered as the promised structured dump. Expose complete native-decoded details, either as typed fields or a structured fallback carried through both mobile wrappers. Until those details are available, explicitly identify incompletely described operations as unsuitable for summary-only approval rather than documenting them as ready to sign.

source: muse-spark-1.3-contributor (phase1-reviewer: general, architecture-layering, ffi-engineer, platform-versioning, rust-quality, security-auditor); gpt-6-astra (phase2-reviewer: general, architecture-layering, ffi-engineer, platform-versioning, rust-quality, security-auditor)

identityId: ByteArray,
leaf: ByteArray,
purpose: ConnectKeyPurpose? = null,
): Pair<ByteArray, ByteArray> = teardownGate.op {

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: Scrub the derived private key when cancellation discards the result

JNI copies the private scalar into a JVM byte array, after which Rust wipes only its own copies. teardownGate.op uses withContext(Dispatchers.IO), whose prompt cancellation can discard a completed derivation before handing the result back to the caller. The caller then never receives the array it is documented to wipe. Use the existing opWithCleanupOnCancellation helper to zero pair.first on a failed handoff. TeardownGateTest.cancelledOpScrubsACompletedSecretResultBeforeDiscardingIt already covers this exact lifecycle for secret-bearing results.

Suggested change
): Pair<ByteArray, ByteArray> = teardownGate.op {
): Pair<ByteArray, ByteArray> = teardownGate.opWithCleanupOnCancellation(
cleanup = { pair -> pair.first.fill(0) },
) {

source: gpt-6-astra (phase2-reviewer: general, architecture-layering, ffi-engineer, platform-versioning, rust-quality, security-auditor)

Comment on lines +481 to +488
#[test]
fn token_transfer_batch_blob_round_trips_and_is_pinned_for_kotlin() {
let bytes = token_transfer_batch_bytes();
let blob = parse_to_blob(&bytes);

let mut r = Reader(&blob);
assert_eq!(r.u8(), PARSED_STATE_TRANSITION_KIND_BATCH);
assert_eq!(r.str16(), "DocumentsBatch([TokenTransfer])");

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 variable-length JNI layout branches in shared fixtures

The shared Rust/Kotlin goldens cover a group-bound identity key and a token-only batch, but neither exercises the document-row branch nor SingleContractDocumentType key bounds. Both branches insert variable-length strings before subsequent fields, so field-order or length mismatches can escape compilation and the existing goldens. Add a mixed document/token batch and a document-type-bound key to the shared fixtures, then assert fields following the strings in both Rust and Kotlin. The Swift parser tests do not exercise this JNI blob encoding.

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

@github-actions github-actions Bot added the waiting-bots Waiting for the review bots to report on this head label Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. waiting-bots Waiting for the review bots to report on this head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants