diff --git a/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift b/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift index 072a42309..df4ef8acd 100644 --- a/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift +++ b/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift @@ -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 `": "` 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 `": "` 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" + ) + } 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 / @@ -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" @@ -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" @@ -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" @@ -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 + /// `": "`, lowercased, so a bare `validation_error` and `validation_error: ` + /// both match. + private static func code(of rawError: String?) -> String { + rawError? + .components(separatedBy: ":") + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + ?? "" + } } diff --git a/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift b/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift index 7bfae3340..18db37ea5 100644 --- a/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift +++ b/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift @@ -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 } @@ -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) } @@ -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) } @@ -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") @@ -727,19 +732,30 @@ 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? { @@ -747,19 +763,6 @@ final class SwapKitSwapProvider: SwapProvider { 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) @@ -767,7 +770,14 @@ final class SwapKitSwapProvider: SwapProvider { 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? { diff --git a/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountViewModel.swift b/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountViewModel.swift index d7b51b7db..28f57808f 100644 --- a/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountViewModel.swift +++ b/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountViewModel.swift @@ -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) } } @@ -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 { diff --git a/DashWallet/Sources/UI/Swap/Convert/SwapConvertViewModel.swift b/DashWallet/Sources/UI/Swap/Convert/SwapConvertViewModel.swift index c54e56dcc..11f7448aa 100644 --- a/DashWallet/Sources/UI/Swap/Convert/SwapConvertViewModel.swift +++ b/DashWallet/Sources/UI/Swap/Convert/SwapConvertViewModel.swift @@ -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( @@ -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) } } @@ -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 ) diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index f9dfb84b1..fbfa5d3f6 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -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."; diff --git a/DashWalletTests/SwapKitQuoteDecodingTests.swift b/DashWalletTests/SwapKitQuoteDecodingTests.swift index 8d3fd6d96..f6c27e055 100644 --- a/DashWalletTests/SwapKitQuoteDecodingTests.swift +++ b/DashWalletTests/SwapKitQuoteDecodingTests.swift @@ -66,3 +66,124 @@ final class SwapKitQuoteDecodingTests: XCTestCase { XCTAssertEqual(best?.meta?.tags?.first, "RECOMMENDED") } } + +// MARK: - Error normalization + +/// Covers the rules `SwapKitErrorCopy` applies to a raw SwapKit failure. They exist because the +/// code is the only stable identifier SwapKit returns — the prose around it is free text and, on +/// a provider-level failure, arrives without the code unless `providerErrorMessage(_:)` puts it +/// back. A refactor that reads the prose again would put every amount error back on the generic +/// "something went wrong" copy, which is the bug these tests guard. +final class SwapKitErrorCopyTests: XCTestCase { + private let coin = SwapCryptoCurrency( + id: "btc", + code: "BTC", + name: "Bitcoin", + swapAsset: "BTC.BTC", + chain: "BTC" + ) + + private func message(_ raw: String?) -> String { + SwapKitErrorCopy.message(for: raw, coin: coin) + } + + private var genericMessage: String { + message("someCodeSwapKitHasNeverReturned") + } + + private func providerError(code: String?, message: String?) -> SwapKitProviderError { + SwapKitProviderError(provider: "NEAR", errorCode: code, message: message) + } + + // MARK: providerErrorMessage + + func testProviderErrorMessagePutsTheCodeInFrontOfTheProse() { + let composed = SwapKitErrorCopy.providerErrorMessage( + providerError(code: "sellAssetAmountTooSmall", + message: "Sell asset amount too small for provider NEAR. Min amount is 0.17498713 DASH.DASH") + ) + + XCTAssertEqual( + composed, + "sellAssetAmountTooSmall: Sell asset amount too small for provider NEAR. Min amount is 0.17498713 DASH.DASH" + ) + } + + func testProviderErrorMessageFallsBackToWhicheverHalfIsPresent() { + XCTAssertEqual(SwapKitErrorCopy.providerErrorMessage(providerError(code: "noRoutesFound", message: nil)), + "noRoutesFound") + XCTAssertEqual(SwapKitErrorCopy.providerErrorMessage(providerError(code: nil, message: "Api request failed")), + "Api request failed") + } + + func testProviderErrorMessageTreatsBlankFieldsAsMissing() { + XCTAssertEqual(SwapKitErrorCopy.providerErrorMessage(providerError(code: " noRoutesFound ", message: " ")), + "noRoutesFound") + XCTAssertNil(SwapKitErrorCopy.providerErrorMessage(providerError(code: "", message: nil))) + XCTAssertNil(SwapKitErrorCopy.providerErrorMessage(nil)) + } + + // MARK: Classification + + func testBelowMinimumMatchesTheCodeFamilyWhateverTheCase() { + XCTAssertTrue(SwapKitErrorCopy.isBelowMinimum("sellAssetAmountTooSmall")) + XCTAssertTrue(SwapKitErrorCopy.isBelowMinimum("BUYASSETAMOUNTTOOLOW")) + // The composed `": "` shape must classify the same as the bare code. + XCTAssertTrue(SwapKitErrorCopy.isBelowMinimum("sellAssetAmountTooSmall: Min amount is 0.175 DASH.DASH")) + } + + func testBelowMinimumIgnoresUnrelatedBelowThresholdCodes() { + XCTAssertFalse(SwapKitErrorCopy.isBelowMinimum("inboundFeeTooLow")) + XCTAssertFalse(SwapKitErrorCopy.isBelowMinimum("noRoutesFound")) + XCTAssertFalse(SwapKitErrorCopy.isBelowMinimum(nil)) + } + + func testIsNoRouteMatchesBareAndComposedForms() { + XCTAssertTrue(SwapKitErrorCopy.isNoRoute("noRoutesFound")) + XCTAssertTrue(SwapKitErrorCopy.isNoRoute("noRoutesFound: No routes found for DASH.DASH -> BTC.BTC")) + XCTAssertFalse(SwapKitErrorCopy.isNoRoute("apiRequestFailed")) + XCTAssertFalse(SwapKitErrorCopy.isNoRoute(nil)) + } + + /// The regression itself: before the fix the prose reached the mapper without its code, and + /// "No routes found for …" matches nothing. Prose alone must still fall through — the fix is + /// that callers no longer send it alone, not that the mapper started guessing from text. + func testProseWithoutItsCodeIsNotMistakenForAMapping() { + XCTAssertEqual(message("No routes found for DASH.DASH -> BTC.BTC"), genericMessage) + XCTAssertNotEqual(message("noRoutesFound: No routes found for DASH.DASH -> BTC.BTC"), genericMessage) + } + + // MARK: message(for:coin:) + + func testDetailAfterTheCodeDoesNotChangeTheMapping() { + XCTAssertEqual(message("validation_error: body/sellAmount sellAmount must be greater than 0"), + message("validation_error")) + } + + func testBelowMinimumGetsItsOwnCopy() { + let belowMinimum = message("sellAssetAmountTooSmall: Min amount is 0.17498713 DASH.DASH") + + XCTAssertNotEqual(belowMinimum, genericMessage) + // Distinct from the no-route copy: one asks for a larger amount, the other for a retry. + XCTAssertNotEqual(belowMinimum, message("noRoutesFound")) + } + + /// The four codes SwapKit returns today that used to fall through to the generic copy. + func testNewlyMappedCodesAreNoLongerGeneric() { + for code in ["apiRequestFailed", "invalidRoute", "invalidAsset", "memoTooLongForSourceChain"] { + XCTAssertNotEqual(message(code), genericMessage, "\(code) still falls through") + } + } + + /// SwapKit's name for the over-length memo the wallet also catches locally before broadcasting; + /// one situation, so one message. + func testServerAndLocalMemoTooLongShareOneMessage() { + XCTAssertEqual(message("memoTooLongForSourceChain"), + message(SwapKitErrorCopy.mayaMemoTooLongErrorCode)) + } + + func testUnknownAndEmptyErrorsFallBackToTheGenericCopy() { + XCTAssertEqual(message(nil), genericMessage) + XCTAssertEqual(message(""), genericMessage) + } +}