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/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/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..072a42309 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.", @@ -92,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/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..7bfae3340 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,37 @@ final class SwapKitSwapProvider: SwapProvider { return code } + /// 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] + } + + let key = toAsset.uppercased() + if mayaOnlyAssets.contains(key) { + return SwapKitConstants.mayaProviders + } + if bothAssets.contains(key) { + return [SwapKitConstants.providerNear] + SwapKitConstants.mayaProviders + } + + return [SwapKitConstants.providerNear] + } + // MARK: - Private: Amount Conversion private func baseUnitsToHuman(_ satoshis: Int64) -> String { @@ -959,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/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/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 { 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/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() 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 dd66aafc7..f69d77806 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,18 +499,28 @@ 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 ) } // 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)) @@ -498,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 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.