feat(sdk)!: key limits, DIP-14 sub-feature derivation and decode-any-kind for DashPay Connect - #4844
PastaPastaPasta wants to merge 4 commits into
Conversation
…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>
|
Warning Review limit reachedNext included review available in 10 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: Repository: dashpay/platform/.coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (3)
📒 Files selected for processing (25)
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. Comment |
PR HygieneState: waiting-bots · commit
Self-review is an author attestation that you have read the diff: This check passes when the policy is satisfied; the repository decides whether merging requires it. |
|
⛔ Final review complete — 4 blocking finding(s) (commit 749486f) · triage: critical |
Codecov Report✅ All modified and coverable lines are covered by tests. 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
🚀 New features to boost your workflow:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
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:
criticalbygpt-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); agentphase1-reviewer,muse-spark-1.3-contributor— architecture-layering (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— ffi-engineer (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— platform-versioning (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— security-auditor (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-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; agentastra-verifier - Phase 2 reviewers:
gpt-6-astra— general (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— platform-versioning (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— security-auditor (completed, effort xhigh); agentphase2-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.
| 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}")), |
There was a problem hiding this comment.
🔴 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)
| fun parse(transitionBytes: ByteArray): ParsedStateTransition { | ||
| require(transitionBytes.isNotEmpty()) { "transitionBytes must not be empty" } | ||
| val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) } |
There was a problem hiding this comment.
🔴 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.
| 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)
| 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; |
There was a problem hiding this comment.
🔴 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)
| TokenTransition::DirectPurchase(t) => (Some(t.total_agreed_price()), None), | ||
| TokenTransition::Claim(_) | ||
| | TokenTransition::EmergencyAction(_) | ||
| | TokenTransition::ConfigUpdate(_) | ||
| | TokenTransition::SetPriceForDirectPurchase(_) => (None, None), |
There was a problem hiding this comment.
🔴 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 { |
There was a problem hiding this comment.
🟡 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.
| ): 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)
| #[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])"); |
There was a problem hiding this comment.
🟡 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)
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/platformrows 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)IdentityPubkeyFFIalready carriedhas_total_budget/total_budget/has_expires_at/expires_at(#4811). This PR pins the path with tests: a row with either limit set decodes toIdentityPublicKey::V1, andIdentityUpdateTransition::try_from_identity_with_signer(whichupdate_identity_with_signercalls) turns it intoIdentityPublicKeyInCreation::V1so the limits sit inside the signed bytes; a row with neither stays V0. The struct andupdate_identity_with_signerdocs 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).ParsedIdentityUpdatePublicKeyFFIgains the same four fields at the end, so a parsed identity update shows the limits of every key it adds; SwiftIdentityPubkey(which already hadtotalBudget/expiresAt, andContractBounds.contractGroup(id:)) reads them.2. DIP-14 sub-feature derivation
New
connect_key_derivation_path/derive_connect_keypair_from_masterinplatform-wallet, and the FFI entrydash_sdk_derive_connect_key_with_resolvernext todash_sdk_derive_identity_key_at_slot_with_resolver, derive from the resolver-backed seed atcoin'follows the network as the existing identity paths do (5'mainnet,1'otherwise).subFeature'is6'for the session authentication key (leaf = the connect request id,hash256(appEphemeralPubKey)) or7'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'andleaf'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 or2'DECRYPTION, so an identity holds the DashPay-style pair per contract. The FFI takespurpose: u32with0meaning no purpose level and refuses anything other than0,1,2; the Swift and Kotlin wrappers take a two-caseConnectKeyPurpose(nil = no purpose level) so0is unrepresentable there.This is the DIP-13 amendment in dashpay/dips#191 (sub-features
6'and7'plus the DIP-9 table row). Vectors for the all-zero-entropy test mnemonic, identity0x35*32, leaf0x6B*32are pinned in Rust and re-derived through the FFI by the Swift tests.Surfaces: Swift
ManagedPlatformWallet.deriveConnectKey(subFeature:identityId:leaf:purpose:network:storage:); KotlinPlatformWalletManager.deriveConnectKey(walletId, subFeature, identityId, leaf, purpose)over a new JNI exportIdentityNative.deriveConnectKeyWithResolver. Both resolve the mnemonic through the client-owned resolver for the call only; the Rust side zeroizes its copy. While addingwithExtendedLifetime(resolver)around the new Swift FFI call, the same omission in the pre-existingderiveIdentityAuthKeyAtSlotwas fixed too (ARC could otherwise release the resolver'spassUnretainedctx mid-call).3. Decode-any-kind
parse_state_transitionplatform_wallet_parse_state_transitionpreviously accepted only anIdentityUpdateor a batch carrying exactly oneTokenDirectPurchaseand rejected everything else. It now decodes everyStateTransitionkind with the untrusted decoder and returns:kind_name(StateTransition::name()),owner_id,is_signed, andserialized(the bytes that actually decoded, always tagged, so a client signs what it showed);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) andDataContractCreate/DataContractUpdate(contract id, owner, document type names);PARSED_STATE_TRANSITION_KIND_OTHERwith 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 aParsedStateTransition(kindName,ownerId,isSigned,serialized,kind: ParsedStateTransitionKind); Kotlin gainsStateTransitionParser.parse(bytes)over a new JNI exportTransactionsNative.parseStateTransitionthat packs the result into a big-endian blob (layout documented inrs-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,OTHERfor 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 ofDashSDKFFI.xcframeworkbuilt withbuild_ios.sh --target mac --profile dev):swift test --filter SwiftDashSDKTests: 612 tests, 0 failures (1 pre-existing skip). NewConnectKeyDerivationTests(3) re-derive the pinned vectors through the FFI and check distinctness and error paths; newParseStateTransitionTests(7) decode one fixture of each kind. The fixtures underSwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/are serialized by the Rust testfixture_bytes_are_pinned_for_the_client_suites, whichinclude_str!s the same files, so the two suites cannot disagree about the bytes.Kotlin (
packages/kotlin-sdk): implemented but not run locally. Thesdkmodule is an Android library and this machine has no Android SDK (./gradlew :sdk:testDebugUnitTeststops atSDK location not found). The newStateTransitionParserTest(5 tests) is a host-JVM test that decodes the goldens undersdk/src/test/resources/golden/parsed_*_v1.bin, which the JNI testsinclude_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_transitionnow describes every kind instead of refusing all but two, and its result struct changed shape:PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE,ParsedTokenDirectPurchaseFFI, and thetoken_direct_purchasefield ofParsedStateTransitionFFI. A direct purchase now arrives as aBatchrow withaction == "DirectPurchase",amount= total agreed price.ParsedStateTransitionFFIgainedkind_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.ParsedIdentityUpdatePublicKeyFFIgainedhas_total_budget,total_budget,has_expires_at,expires_at(appended).ManagedPlatformWallet.ParsedTokenPurchaseTransitionand the two-caseParsedStateTransitionenum are removed;parseStateTransition(_:)returns the newParsedStateTransitionstruct.IdentityPubkeyandParsedIdentityUpdateTransitionare nowEquatable.deserialize_transition_with_flexible_framingreturns(StateTransition, Vec<u8>).No wire format, consensus or data-contract change.
Checklist:
For repository code-owners and collaborators only
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes