feat(platform-wallet): parse a state transition's kind and token-purchase intent - #4584
Conversation
…hase intent DashConnect's `dash-st:` link hands a wallet a serialized state transition. The only parser exposed was `platform_wallet_parse_identity_update_transition`, which accepts an `IdentityUpdateTransition` and nothing else — the DashConnect key-registration step. A dApp asking a wallet to authorize a token purchase was rejected with "Expected IdentityUpdateTransition, got Batch(...)". Adds `platform_wallet_parse_state_transition`, which deserializes once and reports which kind it found alongside the matching payload, so a caller branches on a discriminant rather than on a thrown error. Two kinds are recognised: identity update (projected exactly as before) and a batch carrying a single token direct purchase, projected to the fields a purchase needs and a user must see — owner, data contract, token id and position, count, and total agreed price. Deliberately a parser, not a signer. There is no entry point that signs caller-supplied bytes: a wallet reads the intent, shows it, and rebuilds the purchase through the existing `platform_wallet_token_purchase`, so it only ever signs a transition it constructed itself. Rejected rather than projected, because a user cannot meaningfully approve them and the rebuild path could not reproduce them faithfully: a batch that is not exactly one token purchase (empty, several, or another transition kind), and a purchase carrying `using_group_info`, which the rebuild submits without. `identity_update.rs` keeps its narrow public contract; its tagged/tagless framing fallback is generalized to several candidate variant tags and shared. Swift: `ParsedStateTransition` / `ParsedTokenPurchaseTransition` and `ManagedPlatformWallet.parseStateTransition(_:)` beside the existing identity-update pair.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (4)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe Rust FFI adds flexible parsing for identity updates and token direct purchases. The Swift SDK exposes parsed state-transition types and maps FFI results to Swift values. ChangesState Transition Parsing
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: 🔵 Low · up to This change lets wallets parse and display token-purchase intents, but ambiguous framing and the lack of a built-in binding between displayed purchase details and later signing inputs could allow an incorrect intent to be approved if a consumer mishandles the values. The change is mergeable with explicit security-owner awareness and follow-up on canonical framing and approval binding. Sequence Diagram(s)sequenceDiagram
participant ManagedPlatformWallet
participant RustFFI
participant StateTransitionParser
ManagedPlatformWallet->>RustFFI: Call platform_wallet_parse_state_transition
RustFFI->>StateTransitionParser: Parse tagged or tagless bytes
StateTransitionParser-->>RustFFI: Return parsed kind and fields
RustFFI-->>ManagedPlatformWallet: Return ParsedStateTransitionFFI
ManagedPlatformWallet->>RustFFI: Free FFI output
ManagedPlatformWallet-->>ManagedPlatformWallet: Build ParsedStateTransition
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 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 |
|
ℹ️ Review skipped (commit a77c557) |
llbartekll
left a comment
There was a problem hiding this comment.
Reviewed the full diff against the base and read parse_state_transition.rs, the identity_update.rs refactor and the Swift wrapper in context. Verified locally on a worktree of a77c5577:
cargo fmt -p platform-wallet-ffi -- --checkcleancargo clippy -p platform-wallet-ffi --lib— no warnings in this cratecargo test -p platform-wallet-ffi --lib -- parse_state_transition identity_update— 16 passed- Additionally fed the real captured Yappr tagless
IdentityUpdatefixture (fromidentity_update::tests) through the new umbrellaplatform_wallet_parse_state_transition: parses, reportsKIND_IDENTITY_UPDATE, both login keys present. So the two-candidate framing heuristic handles the one real-world tagless payload we have.
What I like
- Parser-not-signer boundary is explicit in the module docs and in the API shape (no entry point takes bytes and a signer). The rebuild-through-
platform_wallet_token_purchaseflow means the wallet only ever signs what it constructed. - The rejection rules (batch ≠ exactly one
DirectPurchase,using_group_infopresent) are justified in terms of what the user can approve and what the rebuild path can reproduce, and each has a test. Error messages carryStateTransition::name()so a rejected batch says what it actually contained. outis reset to default before any work and only populated on success, so an error mid-way can't leak the identity-update allocation._freeis idempotent and safe on a default value (tested).- The
identity_update.rsframing fallback is generalized without changing its public contract; the existing 7 tests still pin it.
Non-blocking suggestions
- Swift
parseStateTransitionswitches on literal1/2with comments. The header exportsPARSED_STATE_TRANSITION_KIND_*as#defines and the file already uses that pattern elsewhere (case UInt8(CONTACT_INFO_SKIPPED_WATCH_ONLY)), socase UInt8(PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE)would keep the discriminant in one place. - Worth adding the ad-hoc test above to
parse_state_transition::tests(the real Yappr fixture through the umbrella parser). Today the taglessIdentityUpdatepath is only covered via the single-candidate call inidentity_update.rs; the umbrella parser has a different candidate order (as-isvs prepend-6 vs prepend-2 depending on the first byte), which is exactly the thing a future refactor could break. - Doc nit:
owner_idis described as "the identity whose credits pay for the purchase", but the rebuild path charges the wallet's own identity regardless. The app should compareowner_idto its identity before showing the sheet, otherwise the prompt could name a payer that isn't the one who will actually be charged. A one-line note onParsedTokenDirectPurchaseFFI::owner_id(or in the Swift doc) would make that contract explicit. - FYI, not actionable here:
PlatformDeserializable::deserialize_from_bytesignores trailing bytes (decode_from_slice(...).map(|(a, _)| a)), so the framing probe can in principle accept a payload under more than one framing. No security consequence given the parse → show → rebuild design, but it's one more reason the wire protocol would benefit from an explicit request type / discriminator rather than sniffing DPP bytes — thekindfield here is already that discriminator, reconstructed after the fact.
Approving — the change is additive, well-tested, and the security posture is the right one.
…ucer Brings in the squash-merged #4558 (seam) and #4559 (SQLite store) this branch was stacked on, plus #4584 and #4594. One conflict, in the comment above the reinstated-txid retraction in CoreChangeSet::merge: #4594 dropped the PR-history reference from the line below it; kept the retraction block and the new wording.
Brings in the squash-merged producer (#4560) this branch was stacked on, plus #4584. Conflicts in changeset.rs and core_bridge.rs were this branch's copies of the producer commits against their squash; this branch never touched either file, so the base version was taken and the tree outside packages/swift-sdk is identical to v4.2-dev.
…ified token id Six findings from the automated review of #1109, plus one merge repair. **A failed purchase is no longer offered for a blind retry.** Every approval builds, signs and submits a NEW direct purchase against the identity's next nonce, and the SDK reports one opaque error for both "the transition was rejected" and "it was submitted and waiting on the outcome failed". The old catch called all of it an incomplete request and left the sheet ready to approve again, so a purchase Platform had already accepted could be bought a second time. `approveTokenPurchase` now throws `DashConnectTokenPurchaseFailure`, which says which side of submission the failure came from: everything up to and including authentication is `beforeSubmission` and keeps today's retry-in-place, while anything out of `tokenPurchase(...)` is `outcomeUnknown` — the sheet closes and the user is told to check this identity's tokens and credit balance before buying again. **The token id on the sheet is now the token that gets bought.** The id was copied from the untrusted payload while the purchase is rebuilt from the contract id and position alone, so a crafted payload could display one token and buy another. The request is now refused unless the payload's id matches `calculateTokenId(contractId:position:)` — the protocol formula the SDK itself derives from. Also: - `expectedTotalCost` is a ceiling, not an exact price: Platform rejects a higher current price and charges a lower one. Both comments claiming the shown and charged amounts "cannot diverge" said more than that. - The sheet renders the price at full credit precision. Credits are 1e11 per DASH, and the eight-decimal rendering rounded away a sub-duff remainder that is nevertheless charged — on a money-authorization surface. - The credits-per-DASH divisor comes from `PlatformCreditsFormatter` instead of a second literal definition. - Tests cover the token-purchase parse branch and the credits→DASH conversion, including zero, a whole DASH, a whole-duff value and a single credit (the sub-duff case the display used to drop). The merge repair: this branch carried its own copy of the `WalletStartupStatus` cases while the same fix landed on develop as #1108, so the merge left the switch with two of each. Kept develop's. Built against platform `v4.2-dev` (which now carries dashpay/platform#4584); succeeds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RstH83XqqRc6oudvpu7UxZ
Issue being fixed or feature implemented
DashConnect's
dash-st:link hands a wallet a serialized state transition. The only parser exposed wasplatform_wallet_parse_identity_update_transition, which accepts anIdentityUpdateTransitionand nothing else — the DashConnect key-registration step.A dApp asking a wallet to authorize a token purchase was therefore rejected outright:
Observed against a real dApp (Yappr on a devnet). Its fallback without this was to ask the user to paste a CRITICAL private key into a web page, which is what DashConnect exists to avoid.
What was done?
packages/rs-platform-wallet-ffi/src/parse_state_transition.rsexposingplatform_wallet_parse_state_transition/..._free. It deserializes once and reports which kind it found alongside the matching payload, so a caller branches on a discriminant instead of on a thrown "expected X, got Y" error.ParsedStateTransitionFFIcarrieskind(NONE/IDENTITY_UPDATE/TOKEN_DIRECT_PURCHASE, exported as#defines) plus both payloads by value; exactly one is populated.ParsedTokenDirectPurchaseFFIis a POD struct withowner_id,data_contract_id,token_id,token_contract_position,token_countandtotal_agreed_price— whatplatform_wallet_token_purchaseneeds, plus what a user must see before approving.identity_update.rskeeps its existing public contract untouched, so current callers keep working; its tagged/tagless framing fallback is generalized to several candidate variant tags and shared throughdeserialize_transition_with_flexible_framing.ParsedStateTransition/ParsedTokenPurchaseTransitionandManagedPlatformWallet.parseStateTransition(_:)beside the existing identity-update pair.Deliberately a parser, not a signer. There is no entry point that signs caller-supplied bytes. The intended flow is parse → show the intent to the user → rebuild the purchase through the existing
platform_wallet_token_purchase, so a wallet only ever signs a transition it constructed itself.Rejected rather than projected, because a user cannot meaningfully approve them and the rebuild path could not reproduce them faithfully: a batch that is not exactly one token purchase (empty, several, or another transition kind), and a purchase carrying
using_group_info, which the rebuild submits without.How Has This Been Tested?
cargo test -p platform-wallet-ffi --lib— 307 passed, including 10 new tests inparse_state_transition::tests: tagged and tagless purchase, identity update through the umbrella parser, and rejection of multi-transition, empty, non-purchase, group-gated and malformed input, plus a free-is-safe case. The 7 existingidentity_updatetests guard the framing refactor and still pass, including the real captured fixture.cargo check -p platform-wallet-ffiandcargo clippy -p platform-wallet-ffi --libclean.packages/swift-sdk/build_ios.sh --target ios --target sim— xcframework rebuilt for both slices; the generated header carries the new symbols and kind constants, and the script's SwiftExampleApp build (-warnings-as-errors) compiles the modifiedManagedPlatformWallet.swift.dash-st:purchase link, renders 100 tokens for 0.001 DASH, and completes the purchase.Breaking Changes
None. Everything added is new surface;
platform_wallet_parse_identity_update_transitionis unchanged.Checklist:
Summary by CodeRabbit