Skip to content

fix(dashpay): stop offering Pay for a contact we cannot pay - #984

Merged
romchornyi merged 2 commits into
developfrom
fix/dashpay-pay-gate
Aug 12, 2026
Merged

fix(dashpay): stop offering Pay for a contact we cannot pay#984
romchornyi merged 2 commits into
developfrom
fix/dashpay-pay-gate

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

A mainnet user tapped Pay on a DashPay contact and got this alert:

Błąd
Invalid identity data: No DashpayExternalAccount found for contact
6ridihVjZZXGJCJ63hFqLAxXgxRMUnRoHRZnjhqHNT4y
— call register_external_contact_account first

That is an internal SDK diagnostic, shown verbatim, naming an API only the SDK can call. It reads as an instruction to the user for something the user cannot do.

The underlying reason is that .established was the only gate on the Pay CTA. Established means the contact requests are mutual — paying additionally needs the DIP-15 external contact account those requests are the input to. The app treated "we're friends" and "I can pay them" as the same state; they are not.

Root cause of why the account was missing is fixed separately in dashpay/platform#4372 (legacy Android/dashj contact requests were rejected by our key-purpose policy). This PR is the other half: the app should never route a user into a spend it already knows cannot complete, whatever the reason.

What was done?

Surface the channel state the SDK already persists

  • ContactItem gains paymentChannelBroken, read from the PersistentDashpayContactRequest rows — the persister already mirrors EstablishedContact.payment_channel_broken onto both directions, so this needs no new SDK API or FFI.

Don't offer a spend that cannot succeed

  • When the channel is permanently broken, ContactProfileSheet renders an explanation in place of the Pay button. It states what actually fixes it — "Ask them to send you a new contact request" — because the flag only clears when the contact sends a fresh request. A greyed-out button with no reason would leave the user tapping and guessing.

Translate the one diagnostic that reaches users

  • A failed send maps the missing-account case to "This contact's payment channel isn't ready yet. It's still being set up in the background — please try again in a few minutes." That is accurate: the channel is built in the background from the counterparty's request.
  • Deliberately narrow — only that known string is translated. Every other error keeps its own message rather than disappearing behind something generic.

How Has This Been Tested?

dashpay scheme, iOS Simulator, Debug — builds clean.

Not yet verified on device against a real broken channel. The reporting user's 27 legacy contacts are queued, not broken, so their wallet does not exercise the broken-channel branch; it exercises the error-message branch. Reproducing the broken branch needs a contact whose request permanently fails validation.

What I would ask the reporter to confirm on a TestFlight build (together with dashpay/platform#4372):

  1. Legacy contacts become payable — the actual fix.
  2. If any single contact still fails, the alert is now the readable "not ready yet" message, not the register_external_contact_account string.
  3. No contact that used to show Pay has lost it (the broken-flag gate should be a no-op for their wallet).

Build note: the Rust fix lives in dashpay/platform#4372. This app builds against the sibling ../platform/packages/swift-sdk local package, so a TestFlight build that actually fixes those contacts needs the platform checkout on that branch and the xcframework rebuilt (packages/swift-sdk/build_ios.sh). Building this PR alone only changes the UI behaviour.

Breaking Changes

None. ContactItem is an internal read model; the new field defaults to what the persisted rows already carry. No contact that is payable today loses its Pay button.

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

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of contacts whose payment channel cannot be established.
    • Removed the Pay button for affected established contacts and displayed guidance to request a new contact connection.
    • Added clearer messaging when payment account details are unavailable.
    • Preserved specific error messages when available.

A mainnet user tapped Pay on a DashPay contact and got an alert reading
"Invalid identity data: No DashpayExternalAccount found for contact <id> —
call register_external_contact_account first". That is an internal SDK
diagnostic naming an API only the SDK can call, so it reads as a demand the
user cannot act on.

Being `.established` was the only gate on the Pay CTA, but established means
the contact requests are mutual — paying additionally needs the DIP-15
external account those requests are the input to. The two states are not the
same, and the UI conflated them.

- `ContactItem` now carries `paymentChannelBroken`, read from the
  `PersistentDashpayContactRequest` rows the persister already mirrors it onto.
  When the SDK has permanently given up on building the channel, the profile
  sheet shows an explanation in place of the Pay button. It says what actually
  fixes it — the flag only clears when the CONTACT sends a fresh request — so
  the user has something to do rather than a disabled control.
- A failed send no longer surfaces the raw SDK string. The missing-account case
  becomes "the payment channel isn't ready yet, try again in a few minutes",
  which is true: the channel is built in the background from the counterparty's
  request. Only that one known diagnostic is translated; every other error keeps
  its own message rather than being hidden behind something generic.

The broken flag alone would not have helped the user who reported this: their
27 legacy contacts were queued rather than broken, which dashpay/platform#4372
fixes. This is the other half — the app should never route a user into a spend
it knows cannot complete, whatever the reason.
@coderabbitai

coderabbitai Bot commented Aug 11, 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: 1b1f319f-e0d7-4536-acf1-03bd47eec96c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e05922 and 4db0aa5.

📒 Files selected for processing (2)
  • DashWallet/Sources/Models/Transactions/WalletSendService.swift
  • DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift

📝 Walkthrough

Walkthrough

The contact model now reports permanently broken payment channels. The contact service populates this state from contact-request rows. The contact profile hides payment actions, shows recovery guidance, and translates known payment failures into retry messages.

Changes

Payment channel handling

Layer / File(s) Summary
Contact state propagation
DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/ContactItem.swift, DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift
ContactItem stores paymentChannelBroken. The contacts service sets it when any associated request row reports a broken payment channel.
Contact profile payment behavior
DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift
When the payment channel is broken, the profile replaces the Pay action with a payment-unavailable notice and recovery guidance.
Contact payment error mapping
DashWallet/Sources/Models/Transactions/WalletSendService.swift
sendToContact maps missing external-account diagnostics to a localized .dashPayPaymentUnavailable error. Other errors remain unchanged.

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

Sequence Diagram(s)

sequenceDiagram
  participant SwiftDashSDKContactsService
  participant ContactItem
  participant ContactProfileSheet
  participant WalletSendService
  SwiftDashSDKContactsService->>ContactItem: Set paymentChannelBroken from contact-request rows
  ContactItem->>ContactProfileSheet: Provide contact state
  ContactProfileSheet->>ContactProfileSheet: Show notice or Pay action
  ContactProfileSheet->>WalletSendService: Send contact payment
  WalletSendService->>WalletSendService: Map missing external-account diagnostic
Loading

Suggested reviewers: llbartekll, jeanpierreroma

🚥 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 summarizes the main change: preventing users from starting payments for contacts with an unusable payment channel.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 fix/dashpay-pay-gate

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

🤖 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/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift`:
- Around line 747-778: Move the SDK-specific diagnostic translation from
PayContactSheet.payFailureMessage(for:) into its `@MainActor` ObservableObject
ViewModel, and have the payment failure flow use that ViewModel mapping. Remove
the mapping helper and related SDK diagnostic knowledge from the View so
PayContactSheet only renders state and forwards user actions, while preserving
the existing localized message and fallback to error.localizedDescription.
🪄 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: c0f17512-4707-4cb4-a279-eb7722a6382b

📥 Commits

Reviewing files that changed from the base of the PR and between 106755d and 9e05922.

📒 Files selected for processing (3)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/ContactItem.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift
  • DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift

Comment on lines +747 to +778
errorMessage = Self.payFailureMessage(for: error)
}
}
}
}

/// User-facing text for a failed contact payment.
///
/// The SDK surfaces a missing DIP-15 external account as its own
/// internal diagnostic — "Invalid identity data: No
/// DashpayExternalAccount found for contact <id> — call
/// register_external_contact_account first". That reached users
/// verbatim in an alert: unreadable, and it names an API only the
/// SDK can call, so it reads as "do something" when there is
/// nothing for the user to do.
///
/// The channel is built in the background from the counterparty's
/// contact request, so the honest advice is to wait and retry. Any
/// other failure keeps its own message — this deliberately
/// translates one known string rather than swallowing every error
/// behind a generic one.
static func payFailureMessage(for error: Error) -> String {
let description = error.localizedDescription
guard description.contains("DashpayExternalAccount")
|| description.contains("register_external_contact_account")
else {
return description
}
return NSLocalizedString(
"This contact's payment channel isn't ready yet. It's still being set up in the background — please try again in a few minutes.",
comment: "DashPay Contacts")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move SDK diagnostic mapping out of PayContactSheet.

payFailureMessage(for:) maps an SDK-specific diagnostic in a View struct. Move this logic into an @MainActor ObservableObject ViewModel. Keep PayContactSheet limited to rendering state and forwarding user actions.

As per coding guidelines, “Keep SwiftUI views lightweight” and keep “network mapping” and “protocol constants” out of View structs.

🤖 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/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift`
around lines 747 - 778, Move the SDK-specific diagnostic translation from
PayContactSheet.payFailureMessage(for:) into its `@MainActor` ObservableObject
ViewModel, and have the payment failure flow use that ViewModel mapping. Remove
the mapping helper and related SDK diagnostic knowledge from the View so
PayContactSheet only renders state and forwards user actions, while preserving
the existing localized message and fallback to error.localizedDescription.

Source: Coding guidelines

…dary

Review flagged the translation helper living in a SwiftUI View, against the
repo guideline that FFI/SDK knowledge belongs in a ViewModel or a service.

Moved into `WalletSendService.sendToContact`, which owns the SDK call, rather
than into a ViewModel: the diagnostic now stops at the boundary that produces
it and never reaches the presentation layer at all, and every present and
future caller of `sendToContact` gets the same treatment instead of each
re-implementing it. The sheet is back to plain `error.localizedDescription`.

Behaviour is unchanged — same localized message, same narrow match, everything
else still surfaces its own error.
@romchornyi

Copy link
Copy Markdown
Contributor Author

Addressed in 4db0aa5 (the review above ran against the earlier commit — payFailureMessage(for:) no longer exists in the View).

I moved it into WalletSendService.sendToContact rather than a ViewModel. The guideline says such logic lives "in the ViewModel or a service", and the service is the better of the two here: sendToContact is what makes the SDK call and therefore what produces the diagnostic, so mapping it there means the string never reaches the presentation layer at all, and every present and future caller gets the same treatment instead of each re-implementing the mapping. The sheet is back to plain error.localizedDescription.

Same localized message, same deliberately narrow match — any other error still surfaces its own text rather than being hidden behind something generic.

Simulator build passes.

@romchornyi
romchornyi requested a review from llbartekll August 12, 2026 09:27

@llbartekll llbartekll left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good

@romchornyi
romchornyi merged commit df26e80 into develop Aug 12, 2026
3 checks passed
@romchornyi
romchornyi deleted the fix/dashpay-pay-gate branch August 12, 2026 13:33
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