Skip to content

fix(wallet): correct Platform-to-Shielded Max calculation - #971

Merged
QuantumExplorer merged 2 commits into
developfrom
codex/fix-platform-to-shielded-max
Aug 10, 2026
Merged

fix(wallet): correct Platform-to-Shielded Max calculation#971
QuantumExplorer merged 2 commits into
developfrom
codex/fix-platform-to-shielded-max

Conversation

@llbartekll

@llbartekll llbartekll commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • use the SDK's account-aware shield-capacity preflight for Max and manual validation
  • freeze the confirmed amount and revalidate immediately before authorization
  • fail closed while capacity is unknown or stale, and resync after a live insufficient-capacity race
  • preserve manual amounts and never silently reduce a transfer
  • add regression coverage for the reported Max failure and stale-capacity paths

Why

The UI previously used the aggregate displayed Platform balance when calculating Max. That amount can be larger than the value the Platform Payment account can actually shield after protocol reserve and input-selection constraints.

Companion Platform SDK PR: dashpay/platform#4360

Testing

  • dashpay generic iOS Simulator build succeeded with code signing disabled
  • changed Swift sources and tests parsed successfully
  • the app test bundles could not be executed locally: dashpay has no test action, while dashwallet requires the unavailable watchOS 26.5 runtime

Summary by CodeRabbit

  • New Features

    • Added dynamic Platform-to-Shielded transfer capacity checks.
    • Added Max amount support based on the latest available shieldable capacity.
    • Added loading, unavailable-capacity, and held-back balance messaging.
  • Bug Fixes

    • Prevented transfers from proceeding when capacity data is unavailable or outdated.
    • Revalidates capacity before authorization and refreshes balances when capacity changes.
    • Improved confirmation handling when transfer limits change.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f1f98abf-3ac3-4b91-a3b5-0fd51740c20d

📥 Commits

Reviewing files that changed from the base of the PR and between f8129b1 and 89b0334.

📒 Files selected for processing (3)
  • DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift
  • DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift
🚧 Files skipped from review as they are similar to previous changes (3)
  • DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift

📝 Walkthrough

Walkthrough

The transfer flow obtains Platform Shield capacity from SDK preflight, uses it for validation and Max handling, revalidates it before submission, and recovers from capacity changes during confirmation.

Changes

Platform Shield capacity

Layer / File(s) Summary
SDK shield preflight contract
DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift
Exposes preflightShield(paymentAccount:) after coordinator and wallet readiness checks.
Capacity policy and lifecycle
DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift, DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift
Tracks SDK capacity, refresh state, Max provenance, stale results, validation, and capacity messages. Tests cover capacity limits, held-back credits, stale refreshes, and capacity changes.
Final shield validation
DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift
Revalidates capacity before authorization and maps changed or insufficient capacity to a dedicated error.
Confirmation snapshot and capacity recovery
DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift, DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift
Captures immutable confirmation values and handles capacity changes once.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant InternalTransferViewModel
  participant PlatformAddressSyncCoordinator
  participant PlatformWalletManager
  participant ShieldedTransferCoordinator
  InternalTransferViewModel->>PlatformAddressSyncCoordinator: request preflightShield(paymentAccount)
  PlatformAddressSyncCoordinator->>PlatformWalletManager: shieldedShieldPreflight(paymentAccount)
  PlatformWalletManager-->>InternalTransferViewModel: return SDK shield capacity
  InternalTransferViewModel->>ShieldedTransferCoordinator: submit frozen shield amount
  ShieldedTransferCoordinator->>PlatformAddressSyncCoordinator: revalidate shield capacity
  ShieldedTransferCoordinator-->>InternalTransferViewModel: report capacity change or continue authorization
Loading

Possibly related PRs

Suggested reviewers: romchornyi

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: correcting the Platform-to-Shielded Max calculation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fix-platform-to-shielded-max

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift (1)

652-653: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Drop the empty associated-value pattern and match the file's existing match style.

SwiftLint reports empty_enum_arguments on Line 653. The other PlatformWalletError checks in this file match the error directly, for example Line 1228 (case PlatformWalletError.shieldedSpendUnconfirmed = error). Use the same shape here.

As per coding guidelines "Use SwiftFormat and SwiftLint conventions".

♻️ Proposed fix
-            if let walletError = error as? PlatformWalletError,
-               case .shieldedInsufficientBalance(_) = walletError {
+            if case PlatformWalletError.shieldedInsufficientBalance = error {
                 handleFailure(CoordinatorError.platformShieldCapacityChanged(
                     maxShieldableCredits: nil))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift`
around lines 652 - 653, Update the PlatformWalletError matching in the shielded
transfer error handling to use the direct enum-case pattern for
shieldedInsufficientBalance, removing the empty associated-value placeholder and
matching the existing style used elsewhere in the file.

Sources: Coding guidelines, Linters/SAST tools

DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift (1)

444-448: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for the remaining fail-closed branches.

The new tests cover the resolved-capacity paths and the nil capacity path. Three branches of the fail-closed contract stay untested:

  • canSubmit(requestedCredits: 0, capacity:) must return false. canContinue relies on this to keep Continue disabled for a zero amount.
  • canShield == false must make maximumDuffs return 0 and canSubmit return false. applyPlatformShieldMax branches on that zero to pick between the headroom notice and the empty-balance notice.
  • heldBackCredits must return 0 when submittedDuffs * 1000 overflows, and when the submitted amount is at or above the displayed aggregate.

These are the branches that guarantee the SDK preflight stays the sole authority, so pinning them is worthwhile.

💚 Proposed additional tests
func testPlatformShieldRejectsZeroAmountAndUnshieldableAccount() {
    let unshieldable = PlatformShieldCapacity(
        canShield: false,
        accountBalanceCredits: 3_921_114_000,
        usableBalanceCredits: 0,
        feeReserveCredits: 1_000_000_000,
        maxShieldableCredits: 0,
        reason: "insufficient headroom")

    XCTAssertEqual(
        PlatformShieldAmountPolicy.maximumDuffs(capacity: unshieldable),
        0)
    XCTAssertFalse(PlatformShieldAmountPolicy.canSubmit(
        requestedCredits: 1_000,
        capacity: unshieldable))

    let shieldable = PlatformShieldCapacity(
        canShield: true,
        accountBalanceCredits: 3_921_114_000,
        usableBalanceCredits: 3_623_849_220,
        feeReserveCredits: 1_000_000_000,
        maxShieldableCredits: 2_623_849_220)

    XCTAssertFalse(PlatformShieldAmountPolicy.canSubmit(
        requestedCredits: 0,
        capacity: shieldable))
}

func testPlatformShieldHeldBackReportsZeroOnOverflowAndFullySubmittedBalance() {
    // Overflow of submittedDuffs * 1000 must never invent a remainder.
    XCTAssertEqual(
        PlatformShieldAmountPolicy.heldBackCredits(
            displayedPlatformCredits: 4_500_000_000,
            accountBalanceCredits: 3_921_114_000,
            submittedDuffs: UInt64.max),
        0)
    // Nothing is held back when the whole aggregate is submitted.
    XCTAssertEqual(
        PlatformShieldAmountPolicy.heldBackCredits(
            displayedPlatformCredits: 2_623_849_000,
            accountBalanceCredits: 2_623_849_000,
            submittedDuffs: 2_623_849),
        0)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift` around lines 444 - 448,
Add tests in SwiftDashSDKCoreLifecycleTests for the remaining
PlatformShieldAmountPolicy fail-closed branches: verify canSubmit rejects zero
requested credits, an unshieldable capacity yields maximumDuffs of 0 and rejects
submission, and heldBackCredits returns 0 for submittedDuffs multiplication
overflow and fully submitted balances. Use the proposed PlatformShieldCapacity
scenarios and preserve existing resolved and nil-capacity coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift`:
- Around line 1194-1209: Update the nil-capacity branch handling the platform
shield resync barrier to set isPlatformShieldPreflightLoading to false after
cancelling the task. Add a bounded manual-rearm state such as
allowsManualPlatformShieldRearm, and update fillPlatformShieldMax to consume
that allowance by clearing awaitingPlatformShieldResync and starting one fresh
preflight when the user explicitly taps Max; preserve the existing fail-closed
behavior until that action occurs.

---

Nitpick comments:
In
`@DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift`:
- Around line 652-653: Update the PlatformWalletError matching in the shielded
transfer error handling to use the direct enum-case pattern for
shieldedInsufficientBalance, removing the empty associated-value placeholder and
matching the existing style used elsewhere in the file.

In `@DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift`:
- Around line 444-448: Add tests in SwiftDashSDKCoreLifecycleTests for the
remaining PlatformShieldAmountPolicy fail-closed branches: verify canSubmit
rejects zero requested credits, an unshieldable capacity yields maximumDuffs of
0 and rejects submission, and heldBackCredits returns 0 for submittedDuffs
multiplication overflow and fully submitted balances. Use the proposed
PlatformShieldCapacity scenarios and preserve existing resolved and nil-capacity
coverage.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1a4eb5f9-ef67-4aad-9103-94a54e6f132e

📥 Commits

Reviewing files that changed from the base of the PR and between 4ac2e0b and f8129b1.

📒 Files selected for processing (6)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift
  • DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift

@llbartekll
llbartekll requested a review from romchornyi August 10, 2026 22:02
@llbartekll llbartekll changed the title Fix Platform-to-Shielded Max calculation fix(wallet): correct Platform-to-Shielded Max calculation Aug 10, 2026
@romchornyi

Copy link
Copy Markdown
Contributor

One thing I'd like to see addressed before this merges: the awaitingPlatformShieldResync path can leave the amount form permanently locked.

When the live insufficient-capacity race fires, performShield reports platformShieldCapacityChanged(maxShieldableCredits: nil) and calls schedulePlatformResync(). The nil branch of handlePlatformShieldCapacityChanged then does:

platformShieldPreflightGeneration &+= 1
platformShieldPreflightTask?.cancel()
platformShieldPreflightTask = nil
platformShieldCapacity = nil
isPlatformShieldPreflightLoading = true
awaitingPlatformShieldResync = true

So the form sits in a loading state with no task in flight, and the only thing that can clear it is a $platformBalance publication:

  • shouldRefreshPreflight(after:awaitingPlatformResync:) rejects every .other event, so a route change or a Max tap cannot re-arm the preflight;
  • cancelPlatformShieldPreflight() on route change preserves the flag via awaitingPlatformResync(current:after: .other), so leaving and re-entering the route does not reset it;
  • fillPlatformShieldMax() returns early with the "sync and try again" notice.

schedulePlatformResync() only makes three attempts (0 / 4 / 12 s), and PlatformAddressSyncCoordinator calls refreshBalanceSnapshot() only on the success branch of the sync result. If all three of those syncs fail — flaky connection, unhealthy evonode — nothing ever publishes a balance, and both Continue and Max stay dead with no retry offered anywhere in the UI. The only escape is dismissing the whole transfer screen so InternalTransferHostingController builds a fresh view model, which is not discoverable from a message that reads "Sync and try again".

Could we add either a user-visible retry (a tappable retry on the notice, or letting a Max tap force one preflight attempt while awaiting), or a bounded timeout that clears awaitingPlatformShieldResync and lets the normal fail-closed preflight path run again? Fail-closed is the right default here; I'd just like an exit that does not require leaving the screen.

The rest of the change reads well to me — the frozen InternalTransferConfirmation is the right fix for the underlying bug, preflightShield's paymentAccount: 0 matches the shieldedShield call in performShield, and handleFailure sets lastFailure before phase = .failed, so the confirm sheet's onChange observes the typed error rather than a stale nil.

@llbartekll

Copy link
Copy Markdown
Contributor Author

Addressed the remaining automated-review feedback in 89b0334:

  • nil-capacity recovery now stays fail-closed but lets an explicit Max tap retry a full Platform sync; it never preflights stale cache
  • the PlatformWalletError match now follows the direct enum-case style
  • added coverage for zero requests, unshieldable capacity, held-back overflow/full submission, and manual resync gating

Validation: swiftc parse and git diff check passed. The Conventional Commit title check is also green.

@QuantumExplorer
QuantumExplorer merged commit f781300 into develop Aug 10, 2026
3 checks passed
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