Skip to content

feat(wallet): retry stuck asset-lock transfers from the tx detail sheet - #949

Merged
QuantumExplorer merged 2 commits into
developfrom
feat/asset-lock-rebroadcast
Aug 10, 2026
Merged

feat(wallet): retry stuck asset-lock transfers from the tx detail sheet#949
QuantumExplorer merged 2 commits into
developfrom
feat/asset-lock-rebroadcast

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 9, 2026

Copy link
Copy Markdown
Member

What

A funding asset lock can park mid-transfer: built or broadcast but never IS/CL-locked (the tx detail sheet's "Broadcasting" status — see the 2 DASH identity top-up that motivated this), or locked on Core but with the Platform transition never landing (app killed, network drop between broadcast and submit). The detail sheet showed the stuck status with no way forward.

It now offers a recovery action for those states:

  • "Rebroadcast" while the transaction is unconfirmed (asset-lock status built/broadcast)
  • "Complete Transfer" once the lock is on-chain and only the Platform side remains (IS/CL-locked)

How

Both drive the SDK's crash-recovery resume entry points on the existing tracked outpoint — rebroadcast if needed, IS/CL wait, Platform submit, consume — so a retry can never build (and strand) a second lock:

Funding type Route
1/2 — identity top-up resumeTopUpWithAssetLock against the wallet's identity, PIN-gated; identity snapshot refreshes on success
4 — Core → Platform address funding ShieldedTransferCoordinator.resumeFundPlatform
5 — Core → Shielded funding ShieldedTransferCoordinator.resumeAssetLock
0/3 — registration / invitation deliberately excluded — they recover through the Join DashPay flow, which owns key preparation. consumeInvitationVoucher stays false on the generic surface, so a shared invitation voucher can never be silently consumed.

New AssetLockRecoveryService dispatches by funding type; TxDetailModel.stuckAssetLockRetry derives the button from the same live ShieldedTxLookup info that renders the status row (statuses 0–3 only — consumed and restored-from-chain locks never show it). The await spans the whole recovery under a progress HUD (worst case includes the IS/CL wait), and the rows re-derive afterward either way — even a failed retry can have advanced the lock, and the status row should say so.

Testing

Clean dashpay build; installed on the QA simulator and live-tested against the 2 DASH top-up stuck at "Broadcasting": the retry correctly found the built-never-locked lock and re-broadcast it to 2/3 peers (first successful send of that tx this session — the launch-time auto catch-up had failed on an SPV startup race).

The test also uncovered why that particular lock can never complete: its input was already spent on-chain at height 1510203 (a coin-selection race during the restored wallet's history scan), so peers silently drop the tx — no retry can fix it. Follow-ups filed on the platform side: (a) the launch catch-up racing SPV startup, (b) quarantining freshly-scanned UTXOs during restore catch-up + a typed permanently-invalid error from resume_asset_lock so this button can offer discarding such a lock instead.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added recovery actions for stuck asset-lock transfers in transaction details.
    • Supports rebroadcasting or completing eligible transfers based on their current status.
    • Added progress indicators, success and error feedback, and automatic transaction refresh.
    • Added localized messaging for recovery states, including unsupported, canceled, or completed transfers.
    • Prevents duplicate recovery attempts while a retry is in progress.

A funding asset lock can park mid-transfer: built or broadcast but
never IS/CL-locked (the "Broadcasting" status), or locked on Core but
with the Platform transition never landing (app killed, network drop).
Until now the detail sheet just showed the stuck status with no way
forward.

The sheet now offers a recovery action for those states:
"Rebroadcast" while the transaction is unconfirmed (status built/
broadcast), "Complete Transfer" once the lock is on-chain and only the
Platform side remains. Both drive the SDK's crash-recovery resume
entry points on the EXISTING tracked outpoint - rebroadcast if needed,
IS/CL wait, Platform submit, consume - so a retry can never build (and
strand) a second lock:

- identity top-up locks (types 1/2): resumeTopUpWithAssetLock against
  the wallet's identity, PIN-gated, refreshing the identity snapshot
  on success. consumeInvitationVoucher stays false - a generic retry
  surface must never silently consume an invitation voucher.
- Core -> Platform address funding (type 4):
  ShieldedTransferCoordinator.resumeFundPlatform.
- Core -> Shielded funding (type 5):
  ShieldedTransferCoordinator.resumeAssetLock.
- identity registration/invitation locks (types 0/3) are deliberately
  excluded - they recover through the Join DashPay flow, which owns
  key preparation.

The await spans the whole recovery under a progress HUD (a worst case
includes the IS/CL wait), the rows re-derive afterward either way -
even a failed retry can have advanced the lock, and the status row
should say so - and backing out of the PIN prompt is treated as a
non-error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 9, 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: d168a6f0-eab5-4023-ab0e-e1884c3356ca

📥 Commits

Reviewing files that changed from the base of the PR and between 65b3e3d and aa08b35.

📒 Files selected for processing (3)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift
  • DashWallet/en.lproj/Localizable.strings
🚧 Files skipped from review as they are similar to previous changes (2)
  • DashWallet/en.lproj/Localizable.strings
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift

📝 Walkthrough

Walkthrough

Adds asset-lock recovery for supported funding types. Transaction details expose retryable locks, invoke the recovery service, show progress and results, refresh transaction data, and display localized recovery states.

Changes

Asset-lock recovery

Layer / File(s) Summary
Recovery service and project wiring
DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift, DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift, DashWallet.xcodeproj/project.pbxproj
Adds recovery dispatch for funding types 1, 2, 4, and 5. The service validates readiness, handles authorization cancellation, maps coordinator errors, refreshes state, and is added to the relevant targets. The coordinator stores underlying transfer failures.
Retry metadata and transaction UI
DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift, DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift
Identifies retryable asset locks and adds a recovery action. The view controller prevents concurrent attempts, manages progress feedback, invokes recovery, handles results, and reloads transaction rows.
Recovery status localization
DashWallet/en.lproj/Localizable.strings
Adds localized strings for rebroadcasting, transfer completion, progress, failure, unsupported routes, and completed states.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TxDetailModel
  participant TxDetailViewController
  participant AssetLockRecoveryService
  participant SwiftDashSDK
  TxDetailModel-->>TxDetailViewController: provide retry metadata
  TxDetailViewController->>AssetLockRecoveryService: recover asset lock
  AssetLockRecoveryService->>SwiftDashSDK: resume supported funding route
  SwiftDashSDK-->>AssetLockRecoveryService: return result
  AssetLockRecoveryService-->>TxDetailViewController: return success or error
  TxDetailViewController->>TxDetailModel: refresh transaction state
Loading

Possibly related PRs

Suggested reviewers: llbartekll

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 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: retrying stuck asset-lock transfers from the transaction detail sheet.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/asset-lock-rebroadcast

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/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift (2)

91-102: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Distinguish the two notReady causes.

Both a missing wallet and a missing identityId map to RecoveryError.notReady, which renders as "Wallet is not ready". For an identity top-up retry, a missing identity is a different condition. Separate the guards so the log and the message identify the actual cause.

🤖 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/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift`
around lines 91 - 102, Update retryIdentityTopUp to use separate guards for the
wallet and identityId prerequisites instead of combining them. Keep the wallet
failure mapped to the existing wallet-not-ready error, and map the missing
identityId to a distinct recovery error/message so logs and user-facing output
identify the actual cause.

66-84: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Collapse the duplicate coordinator resume branches.

ShieldedTransferCoordinator is @MainActor; both resume methods are non-throwing async methods with the specified labels, and Phase defines .failed(String). Combine cases 4 and 5, then call checkTerminalPhase once.

🤖 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/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift`
around lines 66 - 84, Update retry in AssetLockRecoveryService to combine
funding types 4 and 5 into one branch, instantiate a single
ShieldedTransferCoordinator, invoke the appropriate resume method based on the
funding type, and call checkTerminalPhase once afterward. Preserve the existing
method labels, await behavior, and unsupportedRoute handling.
🤖 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/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift`:
- Around line 107-113: Update checkTerminalPhase to preserve and inspect the
typed CoordinatorError.authCancelled state, or the coordinator’s explicit
cancelled phase, instead of comparing localized errorDescription text. Map that
typed cancellation to DWIdentityAuthorizer.AuthError.cancelled while continuing
to convert other failed messages to RecoveryError.failed.

---

Nitpick comments:
In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift`:
- Around line 91-102: Update retryIdentityTopUp to use separate guards for the
wallet and identityId prerequisites instead of combining them. Keep the wallet
failure mapped to the existing wallet-not-ready error, and map the missing
identityId to a distinct recovery error/message so logs and user-facing output
identify the actual cause.
- Around line 66-84: Update retry in AssetLockRecoveryService to combine funding
types 4 and 5 into one branch, instantiate a single ShieldedTransferCoordinator,
invoke the appropriate resume method based on the funding type, and call
checkTerminalPhase once afterward. Preserve the existing method labels, await
behavior, and unsupportedRoute handling.
🪄 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: e5c293c2-8ce8-48b5-970a-ea3e873492b9

📥 Commits

Reviewing files that changed from the base of the PR and between b326045 and 65b3e3d.

📒 Files selected for processing (5)
  • DashWallet.xcodeproj/project.pbxproj
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift
  • DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift
  • DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift
  • DashWallet/en.lproj/Localizable.strings

…entity error

Addresses the review on #949.

- `ShieldedTransferCoordinator` now records the typed error behind a
  `.failed` phase in `lastFailure`, so `AssetLockRecoveryService` can tell
  a PIN cancel from a real failure without comparing localized text (the
  `Phase` payload is display copy and would silently stop matching on any
  wording or locale change). Cleared on `beginTransfer()`/`reset()`; the
  single `phase = .failed` assignment site keeps the two in step.
- Split the identity top-up preconditions: a missing wallet stays
  `.notReady`, a missing identity gets its own `.noIdentity` error and log
  line, so both the log and the alert name the actual cause.
- Collapse the two coordinator resume branches (funding types 4 and 5) so
  the resume call and its terminal-phase check stay a single pair.

Clean `dashpay` simulator build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant