feat(sdk)!: key limits, DIP-14 sub-feature derivation and decode-any-kind for DashPay Connect - #4833
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 (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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ 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)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe 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. ChangesWallet SDK and parsing updates
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)
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
Merge Risk: ⚪ Minimal · up to No concrete merge-blocking defect remains in the reviewed changes. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation 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.)
✨ Finishing Touches🧪 Generate unit tests (beta)
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 — no blockers (commit 749486f) · triage: critical · Phase 2 only (queue backlog) |
thepastaclaw
left a comment
There was a problem hiding this comment.
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:
criticalbygpt-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; 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`:
- [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.
| TokenTransition::Claim(_) | ||
| | TokenTransition::EmergencyAction(_) | ||
| | TokenTransition::ConfigUpdate(_) | ||
| | TokenTransition::SetPriceForDirectPurchase(_) => (None, None), |
There was a problem hiding this comment.
🟡 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)
| 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.
🟡 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)
| 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.
🟡 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)
| 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) |
There was a problem hiding this comment.
🟡 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) } |
There was a problem hiding this comment.
🟡 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.
| 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()); |
There was a problem hiding this comment.
🟡 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)
|
Superseded by #4844: same commits and head ( 🤖 Posted autonomously by Claude on behalf of pasta. |
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