Skip to content

fix(dashdex): show which provider can swap each coin right now - #1297

Open
romchornyi wants to merge 9 commits into
developfrom
fix/swap-live-route-availability
Open

romchornyi wants to merge 9 commits into
developfrom
fix/swap-live-route-availability

Conversation

@romchornyi

@romchornyi romchornyi commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

The DEX coin picker decides which provider carries a coin, and whether the coin can be picked at all, from SwapKit's /tokens?provider= lists. Those lists describe capability, not live state. On 2026-11-02 on mainnet:

  • with providers: ["NEAR"], every DASH ↔ X quote returned noRoutesFound, so NEAR's DASH leg was down in both directions;
  • with Maya forced, BTC and ETH.USDT returned HTTP 200 with empty routes and swapHalted (mayanode had BTC/ETH trading halted), and ARB.* returned 404 noRoutesFound;
  • /swapTo still listed all of these coins.

The picker kept offering them, labelled "Multiple" or "NEAR". After you picked one (Sell DASH → USDT on Arbitrum), the address screen turned the 404 noRoutesFound into "Address validation unavailable — please check your connection". It also treated HTTP 200 with no route plus swapHalted as a valid address.

What was done?

Live route availability (SwapRouteAvailability, new):

  • A coin is probed with /v3/quote forced to one provider, at about $50 of the sold asset, so a top-level noRoutesFound is conclusive. Each provider's own providerErrors are classified separately: swapHalted-family codes mean halted, noRoutesFound means no route, below-minimum counts as routable, and anything else (including an error without a code) is inconclusive.
  • NEAR: two liquid sentinel coins are probed first (BTC, ETH; SOL too whenever those two do not settle the question either way). If two answer with no route, NEAR is treated as down for every coin and no per-coin NEAR probes are sent. Otherwise every NEAR-capable coin is probed. A halt on a sentinel's chain does not count as NEAR being down.
  • Maya: probed per coin, because Maya halts are per chain.
  • Load: at most two probes in flight. Answers are cached for 10 minutes and "NEAR down" for 2 minutes. Inconclusive answers are never stored; they back off for 2 minutes and keep the static label. Answers older than 30 minutes are dropped and the picker re-renders. Probes run only while the picker is on screen.

Picker (SelectCoinViewModel, SwapKitSwapProvider):

  • The label is computed per coin and per direction from the providers that can route it now: both → "Multiple", one → that provider's label, none (halted or no route) → the existing halted row and toast.
  • Buy is included. A Buy coin with no live route is shown as halted instead of being hidden. Buy goes only through NEAR.
  • The amount and confirmation quotes no longer ask a provider that the cache has just seen halted or without a route. They fall back to the static set when nothing fresh is known.

Address screen (validateAddress):

  • Non-2xx bodies are decoded. "Check your connection" is kept only for a transport failure.
  • The address check asks the same providers as the real Sell quote: NEAR and Maya, or NEAR alone when the coin has no live classification yet.
  • A refused address (invalidDestinationAddress, a blacklisted or invalid asset) blocks with the address copy. Maya's two flavours are judged separately; when both refuse, the first refusal in SwapKit's order is shown.
  • A missing route is re-checked without the address. If a provider routes, the address copy is shown; if no provider routes, "%@ can't be swapped at the moment" is shown; if the re-check is inconclusive, the user is let through.
  • An answer the wallet cannot read (a non-2xx body that does not decode) says "The swap service isn't responding. Please try again later." instead of blaming the connection. This is the only new localization key; it is added to all 43 catalogs with the English text.

Note: SwapKit's /v3/quote does not validate the destination address itself (a garbage address still gets a route). The format check on the address screen stays the app's own isAddressValid.

Decisions (Proposed, in docs/adr/; summary in docs/features/swap-live-routes/README.md):

  • ADR-0006 — probe live routes with address-free quotes forced to one provider.
  • ADR-0007 — keep the answers in one shared in-memory store with two-step ageing.
  • ADR-0008 — detect a NEAR outage from sentinel coins before per-coin checks.

Numbers 0003–0005 are taken by #1311; whichever PR merges second renumbers if needed.

How Has This Been Tested?

  • New SwapRouteAvailabilityTests cover every NEAR/Maya combination: NEAR up with Maya halted → "NEAR"; NEAR down with Maya up → "Maya", and NEAR-only coins halted; both down → halted; halted sentinels; one Maya provider halted while the other is inconclusive; cache ageing and back-off; cancellation and concurrent refreshes.
  • SwapKitQuoteDecodingTests cover the per-provider classification and the address-screen decision on real SwapKit error bodies.
  • Further tests pin the address screen's outcome after the address-free re-check, that a route check carries no address, that a NEAR outage on Buy halts every Buy coin, and the two-minute back-off after an inconclusive sentinel round.
  • Further tests pin a codeless provider error as inconclusive, per-flavour Maya refusals, SOL standing in for the sentinels, the 30-minute expiry re-render, and that the address check asks only NEAR and Maya.
  • Every swap suite (SwapRouteAvailabilityTests, SwapKitQuoteDecodingTests, SwapKitQuoteBoundaryTests, SwapKitErrorCopyTests, SwapAddressValidatorTests, SwapNotificationProducerTests, SwapStatusUIVisibilityTests, BuySwapOrderTests) plus DashAmountFormatterTests, 192 tests, pass on the iPhone 17 simulator (dashwallet-dashpay, Debug). SwiftLint reports no new findings on the changed Swift files.
  • The full DashWalletTests run had 6 failures, none in swap code: 5 in InSheetPushStackTests and 1 in String_DashWallet testAssumptionThatCurrencySymbolIsEitherAtTheBeginningOrAtTheEnd.
  • The probe responses were measured live against api.swapkit.dev on mainnet.
  • Ran the Debug build on the simulator against mainnet SwapKit, from a fresh wallet (2026-11-05):
    • Sell: coins open with their published labels, then take the live ones (BTC "Multiple", most others "NEAR"); AVAX turned into the halted row and the halted-chain toast appeared.
    • Buy: BTC is labelled "NEAR" (Maya never counts on Buy), and AVAX is available, so the verdict is per direction.
    • Address screen: an invalid BTC address is refused on Continue, and Continue stays disabled.
  • Re-ran on the final code with Maestro flows (sell_coin_picker_live_labels, buy_coin_picker_near_only), Debug simulator build, mainnet, fresh wallet (2026-11-05): Sell BTC "Multiple" and the rest "NEAR"; OKB, OP and POL shown as halted rows that do not open, with the halted-chain toast; every Buy row "NEAR".
  • Release simulator build of the same code (Debug builds skip address errors), mainnet, fresh wallet: a valid BTC address passes the address check and Continue opens the amount screen; back in the picker BTC stays "Multiple". With the network off, the address screen shows "No internet connection" and keeps Continue disabled, so the check does not run; the "no response" copy is covered by unit tests.
  • Still to check on a device: a SwapKit address refusal, the no-route re-check, and the service-failure copy.

Breaking Changes

None. SwapProvider gains networkLabels(for:direction:), haltedAssets(from:pools:direction:), the refresh start/stop methods and routeAvailabilityChanges, and loses onBuyRoutabilityChanged. All conformers in this repo are updated.

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
  • ADR (ADR-0001): ADR-0006 added, ADR-0007 added, ADR-0008 added
  • Nothing in this PR (files, commit messages, branch name, description, comments, attachments, linked issues) holds customer data, unfixed vulnerability detail, secrets, or step-by-step attack detail against keys, PIN or encryption; such content is left out and handled privately with the maintainers (ADR-0001, rule 2)

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

The coin picker labelled and offered coins from SwapKit's static
/tokens lists, so a coin stayed selectable while neither NEAR nor Maya
could route it, and the address screen then reported a 404
noRoutesFound as a connection problem.

Probe each coin with a quote forced to one provider (about $50 of the
sold asset, at most two requests in flight, cached): NEAR is checked on
sentinel coins first and treated as down for every coin when they have
no route; Maya is checked per coin. Both usable -> "Multiple", one ->
that provider, none -> the existing halted row. Buy is included.

The address screen reads the error body: a refused address keeps the
address copy, a missing route is re-checked without the address, and
only a transport failure says "check your connection".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 119 files, which is 19 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c88f8e6b-c7d5-4150-98db-c2c8f2ffb8c0
Commits

Reviewing files that changed from the base of the PR and between 2ba8558 and c7fcc41.

Files ignored due to path filters: 3

  • DashWallet/Resources/AppAssets.xcassets/Explore Dash/ctx.logo.imageset/logo.ctx.png is excluded by !**/*.png
  • DashWallet/Resources/AppAssets.xcassets/Explore Dash/ctx.logo.imageset/logo.ctx@2x.png is excluded by !**/*.png
  • DashWallet/Resources/AppAssets.xcassets/Explore Dash/ctx.logo.imageset/logo.ctx@3x.png is excluded by !**/*.png
Files selected for processing: 119

  • .github/PULL_REQUEST_TEMPLATE.md
  • .platform-ref
  • CLAUDE.md
  • DashPayRecoveryUITests/ShieldedWithdrawalHistoryUITests.swift
  • DashWallet.xcodeproj/project.pbxproj
  • DashWallet/AppDelegate.m
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockInputPolicy.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKPhraseRepairer.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift
  • DashWallet/Sources/Models/CrowdNode/CrowdNode.swift
  • DashWallet/Sources/Models/Maya/MayaSwapProvider.swift
  • DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift
  • DashWallet/Sources/Models/Swap/SwapProvider.swift
  • DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift
  • DashWallet/Sources/Models/SwapKit/SwapRouteAvailability.swift
  • DashWallet/Sources/UI/CrowdNode/CrowdNodeModel.swift
  • DashWallet/Sources/UI/DashPay/Profile/SDKIdentityProfileSheet.swift
  • DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewController.swift
  • DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewModel.swift
  • DashWallet/Sources/UI/Home/Views/CoinJoinMoveFunds/CoinJoinMoveDestinationPolicy.swift
  • DashWallet/Sources/UI/Home/Views/HomeView.swift
  • DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
  • DashWallet/Sources/UI/Home/Views/ShieldedActivityHistory.swift
  • DashWallet/Sources/UI/Home/Views/ShieldedWithdrawalHistoryFixture.swift
  • DashWallet/Sources/UI/Menu/Security/ResetWalletInfo/DWResetWalletInfoViewController.h
  • DashWallet/Sources/UI/Menu/Security/ResetWalletInfo/DWResetWalletInfoViewController.m
  • DashWallet/Sources/UI/Menu/Security/ResetWalletInfo/ResetWalletInfo.storyboard
  • DashWallet/Sources/UI/Menu/Security/ResetWalletInfo/ResetWalletInfoScreen.swift
  • DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift
  • DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift
  • DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift
  • DashWallet/Sources/UI/Menu/Tools/ExtendedKeys/ExtendedPublicKeysModel.swift
  • DashWallet/Sources/UI/Menu/Tools/Masternode Keys/DerivationPathKeys/Models/DerivationPathKeysModel.swift
  • DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/Model/WalletKeysOverviewModel.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferConfirmSheet.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferConfirmViewModel.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferRunner.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferToastModifier.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/CoreCoinConsolidationSheet.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/CoreCoinConsolidationViewModel.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedTransferCoordinator.swift
  • DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedWithdrawalStore.swift
  • DashWallet/Sources/UI/Payments/Pay/SendScreen.swift
  • DashWallet/Sources/UI/Swap/Buy/RefundAddress/RefundAddressView.swift
  • DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift
  • DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinViewModel.swift
  • DashWallet/ar.lproj/Localizable.strings
  • DashWallet/bg.lproj/Localizable.strings
  • DashWallet/ca.lproj/Localizable.strings
  • DashWallet/cs.lproj/Localizable.strings
  • DashWallet/da.lproj/Localizable.strings
  • DashWallet/dashwallet-Bridging-Header.h
  • DashWallet/de.lproj/Localizable.strings
  • DashWallet/el.lproj/Localizable.strings
  • DashWallet/en.lproj/Localizable.strings
  • DashWallet/eo.lproj/Localizable.strings
  • DashWallet/es.lproj/Localizable.strings
  • DashWallet/et.lproj/Localizable.strings
  • DashWallet/fa.lproj/Localizable.strings
  • DashWallet/fi.lproj/Localizable.strings
  • DashWallet/fil.lproj/Localizable.strings
  • DashWallet/fr.lproj/Localizable.strings
  • DashWallet/hr.lproj/Localizable.strings
  • DashWallet/hu.lproj/Localizable.strings
  • DashWallet/id.lproj/Localizable.strings
  • DashWallet/it.lproj/Localizable.strings
  • DashWallet/ja.lproj/Localizable.strings
  • DashWallet/ko.lproj/Localizable.strings
  • DashWallet/mk.lproj/Localizable.strings
  • DashWallet/ms.lproj/Localizable.strings
  • DashWallet/nb.lproj/Localizable.strings
  • DashWallet/nl.lproj/Localizable.strings
  • DashWallet/pl.lproj/Localizable.strings
  • DashWallet/pt.lproj/Localizable.strings
  • DashWallet/ro.lproj/Localizable.strings
  • DashWallet/ru.lproj/Localizable.strings
  • DashWallet/sk.lproj/Localizable.strings
  • DashWallet/sl.lproj/Localizable.strings
  • DashWallet/sl_SI.lproj/Localizable.strings
  • DashWallet/sq.lproj/Localizable.strings
  • DashWallet/sr.lproj/Localizable.strings
  • DashWallet/sv.lproj/Localizable.strings
  • DashWallet/th.lproj/Localizable.strings
  • DashWallet/tr.lproj/Localizable.strings
  • DashWallet/uk.lproj/Localizable.strings
  • DashWallet/vi.lproj/Localizable.strings
  • DashWallet/zh-Hans.lproj/Localizable.strings
  • DashWallet/zh-Hant-TW.lproj/Localizable.strings
  • DashWallet/zh.lproj/Localizable.strings
  • DashWallet/zh_TW.lproj/Localizable.strings
  • DashWalletTests/AssetLockInputPolicyTests.swift
  • DashWalletTests/CoinJoinMoveDestinationPolicyTests.swift
  • DashWalletTests/CrowdNodeOwnershipTests.swift
  • DashWalletTests/DashModelContainer+Testing.swift
  • DashWalletTests/HostRecoveryTests.swift
  • DashWalletTests/KeyToolsTests.swift
  • DashWalletTests/PhraseRepairEngineTests.swift
  • DashWalletTests/PlatformBalanceReaderTests.swift
  • DashWalletTests/ResetWalletInfoWipeForwardingTests.swift
  • DashWalletTests/ShieldedWithdrawalHistoryTests.swift
  • DashWalletTests/SwapKitQuoteDecodingTests.swift
  • DashWalletTests/SwapRouteAvailabilityTests.swift
  • docs/README.md
  • docs/adr/0001-record-architecture-decisions-as-adrs.md
  • docs/adr/0002-keep-the-decision-log-in-docs-adr.md
  • docs/adr/0006-probe-live-swap-routes-with-address-free-forced-quotes.md
  • docs/adr/0007-keep-live-swap-route-answers-in-one-shared-in-memory-store.md
  • docs/adr/0008-detect-a-near-outage-from-sentinel-coins.md
  • docs/adr/README.md
  • docs/architecture/README.md
  • docs/features/docs-and-adr-layout/README.md
  • docs/features/swap-live-routes/README.md

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

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@thepastaclaw

thepastaclaw commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

⛔ Final review complete — 3 blocking finding(s) (commit c7fcc41) · triage: critical

@thepastaclaw thepastaclaw 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.

Final validation — Phase 1 + Phase 2

Static verification of head 1db5c15 confirms two blocking defects: one Maya flavor can incorrectly veto another, and expired availability answers can leave picker rows disabled. Current exact-head CI reports simulator tests, test-target membership, and accessibility passing; the PR-title check fails because swap is not an allowed scope. No local builds or tests were run.

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

2 finding(s) not shown inline (the lines are not part of this PR's diff)

🟡 Suggestion: Use availability-neutral toast copy for non-chain outages
DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift:72

The new availability logic sets isHalted and shows this toast when every capable provider reports no route, including a NEAR DASH-leg outage without any chain halt. The existing message therefore attributes these newly handled failures to a halted chain even when none was reported. Use cause-neutral localized copy such as “Some coins can't be swapped at the moment” so the toast accurately covers both chain halts and provider routing outages.

source: muse-spark-1.3-contributor (phase1-reviewer: general)

💬 Nitpick: Remove the unused Array.chunked helper
DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift:1178-1193

The PR removes verifyBuyRoutability, including the sole call to chunked(into:). A repository-wide search confirms this private extension now has no callers. Remove it with the batching implementation it supported.

source: muse-spark-1.3-contributor (phase1-reviewer: general)

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: normal by gpt-6.1-sol (effort low) — The change is substantial and intricate but modifies swap availability probing, provider filtering, and address-validation feedback rather than transaction execution, UTXO selection, cryptography, or another listed critical surface.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity quota lookup failed), glm-5.3-flash (not used above high effort; tier asks max)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort high); agent phase2-reviewer
🤖 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/Models/SwapKit/SwapKitSwapProvider.swift`:
- [BLOCKING] DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift:324-327: Evaluate Maya flavor refusals before collapsing them into one provider
  `refusedBy` maps both MAYACHAIN and MAYACHAIN_STREAMING to `.maya`, so a refusal from either flavor removes the entire aggregated Maya state here. With `requested == [.maya]`, MAYACHAIN reporting `invalidAsset` and MAYACHAIN_STREAMING reporting `apiRequestFailed` produces `.unknown` in `classifyPerProvider`, but this loop deletes it and returns `.refused("invalidAsset")`. The same incorrect rejection occurs when the other flavor reports `sellAssetAmountTooSmall`, which the classifier treats as evidence of routability. Both flavors are requested together and have different token lists, so this can block a coin supported by the other flavor. Evaluate refusals per raw SwapKit provider before aggregating Maya, and add address-check regression cases for refusal plus inconclusive and refusal plus below-minimum responses.
- [NITPICK] DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift:1178-1193: Remove the unused Array.chunked helper
  The PR removes `verifyBuyRoutability`, including the sole call to `chunked(into:)`. A repository-wide search confirms this private extension now has no callers. Remove it with the batching implementation it supported.

In `DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinViewModel.swift`:
- [BLOCKING] DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinViewModel.swift:180-184: Re-render picker rows when availability answers expire
  This loop starts probes but never re-renders existing rows. `SwapRouteAvailability.readableEntry` and `readableVerdict` stop returning answers after 1,800 seconds without emitting `changes`, while inconclusive probes only update back-off timestamps. If a coin was rendered halted and subsequent refreshes return only inconclusive answers, its row remains disabled after the store has forgotten the negative answer and would offer the provider again. Returning to the same picker does not repair this because `loadCoins()` skips an already-populated list. Make expiration observable, or re-render the cached pools on freshness ticks and re-entry. Cover the halted answer → expiration → inconclusive refresh sequence at the ViewModel boundary.

In `DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift`:
- [SUGGESTION] DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift:72: Use availability-neutral toast copy for non-chain outages
  The new availability logic sets `isHalted` and shows this toast when every capable provider reports no route, including a NEAR DASH-leg outage without any chain halt. The existing message therefore attributes these newly handled failures to a halted chain even when none was reported. Use cause-neutral localized copy such as “Some coins can't be swapped at the moment” so the toast accurately covers both chain halts and provider routing outages.
Out-of-scope follow-up suggestions (1)

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.

  • Coins no provider can ever carry are still listed as available — The base already returned all Sell pools and left assets outside both capability lists selectable and unlabelled. This PR does not introduce that discovery/classification mismatch, and filtering those legacy entries would expand the availability fix into a separate asset-discovery policy change. It does not meet the bar for an exceptional high-value out-of-scope follow-up.
    • Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.

Comment on lines +324 to +327
var states = LiveRouteState.classifyPerProvider(response, requested: requested)
for provider in refusedBy.keys {
states[provider] = nil
}

@thepastaclaw thepastaclaw Nov 2, 2026 •

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.

✅ Resolved at c7fcc41d; see the replies below.

🔴 Blocking: Evaluate Maya flavor refusals before collapsing them into one provider

refusedBy maps both MAYACHAIN and MAYACHAIN_STREAMING to .maya, so a refusal from either flavor removes the entire aggregated Maya state here. With requested == [.maya], MAYACHAIN reporting invalidAsset and MAYACHAIN_STREAMING reporting apiRequestFailed produces .unknown in classifyPerProvider, but this loop deletes it and returns .refused("invalidAsset"). The same incorrect rejection occurs when the other flavor reports sellAssetAmountTooSmall, which the classifier treats as evidence of routability. Both flavors are requested together and have different token lists, so this can block a coin supported by the other flavor. Evaluate refusals per raw SwapKit provider before aggregating Maya, and add address-check regression cases for refusal plus inconclusive and refusal plus below-minimum responses.

source: gpt-6.1-sol (phase2-reviewer: general)

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.

Resolved (re-reviewed at c7fcc41d): You now keep refusals at the concrete Maya-flavor level and classify the remaining errors before eliminating the aggregate provider. The added tests verify that a usable or inconclusive sibling permits continuation and that two refusals preserve SwapKit's response order.

Comment on lines +180 to +184
while !Task.isCancelled {
if !displayedPools.isEmpty {
swapProvider.refreshRouteAvailability(for: displayedPools, direction: direction)
}
try? await Task.sleep(nanoseconds: routeRefreshInterval)

@thepastaclaw thepastaclaw Nov 2, 2026 •

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.

✅ Resolved at c7fcc41d; see the replies below.

🔴 Blocking: Re-render picker rows when availability answers expire

This loop starts probes but never re-renders existing rows. SwapRouteAvailability.readableEntry and readableVerdict stop returning answers after 1,800 seconds without emitting changes, while inconclusive probes only update back-off timestamps. If a coin was rendered halted and subsequent refreshes return only inconclusive answers, its row remains disabled after the store has forgotten the negative answer and would offer the provider again. Returning to the same picker does not repair this because loadCoins() skips an already-populated list. Make expiration observable, or re-render the cached pools on freshness ticks and re-entry. Cover the halted answer → expiration → inconclusive refresh sequence at the ViewModel boundary.

source: gpt-6.1-sol (phase2-reviewer: general)

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.

Resolved (re-reviewed at c7fcc41d): You now remove expired entries and verdicts at refresh entry and publish a change independently of replacement probe results. That reaches the picker's existing subscription, and the added expiry-notification tests cover both individual answers and the NEAR verdict.

llbartekll and others added 7 commits November 3, 2026 10:57
…Core coins (#1287)

Closes #1274.

Pre-checks Core-funded asset locks against the 100-input cap before the PIN prompt, maps the SDK's refusal (FFI code 60) on every route to one Combine coins sheet, and pins platform e72022ab (dashpay/platform#5712).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: preserve failed shielded withdrawals in history

* fix: reconcile failed shielded withdrawal destination tags

* fix: preserve unknown shielded activity status
* chore(ci): pin platform to the KeyWallet helpers

Point `.platform-ref` at the head of dashpay/platform#5727, `v5.1-dev` plus the `Mnemonic` and `ManagedPlatformWallet` helpers this branch uses. It contains the previous pin `57b5e5fa3`, and its protocol 13 and 14 (`v13.rs`, `v14.rs`) are identical to it.

This is a temporary integration pin. Move it to the merge commit of #5727 once that PR is merged.

* refactor(tests): share the CloudKit-free in-memory container

`PlatformBalanceReaderTests` kept its in-memory SwiftData container builder private. It moves to `DashModelContainer.inMemoryWithoutCloudKit()` in shared test support, so the key tools tests can reuse it instead of copying it.

* refactor(wallet): derive wallet ids with `Mnemonic.walletId`

The platform SDK is dropping the key-wallet FFI, so `Wallet(mnemonic:network:)`, `Mnemonic.toSeed` and `key_wallet_derive_address_from_seed` go away. Wallet ids now come from `Mnemonic.walletId(for:network:)` in the host, the wiper's network resolver and the remove-wallet check.

The migrator and creator no longer run their seed sanity check before creating the wallet. It compared two runs of a pure function, so it could never fail, and `createOrImportWallet` derives the id itself, failing through the same retry path.

`SwiftDashSDKStoredWalletNetworkResolver.walletIds(for:)` maps a failed id derivation to `SwiftDashSDKWalletDeletionError.invalidMnemonic`, which `keysFailure` already classifies. `PlatformWalletError` stays a store failure because store calls throw it too.

The phrase repairer takes a candidate's first receive address from `Mnemonic.firstReceiveAddress(for:network:)`, which accepts every SDK language, and the test pins the address of the all-`abandon` phrase.

* refactor(crowdnode): sign and check ownership through the wallet

The signer and the ownership check built a throwaway key-wallet from the mnemonic with `SwiftDashSDKHost.derivationWallet()` and scanned BIP44 account 0 in Swift, and that FFI surface is going away.

Both now ask the running wallet, which runs the same scan in Rust: BIP44 account 0, external chain then internal, indexes 0 to 299, first match wins. `sign` takes the key from `bip44AddressPrivateKey(address:)`, which Rust only returns when the key controls the address, so the signer fails closed. `ownership(of:)` takes the path from `findBIP44AddressPath(address:)`, which uses public keys only, so the check never reads the phrase. A lookup that throws reads as `.walletUnavailable`, not as a miss.

Nothing needs the address's hash160 any more, so `hash160(ofAddress:network:)` becomes `isP2PKH(_:network:)` and `OwnershipLookup` takes an `isP2PKH` closure. A P2SH address still reads `.foreign`. Rust tests cover the search and the key check.

* refactor(tools): read the BIP44 xpub from the persisted account

`ExtendedPublicKeysModel` rendered the xpub of a throwaway KeyWallet wallet built from the phrase. It now renders the persisted BIP44 account 0 `accountExtendedPubKeyBytes` with `PlatformWalletManager.accountExtendedPubKeyString(bytes:)`. The running wallet is rebuilt from the persisted account bytes, so its BIP44 account 0 exists only when they do. Without them the sheet shows "Not available", and the phrase is never read.

`SwiftDashSDKWalletSource` kept the Standard `accountType` as a private constant, so it moves to an internal `PersistentAccount.standardAccountType` that both read.

`loadBIP44AccountXpub` takes the model context and wallet id, so the new `KeyToolsTests` run it on an in-memory store. They pin that it ignores rows differing in account index, BIP32 tag, account type or wallet, answers nil without the row or its bytes, and renders the BIP44 account 0 row.

* refactor(tools): read masternode keys from the running wallet

The masternode owner and voting key screens read the running wallet's provider address pool, and `providerKeyAtIndex` derives the keys. The pool a throwaway KeyWallet wallet derived from the phrase is gone.

An empty pool means the running wallet could not read a provider account of that kind, for example because it has none, and `providerKeyAtIndex` throws for it. `poolIsLive` becomes a computed `hasProviderPool`.

`SwiftDashSDKHost.derivationWallet()`, the throwaway key-wallet these screens and CrowdNode used, has no callers left and is deleted.
…1308)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(ui): rebuild Reset wallet info in SwiftUI

The wipe warning (More → Security → Reset wallet, and removing the last
wallet in Wallets) is now a SwiftUI screen with the DashUIKit nav bar
and buttons, replacing the Objective-C controller and its storyboard.
Same copy and behavior: Continue opens the recovery-phrase check in
wipe mode, and a completed wipe still goes to the caller's
DWWipeDelegate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): address review on the SwiftUI Reset wallet info screen

- "Wipe Wallet" carries the header trait for VoiceOver.
- Doc comments name both entry points (Security, and removing the last
  wallet in Wallets).
- The hosting controller builds its root view once; the buttons reach it
  through a small target object instead of placeholder closures.
- ResetWalletInfoWipeForwardingTests: a completed wipe reaches the
  DWWipeDelegate, which is held weakly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SDD-Published-From: b74eae0efdd70d3ee1a0a80343f3a5dfc106651a
@romchornyi romchornyi changed the title fix(swap): show which provider can swap each coin right now fix(DashDex): show which provider can swap each coin right now Nov 5, 2026

@thepastaclaw thepastaclaw 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.

Re-review — Final validation — Phase 1 + Phase 2

Static verification of head 512adee confirms defects in expiry-driven rendering, Maya refusal aggregation, code-less error classification, and sentinel-based recovery detection. Current CI passes simulator tests, accessibility auditing, and test-target membership; only PR-title validation fails, which is unrelated to code correctness. No local builds or tests were run.

🔴 4 blocking | 🟡 1 suggestion(s) | 💬 1 nitpick(s)

2 finding(s) not shown inline (the lines are not part of this PR's diff)

🟡 Suggestion: Use availability-neutral toast copy for non-chain outages
DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift:78

The new haltedAssets source includes provider-wide NEAR outages and per-coin noRoutesFound responses, but this toast attributes all unavailable coins to a halted chain. A routing-provider outage can therefore display this warning while the destination chain is operating normally. Use localized availability-neutral wording for the combined state, or preserve the reason and reserve chain-halt copy for actual chain halts.

source: muse-spark-1.3-contributor (phase1-reviewer: general); gpt-6.1-sol (phase2-reviewer: general)

💬 Nitpick: Remove the unused Array.chunked helper
DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift:1225-1240

Replacing the old Buy verification batches removed the only chunked(into:) call. The private Array extension has no remaining callers on this head; remove it with the batching implementation it supported.

source: muse-spark-1.3-contributor (phase1-reviewer: general); gpt-6.1-sol (phase2-reviewer: general)

2 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 3: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The supplied diff includes intricate funds-movement and coin-selection changes in SwiftDashSDKTransactionSender.swift and AssetLockInputPolicy.swift, including Core coin consolidation, input budgeting, and transaction signing paths.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity quota lookup failed), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 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/Models/SwapKit/SwapRouteAvailability.swift`:
- [BLOCKING] DashWallet/Sources/Models/SwapKit/SwapRouteAvailability.swift:67-70: Keep code-less provider errors inconclusive
  SwapKitProviderError.errorCode is optional, but compactMap discards errors with missing or blank codes instead of preserving them as .unknown. A forced NEAR response containing top-level noRoutesFound and a message-only provider error consequently reaches the empty-state fallback and becomes conclusive .noRoute; two such sentinel replies disable NEAR for every coin. A message-only Maya flavor error beside the other flavor's swapHalted error likewise becomes .halted instead of inconclusive. Map unclassified provider errors to .unknown so they participate in the existing most-favorable-wins rule, and test both the top-level-no-route and mixed-flavor cases.
- [BLOCKING] DashWallet/Sources/Models/SwapKit/SwapRouteAvailability.swift:382-385: Allow the third sentinel to detect recovery after chain halts
  A halted sentinel increments neither noRoutes nor inconclusive, so halted + noRoute or halted + halted skips SOL even though its answer could establish a verdict. This also blocks recovery: after a cached NEAR .down verdict, BTC halted and ETH halted leave that verdict unchanged even if SOL would route. refresh then suppresses every per-coin NEAR check, keeping unrelated NEAR-capable coins disabled until another verdict can be established or the old one expires. The Proposed ADR and testAHaltedSentinelIsNotStoodInFor explicitly encode this gate, but do not address that recovery failure. Allow the third probe when the first two have established neither up nor down; this remains within the three-probe limit and does not count a halt as evidence of an outage. Update the proposed decision and tests accordingly.

In `DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift`:
- [SUGGESTION] DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift:78: Use availability-neutral toast copy for non-chain outages
  The new haltedAssets source includes provider-wide NEAR outages and per-coin noRoutesFound responses, but this toast attributes all unavailable coins to a halted chain. A routing-provider outage can therefore display this warning while the destination chain is operating normally. Use localized availability-neutral wording for the combined state, or preserve the reason and reserve chain-halt copy for actual chain halts.

In `DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift`:
- [NITPICK] DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift:1225-1240: Remove the unused Array.chunked helper
  Replacing the old Buy verification batches removed the only chunked(into:) call. The private Array extension has no remaining callers on this head; remove it with the batching implementation it supported.
- [BLOCKING] DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift:324-327: Evaluate Maya flavor refusals before collapsing them into one provider
  (existing thread: https://github.com/dashpay/dashwallet-ios/pull/1297#discussion_r4447683220)
  refusedBy collapses MAYACHAIN and MAYACHAIN_STREAMING into the same .maya key, and this loop removes their entire aggregated outcome when either flavor refuses. With requested: [.maya], a streaming invalidDestinationAddress error plus a non-streaming sellAssetAmountTooSmall answer produces .available in classifyPerProvider, but that state is deleted and the address check returns .refused. A sibling apiRequestFailed answer is similarly overridden even though inconclusive answers are intended to permit the real amount quote. Nonempty routes already pass through the earlier guard; the defect affects these route-less but usable sibling outcomes. Evaluate refusals and usability per raw provider name before collapsing Maya flavors, and cover the mixed-refusal cases in tests.

In `DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinViewModel.swift`:
- [BLOCKING] DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinViewModel.swift:180-184: Re-render picker rows when availability answers expire
  (existing thread: https://github.com/dashpay/dashwallet-ios/pull/1297#discussion_r4447683227)
  The freshness loop starts probes but does not re-render existing rows. After 30 minutes, readableEntry/readableVerdict silently discard the last conclusive answers, while inconclusive replacement probes only update back-off timestamps and emit no changes event. A previously halted coin can therefore remain disabled indefinitely in the published coins array even though the store now falls back to usable static capability. Returning to the same picker does not repair this because loadCoins() skips an already-loaded list. Publish effective expiry transitions, or re-render independently on freshness ticks and re-entry. Add a ViewModel regression test covering a halted answer that expires while subsequent probes remain unknown; the existing ageing test checks only direct store reads.

Comment on lines +67 to +70
let states = (response.providerErrors ?? []).compactMap { error -> LiveRouteState? in
guard let code = error.errorCode?.trimmingCharacters(in: .whitespacesAndNewlines), !code.isEmpty else {
return nil
}

@thepastaclaw thepastaclaw Nov 5, 2026 •

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.

✅ Resolved at c7fcc41d; see the replies below.

🔴 Blocking: Keep code-less provider errors inconclusive

SwapKitProviderError.errorCode is optional, but compactMap discards errors with missing or blank codes instead of preserving them as .unknown. A forced NEAR response containing top-level noRoutesFound and a message-only provider error consequently reaches the empty-state fallback and becomes conclusive .noRoute; two such sentinel replies disable NEAR for every coin. A message-only Maya flavor error beside the other flavor's swapHalted error likewise becomes .halted instead of inconclusive. Map unclassified provider errors to .unknown so they participate in the existing most-favorable-wins rule, and test both the top-level-no-route and mixed-flavor cases.

source: gpt-6.1-sol (phase2-reviewer: general)

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.

Resolved (re-reviewed at c7fcc41d): You now classify absent or blank provider codes as unknown instead of dropping them. The classifier and added regressions preserve that uncertainty beside a top-level no-route or another Maya flavor's halt.

Comment on lines +382 to +385
for (index, sentinel) in Self.nearSentinels.enumerated() where noRoutes < 2 {
// The third sentinel only stands in for one that gave no conclusive answer.
if index >= 2, inconclusive == 0 {
break

@thepastaclaw thepastaclaw Nov 5, 2026 •

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.

✅ Resolved at c7fcc41d; see the replies below.

🔴 Blocking: Allow the third sentinel to detect recovery after chain halts

A halted sentinel increments neither noRoutes nor inconclusive, so halted + noRoute or halted + halted skips SOL even though its answer could establish a verdict. This also blocks recovery: after a cached NEAR .down verdict, BTC halted and ETH halted leave that verdict unchanged even if SOL would route. refresh then suppresses every per-coin NEAR check, keeping unrelated NEAR-capable coins disabled until another verdict can be established or the old one expires. The Proposed ADR and testAHaltedSentinelIsNotStoodInFor explicitly encode this gate, but do not address that recovery failure. Allow the third probe when the first two have established neither up nor down; this remains within the three-probe limit and does not count a halt as evidence of an outage. Update the proposed decision and tests accordingly.

source: muse-spark-1.3-contributor (phase1-reviewer: general)

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.

Resolved (re-reviewed at c7fcc41d): Your sentinel loop now asks SOL after two halted answers because neither contributes to the no-route count. The new testTwoHaltedSentinelsDoNotHideARecovery case covers the original recovery failure.

…hecks

SDD-Published-From: ed9c59385523bdfdc167485480c5aced6e69ce52
@romchornyi romchornyi changed the title fix(DashDex): show which provider can swap each coin right now fix(dashdex): show which provider can swap each coin right now Nov 5, 2026

@thepastaclaw thepastaclaw 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.

Re-review — Final validation — Phase 1 + Phase 2

Static verification of c7fcc41 confirms five prior findings are fixed, but three blocking correctness issues remain in address rechecks, concurrent cache writes, and sentinel recovery; the availability-toast suggestion also remains valid. Current CI passes target-membership, accessibility, and PR-title checks, while simulator tests are pending; no local builds or tests were run. The supplied base drift is a non-overlapping CI-title change with no reported merge conflicts.

🔴 3 blocking | 🟡 1 suggestion(s)

1 finding(s) not shown inline (the lines are not part of this PR's diff)

🟡 Suggestion: Use availability-neutral toast copy for non-chain outages
DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift:78

This toast attributes every unavailable row to a halted chain, but SwapKitSwapProvider.haltedAssets now also includes .noRoute answers and the inferred provider-wide NEAR outage. A NEAR outage on Buy can disable every Buy coin without establishing that any underlying chain is halted. Use localized, availability-neutral wording for SwapKit, or distinguish provider unavailability from an actual chain halt if Maya should retain the chain-specific explanation.

source: muse-spark-1.3-contributor (phase1-reviewer: general); gpt-6.1-sol (phase2-reviewer: general)

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 3: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 7: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large diff directly changes coin selection and funds movement in AssetLockInputPolicy.swift and SwiftDashSDKTransactionSender.swift, including input limits, consolidation, and transaction building and signing.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity quota lookup failed), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 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/Models/SwapKit/SwapKitSwapProvider.swift`:
- [BLOCKING] DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift:407-412: Do not blame the address after a differently sized quote succeeds
  The address quote sends sellAmount "1", but this recheck calls probeRoute, which normally sends 50 divided by the cached DASH price. It therefore changes both the destination and the amount. SwapKitErrorCopy documents that top-level noRoutesFound can be amount-dependent, and LiveRouteState.classify also maps explicit below-minimum replies to .available. Consequently, a nominal quote below a route's floor followed by a differently sized route or minimum-error response becomes validation_error in addressReCheckMessage, blocking a format-valid destination before the user can enter a sufficient amount. Compare requests at the same amount when isolating the destination, and distinguish an actual successful route from a below-minimum response before attributing failure to the address. Add coverage for an amount-dependent no-route followed by both kinds of response.

In `DashWallet/Sources/Models/SwapKit/SwapRouteAvailability.swift`:
- [BLOCKING] DashWallet/Sources/Models/SwapKit/SwapRouteAvailability.swift:435-440: Prevent an older in-flight probe from overwriting a newer recorded answer
  The refresh lock serializes refreshes, but not record() or probeNow(). While this await is suspended, a newer address quote can record .available for the same direction/provider/asset. The older picker probe then unconditionally stores its negative result with a new checkedAt timestamp, replacing that successful observation and making the negative answer fresh for another ten minutes. Leaving the picker does not prevent this: in-flight answers are explicitly retained, and HTTPClient's async request does not cancel its underlying request when the task is cancelled. This can disable a working coin on return or exclude its working provider from subsequent quotes. Track a per-key revision or equivalent ordering and discard a probe result superseded by a newer conclusive observation. Add a regression that records availability while an older negative probe is held open.
- [BLOCKING] DashWallet/Sources/Models/SwapKit/SwapRouteAvailability.swift:389-393: Recheck negative sentinel entries when NEAR recovers
  A down round stores ordinary ten-minute .noRoute entries for BTC and ETH as well as the two-minute NEAR-down verdict. If all routes recover at t=121, BTC returns .available here and ends the sentinel round, but ETH's outage-era negative entry remains. The per-coin job construction then skips ETH because isDue(key) still uses the ten-minute interval. On Buy, ETH therefore remains disabled until approximately t=600 despite the successful two-minute recovery check; SOL is similarly affected when it supplied a negative answer during the down round. On a down-to-up transition, make the outage-era negative sentinel entries due for independent rechecking rather than treating BTC's success as proof about the other coins. Extend the recovery test to display ETH and verify that it is reprobed and restored when its own response succeeds.

In `DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift`:
- [SUGGESTION] DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift:78: Use availability-neutral toast copy for non-chain outages
  This toast attributes every unavailable row to a halted chain, but SwapKitSwapProvider.haltedAssets now also includes .noRoute answers and the inferred provider-wide NEAR outage. A NEAR outage on Buy can disable every Buy coin without establishing that any underlying chain is halted. Use localized, availability-neutral wording for SwapKit, or distinguish provider unavailability from an actual chain halt if Maya should retain the chain-specific explanation.

Comment on lines +407 to +412
var reAsked: [LiveRouteState] = []
for provider in [RouteProvider.near, .maya] where noRouteProviders.contains(provider) {
reAsked.append(await routeAvailability.probeNow(asset: toAsset, provider: provider, direction: .sell) { [weak self] asset, provider, direction in
guard let self else { return .unknown }
return await self.probeRoute(asset: asset, provider: provider, direction: direction)
})

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.

🔴 Blocking: Do not blame the address after a differently sized quote succeeds

The address quote sends sellAmount "1", but this recheck calls probeRoute, which normally sends 50 divided by the cached DASH price. It therefore changes both the destination and the amount. SwapKitErrorCopy documents that top-level noRoutesFound can be amount-dependent, and LiveRouteState.classify also maps explicit below-minimum replies to .available. Consequently, a nominal quote below a route's floor followed by a differently sized route or minimum-error response becomes validation_error in addressReCheckMessage, blocking a format-valid destination before the user can enter a sufficient amount. Compare requests at the same amount when isolating the destination, and distinguish an actual successful route from a below-minimum response before attributing failure to the address. Add coverage for an amount-dependent no-route followed by both kinds of response.

source: gpt-6.1-sol (phase2-reviewer: general)

Comment on lines +435 to +440
let state = await probe(asset, key.provider, key.direction)
if state == .unknown {
inconclusiveAt[key] = now()
} else {
inconclusiveAt[key] = nil
store(state, for: key)

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.

🔴 Blocking: Prevent an older in-flight probe from overwriting a newer recorded answer

The refresh lock serializes refreshes, but not record() or probeNow(). While this await is suspended, a newer address quote can record .available for the same direction/provider/asset. The older picker probe then unconditionally stores its negative result with a new checkedAt timestamp, replacing that successful observation and making the negative answer fresh for another ten minutes. Leaving the picker does not prevent this: in-flight answers are explicitly retained, and HTTPClient's async request does not cancel its underlying request when the task is cancelled. This can disable a working coin on return or exclude its working provider from subsequent quotes. Track a per-key revision or equivalent ordering and discard a probe result superseded by a newer conclusive observation. Add a regression that records availability while an older negative probe is held open.

source: gpt-6.1-sol (phase2-reviewer: general)

Comment on lines +389 to +393
let state = await probeOne(Key(direction, .near, sentinel), asset: sentinel, force: true, probe: probe)
if state == .available {
inconclusiveRoundAt[direction] = nil
setNearVerdict(.up, direction: direction)
return .up

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.

🔴 Blocking: Recheck negative sentinel entries when NEAR recovers

A down round stores ordinary ten-minute .noRoute entries for BTC and ETH as well as the two-minute NEAR-down verdict. If all routes recover at t=121, BTC returns .available here and ends the sentinel round, but ETH's outage-era negative entry remains. The per-coin job construction then skips ETH because isDue(key) still uses the ten-minute interval. On Buy, ETH therefore remains disabled until approximately t=600 despite the successful two-minute recovery check; SOL is similarly affected when it supplied a negative answer during the down round. On a down-to-up transition, make the outage-era negative sentinel entries due for independent rechecking rather than treating BTC's success as proof about the other coins. Extend the recovery test to display ETH and verify that it is reprobed and restored when its own response succeeds.

source: gpt-6.1-sol (phase2-reviewer: general)

This branch has not been deployed

No deployments
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.

6 participants