Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 88 additions & 12 deletions DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,56 @@
import Foundation

/// Maps raw SwapKit error code strings to user-facing messages.
/// Mirrors Android's `SwapKitErrors.messageResFor`.
/// Mirrors Android's `SwapKitErrors`.
///
/// SwapKit reports a failure in one of two places, and the code is the only stable identifier in
/// either: the top-level `error` field (`noRoutesFound`, `validation_error`, …) on a non-2xx body,
/// or `providerErrors[].errorCode` on a 200 that carries no routes. Provider-level failures reach
/// this type through `providerErrorMessage(_:)`, which puts the code back in front of the prose so
/// a single `"<code>: <detail>"` shape covers both — matching on the prose instead would drop
/// `sellAssetAmountTooSmall` into the generic copy, which is exactly the case where the user needs
/// to be told to raise the amount.
enum SwapKitErrorCopy {
static let mayaMemoTooLongErrorCode = "mayaMemoTooLong"
/// Top-level code for "no provider can carry this pair/amount", lowercased for matching.
static let noRoutesFoundCode = "noroutesfound"

/// A provider-level failure rendered in the same `"<code>: <detail>"` shape the top-level
/// `error` field uses, so both reach `message(for:coin:)` through one path.
/// Nil when the provider reported neither a code nor prose.
static func providerErrorMessage(_ error: SwapKitProviderError?) -> String? {
let code = error?.errorCode?.trimmingCharacters(in: .whitespacesAndNewlines)
let detail = error?.message?.trimmingCharacters(in: .whitespacesAndNewlines)
switch (code?.isEmpty == false ? code : nil, detail?.isEmpty == false ? detail : nil) {
case let (code?, detail?):
return "\(code): \(detail)"
case let (code?, nil):
return code
case let (nil, detail?):
return detail
case (nil, nil):
return nil
}
}

static func message(for rawError: String?, coin: SwapCryptoCurrency) -> String {
let code = rawError?
.components(separatedBy: ":")
.first?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
?? ""
let code = code(of: rawError)

// Per-provider below-minimum codes are family-matched rather than enumerated: SwapKit does
// not document the per-provider vocabulary, and the prefix names whichever side was too
// small (`sellAssetAmountTooSmall` today). Both forms carry "amount", so an unrelated
// below-threshold code — a too-low fee, say — stays out.
if isBelowMinimumCode(code) {
return NSLocalizedString(
"This amount is below the minimum for this swap. Please enter a larger amount.",
comment: "Dash DEX / dex_error_amount_too_small"
)
}
Comment on lines +40 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Suggestion: Add regression tests for the new SwapKit error normalization

This bug fix introduces several interacting parsing and classification rules without executable coverage. Extend the existing SwapKit tests with compile-ready cases for provider code/detail composition, code-only and detail-only responses, whitespace and nil handling, code-first top-level decoding, case-insensitive AmountTooSmall and AmountTooLow suffixes, the four newly mapped codes, unknown-code fallback, and routability outcomes for explicit below-minimum versus ambiguous noRoutesFound responses. These tests are needed to prevent a decoder or mapper refactor from dropping the stable code behind provider prose again.

source: ['claude', 'codex']

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 46ebf78 — a SwapKitErrorCopyTests case alongside the existing decoding tests, 12 methods covering: code-before-prose composition and its code-only / detail-only / blank / nil fallbacks; the case-insensitive AmountTooSmall / AmountTooLow suffix family, including the composed "<code>: <detail>" form and a negative for inboundFeeTooLow; isNoRoute on both forms; the four newly mapped codes; memoTooLongForSourceChain sharing the local memo copy; detail-after-code not changing the mapping; unknown and empty falling back to generic.

The anchor case is the regression itself: message(for: "No routes found for DASH.DASH -> BTC.BTC") must still be generic. The fix is that callers stopped sending prose alone, not that the mapper started guessing from text — a test asserting the prose maps would cement exactly the wrong behaviour.

Two limits worth stating plainly:

  • Not executed. The test target cannot be built on this machine: the ../platform checkout it takes SwiftDashSDK from sits on a side branch that predates several symbols develop needs (ManagedPlatformWallet.ParsedIdentityUpdateTransition, PlatformWalletManager.trackedMasternodes, MasternodeKeyRole), and DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift references the first of those directly. That is unrelated to this PR and pre-dates it. To get a real signal I extracted SwapKitErrorCopy verbatim into a standalone target with stubbed SwapKitProviderError / SwapCryptoCurrency / DWLogger and ran the same 28 assertions these tests encode — all pass. Whoever has an up-to-date ../platform should run the XCTest cases before merge.
  • Two call sites stay covered only indirectly. routability(from:) and decodeQuoteError(from:) are private, and reaching them would mean widening the access of a private nested enum and a method purely for tests. Every rule they depend on (providerErrorMessage, isNoRoute, isBelowMinimum, code-prefix extraction) is tested directly, so a refactor that puts prose back in front of the code fails here. Say the word if you would rather have them internal and asserted end-to-end.


switch code {
case "mayamemotoolong":
// `memoTooLongForSourceChain` is SwapKit's own name for what the wallet also detects
// locally before broadcasting; both mean the OP_RETURN will not fit.
case "mayamemotoolong", "memotoolongforsourcechain":
// Two things drive the memo past the 80-byte OP_RETURN limit: the destination
// address, and the amount-dependent streaming-limit field. Measured on 2026-08-04,
// one ARB.YUM route to a fixed address ran 79 / 80 / 79 / 79 bytes at 0.1 / 1 / 10 /
Expand All @@ -43,12 +79,12 @@ enum SwapKitErrorCopy {
"This swap's Maya instruction doesn't fit in a Dash transaction. Try a different amount, or a shorter %@ address.",
comment: "Dash DEX / dex_error_maya_memo_too_long"
), chainLabel)
case "noroutesfound":
case noRoutesFoundCode:
return NSLocalizedString(
"This amount can't be swapped right now. Routes can be briefly unavailable — try again shortly, or try a different amount.",
comment: "Dash DEX / dex_error_no_route"
)
case "blacklistasset":
case "blacklistasset", "invalidasset":
return String(format: NSLocalizedString(
"%@ can't be swapped at the moment.",
comment: "Dash DEX / dex_error_blacklisted"
Expand All @@ -58,7 +94,9 @@ enum SwapKitErrorCopy {
"We couldn't set up your swap. Please check the amount and address, then try again.",
comment: "Dash DEX / dex_error_validation"
)
case "apikeyinvalid", "unauthorized":
// `apiRequestFailed` is SwapKit failing to reach the provider it quotes through — an
// upstream outage from the user's side, same as an unavailable swap desk.
case "apikeyinvalid", "unauthorized", "apirequestfailed":
return NSLocalizedString(
"Swaps are temporarily unavailable. Please try again later.",
comment: "Dash DEX / dex_error_unavailable"
Expand All @@ -83,7 +121,9 @@ enum SwapKitErrorCopy {
"This token needs approval before it can be swapped.",
comment: "Dash DEX / dex_error_allowance"
)
case "unabletobuildtransaction":
// `invalidRoute` is a quoted route SwapKit then refuses to execute — nothing the user can
// correct, and retrying re-quotes, so it takes the same copy as a failed build.
case "unabletobuildtransaction", "invalidroute":
return NSLocalizedString(
"We couldn't prepare this swap. Please try again.",
comment: "Dash DEX / dex_error_build_failed"
Expand Down Expand Up @@ -115,4 +155,40 @@ enum SwapKitErrorCopy {
)
}
}

/// True when the failure is the "no provider can carry this pair/amount" code — reported
/// top-level and, per provider, inside `providerErrors[]`.
///
/// Only the per-provider form is conclusive. SwapKit answers top-level `noRoutesFound` for an
/// amount far below a route's floor as well as for a pair it cannot carry: measured
/// 2026-08-28, DASH → BTC returned it at 0.01 DASH, `sellAssetAmountTooSmall` (min 0.175) at
/// 0.05, and a route at 0.3. Callers must not read the top-level form as "this cannot be
/// swapped at all" — hence the neutral copy, and `routability(from:)` ignoring it.
static func isNoRoute(_ rawError: String?) -> Bool {
code(of: rawError) == noRoutesFoundCode
}

/// True when the failure is specifically "under this route's minimum" — the unambiguous
/// counterpart to [isNoRoute]. Routability probing needs both: a provider-level
/// `noRoutesFound` does mean the provider can't carry the asset, whereas a below-minimum
/// reply only says the probe amount was too small.
static func isBelowMinimum(_ rawError: String?) -> Bool {
isBelowMinimumCode(code(of: rawError))
}

private static func isBelowMinimumCode(_ code: String) -> Bool {
code.hasSuffix("amounttoosmall") || code.hasSuffix("amounttoolow")
}

/// The SwapKit code carried by a raw error: the leading token before an optional
/// `": <detail>"`, lowercased, so a bare `validation_error` and `validation_error: <detail>`
/// both match.
private static func code(of rawError: String?) -> String {
rawError?
.components(separatedBy: ":")
.first?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
?? ""
}
}
62 changes: 36 additions & 26 deletions DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,9 @@ final class SwapKitSwapProvider: SwapProvider {
scheduleBuyRoutabilityVerification(for: candidates.map { $0.asset })

// Optimistic filter: show until a probe conclusively proves the asset cannot route
// NEAR→DASH. This keeps first-open responsive while background verification prunes.
// NEAR→DASH — which now means NEAR itself reporting a no-route in `providerErrors`, the
// only unambiguous negative (see `routability(from:)`). This keeps first-open responsive
// while background verification prunes.
return candidates.filter { pool in
cachedBuyRoutability(for: pool.asset) != .notRoutable
}
Expand Down Expand Up @@ -346,7 +348,8 @@ final class SwapKitSwapProvider: SwapProvider {
}

guard let best = bestRoute(from: quoteResponse.routes ?? []) else {
let msg = quoteResponse.providerErrors?.first?.message
let providerError = quoteResponse.providerErrors?.first
let msg = SwapKitErrorCopy.providerErrorMessage(providerError)
?? NSLocalizedString("No route available", comment: "SwapKit")
return errorResult(msg)
}
Expand Down Expand Up @@ -375,7 +378,8 @@ final class SwapKitSwapProvider: SwapProvider {

// Step 2: pick RECOMMENDED → CHEAPEST → first (mirrors Android bestRoute()).
guard let best = bestRoute(from: quoteResponse.routes ?? []) else {
let msg = quoteResponse.providerErrors?.first?.message
let providerError = quoteResponse.providerErrors?.first
let msg = SwapKitErrorCopy.providerErrorMessage(providerError)
?? NSLocalizedString("No route available", comment: "SwapKit")
return errorResult(msg)
}
Expand Down Expand Up @@ -614,7 +618,8 @@ final class SwapKitSwapProvider: SwapProvider {
}

guard let best = bestRoute(from: quoteResponse.routes ?? []) else {
let message = quoteResponse.providerErrors?.first?.message
let providerError = quoteResponse.providerErrors?.first
let message = SwapKitErrorCopy.providerErrorMessage(providerError)
?? quoteResponse.message
?? quoteResponse.error
?? NSLocalizedString("No route available", comment: "SwapKit")
Expand Down Expand Up @@ -727,47 +732,52 @@ final class SwapKitSwapProvider: SwapProvider {
return .routable
}

let message = [response.error, response.message, response.providerErrors?.first?.message]
.compactMap { $0 }
.joined(separator: " ")

if isConfirmedNoRoute(message) {
return .notRoutable
// Classify on the codes, not the prose: a provider reports its reason in
// `providerErrors[].errorCode`, and only the code is a stable identifier.
let providerCode = SwapKitErrorCopy.providerErrorMessage(response.providerErrors?.first)

guard let providerCode else {
// A top-level `noRoutesFound` is deliberately NOT treated as proof of unroutability.
// SwapKit answers with it for an amount far below a route's floor as well as for a
// pair it cannot carry — measured 2026-08-28, DASH → BTC returned it at 0.01 DASH
// and quoted a route at 0.3 — and the probe amount is a $50 estimate that falls back
// to one whole unit when no USD price is cached. Pruning on it would drop a routable
// asset out of the picker for the whole cache window.
return nil
}

if isMinimumOrAmountError(message) {
if SwapKitErrorCopy.isBelowMinimum(providerCode) {
// The probe amount was under this route's floor, which says nothing about whether
// the asset is routable — the picker must not hide it on this evidence.
return .routable
}

return nil
// A provider naming its own no-route is the one conclusive negative: it is answering for
// the single provider the probe asked about. Anything else it reports (an upstream
// `apiRequestFailed`, say) leaves the question open.
return SwapKitErrorCopy.isNoRoute(providerCode) ? .notRoutable : nil
}

private static func decodedQuoteResponse(from error: Error) -> SwapKitQuoteResponse? {
guard case HTTPClientError.statusCode(let response) = error else { return nil }
return try? JSONDecoder().decode(SwapKitQuoteResponse.self, from: response.data)
}

private static func isConfirmedNoRoute(_ message: String) -> Bool {
let normalized = message.lowercased()
return normalized.contains("noroutesfound") || normalized.contains("no routes found")
}

private static func isMinimumOrAmountError(_ message: String) -> Bool {
let normalized = message.lowercased()
return normalized.contains("below minimum")
|| normalized.contains("amount too small")
|| normalized.contains("too small")
|| normalized.contains("minimum")
}

private func decodeQuoteError(from error: Error) -> String? {
guard case HTTPClientError.statusCode(let response) = error,
let body = try? JSONDecoder().decode(SwapKitQuoteResponse.self, from: response.data)
else {
return nil
}

return body.message ?? body.error ?? body.providerErrors?.first?.message
if let code = body.error, !code.isEmpty {
if let message = body.message, !message.isEmpty {
return "\(code): \(message)"
}
return code
}

return SwapKitErrorCopy.providerErrorMessage(body.providerErrors?.first) ?? body.message
}

private func decodeSwapError(from error: Error) -> String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ final class BuyEnterAmountViewModel: ObservableObject {
} catch {
guard requestID == validationRequestID else { return }
isValidating = false
validationErrorMessage = Self.validationMessage(from: error)
validationErrorMessage = validationMessage(from: error)
}
}

Expand Down Expand Up @@ -343,19 +343,9 @@ final class BuyEnterAmountViewModel: ObservableObject {
return formatter.string(from: rounded as NSDecimalNumber) ?? rounded.string
}

private static func validationMessage(from error: Error) -> String {
private func validationMessage(from error: Error) -> String {
let raw = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
if raw.localizedCaseInsensitiveContains("noRoutesFound") {
return NSLocalizedString(
"This amount can't be swapped right now. Routes can be briefly unavailable — try again shortly, or try a different amount.",
comment: "Dash DEX / dex_error_no_route"
)
}

return NSLocalizedString(
"This amount can't be swapped right now. Try a different amount, or try again shortly.",
comment: "Dash DEX / dex_enter_amount_invalid"
)
return SwapKitErrorCopy.message(for: raw, coin: coin)
}

private enum RateError: Error {
Expand Down
14 changes: 8 additions & 6 deletions DashWallet/Sources/UI/Swap/Convert/SwapConvertViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,8 @@ final class SwapConvertViewModel: ObservableObject {
comment: "Dash DEX / dex_error_no_route"
)
} else if apiError.localizedCaseInsensitiveContains("invalidDestinationAddress") {
// Keeps the chain-specific wording this screen already had: the address was entered a
// step earlier, so the user needs to be told which chain it has to belong to.
let chainLabel = SwapCryptoCurrency.chainDisplayName(coin.chain)
errorMessage = String(
format: NSLocalizedString(
Expand All @@ -327,10 +329,7 @@ final class SwapConvertViewModel: ObservableObject {
chainLabel
)
} else {
errorMessage = NSLocalizedString(
"This amount can't be swapped right now. Routes can be briefly unavailable — try again shortly, or try a different amount.",
comment: "Dash DEX / dex_error_no_route"
)
errorMessage = SwapKitErrorCopy.message(for: apiError, coin: coin)
}
}

Expand Down Expand Up @@ -697,10 +696,13 @@ private extension SwapConvertViewModel {
amountText = "\(formatted) \(coin.code)"
}

// Short form per the redesign, e.g. "Max $205.32" — mirrors Android's
// `maya_max_amount_error` (Figma 24034:44864), which replaced the long
// "The maximum transaction amount is …" sentence on this screen.
return String(
format: NSLocalizedString(
"The maximum transaction amount is %@",
comment: "Dash DEX"
"Max %@",
comment: "Dash DEX / maya_max_amount_error"
),
amountText
)
Expand Down
7 changes: 5 additions & 2 deletions DashWallet/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -4470,8 +4470,11 @@
/* Dash DEX / dex_error_no_route */
"This amount can't be swapped right now. Routes can be briefly unavailable — try again shortly, or try a different amount." = "This amount can't be swapped right now. Routes can be briefly unavailable — try again shortly, or try a different amount.";

/* Dash DEX / dex_enter_amount_invalid */
"This amount can't be swapped right now. Try a different amount, or try again shortly." = "This amount can't be swapped right now. Try a different amount, or try again shortly.";
/* Dash DEX / dex_error_amount_too_small */
"This amount is below the minimum for this swap. Please enter a larger amount." = "This amount is below the minimum for this swap. Please enter a larger amount.";

/* Dash DEX / maya_max_amount_error */
"Max %@" = "Max %@";

/* DashSpend */
"This card works only in the United States." = "This card works only in the United States.";
Expand Down
Loading
Loading