Skip to content

feat(dashpay): authorize a token purchase from a DashConnect link - #1109

Open
romchornyi wants to merge 3 commits into
feat/devnet-supportfrom
feat/dashconnect-token-purchase
Open

feat(dashpay): authorize a token purchase from a DashConnect link#1109
romchornyi wants to merge 3 commits into
feat/devnet-supportfrom
feat/dashconnect-token-purchase

Conversation

@romchornyi

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

A dash-st: link was parsed strictly as an IdentityUpdateTransition — the DashConnect key-registration step — so a dApp asking the wallet to authorize a token purchase failed outright with Expected IdentityUpdateTransition, got Batch(...TokenDirectPurchase...).

Yappr's fallback without a wallet path was to ask the user to paste their CRITICAL private key into a web page, which is precisely what DashConnect exists to prevent — the approval sheet's own promise is that the app never sees your keys.

Depends on dashpay/platform#4584, which adds the parser this uses. Merge that first; this branch does not build against a v4.2-dev without it.

What was done?

  • The dash-st path parses once through the SDK's new parseStateTransition and switches on the kind, rather than branching on a thrown "expected X, got Y" error. Identity updates keep today's key-registration behaviour unchanged, including its wrong-identity, unexpected-mutation and mismatched-derived-key validation; the protocol method became handleStateTransition and the old body is now a private continuation, so no rule moved.
  • A token purchase gets its own approval sheet (ApproveTokenPurchaseSheet) showing what is being authorized: token count, the total price rendered in DASH, the token id, and which identity of the user's will be charged.
  • The wallet never signs the bytes it was handed. It reads the intent from them, shows it, and rebuilds the purchase through tokenPurchase, passing the parsed price as expectedTotalCost so the amount shown and the amount charged cannot diverge.
  • Ownership is checked before the sheet is built and again immediately before signing, since the sheet can sit open while the active identity changes.
  • Signing uses a CRITICAL key, which the SDK's signer selects itself (a HIGH login key can post but not spend). An identity without one fails with a visible message rather than a silent no-op.
  • approveLogin gained step markers. It logged that it started and then went silent until it finished, so a stall in key derivation, the Platform write or the connection preview looked identical from a log.
  • Two helpers shared with the connection sheet were promoted out of it rather than copied.

Also included, because the branch builds against a bumped platform: the WalletStartupStatus switch fix. It is submitted separately as #1108 against develop, since develop does not currently compile against v4.2-dev head without it; the copy here is what lets this branch build and will fold away once #1108 lands.

How Has This Been Tested?

xcodebuild -workspace DashWallet.xcworkspace -scheme dashpay -sdk iphonesimulator ARCHS=arm64 — succeeds.

End to end on a device against devnet-moutai: a real Yappr dash-st: purchase link opens the wallet, the sheet renders 100 tokens for 0.001 DASH against the correct identity, and the purchase completes on chain. The price shown was cross-checked against Yappr's own screen independently.

Getting there also surfaced, and required, a platform bump to v4.2-dev head: an older pin could not read the rankedCountable at-form introduced by dashpay/platform#4531, which Yappr's token contract uses, and failed with Proof verification error: rankedCountable value must be a boolean.

Not covered: the unit-test target is the known-broken one described in CLAUDE.md, so tests compile but were not run. The identity-update (dash-st key registration) path was verified by reading the final code rather than re-exercised, since its body is unchanged.

Breaking Changes

None.

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 made corresponding changes to the documentation

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

`WalletStartupStatus` gained `seedBindingUnverified` and
`identityScanIncomplete`, so the DashPay readiness log stopped compiling.

Both are logged as warnings rather than errors: unlike `discoveryFailed`,
which is a local fault nothing will clear on its own, each of these is
recoverable on a later start — a rerun with the matching signer completes the
queued contact-account work, and an incomplete identity scan stays on record
so the next launch re-scans instead of taking the warm shortcut.
A `dash-st:` link was parsed strictly as an `IdentityUpdateTransition` — the
DashConnect key-registration step — so a dApp asking the wallet to authorize a
token purchase failed with "Expected IdentityUpdateTransition, got Batch(...)".
Yappr's own fallback was to ask the user to paste a CRITICAL private key into a
web page, which is exactly what DashConnect exists to avoid.

The `dash-st` path now parses once through the SDK's new `parseStateTransition`
and switches on the kind rather than on a thrown error. Identity updates keep
today's key-registration behaviour unchanged, including its wrong-identity,
unexpected-mutation and mismatched-key validation. A token purchase gets an
approval sheet showing what is being authorized — token count, the total price
rendered in DASH, and which identity of the user's will be charged — and only
then signs.

The wallet never signs the bytes it was handed. It reads the intent from them,
shows it, and rebuilds the purchase through `tokenPurchase`, passing the parsed
price as `expectedTotalCost` so the amount shown and the amount charged cannot
diverge. Ownership is checked before the sheet is built and again immediately
before signing, since the sheet can sit open while the active identity changes.
Signing uses a CRITICAL key, which the signer selects itself; an identity
without one fails with a visible message rather than a silent no-op.

Two helpers shared with the connection sheet were promoted out of it rather
than copied.
`approveLogin` logged that it started and then went silent until it finished,
so a stall anywhere across key derivation, the Platform document write and the
connection preview looked identical from a log — the whole approval was one
opaque span.

Adds four markers around the steps that can block, which is what let a report
of "the QR never disappears" be narrowed to the wallet having completed its
side and the dApp not picking the document up.
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 739d2aff-9b23-4580-9473-92ddb3518456

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@thepastaclaw

thepastaclaw commented Sep 2, 2026

Copy link
Copy Markdown

⛔ Blockers found — Phase 2 deferred (commit 43bfabd)
Canonical validated blockers: 1

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preliminary review — Phase 1 blocker gate

The purchase flow correctly parses intent, verifies identity ownership, rebuilds the operation through the SDK, and requires explicit authorization rather than signing untrusted transition bytes. One blocking issue remains around retrying an ambiguously submitted purchase, along with narrower display-integrity, precision, documentation, duplication, and test-coverage findings.

Source: reviewer 1: glm-5.3-flash (agent: phase1-reviewer, role: general); reviewer 2: glm-5.3-flash (agent: phase1-reviewer, role: ffi-engineer); reviewer 3: glm-5.3-flash (agent: phase1-reviewer, role: security-auditor); final verifier: gpt-5.6-sol (agent: sol-verifier, role: verifier)

Validated blockers were found by the Phase-1 review and confirmed by a fresh verifier. Phase 2 is deferred until a fresh same-head revalidation clears the blocker gate.

Review provenance

  • Phase 1 reviewers: glm-5.3-flash — general (completed); agent phase1-reviewer, glm-5.3-flash — ffi-engineer (completed); agent phase1-reviewer, glm-5.3-flash — security-auditor (completed); agent phase1-reviewer
  • Fresh verifier: gpt-5.6-sol — verifier; agent sol-verifier
  • Phase 2 reviewers: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 2 suggestion(s) | 💬 3 nitpick(s)

1 additional finding(s) omitted (not in diff).

🤖 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 `DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift`:
- [BLOCKING] DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift:177-185: An ambiguous post-submit failure can turn Retry into a second purchase
  Each call to `approveTokenPurchase` builds, signs, and submits a new direct purchase. The SDK can fail while awaiting confirmation after the transition has already reached Platform, but this catch path describes every error as an incomplete request and leaves the same sheet ready to approve again. If the first transition was accepted despite a finality timeout or similar DAPI failure, the retry can use the identity's next nonce and buy the tokens a second time. Distinguish deterministic pre-submit failures from ambiguous post-submit outcomes; for an ambiguous result, dismiss or disable retry and tell the user to verify the identity's tokens and credit balance before initiating another purchase.

In `DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift`:
- [SUGGESTION] DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift:635-658: Validate the displayed token ID against the purchase being rebuilt
  The approval request copies `tokenId` directly from the untrusted serialized transition, but approval rebuilds the operation solely from `dataContractId` and `tokenContractPosition`; the SDK derives the actual token ID and never receives `request.tokenId`. A crafted payload can therefore display the identifier of one token while purchasing another token selected by the contract and position. Validate that the parsed ID equals the token ID derived from those fields before presenting the sheet, or remove the Token ID row so the UI does not authenticate a value that execution ignores.
- [NITPICK] DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift:672-675: Describe expectedTotalCost as a maximum rather than an exact-price guarantee
  Platform rejects the purchase when the current required price exceeds `expectedTotalCost`, but a lower current price is accepted and the lower amount is charged. The comment therefore overstates the invariant by claiming that the charged amount cannot diverge from the displayed amount. The corresponding `DashConnectTokenPurchaseRequest.totalAgreedPriceCredits` comment should also describe this value as the maximum approved cost.

In `DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift`:
- [SUGGESTION] DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift:165-199: Add compile-ready coverage for token-purchase parsing and price conversion
  The existing parser test exercises only the renamed identity-update branch. The newly introduced token-purchase mapping, including owner ID, contract ID, token ID, position, count, and agreed price, has no regression coverage despite being directly testable through the parser's injected closure. Add a token-purchase branch test and boundary cases for converting Platform credits to DASH, including zero, a normal whole-duff value, and a value with sub-duff credit precision. These tests remain valuable as compile-ready coverage while the repository's unit-test runner is broken.

In `DashWallet/Sources/UI/DashConnect/ApproveTokenPurchaseSheet.swift`:
- [NITPICK] DashWallet/Sources/UI/DashConnect/ApproveTokenPurchaseSheet.swift:70-73: Render the full Platform-credit precision on the approval sheet
  Platform credits have 11 decimal places relative to DASH, but `formattedDashAmount` limits the displayed amount to 8 fractional digits. When `totalAgreedPriceCredits` is not divisible by 1,000, the sheet rounds away the sub-duff remainder while the exact credit amount is passed to `expectedTotalCost` and charged. The discrepancy is less than one duff, but this is a money-authorization surface and the displayed amount should preserve all 11 credit-backed decimal places.

In `DashWallet/Sources/Models/DashConnect/DashConnectModels.swift`:
- [NITPICK] DashWallet/Sources/Models/DashConnect/DashConnectModels.swift:63-70: Reuse the existing credits-per-DASH definition
  This introduces another literal definition of the Platform credits conversion even though `PlatformCreditsFormatter.creditsPerDash` is already internal and available to this module. The same divisor also has private copies in two payment views. Reusing the existing definition here prevents the money-display conversion from drifting independently.
Out-of-scope follow-up suggestions (3)

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.

  • DashUIKit's pinned Swift tools version is incompatible with documented Xcode 16 support — The workspace pins DashUIKit revision 83cf65a84834f6a7a1f0a82446ea1258ea86f37d, whose Swift tools requirement cannot be resolved by Xcode 16.4's Swift 6.1 toolchain. That pin predates this PR, so it is not caused by the token-purchase work, but it conflicts with the repository's documented Xcode 16+ requirement and blocks canonical builds on that supported setup.
    • Follow-up: Track this as a separate build-infrastructure issue: either use a DashUIKit revision compatible with Swift 6.1 or update the documented minimum Xcode/toolchain requirement.
  • Synchronous FFI parse of untrusted dash-st bytes runs on the main thread with no payload-size cap — Out of scope: the previous DashConnect state-transition path already synchronously decoded and parsed the same unbounded payload on the main actor. This PR changes the accepted transition kinds but does not introduce that execution pattern.
    • Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
  • Platform #4584 SDK-side boundary observations — Out of scope: the defer ordering and Rust panic policy are implementation details of the separately reviewed platform#4584 dependency, not code changed in this repository.
    • Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.

Comment on lines +177 to +185
} catch {
// Keep the sheet up so the user can retry without rescanning
// the QR. Failures — including "the identity has no CRITICAL
// key" from the SDK — surface here as the sheet's error text.
self.purchaseApproveError = String(
format: NSLocalizedString("Could not complete the DashConnect request: %@", comment: "DashConnect"),
error.localizedDescription
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: An ambiguous post-submit failure can turn Retry into a second purchase

Each call to approveTokenPurchase builds, signs, and submits a new direct purchase. The SDK can fail while awaiting confirmation after the transition has already reached Platform, but this catch path describes every error as an incomplete request and leaves the same sheet ready to approve again. If the first transition was accepted despite a finality timeout or similar DAPI failure, the retry can use the identity's next nonce and buy the tokens a second time. Distinguish deterministic pre-submit failures from ambiguous post-submit outcomes; for an ambiguous result, dismiss or disable retry and tell the user to verify the identity's tokens and credit balance before initiating another purchase.

source: ['codex']

Comment on lines +635 to +658
private func makeTokenPurchaseRequest(
_ purchase: DashConnectTokenPurchaseTransition,
context: Context
) throws -> DashConnectTokenPurchaseRequest {
// Checked before anything is shown: a purchase that would charge a
// different identity must be refused, not rendered for approval.
guard purchase.ownerId == context.identityId else {
throw DashConnectPlatformError.tokenPurchaseWrongIdentity
}

let contractIdBase58 = purchase.dataContractId.toBase58String()
return DashConnectTokenPurchaseRequest(
// A connection approved earlier for the same contract names the
// app; otherwise the sheet falls back to the contract id.
appName: subject.value.first { $0.id == contractIdBase58 }?.name,
ownerId: purchase.ownerId,
dataContractId: purchase.dataContractId,
tokenId: purchase.tokenId,
tokenContractPosition: purchase.tokenContractPosition,
tokenCount: purchase.tokenCount,
totalAgreedPriceCredits: purchase.totalAgreedPrice,
walletUsername: context.storedUsername,
walletIdentityId: context.identityId.toBase58String()
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Validate the displayed token ID against the purchase being rebuilt

The approval request copies tokenId directly from the untrusted serialized transition, but approval rebuilds the operation solely from dataContractId and tokenContractPosition; the SDK derives the actual token ID and never receives request.tokenId. A crafted payload can therefore display the identifier of one token while purchasing another token selected by the contract and position. Validate that the parsed ID equals the token ID derived from those fields before presenting the sheet, or remove the Token ID row so the UI does not authenticate a value that execution ignores.

source: ['codex']

Comment on lines +70 to +73
DashConnectDetailRow(
label: NSLocalizedString("Total price", comment: "DashConnect token purchase"),
value: request.totalPriceDash.formattedDashAmount
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Render the full Platform-credit precision on the approval sheet

Platform credits have 11 decimal places relative to DASH, but formattedDashAmount limits the displayed amount to 8 fractional digits. When totalAgreedPriceCredits is not divisible by 1,000, the sheet rounds away the sub-duff remainder while the exact credit amount is passed to expectedTotalCost and charged. The discrepancy is less than one duff, but this is a money-authorization surface and the displayed amount should preserve all 11 credit-backed decimal places.

source: ['codex']

Comment on lines +672 to +675
let signer = KeychainSigner(modelContainer: context.modelContainer)
// `expectedTotalCost` is the same credits figure the approval sheet
// rendered — Platform rejects the transition if the on-chain price
// disagrees, so the charged amount cannot diverge from the shown one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Describe expectedTotalCost as a maximum rather than an exact-price guarantee

Platform rejects the purchase when the current required price exceeds expectedTotalCost, but a lower current price is accepted and the lower amount is charged. The comment therefore overstates the invariant by claiming that the charged amount cannot diverge from the displayed amount. The corresponding DashConnectTokenPurchaseRequest.totalAgreedPriceCredits comment should also describe this value as the maximum approved cost.

Suggested change
let signer = KeychainSigner(modelContainer: context.modelContainer)
// `expectedTotalCost` is the same credits figure the approval sheet
// rendered — Platform rejects the transition if the on-chain price
// disagrees, so the charged amount cannot diverge from the shown one.
// `expectedTotalCost` is the same credits figure the approval sheet
// rendered. Platform rejects the transition if the on-chain price
// exceeds it, so the user cannot be charged more than the shown price;
// if the on-chain price falls, the purchase charges less.

source: ['codex']

Comment on lines +63 to +70
extension DashConnectTokenPurchaseRequest {
/// Platform credits per DASH (1e11 — 1e8 duffs x 1000 credits per duff).
static let creditsPerDash: Decimal = 100_000_000_000

/// The total price converted to DASH for display.
var totalPriceDash: Decimal {
Decimal(totalAgreedPriceCredits) / Self.creditsPerDash
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Reuse the existing credits-per-DASH definition

This introduces another literal definition of the Platform credits conversion even though PlatformCreditsFormatter.creditsPerDash is already internal and available to this module. The same divisor also has private copies in two payment views. Reusing the existing definition here prevents the money-display conversion from drifting independently.

Suggested change
extension DashConnectTokenPurchaseRequest {
/// Platform credits per DASH (1e11 — 1e8 duffs x 1000 credits per duff).
static let creditsPerDash: Decimal = 100_000_000_000
/// The total price converted to DASH for display.
var totalPriceDash: Decimal {
Decimal(totalAgreedPriceCredits) / Self.creditsPerDash
}
extension DashConnectTokenPurchaseRequest {
/// The total price converted to DASH for display.
var totalPriceDash: Decimal {
Decimal(totalAgreedPriceCredits) / Decimal(PlatformCreditsFormatter.creditsPerDash)
}
}

source: ['codex']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants