From 09c34b524f5bdf4f1090472955bba303d9b6082f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:57:22 +0300 Subject: [PATCH 1/5] fix(crowdnode): unwrap the error publisher before reading localizedDescription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `viewModel.$error` publishes `Error?`, and `Optional` has no `localizedDescription`, so both error sinks failed to compile. The dashpay scheme did not build at all on this branch until this was fixed. Unrelated to the Maya work that surfaced it β€” split out so it can be reviewed and landed against the base branch on its own. --- .../UI/CrowdNode/Online/OnlineAccountEmailController.swift | 1 + .../UI/CrowdNode/Portal/CrowdNodePortalViewController.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift b/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift index 3dcc13f7a..85ccef8d8 100644 --- a/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift +++ b/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift @@ -99,6 +99,7 @@ final class OnlineAccountEmailController: UIViewController { viewModel.$error .receive(on: DispatchQueue.main) + .compactMap { $0 } .sink { [weak self] error in if error is CrowdNode.Error { self?.viewModel.clearError() diff --git a/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift b/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift index 3ce47eb57..b1aae0a67 100644 --- a/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift +++ b/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift @@ -183,7 +183,7 @@ extension CrowdNodePortalController { viewModel.$error .receive(on: DispatchQueue.main) - .filter { error in error != nil } + .compactMap { $0 } .sink(receiveValue: { [weak self] error in if error is CrowdNode.Error { self?.viewModel.clearError() From e4dce141d094de947c173b2b5d83092caf0d8610 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:57:40 +0300 Subject: [PATCH 2/5] feat(dex): restore MAYACHAIN routes with OP_RETURN memo deposits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maya was disabled during the DashSync unlink because SwiftDashSDK could not build an OP_RETURN output. With the SDK-side controls in place, Maya returns as routes inside Dash DEX β€” the standalone Maya portal stays hidden. Send path: - SwiftDashSDKTransactionSender.buildAndSignSwapDeposit builds VOUT0=vault, VOUT1=zero-value OP_RETURN memo, VOUT2=change back to VIN0, then decodes the signed bytes and refuses to return unless the shape, the memo payload, the VOUT2==VIN0 script and a >= 1 duff/byte fee all check out - WalletSendService.sendSwapDeposit reuses the existing sync/online/authorize plumbing; the standard send signature is untouched - SendCoinsService.sendSwapKitSwap routes memo-bearing deposits to it and keeps memo-less NEAR routes on the plain send Routing: - MAYACHAIN and MAYACHAIN_STREAMING are separate SwapKit providers with different token lists (31 vs 18); classification unions both, otherwise Maya-routable assets such as KUJI.KUJI and XRD.XRD are invisible - NEAR stays preferred; Maya is requested only for assets NEAR cannot route - mayaOnly assets are no longer hidden from the Sell picker Guards (both kept β€” a memo-less deposit orphans the funds at the vault): - the provider and OrderPreviewViewModel each refuse a memo over the 80-byte OP_RETURN limit, reporting a specific error rather than "coin unavailable" - Maya's 10k-duff dust floor is enforced, keyed on the memo rather than on the execution-network display label Measured live: real Maya memos run 72-80 bytes, and for one route the length varies with the amount (79/80/79/79 at 0.1/1/10/50 DASH), so the ceiling is reachable in production and the copy names both the amount and the address. --- .../SwiftDashSDKTransactionSender.swift | 107 ++++++++++++++++++ .../Models/Swap/SwapExecutionData.swift | 5 +- .../Models/Swap/SwapKitErrorCopy.swift | 12 ++ .../Models/Swap/SwapTrackingService.swift | 4 +- .../Models/SwapKit/SwapKitConstants.swift | 12 +- .../Models/SwapKit/SwapKitSwapProvider.swift | 70 +++++++----- .../Transactions/SendCoinsService.swift | 23 ++-- .../Transactions/WalletSendService.swift | 38 +++++++ .../UI/Buy Sell/BuySellPortalView.swift | 6 +- .../OrderPreview/OrderPreviewViewModel.swift | 47 ++++++-- .../Swap/SelectCoin/SelectCoinViewModel.swift | 9 +- 11 files changed, 270 insertions(+), 63 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift index 6aa6b0927..bbbe1ff6f 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift @@ -49,6 +49,8 @@ final class SwiftDashSDKTransactionSender: NSObject { private static let coinJoinTypeTag: UInt8 = 1 /// Only CoinJoin account 0 is created and swept (matches the balance reader). private static let coinJoinAccountIndex: UInt32 = 0 + /// MAYACHAIN memo standardness limit for OP_RETURN payloads. + private static let maxSwapMemoBytes = 80 // MARK: - Selected-input send constants @@ -119,6 +121,62 @@ final class SwiftDashSDKTransactionSender: NSObject { return (tx, txHash) } + /// Build + sign a MAYACHAIN-style swap deposit: vault payment at VOUT0, a zero-value + /// OP_RETURN memo at VOUT1, and change returned to VIN0 when change exists. + /// Nothing is broadcast β€” pass the result to `broadcast(_:)`. + static func buildAndSignSwapDeposit( + vaultAddress: String, + amountDuffs: UInt64, + memo: String + ) throws -> (tx: CoreTransaction, txHash: Data) { + let memoData = Data(memo.utf8) + guard memoData.count <= Self.maxSwapMemoBytes else { + throw SendError.invalidSwapMemo("Swap memo is too long. Please refresh and try again.") + } + + logger.info("πŸ’Έ TXSEND :: building+signing MAYA swap deposit via PlatformWalletManager.coreWallet") + + let build = { @MainActor () throws -> (tx: CoreTransaction, network: Network) in + guard let wallet = SwiftDashSDKHost.shared.wallet, + let network = SwiftDashSDKHost.shared.runningNetwork else { + throw SendError.walletNotReady("PlatformWalletManager wallet is not available") + } + + let builder = try CoreTransactionBuilder(network: network) + try builder.addOutput(address: vaultAddress, amountDuffs: amountDuffs) + try builder.addOpReturn(memoData) + try builder.preserveOutputOrder() + try builder.changeToFirstInput() + try builder.setFunding(wallet: wallet, accountType: .bip44, accountIndex: 0) + let tx = try builder.buildSigned(wallet: wallet, accountType: .bip44, accountIndex: 0) + return (tx, network) + } + + let built: (tx: CoreTransaction, network: Network) + if Thread.isMainThread { + built = try MainActor.assumeIsolated { try build() } + } else { + var captured: Result<(tx: CoreTransaction, network: Network), Error> = + .failure(SendError.walletNotReady("uninitialized result")) + DispatchQueue.main.sync { + captured = Result { try MainActor.assumeIsolated { try build() } } + } + built = try captured.get() + } + + try assertSwapDepositShape( + tx: built.tx, + network: built.network, + vaultAddress: vaultAddress, + amountDuffs: amountDuffs, + memoData: memoData + ) + + let txHash = computeTxHash(from: built.tx.data) + logger.info("πŸ’Έ TXSEND :: built+signed MAYA swap deposit β€” txHash=\(txHash.map { String(format: "%02x", $0) }.joined(), privacy: .public) fee=\(built.tx.fee, privacy: .public) duffs size=\(built.tx.data.count, privacy: .public) bytes") + return (built.tx, txHash) + } + // MARK: - CoinJoin Sweep /// Sweep the entire CoinJoin-account balance to `address` (the user's own @@ -512,10 +570,57 @@ final class SwiftDashSDKTransactionSender: NSObject { return Data(hash2.reversed()) } + private static func assertSwapDepositShape( + tx: CoreTransaction, + network: Network, + vaultAddress: String, + amountDuffs: UInt64, + memoData: Data + ) throws { + let decoded = try TransactionDecoder.decode(tx.data, network: network) + guard decoded.outputs.count >= 2, decoded.outputs.count <= 3 else { + throw SendError.invalidInput("swap deposit must have 2 or 3 outputs") + } + + let vaultOutput = decoded.outputs[0] + guard vaultOutput.address == vaultAddress, vaultOutput.valueDuffs == amountDuffs else { + throw SendError.invalidInput("swap deposit VOUT0 does not match the requested vault payment") + } + + let memoOutput = decoded.outputs[1] + guard memoOutput.valueDuffs == 0, + memoOutput.scriptPubkey.first == 0x6a, + RawTransactionInspector.opReturnData(script: memoOutput.scriptPubkey) == memoData + else { + throw SendError.invalidInput("swap deposit VOUT1 does not contain the requested OP_RETURN memo") + } + + if decoded.outputs.count == 3 { + // `DecodedTransaction.Input.address` is recovered from a P2PKH-shaped scriptSig and + // is nil for anything else. Every BIP44 account UTXO this builder can spend is + // P2PKH, so nil here means the transaction is not the shape we asked for β€” refuse + // rather than skip the check. + guard let inputAddress = decoded.inputs.first?.address else { + throw SendError.invalidInput("swap deposit VIN0 address could not be recovered") + } + let paymentNetwork = try PaymentNetworkResolver.current() + guard let inputScript = ScriptAddressCodec.scriptPubKey(forAddress: inputAddress, network: paymentNetwork), + decoded.outputs[2].scriptPubkey == inputScript + else { + throw SendError.invalidInput("swap deposit VOUT2 does not return change to VIN0") + } + } + + guard tx.fee >= UInt64(tx.data.count) else { + throw SendError.invalidInput("swap deposit fee rate fell below the 1 duff/byte relay minimum") + } + } + // MARK: - Errors enum SendError: LocalizedError { case invalidInput(String) + case invalidSwapMemo(String) case walletNotReady(String) case insufficientSelectedFunds(selected: UInt64, amount: UInt64, fee: UInt64) case transactionRejected(txid: String, reason: String) @@ -525,6 +630,8 @@ final class SwiftDashSDKTransactionSender: NSObject { switch self { case .invalidInput(let reason): return "Invalid transaction input: \(reason)" + case .invalidSwapMemo(let reason): + return reason case .walletNotReady(let reason): return "Wallet not ready: \(reason)" case .insufficientSelectedFunds(let selected, let amount, let fee): diff --git a/DashWallet/Sources/Models/Swap/SwapExecutionData.swift b/DashWallet/Sources/Models/Swap/SwapExecutionData.swift index 633a46526..a322bfcab 100644 --- a/DashWallet/Sources/Models/Swap/SwapExecutionData.swift +++ b/DashWallet/Sources/Models/Swap/SwapExecutionData.swift @@ -20,8 +20,9 @@ import Foundation struct SwapExecutionData { - /// The route's unique deposit address. DashDEX exposes only memo-less NEAR-intents routes, - /// so a plain send to this address is the whole swap β€” no OP_RETURN memo is involved. + /// The route's unique deposit address. let vaultAddress: String + /// Non-nil when the DASH deposit must carry this memo in a zero-value OP_RETURN output. + let memo: String? let executionNetwork: String } diff --git a/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift b/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift index 3df5c18e1..391e8faa4 100644 --- a/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift +++ b/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift @@ -22,6 +22,8 @@ import Foundation /// Maps raw SwapKit error code strings to user-facing messages. /// Mirrors Android's `SwapKitErrors.messageResFor`. enum SwapKitErrorCopy { + static let mayaMemoTooLongErrorCode = "mayaMemoTooLong" + static func message(for rawError: String?, coin: SwapCryptoCurrency) -> String { let code = rawError? .components(separatedBy: ":") @@ -31,6 +33,16 @@ enum SwapKitErrorCopy { ?? "" switch code { + case "mayamemotoolong": + // 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 / + // 50 DASH β€” so blaming the address alone would send the user to the wrong fix. + let chainLabel = SwapCryptoCurrency.chainDisplayName(coin.chain) + return String(format: NSLocalizedString( + "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": return NSLocalizedString( "This amount can't be swapped right now. Routes can be briefly unavailable β€” try again shortly, or try a different amount.", diff --git a/DashWallet/Sources/Models/Swap/SwapTrackingService.swift b/DashWallet/Sources/Models/Swap/SwapTrackingService.swift index 28fc405ae..6654846f6 100644 --- a/DashWallet/Sources/Models/Swap/SwapTrackingService.swift +++ b/DashWallet/Sources/Models/Swap/SwapTrackingService.swift @@ -33,9 +33,9 @@ class SwapTrackingServiceObjcWrapper: NSObject { /// Mirrors Android's `SwapTrackingService.kt`: /// - `start()` at app launch resumes all non-terminal orders. /// - Polls `/track` every 30 s for active orders. -/// - NEAR fallback by `depositAddress` when hash lookup errors. +/// - Tracks NEAR-routed sells by `depositAddress` and Maya-routed sells by tx hash. /// - Material-change-only writes (unconditional writes turn the ticker into a tight loop). -/// - Ages out an order still unresolved after 24 h β†’ `.failed`. +/// - Ages out an order still unresolved after 24 h β†’ `.expired`. final class SwapTrackingService { static let shared = SwapTrackingService() diff --git a/DashWallet/Sources/Models/SwapKit/SwapKitConstants.swift b/DashWallet/Sources/Models/SwapKit/SwapKitConstants.swift index 9c5ce7f84..ff737c7ee 100644 --- a/DashWallet/Sources/Models/SwapKit/SwapKitConstants.swift +++ b/DashWallet/Sources/Models/SwapKit/SwapKitConstants.swift @@ -24,8 +24,16 @@ enum SwapKitConstants { /// Default max slippage (percent) for quotes/swaps β€” mirrors Android /// `SwapKitConstants.DEFAULT_SLIPPAGE_PERCENT = 2`. static let defaultSlippagePercent = 2 - /// SwapKit provider IDs for token-list classification (mirrors Android SwapKitConstants.kt). - static let providerMaya = "MAYACHAIN_STREAMING" + /// SwapKit provider IDs (mirrors Android SwapKitConstants.kt). + /// + /// MAYACHAIN and MAYACHAIN_STREAMING are **separate** SwapKit providers, not aliases: + /// streaming performs the swap over time for better price execution. Their `/tokens` + /// lists differ (31 vs 18 assets on 2026-08-03), so both are needed β€” classifying from + /// the streaming list alone hides Maya-routable assets such as `KUJI.KUJI` and `XRD.XRD`. + /// Quotes request both and let SwapKit's routing pick. + static let providerMayaChain = "MAYACHAIN" + static let providerMayaStreaming = "MAYACHAIN_STREAMING" + static let mayaProviders = [providerMayaChain, providerMayaStreaming] static let providerNear = "NEAR" /// routeId is valid 60s, cached ~5min (see SWAPKIT_PROTOCOL.md "Quote Lifecycle"). static let routeFreshnessSeconds: TimeInterval = 60 diff --git a/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift b/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift index c840c08fb..2280a7dfd 100644 --- a/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift +++ b/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift @@ -32,6 +32,10 @@ import Foundation /// etc.) are accessed from a single isolation domain, eliminating concurrent read/write races. @MainActor final class SwapKitSwapProvider: SwapProvider { + private enum Constants { + static let maxMemoBytes = 80 + } + nonisolated var displayName: String { "SwapKit" } nonisolated var usesGenericFeeLabel: Bool { true } nonisolated var buildsSwapKitDeposit: Bool { true } @@ -140,16 +144,8 @@ final class SwapKitSwapProvider: SwapProvider { /// Sell is always unaffected β€” all pools are returned regardless of classification state. private func filteredPools(_ pools: [SwapPool], for direction: SwapDirection) async throws -> [SwapPool] { guard direction == .buy else { - // Sell: hide coins that can ONLY route via MAYACHAIN. Those routes require an - // OP_RETURN memo on the DASH deposit, which SwiftDashSDK cannot build. Coins also - // routable via NEAR (nearOnly or both) stay β€” the Sell quote forces NEAR intents, - // so they deposit memo-less. If classification is unusable (network error) - // mayaOnlyAssets is empty and nothing is hidden; a mayaOnly coin tapped in that - // state simply returns "no route" from the NEAR-forced quote, so no OP_RETURN swap - // can be built. if !classificationBuilt { await buildClassification() } - guard classificationUsable, !mayaOnlyAssets.isEmpty else { return pools } - return pools.filter { !mayaOnlyAssets.contains($0.asset.uppercased()) } + return pools } if !classificationUsable { @@ -417,13 +413,10 @@ final class SwapKitSwapProvider: SwapProvider { return errorResult(NSLocalizedString("No vault address returned by SwapKit", comment: "SwapKit")) } - // Defense-in-depth: NEAR-forced routing must never carry a memo. If a memo does come - // back, the deposit would need an OP_RETURN output that SwiftDashSDK cannot build, so - // fail loudly here instead of silently building an invalid (memo-less) deposit that the - // network would treat as a plain send and never credit the swap. - if let memo = swapResponse.memo, !memo.isEmpty { - DWLogger.log("SwapKit: rejecting memo-bearing route for \(toAsset) β€” OP_RETURN unsupported") - return errorResult(NSLocalizedString("This coin isn’t available for swapping right now.", comment: "SwapKit")) + let memo = swapResponse.memo?.trimmingCharacters(in: .whitespacesAndNewlines) + if let memo, !memo.isEmpty, memo.utf8.count > Constants.maxMemoBytes { + DWLogger.log("SwapKit: rejecting over-length memo for \(toAsset) β€” \(memo.utf8.count) bytes") + return errorResult(SwapKitErrorCopy.mayaMemoTooLongErrorCode) } // Step 4: map to neutral result. @@ -442,8 +435,7 @@ final class SwapKitSwapProvider: SwapProvider { expectedAmountOut: expectedOut, fees: SwapFeeResult(total: feeBaseUnits, outbound: feeBaseUnits), inboundAddress: vaultAddress, - // Always nil after the NEAR-forced routing + guard above; the deposit is a plain send. - memo: nil, + memo: memo?.isEmpty == false ? memo : nil, executionNetwork: executionNetwork ) } @@ -481,11 +473,17 @@ final class SwapKitSwapProvider: SwapProvider { private func buildClassification() async { classificationBuilt = true do { - async let mayaRequest = SwapKitAPIService.shared.tokens(provider: SwapKitConstants.providerMaya) + // Union both Maya providers: their token lists differ, and an asset routable only + // via non-streaming MAYACHAIN would otherwise be classified as un-routable and + // quoted against NEAR, which cannot route it either. + async let mayaChainRequest = SwapKitAPIService.shared.tokens(provider: SwapKitConstants.providerMayaChain) + async let mayaStreamingRequest = SwapKitAPIService.shared.tokens(provider: SwapKitConstants.providerMayaStreaming) async let nearRequest = SwapKitAPIService.shared.tokens(provider: SwapKitConstants.providerNear) - let (mayaTokens, nearTokens) = (try await mayaRequest, try await nearRequest) + let (mayaChainTokens, mayaStreamingTokens, nearTokens) = + (try await mayaChainRequest, try await mayaStreamingRequest, try await nearRequest) - let mayaIds = Set(mayaTokens.map { $0.identifier.uppercased() }) + let mayaIds = Set(mayaChainTokens.map { $0.identifier.uppercased() }) + .union(mayaStreamingTokens.map { $0.identifier.uppercased() }) let nearIds = Set(nearTokens.map { $0.identifier.uppercased() }) let newMayaOnly = mayaIds.subtracting(nearIds) @@ -511,7 +509,7 @@ final class SwapKitSwapProvider: SwapProvider { // Build identifier β†’ logoURI lookup. Maya takes priority; NEAR fills gaps. var logos: [String: String] = [:] - for token in mayaTokens { + for token in mayaChainTokens + mayaStreamingTokens { if let uri = token.logoURI { logos[token.identifier.uppercased()] = uri } } for token in nearTokens { @@ -551,6 +549,8 @@ final class SwapKitSwapProvider: SwapProvider { } private func fetchQuoteResponse(dashSatoshis: Int64, toAsset: String, destination: String) async throws -> SwapKitQuoteResponse { + if !classificationBuilt { await buildClassification() } + let sellAmount = baseUnitsToHuman(dashSatoshis) let quoteRequest = SwapKitQuoteRequest( sellAsset: SwapKitConstants.dashAsset, @@ -559,12 +559,7 @@ final class SwapKitSwapProvider: SwapProvider { slippage: SwapKitConstants.defaultSlippagePercent, sourceAddress: nil, destinationAddress: destination, - // Force NEAR-intents routing: those routes deposit to a unique address with NO - // memo, so the DASH tx is a plain send that SwiftDashSDK can build. MAYACHAIN - // routes would return an OP_RETURN memo the SDK cannot express, so we never ask - // for them here (mayaOnly coins are hidden from the picker; both-routable coins - // stay memoless via NEAR). Mirrors requestBuyRoute, which already forces NEAR. - providers: [SwapKitConstants.providerNear], + providers: sellQuoteProviders(for: toAsset), affiliateFee: nil ) @@ -597,6 +592,8 @@ final class SwapKitSwapProvider: SwapProvider { slippage: SwapKitConstants.defaultSlippagePercent, sourceAddress: refundAddress, destinationAddress: destination, + // Buy deposits are built by the counterparty, so OP_RETURN support in the app does + // not change Buy routing; keep the existing NEAR-only request shape. providers: [SwapKitConstants.providerNear], affiliateFee: nil ) @@ -709,6 +706,7 @@ final class SwapKitSwapProvider: SwapProvider { slippage: SwapKitConstants.defaultSlippagePercent, sourceAddress: nil, destinationAddress: nil, + // Buy routability is still about counterparty-built deposits, so keep probing NEAR. providers: [SwapKitConstants.providerNear], affiliateFee: nil ) @@ -790,6 +788,22 @@ final class SwapKitSwapProvider: SwapProvider { return code } + /// NEAR-preferred routing: NEAR-intents deposits are memo-less, so they keep the simpler + /// on-chain shape and today's fee/latency profile. MAYACHAIN is requested only for assets + /// NEAR cannot route at all β€” those deposits carry an OP_RETURN memo. + /// Both Maya providers are offered so routing is not narrowed to streaming-only. + private func sellQuoteProviders(for toAsset: String) -> [String]? { + guard classificationUsable else { + return [SwapKitConstants.providerNear] + } + + if mayaOnlyAssets.contains(toAsset.uppercased()) { + return SwapKitConstants.mayaProviders + } + + return [SwapKitConstants.providerNear] + } + // MARK: - Private: Amount Conversion private func baseUnitsToHuman(_ satoshis: Int64) -> String { diff --git a/DashWallet/Sources/Models/Transactions/SendCoinsService.swift b/DashWallet/Sources/Models/Transactions/SendCoinsService.swift index 49d08a91e..de91263d0 100644 --- a/DashWallet/Sources/Models/Transactions/SendCoinsService.swift +++ b/DashWallet/Sources/Models/Transactions/SendCoinsService.swift @@ -35,17 +35,13 @@ public final class SendCoinsService: NSObject { ) } - /// Submits a memo-less DashDEX (SwapKit) deposit: a plain send of `dashAmount` to the - /// route's deposit address. DashDEX only exposes NEAR-intents routes, which deposit to a - /// unique per-swap address with NO memo β€” so the DASH transaction is an ordinary send that - /// SwiftDashSDK builds, signs, and broadcasts via `WalletSendService`. There is no - /// OP_RETURN output (the SDK cannot express one); MAYACHAIN-style memo routes never reach - /// here because the provider forces NEAR routing, hides Maya-only coins, and rejects any - /// residual memo-bearing quote before submission. + /// Submits a DashDEX (SwapKit) deposit. Memo-less routes remain a plain send to the + /// route's deposit address; memo-bearing routes build a MAYACHAIN-style deposit with the + /// memo encoded in a zero-value OP_RETURN output. /// /// - Returns: the wire-order txid of the broadcast transaction /// (`Transaction.txHashData` convention). - func sendSwapKitSwap(depositAddress: String, dashAmount: UInt64) async throws -> Data { + func sendSwapKitSwap(depositAddress: String, dashAmount: UInt64, memo: String?) async throws -> Data { // Serialise swaps: don't start a new one until the previous swap tx is InstantSend-locked. if SwapPendingGate.shared.isAwaitingISLock { throw DashSpendError.swapAwaitingInstantLock @@ -53,7 +49,16 @@ public final class SendCoinsService: NSObject { let txidWire: Data do { - txidWire = try await walletSendService.send(address: depositAddress, amount: dashAmount) + let trimmedMemo = memo?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedMemo, !trimmedMemo.isEmpty { + txidWire = try await walletSendService.sendSwapDeposit( + vaultAddress: depositAddress, + amount: dashAmount, + memo: trimmedMemo + ) + } else { + txidWire = try await walletSendService.send(address: depositAddress, amount: dashAmount) + } } catch let error as NSError where WalletSendService.isAuthenticationCancelledError(error) { // Preserve the swap flow's existing auth-cancel handling, which keys on // `DashSpendError.authenticationCancelled` rather than the send service's NSError. diff --git a/DashWallet/Sources/Models/Transactions/WalletSendService.swift b/DashWallet/Sources/Models/Transactions/WalletSendService.swift index 01c9cb46a..318755f8b 100644 --- a/DashWallet/Sources/Models/Transactions/WalletSendService.swift +++ b/DashWallet/Sources/Models/Transactions/WalletSendService.swift @@ -313,6 +313,26 @@ final class WalletSendService: NSObject { return preparedSend.txidWire } + /// - Returns: the wire-order txid of the broadcast transaction + /// (`Transaction.txHashData` convention). + func sendSwapDeposit(vaultAddress: String, amount: UInt64, memo: String) async throws -> Data { + try Self.ensureChainSynced() + try Self.ensureOnline() + try await sendAuthorizer.authorizeSend(spendAmount: amount) + + do { + let preparedSend = try buildPreparedSwapDeposit( + vaultAddress: vaultAddress, + amount: amount, + memo: memo + ) + try preparedSend.broadcast() + return preparedSend.txidWire + } catch SwiftDashSDKTransactionSender.SendError.invalidSwapMemo(let reason) { + throw Self.makeError(code: .invalidSwapMemo, description: reason) + } + } + /// Sweep the entire CoinJoin-account balance into the user's own BIP44 /// spendable balance. The shared flow behind both post-migration sweep /// surfaces (the Home popup and the Settings row): authorize @@ -505,6 +525,23 @@ final class WalletSendService: NSObject { coreTransaction: tx ) } + + private func buildPreparedSwapDeposit(vaultAddress: String, amount: UInt64, memo: String) throws -> PreparedStandardSend { + let (tx, txHash) = try SwiftDashSDKTransactionSender.buildAndSignSwapDeposit( + vaultAddress: vaultAddress, + amountDuffs: amount, + memo: memo + ) + + return PreparedStandardSend( + txData: tx.data, + txHash: txHash, + fee: tx.fee, + address: vaultAddress, + amount: amount, + coreTransaction: tx + ) + } } /// Timeout-guarded wrapper over `DSAuthenticationManager.authenticate(...)`. The bare @@ -603,6 +640,7 @@ private extension WalletSendService { case offline = 8 case broadcastRejected = 9 case broadcastUnknown = 10 + case invalidSwapMemo = 11 } static let errorDomain = "org.dashfoundation.dash.wallet-send-service" diff --git a/DashWallet/Sources/UI/Buy Sell/BuySellPortalView.swift b/DashWallet/Sources/UI/Buy Sell/BuySellPortalView.swift index 1ba36d062..16d6f9911 100644 --- a/DashWallet/Sources/UI/Buy Sell/BuySellPortalView.swift +++ b/DashWallet/Sources/UI/Buy Sell/BuySellPortalView.swift @@ -48,7 +48,8 @@ private struct MenuCardStyle: ViewModifier { struct BuySellPortalView: View { let showCoinbase: Bool /// Dash DEX (SwapKit) entry visibility, decided by the controller (mainnet + API key - /// configured). Dash DEX now runs on SwiftDashSDK via memo-less NEAR-intents routes. + /// configured). Dash DEX now runs on SwiftDashSDK with NEAR-preferred routing and + /// MAYACHAIN fallback where NEAR cannot route. let showSwapKit: Bool @ObservedObject var model: BuySellPortalModel @@ -59,8 +60,7 @@ struct BuySellPortalView: View { var onMaya: () -> Void var onSwapKit: () -> Void - // Maya remains withheld: its swap path is inherently OP_RETURN (memo-based), which - // SwiftDashSDK cannot build. Dash DEX (SwapKit) replaces it via memo-less NEAR routing. + // The standalone Maya portal remains withheld. Maya returns only as a route inside Dash DEX. private var showsMaya: Bool { false } var body: some View { diff --git a/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewViewModel.swift b/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewViewModel.swift index dd66aafc7..4a99f48d8 100644 --- a/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewViewModel.swift +++ b/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewViewModel.swift @@ -79,6 +79,17 @@ enum SwapStatus: Equatable { @MainActor final class OrderPreviewViewModel: ObservableObject { + /// Deliberately NOT named `MayaConstants`: this type is used unqualified elsewhere in the + /// file (`MayaConstants.mayaScanTransactionURL`), and a nested enum of that name shadows + /// the global one. + private enum MayaDepositRules { + /// OP_RETURN standardness limit; a longer memo cannot be encoded on-chain. + static let maxMemoBytes = 80 + /// Maya ignores deposits below its dust threshold. + /// https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions + static let minimumDepositDuffs: Int64 = 10_000 + } + private enum Constants { static let submitCountdownSeconds = 10 static let minimumTolerance = Decimal(string: "0.00000001")! @@ -454,18 +465,31 @@ final class OrderPreviewViewModel: ObservableObject { throw swapFieldError(NSLocalizedString("Deposit address is missing. Please refresh and try again.", comment: "Dash DEX")) } - // Safety net: the deposit is a plain send with NO OP_RETURN (SwiftDashSDK can't build - // one). A memo-bearing quote (e.g. a MAYACHAIN route) would need that memo encoded - // on-chain β€” sending memo-less would silently orphan the funds at the vault. Refuse it - // here, provider-agnostically, so no swap can ever be submitted without its required memo. - if let memo = quote.memo, !memo.isEmpty { - throw swapFieldError(NSLocalizedString("This coin isn’t available for swapping right now.", comment: "Dash DEX")) + let trimmedMemo = quote.memo?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedMemo = (trimmedMemo?.isEmpty == false) ? trimmedMemo : nil + + // Fund-loss safety net. A memo-bearing quote (a MAYACHAIN route) must reach the chain + // with its memo in an OP_RETURN β€” a memo-less send would orphan the funds at the vault. + // Over the 80-byte standardness limit the memo cannot be encoded at all, so refuse the + // swap rather than broadcast one the network would treat as a plain send. + if let resolvedMemo, resolvedMemo.utf8.count > MayaDepositRules.maxMemoBytes { + throw swapFieldError(SwapKitErrorCopy.mayaMemoTooLongErrorCode) + } + + // Maya's dust floor applies to memo-bearing deposits. Keyed on the memo alone: every + // MAYACHAIN route carries one, and `executionNetwork` is a display label from + // `prettifyProviders` β€” matching text in it would tie a money rule to UI copy. + if resolvedMemo != nil, dashSatoshis < MayaDepositRules.minimumDepositDuffs { + let minimum = Decimal(MayaDepositRules.minimumDepositDuffs) / Decimal(kOneDash) + let format = NSLocalizedString("The minimum DASH deposit for this swap is %@.", comment: "Dash DEX") + throw swapFieldError(String(format: format, minimum.formattedDashAmount)) } let resolvedExecutionNetwork = quote.executionNetwork? .trimmingCharacters(in: .whitespacesAndNewlines) return SwapExecutionData( vaultAddress: vaultAddress, + memo: resolvedMemo, executionNetwork: { if let resolvedExecutionNetwork, !resolvedExecutionNetwork.isEmpty { return resolvedExecutionNetwork @@ -475,12 +499,17 @@ final class OrderPreviewViewModel: ObservableObject { ) } - /// Broadcasts the DASH deposit and returns its wire-order txid. DashDEX routes are memo-less - /// (NEAR intents), so this is a plain send built by SwiftDashSDK β€” no OP_RETURN output. + /// Broadcasts the DASH deposit and returns its wire-order txid. + /// + /// `dashSatoshis` is deposited as-is. SwapKit's `sellAmount` **is** the deposit amount and + /// its `expectedBuyAmount` is already net of fees, so nothing is added on top. (Android's + /// direct-Maya path adds the outbound fee to the vault output because MayaNode's + /// `/quote/swap?amount=` means the swap amount, not the deposit β€” a different contract.) private func submitDashTransaction(using execution: SwapExecutionData) async throws -> Data { try await sendCoinsService.sendSwapKitSwap( depositAddress: execution.vaultAddress, - dashAmount: UInt64(dashSatoshis) + dashAmount: UInt64(dashSatoshis), + memo: execution.memo ) } diff --git a/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinViewModel.swift b/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinViewModel.swift index 1bd3c195e..1542946db 100644 --- a/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinViewModel.swift +++ b/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinViewModel.swift @@ -130,7 +130,7 @@ class SelectCoinViewModel: ObservableObject { let fiatCurrency = App.fiatCurrency let formatter = makePriceFormatter(for: fiatCurrency) - let networkLabels = normalizedNetworkLabels(await swapProvider.networkLabels(for: pools)) + let networkLabels = await swapProvider.networkLabels(for: pools) let haltedAssets = await swapProvider.haltedAssets(from: inboundAddresses, pools: pools) let items = makeCoinItems( pools: pools, @@ -199,13 +199,6 @@ class SelectCoinViewModel: ObservableObject { return formatter } - private func normalizedNetworkLabels(_ labels: [String: String]) -> [String: String] { - // Both Buy and Sell now route exclusively through NEAR intents (mayaOnly coins are - // hidden and the quote layer forces NEAR), so a "Multiple networks" label is never - // accurate β€” a both-routable coin effectively resolves to a single provider (NEAR). - return labels.mapValues { $0 == RouteProvider.multiple.shortLabel ? RouteProvider.near.shortLabel : $0 } - } - // MARK: - Private: Chain label /// Appends ` (ChainName)` to every coin's display name. From f1550ab036340f87aed8e55bcffaeebebf70b451 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:43:19 +0300 Subject: [PATCH 3/5] feat(dex): let dual-routable coins pick between NEAR and MAYACHAIN The picker labels a coin routable by both protocols "Multiple networks", but the Sell quote only ever asked NEAR, so the label promised a choice the code never made. Offer both and let SwapKit's route ranking decide. The provider list stays explicit rather than nil: no filter would also admit THORChain, Chainflip and the rest, which neither the classification nor the deposit path accounts for. Maya-only coins keep the Maya providers, NEAR-only keep NEAR, and an unusable classification still falls back to NEAR. A MAYACHAIN route can now win for a coin that previously always deposited memo-less, so the 80-byte memo ceiling and Maya's dust floor apply to dual-routable coins too. Both guards run on the fresh pre-commit quote. --- .../Models/SwapKit/SwapKitSwapProvider.swift | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift b/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift index 2280a7dfd..c65cfc605 100644 --- a/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift +++ b/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift @@ -788,18 +788,33 @@ final class SwapKitSwapProvider: SwapProvider { return code } - /// NEAR-preferred routing: NEAR-intents deposits are memo-less, so they keep the simpler - /// on-chain shape and today's fee/latency profile. MAYACHAIN is requested only for assets - /// NEAR cannot route at all β€” those deposits carry an OP_RETURN memo. - /// Both Maya providers are offered so routing is not narrowed to streaming-only. + /// Best-route selection across the two protocols the classification covers: whatever a + /// coin is actually routable by is offered, and SwapKit picks. A dual-routable coin is + /// therefore quoted against NEAR *and* MAYACHAIN, which is what the picker's "Multiple + /// networks" label promises. + /// + /// The list is always explicit β€” never `nil` β€” so routing stays confined to NEAR and + /// MAYACHAIN. Passing no filter would also admit THORChain, Chainflip and every other + /// SwapKit provider, none of which this classification or the deposit path accounts for. + /// + /// Both Maya providers are named because MAYACHAIN and MAYACHAIN_STREAMING are distinct + /// providers with different token lists. + /// + /// Consequence to keep in mind: a MAYACHAIN route can now win for a coin that previously + /// always deposited memo-less, so the 80-byte memo ceiling and Maya's dust floor apply to + /// dual-routable coins too. Both guards already run on the fresh pre-commit quote. private func sellQuoteProviders(for toAsset: String) -> [String]? { guard classificationUsable else { return [SwapKitConstants.providerNear] } - if mayaOnlyAssets.contains(toAsset.uppercased()) { + let key = toAsset.uppercased() + if mayaOnlyAssets.contains(key) { return SwapKitConstants.mayaProviders } + if bothAssets.contains(key) { + return [SwapKitConstants.providerNear] + SwapKitConstants.mayaProviders + } return [SwapKitConstants.providerNear] } From f1a4cd3d149aff4f0d7421b713135c1e1525de88 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:07:37 +0300 Subject: [PATCH 4/5] fix(wallet): surface an expired Uphold session instead of "Not available" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA hit this picking a Uphold destination address in the Dash DEX flow. `EnterAddressViewModel.resolveSourceState` distinguishes "this coin isn't supported" (.notAvailable) from "your session ended" (.loggedOut) purely by `DWUpholdClient.isAuthorized`. That flag only asks whether a token is stored, never whether Uphold still accepts it β€” and an expired token stays in the keychain. So the whole .loggedOut branch was unreachable for the most common way to lose a session, and the user saw "Not available" with no hint that re-authorizing would fix it: Fetch cards failed (HTTP 401): access token has expired Got 0 cards: [], looking for SOL on solana No card for SOL, creating card and address Create card failed with status 401 for SOL A 401 now clears the stored token via the new `DWUpholdClient.invalidateRejectedSession`, so `isAuthorized` reports NO and the existing UI offers re-login. It deliberately skips the revoke call `logOut` makes: Uphold has already rejected the token, so revoking it would just fail again. Note the 401 also made the code read "no cards" as "user has no card" and go on to create one β€” that second request is now not reached. Also stop asking Uphold to mint an address on a network its address endpoint does not accept. Naming a network in `upholdNetwork(for:)` is not the same as Uphold being able to create one there, so selecting USDC/USDT on Arbitrum produced a guaranteed 400 on every tap: No 'arbitrum' address on card 0b046a36…, creating one Address creation failed (HTTP 400). {"code":"validation_failed","errors":{"network":[{"code":"invalid",…}]}} The UI outcome was already correct β€” nil address plus a live session maps to .notAvailable β€” so this removes the pointless round trip and the misleading error log, not a user-visible bug. Addresses already present on the card are still used for any network. --- .../Models/Swap/ExchangeAddressProvider.swift | 50 ++++++++++++++++++- .../Sources/Models/Uphold/DWUpholdClient.h | 8 +++ .../Sources/Models/Uphold/DWUpholdClient.m | 21 ++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/DashWallet/Sources/Models/Swap/ExchangeAddressProvider.swift b/DashWallet/Sources/Models/Swap/ExchangeAddressProvider.swift index e6bf5ca85..47e1b8369 100644 --- a/DashWallet/Sources/Models/Swap/ExchangeAddressProvider.swift +++ b/DashWallet/Sources/Models/Swap/ExchangeAddressProvider.swift @@ -232,7 +232,16 @@ class ExchangeAddressProvider { return networkAddress } - // No address for this network β€” create one via POST + // No address for this network. Only ask Uphold to mint one for a network its + // address endpoint actually accepts β€” otherwise this is a guaranteed HTTP 400 + // ("network: This value is not valid") on every tap. + guard Self.upholdAddressableNetworks.contains(network) else { + DWLogger.log( + "Maya Uphold: card \(matchingCard.id) has no '\(network)' address and Uphold " + + "cannot create one for that network β€” treating as unavailable") + return nil + } + DWLogger.log("Maya Uphold: No '\(network)' address on card \(matchingCard.id), creating one") if let address = await createUpholdAddress(cardId: matchingCard.id, network: network) { Self.upholdAddressCache[context.cacheKey] = address @@ -242,6 +251,13 @@ class ExchangeAddressProvider { } // Step 2: No card exists β€” create card then address + guard Self.upholdAddressableNetworks.contains(network) else { + DWLogger.log( + "Maya Uphold: no card for \(context.currencyCode) and Uphold cannot create a " + + "'\(network)' address β€” treating as unavailable") + return nil + } + DWLogger.log("Maya Uphold: No card for \(context.currencyCode), creating card and address") if let cardId = await createUpholdCard(currency: context.currencyCode) { if let address = await createUpholdAddress(cardId: cardId, network: network) { @@ -253,6 +269,24 @@ class ExchangeAddressProvider { return nil } + /// Networks Uphold's `POST /me/cards/:id/addresses` endpoint accepts, per its documentation + /// (https://github.com/uphold/docs/blob/master/_cards.md). + /// + /// `upholdNetwork(for:)` maps a Maya chain onto Uphold's naming, but naming a network is not + /// the same as Uphold being able to mint an address on it β€” requesting e.g. `arbitrum` is + /// rejected with `{"code":"validation_failed","errors":{"network":[{"code":"invalid"}]}}`. + /// An address that already exists on the card is still used regardless of this set; the gate + /// only governs whether we ask Uphold to create a new one. + private static let upholdAddressableNetworks: Set = [ + "bitcoin", + "bitcoin-cash", + "bitcoin-gold", + "dash", + "ethereum", + "litecoin", + "xrp-ledger", + ] + /// Maps the selected Maya chain to the Uphold network name used during address creation. private func upholdNetwork(for context: ExchangeAddressLookupContext) -> String { switch context.chain { @@ -279,6 +313,17 @@ class ExchangeAddressProvider { // MARK: - Uphold API Helpers /// Fetches all cards from the Uphold API, including non-Dash crypto cards. + /// Uphold answers 401 once the access token expires, but the token stays in the keychain, so + /// `DWUpholdClient.isAuthorized` keeps reporting YES. `EnterAddressViewModel` uses exactly + /// that flag to tell "this coin isn't supported" (`.notAvailable`) from "your session ended" + /// (`.loggedOut`) β€” so without this the expired case shows "Not available" and the user is + /// never offered the re-login that would actually fix it. + private func invalidateSessionIfRejected(_ statusCode: Int) { + guard statusCode == 401 else { return } + DWLogger.log("Maya Uphold: session rejected (HTTP 401) β€” clearing the stored token") + DWUpholdClient.sharedInstance().invalidateRejectedSession() + } + private func fetchUpholdCards() async -> [UpholdCard] { guard let token = getUpholdAccessToken() else { DWLogger.log("Maya Uphold: No access token available for fetching cards") @@ -300,6 +345,7 @@ class ExchangeAddressProvider { guard (200...299).contains(statusCode) else { let responseBody = String(data: data, encoding: .utf8) ?? "" DWLogger.log("Maya Uphold: Fetch cards failed (HTTP \(statusCode)): \(responseBody)") + invalidateSessionIfRejected(statusCode) return [] } @@ -333,6 +379,7 @@ class ExchangeAddressProvider { (200...299).contains(httpResponse.statusCode) else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 DWLogger.log("Maya Uphold: Create card failed with status \(statusCode) for \(currency)") + invalidateSessionIfRejected(statusCode) return nil } let card = try JSONDecoder().decode(UpholdCard.self, from: data) @@ -377,6 +424,7 @@ class ExchangeAddressProvider { // Log the full response for debugging let responseBody = String(data: data, encoding: .utf8) ?? "" DWLogger.log("Maya Uphold: Address creation failed (HTTP \(statusCode)). Response: \(responseBody)") + invalidateSessionIfRejected(statusCode) return nil } catch { DWLogger.log("Maya Uphold: Failed to create address on card \(cardId): \(error)") diff --git a/DashWallet/Sources/Models/Uphold/DWUpholdClient.h b/DashWallet/Sources/Models/Uphold/DWUpholdClient.h index bfcd02b30..bc5cdcc42 100644 --- a/DashWallet/Sources/Models/Uphold/DWUpholdClient.h +++ b/DashWallet/Sources/Models/Uphold/DWUpholdClient.h @@ -53,6 +53,14 @@ extern NSString *const DWUpholdClientUserDidLogoutNotification; - (void)logOut; +/// Drops a session Uphold has already rejected (HTTP 401), so `isAuthorized` stops reporting +/// YES for a token the server will not accept. Unlike `logOut` this does not call the revoke +/// endpoint β€” the token is already dead, and revoking it would just fail again. +/// +/// Call it from the request layer on a 401 so the UI can offer re-authorization instead of +/// reporting the feature as unavailable. +- (void)invalidateRejectedSession; + @end NS_ASSUME_NONNULL_END diff --git a/DashWallet/Sources/Models/Uphold/DWUpholdClient.m b/DashWallet/Sources/Models/Uphold/DWUpholdClient.m index 82cd4f063..ed01374f5 100644 --- a/DashWallet/Sources/Models/Uphold/DWUpholdClient.m +++ b/DashWallet/Sources/Models/Uphold/DWUpholdClient.m @@ -340,6 +340,27 @@ - (void)logOut { [self performLogOutShouldNotifyObservers:YES]; } +- (void)invalidateRejectedSession { + if (!NSThread.isMainThread) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self invalidateRejectedSession]; + }); + return; + } + + if (!self.accessToken) { + return; + } + + // Skip the revoke call `performLogOutShouldNotifyObservers:` makes: Uphold has already + // rejected this token, so revoking it is a guaranteed second failure. + self.accessToken = nil; + self.lastAccessDate = nil; + [DWKeychainStore setData:nil forAccount:UPHOLD_ACCESS_TOKEN authenticated:YES]; + + [[NSNotificationCenter defaultCenter] postNotificationName:DWUpholdClientUserDidLogoutNotification object:nil]; +} + #pragma mark - Private - (void)createDashCard:(void (^)(DWUpholdCardObject *_Nullable card))completion { From c256c0dfa6904abc103ea4dc81a2ca6be4f62c16 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:14:21 +0300 Subject: [PATCH 5/5] fix(wallet): log the raw reason behind a failed swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A QA report of the "Conversion failed" screen could not be diagnosed from an exported log: the whole swap flow left no trace of why it failed. That screen renders the `default` branch of `SwapKitErrorCopy.message` β€” an error code the mapper does not recognise β€” and every path to it discarded the raw reason on the way: - `SwapKitSwapProvider.errorResult` wrapped the message into a result and returned it unlogged, and it is the funnel for every quote and swap failure - `OrderPreviewViewModel.setFailure` mapped straight to user-facing copy - `SwapConvertViewModel.applyQuoteError` rewrote the API error into UI strings - `SwapKitErrorCopy` collapsed anything unmapped into "something went wrong" So a screenshot was the only evidence that existed, and it says nothing. Log at each of those points, before the message is rewritten, plus one line on the success side when the deposit is broadcast β€” without it a log cannot distinguish "the deposit never went out" from "it went out and the swap failed afterwards". An unmapped code is now visible in the logs, which is also how we find out which SwapKit errors still deserve their own copy. --- DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift | 5 +++++ .../Sources/Models/SwapKit/SwapKitSwapProvider.swift | 6 +++++- .../Sources/UI/Swap/Convert/SwapConvertViewModel.swift | 4 ++++ .../UI/Swap/OrderPreview/OrderPreviewViewModel.swift | 8 ++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift b/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift index 391e8faa4..072a42309 100644 --- a/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift +++ b/DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift @@ -104,6 +104,11 @@ enum SwapKitErrorCopy { comment: "Dash DEX / dex_error_price_moved" ) default: + // The generic copy is a dead end for diagnosis: the screen says only "something + // went wrong" and the raw code is dropped here, so a QA log export contains no + // trace of what actually failed. Record it β€” an unmapped code is either a new + // SwapKit error worth adding above, or a real defect. + DWLogger.log("SwapKit: unmapped swap error for \(coin.code) β€” raw: \(rawError ?? "")") return NSLocalizedString( "Something went wrong setting up your swap. Please try again.", comment: "Dash DEX / dex_error_generic" diff --git a/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift b/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift index c65cfc605..7bfae3340 100644 --- a/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift +++ b/DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift @@ -988,7 +988,11 @@ final class SwapKitSwapProvider: SwapProvider { // MARK: - Private: Helpers private func errorResult(_ message: String) -> SwapQuoteResult { - SwapQuoteResult(error: message, expectedAmountOut: nil, fees: nil, inboundAddress: nil, memo: nil, executionNetwork: nil) + // Every quote/swap failure funnels through here on its way to the UI, which renders a + // mapped (often generic) string. Log the raw message at the source so a failure is + // diagnosable from an exported log instead of only from a screenshot. + DWLogger.log("SwapKit: quote/swap failed β€” raw: \(message)") + return SwapQuoteResult(error: message, expectedAmountOut: nil, fees: nil, inboundAddress: nil, memo: nil, executionNetwork: nil) } } diff --git a/DashWallet/Sources/UI/Swap/Convert/SwapConvertViewModel.swift b/DashWallet/Sources/UI/Swap/Convert/SwapConvertViewModel.swift index 621d1ac6d..c54e56dcc 100644 --- a/DashWallet/Sources/UI/Swap/Convert/SwapConvertViewModel.swift +++ b/DashWallet/Sources/UI/Swap/Convert/SwapConvertViewModel.swift @@ -299,6 +299,10 @@ final class SwapConvertViewModel: ObservableObject { } private func applyQuoteError(_ apiError: String) { + // Same reason as `OrderPreviewViewModel.setFailure`: the branches below rewrite the raw + // error into user-facing copy, so record it first or the amount screen's failures leave + // no trace in an exported log. + DWLogger.log("Swap: quote error on the amount screen for \(coin.code) β€” raw: \(apiError)") latestQuote = nil receiveAmount = nil if apiError.contains("not enough asset to pay for fees") { diff --git a/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewViewModel.swift b/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewViewModel.swift index 4a99f48d8..f69d77806 100644 --- a/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewViewModel.swift +++ b/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewViewModel.swift @@ -516,6 +516,11 @@ final class OrderPreviewViewModel: ObservableObject { // MARK: - Private: State Mutation private func setFailure(_ message: String) { + // Log before mapping: `userFacingErrorMessage` collapses anything unrecognised into a + // generic "something went wrong", so this is the last point at which the real reason + // still exists. Without it a "Conversion failed" screenshot has no counterpart in the + // exported logs and cannot be diagnosed. + DWLogger.log("Swap: conversion failed for \(coin.code) β€” raw: \(message)") // Keep Maya failures on the status sheet path so SwiftUI does not try to // present a native alert and a bottom sheet for the same event. swapStatus = .failed(reason: userFacingErrorMessage(for: message)) @@ -527,6 +532,9 @@ final class OrderPreviewViewModel: ObservableObject { private func setSubmittedSwap(txidWire: Data, depositAddress: String) { let txidHex = Transaction.displayHex(txidWire) + // The success side needs a trace too: without it a log export can't distinguish + // "the deposit never went out" from "it went out and the swap failed later". + DWLogger.log("Swap: deposit broadcast for \(coin.code) β€” txid=\(txidHex) deposit=\(depositAddress)") submittedTxidWire = txidWire submittedTxId = txidHex lastDepositAddress = depositAddress