diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index a7c548dd9..2335ba562 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -1675,6 +1675,10 @@ AA00030F2CA0F58E00B1B405 /* QRCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00030D2CA0F58E00B1B405 /* QRCaptureView.swift */; }; AA0003112CA0F58E00B1B406 /* GenericQRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0003102CA0F58E00B1B406 /* GenericQRScannerView.swift */; }; AA0003122CA0F58E00B1B406 /* GenericQRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0003102CA0F58E00B1B406 /* GenericQRScannerView.swift */; }; + AA0004012DA0F58E00C1C501 /* AddressSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0004002DA0F58E00C1C501 /* AddressSourceView.swift */; }; + AA0004022DA0F58E00C1C501 /* AddressSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0004002DA0F58E00C1C501 /* AddressSourceView.swift */; }; + AA0004042DA0F58E00C1C502 /* MayaExchangeAddressProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0004032DA0F58E00C1C502 /* MayaExchangeAddressProvider.swift */; }; + AA0004052DA0F58E00C1C502 /* MayaExchangeAddressProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0004032DA0F58E00C1C502 /* MayaExchangeAddressProvider.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -3238,6 +3242,8 @@ AA00030A2CA0F58E00B1B404 /* GenericQRScannerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericQRScannerController.swift; sourceTree = ""; }; AA00030D2CA0F58E00B1B405 /* QRCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCaptureView.swift; sourceTree = ""; }; AA0003102CA0F58E00B1B406 /* GenericQRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericQRScannerView.swift; sourceTree = ""; }; + AA0004002DA0F58E00C1C501 /* AddressSourceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressSourceView.swift; sourceTree = ""; }; + AA0004032DA0F58E00C1C502 /* MayaExchangeAddressProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MayaExchangeAddressProvider.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -4108,6 +4114,7 @@ AA00030A2CA0F58E00B1B404 /* GenericQRScannerController.swift */, AA00030D2CA0F58E00B1B405 /* QRCaptureView.swift */, AA0003102CA0F58E00B1B406 /* GenericQRScannerView.swift */, + AA0004002DA0F58E00C1C501 /* AddressSourceView.swift */, ); path = Maya; sourceTree = ""; @@ -4119,6 +4126,7 @@ AA0002042BA0F58E00A1B302 /* MayaPool.swift */, AA0002072BA0F58E00A1B303 /* MayaEndpoint.swift */, AA00020A2BA0F58E00A1B304 /* MayaAPIService.swift */, + AA0004032DA0F58E00C1C502 /* MayaExchangeAddressProvider.swift */, ); path = Maya; sourceTree = ""; @@ -9276,6 +9284,8 @@ AA00030B2CA0F58E00B1B404 /* GenericQRScannerController.swift in Sources */, AA00030E2CA0F58E00B1B405 /* QRCaptureView.swift in Sources */, AA0003112CA0F58E00B1B406 /* GenericQRScannerView.swift in Sources */, + AA0004012DA0F58E00C1C501 /* AddressSourceView.swift in Sources */, + AA0004042DA0F58E00C1C502 /* MayaExchangeAddressProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -10179,6 +10189,8 @@ AA00030C2CA0F58E00B1B404 /* GenericQRScannerController.swift in Sources */, AA00030F2CA0F58E00B1B405 /* QRCaptureView.swift in Sources */, AA0003122CA0F58E00B1B406 /* GenericQRScannerView.swift in Sources */, + AA0004022DA0F58E00C1C501 /* AddressSourceView.swift in Sources */, + AA0004052DA0F58E00C1C502 /* MayaExchangeAddressProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DashWallet/Resources/AppAssets.xcassets/Maya/maya.coinbase.logo.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/Maya/maya.coinbase.logo.imageset/Contents.json new file mode 100644 index 000000000..e6943c12d --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/Maya/maya.coinbase.logo.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "coinbase-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} \ No newline at end of file diff --git a/DashWallet/Resources/AppAssets.xcassets/Maya/maya.coinbase.logo.imageset/coinbase-logo.svg b/DashWallet/Resources/AppAssets.xcassets/Maya/maya.coinbase.logo.imageset/coinbase-logo.svg new file mode 100644 index 000000000..9a97a171c --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/Maya/maya.coinbase.logo.imageset/coinbase-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/Maya/maya.uphold.logo.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/Maya/maya.uphold.logo.imageset/Contents.json new file mode 100644 index 000000000..37ccf6084 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/Maya/maya.uphold.logo.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "uphold-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} \ No newline at end of file diff --git a/DashWallet/Resources/AppAssets.xcassets/Maya/maya.uphold.logo.imageset/uphold-logo.svg b/DashWallet/Resources/AppAssets.xcassets/Maya/maya.uphold.logo.imageset/uphold-logo.svg new file mode 100644 index 000000000..a7c25342a --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/Maya/maya.uphold.logo.imageset/uphold-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/DashWallet/Sources/Models/Coinbase/Accounts/AccountRepository.swift b/DashWallet/Sources/Models/Coinbase/Accounts/AccountRepository.swift index f24592b54..ee4423e1f 100644 --- a/DashWallet/Sources/Models/Coinbase/Accounts/AccountRepository.swift +++ b/DashWallet/Sources/Models/Coinbase/Accounts/AccountRepository.swift @@ -85,4 +85,27 @@ class AccountRepository { return items } + + /// Fetch all crypto accounts regardless of balance. + /// Used by Maya to find accounts for currencies with zero balance. + func allIncludingEmpty() async throws -> [CBAccount] { + var items: [CBAccount] = [] + items.reserveCapacity(300) + + var endpoint: CoinbaseEndpoint? = .accounts + while endpoint != nil { + let response: BasePaginationResponse = try await CoinbaseAPI.shared.request(endpoint!) + items += response.data + .filter { $0.currency.type == .crypto } + .map { .init(info: $0, authInterop: authInterop) } + + if let nextUri = response.pagination.nextURI, !nextUri.isEmpty { + endpoint = .path(nextUri) + } else { + endpoint = nil + } + } + + return items + } } diff --git a/DashWallet/Sources/Models/Coinbase/Accounts/AccountService.swift b/DashWallet/Sources/Models/Coinbase/Accounts/AccountService.swift index c27361c9a..a32c25a84 100644 --- a/DashWallet/Sources/Models/Coinbase/Accounts/AccountService.swift +++ b/DashWallet/Sources/Models/Coinbase/Accounts/AccountService.swift @@ -46,6 +46,12 @@ class AccountService { try await accountRepository.all() } + /// Returns all crypto accounts regardless of balance. + /// Used by Maya to find accounts for currencies with zero balance. + public func allAccountsIncludingEmpty() async throws -> [CBAccount] { + try await accountRepository.allIncludingEmpty() + } + public func retrieveAddress(for accountName: String) async throws -> String { let account = try await account(by: accountName) return try await account.retrieveAddress() diff --git a/DashWallet/Sources/Models/Coinbase/Auth/CBAuth.swift b/DashWallet/Sources/Models/Coinbase/Auth/CBAuth.swift index 4562a17f1..8387368a1 100644 --- a/DashWallet/Sources/Models/Coinbase/Auth/CBAuth.swift +++ b/DashWallet/Sources/Models/Coinbase/Auth/CBAuth.swift @@ -138,24 +138,17 @@ extension CBAuth { extension CBAuth { nonisolated private var oAuth2URL: URL { - let path = CoinbaseEndpoint.signIn.path - var queryItems = [ - URLQueryItem(name: "redirect_uri", value: Coinbase.redirectUri), URLQueryItem(name: "response_type", value: Coinbase.responseType), + URLQueryItem(name: "client_id", value: Coinbase.clientID), + URLQueryItem(name: "redirect_uri", value: Coinbase.redirectUri), URLQueryItem(name: "scope", value: Coinbase.scope), - URLQueryItem(name: "meta[send_limit_amount]", value: "\((Coinbase.sendLimitAmount as NSDecimalNumber).intValue)"), - URLQueryItem(name: "meta[send_limit_currency]", value: Coinbase.sendLimitCurrency), - URLQueryItem(name: "meta[send_limit_period]", value: Coinbase.sendLimitPeriod), - URLQueryItem(name: "account", value: Coinbase.account), ] - queryItems.append(URLQueryItem(name: "client_id", value: Coinbase.clientID)) - var urlComponents = URLComponents() urlComponents.scheme = "https" - urlComponents.host = "coinbase.com" - urlComponents.path = path + urlComponents.host = "login.coinbase.com" + urlComponents.path = "/oauth2/auth" urlComponents.queryItems = queryItems guard let url = urlComponents.url else { diff --git a/DashWallet/Sources/Models/Coinbase/Coinbase+Constants.swift b/DashWallet/Sources/Models/Coinbase/Coinbase+Constants.swift index 01e870182..d7843e9a8 100644 --- a/DashWallet/Sources/Models/Coinbase/Coinbase+Constants.swift +++ b/DashWallet/Sources/Models/Coinbase/Coinbase+Constants.swift @@ -19,8 +19,8 @@ import Foundation extension Coinbase { // MARK: API - static let callbackURLScheme = "authhub" - static let redirectUri = "authhub://oauth-callback" + static let callbackURLScheme = "dashwallet" + static let redirectUri = "dashwallet://brokers/coinbase/connect" static let grantType = "authorization_code" static let responseType = "code" static let scope = diff --git a/DashWallet/Sources/Models/Coinbase/Coinbase.swift b/DashWallet/Sources/Models/Coinbase/Coinbase.swift index 251742a96..f322d9375 100644 --- a/DashWallet/Sources/Models/Coinbase/Coinbase.swift +++ b/DashWallet/Sources/Models/Coinbase/Coinbase.swift @@ -261,6 +261,19 @@ extension Coinbase { try await accountService.allAccounts() } + /// Returns all crypto accounts regardless of balance. + /// Used by Maya to find accounts for currencies with zero balance. + public func accountsIncludingEmpty() async throws -> [CBAccount] { + try await accountService.allAccountsIncludingEmpty() + } + + /// Fetches a specific account by currency code (e.g., "BTC", "ETH"). + /// Uses direct `GET /v2/accounts/{currencyCode}` lookup which is more reliable + /// than listing all accounts when you know the currency you need. + public func account(byCurrencyCode currencyCode: String) async throws -> CBAccount { + try await accountService.account(by: currencyCode) + } + public func addUserDidChangeListener(_ listener: @escaping UserDidChangeListenerBlock) -> UserDidChangeListenerHandle { auth.addUserDidChangeListener(listener) } diff --git a/DashWallet/Sources/Models/Coinbase/Infrastructure/API/CoinbaseAPIEndpoint.swift b/DashWallet/Sources/Models/Coinbase/Infrastructure/API/CoinbaseAPIEndpoint.swift index 2dd6ccbb7..bdea1dc44 100644 --- a/DashWallet/Sources/Models/Coinbase/Infrastructure/API/CoinbaseAPIEndpoint.swift +++ b/DashWallet/Sources/Models/Coinbase/Infrastructure/API/CoinbaseAPIEndpoint.swift @@ -164,13 +164,15 @@ extension CoinbaseEndpoint: TargetType, AccessTokenAuthorizable { } public var baseURL: URL { - guard case .path(let string) = self else { + switch self { + case .getToken, .refreshToken, .revokeToken: + return URL(string: "https://login.coinbase.com")! + case .path(let string): + let path = string.removingPercentEncoding ?? string + return URL(string: "https://api.coinbase.com" + path)! + default: return kBaseURL } - - let path = string.removingPercentEncoding ?? string - let url = URL(string: "https://api.coinbase.com" + path)! - return url } public var path: String { @@ -188,9 +190,9 @@ extension CoinbaseEndpoint: TargetType, AccessTokenAuthorizable { case .swapTradeCommit(let tradeId): return "/v2/trades/\(tradeId)/commit" case .accountAddress(let accountId): return "/v2/accounts/\(accountId)/addresses" case .createCoinbaseAccountAddress(let accountId): return "/v2/accounts/\(accountId)/addresses" - case .getToken, .refreshToken: return "/oauth/token" - case .revokeToken: return "/oauth/revoke" - case .signIn: return "/oauth/authorize" + case .getToken, .refreshToken: return "/oauth2/token" + case .revokeToken: return "/oauth2/revoke" + case .signIn: return "/oauth2/auth" default: return "" } @@ -212,13 +214,12 @@ extension CoinbaseEndpoint: TargetType, AccessTokenAuthorizable { "redirect_uri": Coinbase.redirectUri, "code": code, "grant_type": Coinbase.grantType, - "account": Coinbase.account, ] queryItems["client_id"] = Coinbase.clientID queryItems["client_secret"] = Coinbase.clientSecret - - return .requestParameters(parameters: queryItems, encoding: JSONEncoding.default) + + return .requestParameters(parameters: queryItems, encoding: URLEncoding.httpBody) case .refreshToken(let refreshToken): var queryItems: [String: Any] = [ "refresh_token": refreshToken, @@ -227,10 +228,10 @@ extension CoinbaseEndpoint: TargetType, AccessTokenAuthorizable { queryItems["client_id"] = Coinbase.clientID queryItems["client_secret"] = Coinbase.clientSecret - - return .requestParameters(parameters: queryItems, encoding: JSONEncoding.default) + + return .requestParameters(parameters: queryItems, encoding: URLEncoding.httpBody) case .revokeToken(let token): - return .requestParameters(parameters: ["token": token], encoding: JSONEncoding.default) + return .requestParameters(parameters: ["token": token], encoding: URLEncoding.httpBody) case .sendCoinsToWallet(_, _, let dto): return .requestJSONEncodable(dto) case .swapTrade(let dto): diff --git a/DashWallet/Sources/Models/Maya/MayaExchangeAddressProvider.swift b/DashWallet/Sources/Models/Maya/MayaExchangeAddressProvider.swift new file mode 100644 index 000000000..622700510 --- /dev/null +++ b/DashWallet/Sources/Models/Maya/MayaExchangeAddressProvider.swift @@ -0,0 +1,357 @@ +// +// MayaExchangeAddressProvider.swift +// DashWallet +// +// Copyright © 2024 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Provides deposit addresses from Uphold and Coinbase for a given cryptocurrency. +/// Used by the Maya swap flow to let users select a destination address from their exchange accounts. +@MainActor +class MayaExchangeAddressProvider { + + // MARK: - Uphold + + /// Whether the user is currently logged in to Uphold. + var isUpholdAuthorized: Bool { + DWUpholdClient.sharedInstance().isAuthorized + } + + /// In-memory session cache for Uphold addresses, keyed by uppercase currency code. + /// Cleared automatically on app restart. + private static var upholdAddressCache: [String: String] = [:] + + /// Clears the Uphold address cache. Call this on app launch. + static func clearUpholdCache() { + upholdAddressCache.removeAll() + } + + /// Returns the cached Uphold address for the currency, or fetches from API if none is cached. + /// + /// - Parameter currencyCode: The uppercase currency code (e.g., "BTC", "ETH"). + /// - Returns: The deposit address string, or `nil` if the currency is not available. + func fetchUpholdAddress(for currencyCode: String) async -> String? { + DSLogger.log("Maya Uphold: fetchUpholdAddress for \(currencyCode), authorized=\(isUpholdAuthorized)") + guard isUpholdAuthorized else { return nil } + + let key = currencyCode.uppercased() + + // Return cached address if available + if let cached = Self.upholdAddressCache[key] { + DSLogger.log("Maya Uphold: Returning cached address for \(key)") + return cached + } + + DSLogger.log("Maya Uphold: No cache for \(key), fetching from API") + // No cached address — fetch from API and cache + return await fetchAndCacheUpholdAddress(for: currencyCode) + } + + /// Fetches address from Uphold API and caches it for the session. + /// If no card exists for the currency, creates one and generates an address. + /// Called on login and when no cached address exists. + /// + /// - Parameter currencyCode: The uppercase currency code (e.g., "BTC", "ETH"). + /// - Returns: The deposit address string, or `nil` if the currency is not available. + func fetchAndCacheUpholdAddress(for currencyCode: String) async -> String? { + guard isUpholdAuthorized else { return nil } + + let key = currencyCode.uppercased() + let network = upholdNetwork(for: key) + + // Step 1: Try to find an existing card + let cards = await fetchUpholdCards() + let availableCurrencies = cards.map { $0.currency.uppercased() } + DSLogger.log("Maya Uphold: Got \(cards.count) cards: \(availableCurrencies), looking for \(key)") + + if let matchingCard = cards.first(where: { $0.currency.uppercased() == key }) { + // Look for the real crypto address by network key (e.g., "bitcoin", "ethereum"). + // The card's address dictionary can also contain internal Uphold identifiers + // (e.g., "UH1D8C10A5") under non-network keys — skip those. + if let networkAddress = matchingCard.address?[network], !networkAddress.isEmpty { + DSLogger.log("Maya Uphold: Found network address for \(key) on network '\(network)': \(networkAddress)") + Self.upholdAddressCache[key] = networkAddress + return networkAddress + } + + // No address for this network — create one via POST + DSLogger.log("Maya Uphold: No '\(network)' address on card \(matchingCard.id), creating one") + if let address = await createUpholdAddress(cardId: matchingCard.id, network: network) { + Self.upholdAddressCache[key] = address + return address + } + return nil + } + + // Step 2: No card exists — create card then address + DSLogger.log("Maya Uphold: No card for \(key), creating card and address") + if let cardId = await createUpholdCard(currency: key) { + if let address = await createUpholdAddress(cardId: cardId, network: network) { + Self.upholdAddressCache[key] = address + return address + } + } + + return nil + } + + /// Maps a currency code to the Uphold network name for address creation. + private func upholdNetwork(for currencyCode: String) -> String { + switch currencyCode.uppercased() { + case "BTC": return "bitcoin" + case "ETH", "USDC", "USDT", "PEPE", "WSTETH": return "ethereum" + case "DASH": return "dash" + case "XRP": return "xrp-ledger" + case "BCH": return "bitcoin-cash" + case "BTG": return "bitcoin-gold" + default: return currencyCode.lowercased() + } + } + + // MARK: - Uphold API Helpers + + /// Fetches all cards from the Uphold API, including non-Dash crypto cards. + private func fetchUpholdCards() async -> [UpholdCard] { + guard let token = getUpholdAccessToken() else { + DSLogger.log("Maya Uphold: No access token available for fetching cards") + return [] + } + + guard let url = URL(string: DWUpholdConstants.baseURLString())?.appendingPathComponent("v0/me/cards") else { + return [] + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + + guard (200...299).contains(statusCode) else { + let responseBody = String(data: data, encoding: .utf8) ?? "" + DSLogger.log("Maya Uphold: Fetch cards failed (HTTP \(statusCode)): \(responseBody)") + return [] + } + + return try JSONDecoder().decode([UpholdCard].self, from: data) + } catch { + DSLogger.log("Maya Uphold: Failed to fetch/decode cards: \(error)") + return [] + } + } + + /// Creates a new Uphold card for the given currency. + /// - Returns: The card ID if successful, or `nil`. + private func createUpholdCard(currency: String) async -> String? { + guard let token = getUpholdAccessToken() else { return nil } + + guard let url = URL(string: DWUpholdConstants.baseURLString())?.appendingPathComponent("v0/me/cards") else { + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: String] = ["label": "\(currency) Card", "currency": currency] + request.httpBody = try? JSONEncoder().encode(body) + + do { + let (data, _) = try await URLSession.shared.data(for: request) + let card = try JSONDecoder().decode(UpholdCard.self, from: data) + DSLogger.log("Maya Uphold: Created card for \(currency): \(card.id)") + return card.id + } catch { + DSLogger.log("Maya Uphold: Failed to create card for \(currency): \(error)") + return nil + } + } + + /// Creates a new address on an existing Uphold card. + /// - Returns: The address string if successful, or `nil`. + private func createUpholdAddress(cardId: String, network: String) async -> String? { + guard let token = getUpholdAccessToken() else { + DSLogger.log("Maya Uphold: No access token available for address creation") + return nil + } + + guard let url = URL(string: DWUpholdConstants.baseURLString())?.appendingPathComponent("v0/me/cards/\(cardId)/addresses") else { + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: String] = ["network": network] + request.httpBody = try? JSONEncoder().encode(body) + + do { + let (data, response) = try await URLSession.shared.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let address = json["id"] as? String { + DSLogger.log("Maya Uphold: Created address on card \(cardId) (HTTP \(statusCode)): \(address)") + return address + } + + // Log the full response for debugging + let responseBody = String(data: data, encoding: .utf8) ?? "" + DSLogger.log("Maya Uphold: Address creation failed (HTTP \(statusCode)). Response: \(responseBody)") + return nil + } catch { + DSLogger.log("Maya Uphold: Failed to create address on card \(cardId): \(error)") + return nil + } + } + + private func getUpholdAccessToken() -> String? { + getKeychainString("DW_UPHOLD_ACCESS_TOKEN", nil) + } + + // MARK: - Coinbase + + /// Whether the user is currently logged in to Coinbase. + var isCoinbaseAuthorized: Bool { + Coinbase.shared.isAuthorized + } + + /// In-memory session cache for Coinbase addresses, keyed by uppercase currency code. + /// Cleared automatically on app restart. Call `clearCoinbaseCache()` on app launch for safety. + private static var coinbaseAddressCache: [String: String] = [:] + + /// Clears the Coinbase address cache. + static func clearCoinbaseCache() { + coinbaseAddressCache.removeAll() + } + + /// Clears all exchange address caches (Uphold and Coinbase). + static func clearAllCaches() { + clearUpholdCache() + clearCoinbaseCache() + } + + /// Returns the cached Coinbase address for the currency, or creates a new one if none is cached. + /// + /// - Parameter currencyCode: The uppercase currency code (e.g., "BTC", "ETH"). + /// - Returns: The deposit address string, or `nil` if the currency is not available. + func fetchCoinbaseAddress(for currencyCode: String) async -> String? { + DSLogger.log("Maya Coinbase: fetchCoinbaseAddress for \(currencyCode), authorized=\(isCoinbaseAuthorized)") + guard isCoinbaseAuthorized else { return nil } + + let key = currencyCode.uppercased() + + // Return cached address if available + if let cached = Self.coinbaseAddressCache[key] { + DSLogger.log("Maya Coinbase: Returning cached address for \(key)") + return cached + } + + DSLogger.log("Maya Coinbase: No cache for \(key), creating new address") + // No cached address — create a new one + return await createAndCacheCoinbaseAddress(for: currencyCode) + } + + /// Creates a new Coinbase address (POST) and caches it for the session. + /// Called on login and when no cached address exists. + /// + /// Uses direct account lookup by currency code first (`GET /v2/accounts/{code}`), + /// which is more reliable than listing all accounts. Falls back to listing if direct lookup fails. + /// + /// - Parameter currencyCode: The uppercase currency code (e.g., "BTC", "ETH"). + /// - Returns: The newly created deposit address string, or `nil` if the currency is not available. + func createAndCacheCoinbaseAddress(for currencyCode: String) async -> String? { + guard isCoinbaseAuthorized else { return nil } + + let key = currencyCode.uppercased() + + // Step 1: Try direct account lookup by currency code (most reliable) + if let address = await fetchCoinbaseAddressViaDirectLookup(currencyCode: key) { + Self.coinbaseAddressCache[key] = address + return address + } + + // Step 2: Fallback — list all accounts and search (handles edge cases where + // the currency code doesn't work as a direct account identifier) + if let address = await fetchCoinbaseAddressViaAccountList(currencyCode: key) { + Self.coinbaseAddressCache[key] = address + return address + } + + DSLogger.log("Maya Coinbase: Currency \(key) not available on Coinbase (tried direct lookup and account list)") + return nil + } + + /// Fetches the account directly via `GET /v2/accounts/{currencyCode}` and creates an address. + private func fetchCoinbaseAddressViaDirectLookup(currencyCode: String) async -> String? { + do { + DSLogger.log("Maya Coinbase: Trying direct account lookup for \(currencyCode)") + let account = try await Coinbase.shared.account(byCurrencyCode: currencyCode) + DSLogger.log("Maya Coinbase: Direct lookup found account for \(currencyCode): \(account.info.currency.code)") + let address = try await account.retrieveAddress() + DSLogger.log("Maya Coinbase: Created address for \(currencyCode) via direct lookup: \(address)") + return address + } catch { + DSLogger.log("Maya Coinbase: Direct lookup failed for \(currencyCode): \(error)") + return nil + } + } + + /// Lists all accounts and searches for the currency, then creates an address. + private func fetchCoinbaseAddressViaAccountList(currencyCode: String) async -> String? { + do { + DSLogger.log("Maya Coinbase: Falling back to account list for \(currencyCode)") + let accounts = try await Coinbase.shared.accountsIncludingEmpty() + let availableCurrencies = accounts.map { $0.info.currency.code.uppercased() } + DSLogger.log("Maya Coinbase: Got \(accounts.count) accounts, looking for \(currencyCode)") + + guard let account = accounts.first(where: { $0.info.currency.code.uppercased() == currencyCode }) else { + DSLogger.log("Maya Coinbase: \(currencyCode) not found in \(accounts.count) accounts") + return nil + } + + let address = try await account.retrieveAddress() + DSLogger.log("Maya Coinbase: Created address for \(currencyCode) via account list: \(address)") + return address + } catch { + DSLogger.log("Maya Coinbase: Account list fallback failed for \(currencyCode): \(error)") + return nil + } + } +} + +// MARK: - Uphold Card Model (lightweight, for Maya only) + +/// Lightweight Codable model for Uphold card responses. +/// Unlike `DWUpholdCardObject`, this preserves all currencies and address networks. +struct UpholdCard: Decodable { + let id: String + let currency: String + let label: String? + let available: String? + let address: [String: String]? + + /// Returns the first address from the address dictionary, regardless of network key. + var firstAddress: String? { + address?.values.first + } +} diff --git a/DashWallet/Sources/UI/Maya/AddressSourceView.swift b/DashWallet/Sources/UI/Maya/AddressSourceView.swift new file mode 100644 index 000000000..56c0b433e --- /dev/null +++ b/DashWallet/Sources/UI/Maya/AddressSourceView.swift @@ -0,0 +1,160 @@ +// +// AddressSourceView.swift +// DashWallet +// +// Copyright © 2024 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +// MARK: - AddressSourceType + +enum AddressSourceType: Identifiable { + case uphold + case coinbase + + var id: String { + switch self { + case .uphold: return "uphold" + case .coinbase: return "coinbase" + } + } + + var title: String { + switch self { + case .uphold: return "Uphold" + case .coinbase: return "Coinbase" + } + } +} + +// MARK: - AddressSourceState + +enum AddressSourceState { + case loggedOut + case loading + case available(String) + case notAvailable +} + +// MARK: - AddressSourceView + +/// A row in the "Paste address from" menu showing an exchange service or clipboard +/// with its address or login action. +struct AddressSourceView: View { + let sourceType: AddressSourceType + let state: AddressSourceState + let onTap: () -> Void + + private var isLoggedOut: Bool { + if case .loggedOut = state { return true } + return false + } + + private var isLoading: Bool { + if case .loading = state { return true } + return false + } + + var body: some View { + Button(action: onTap, label: { + HStack(spacing: 16) { + icon + .frame(width: 26, height: 26) + + VStack(alignment: .leading, spacing: 1) { + HStack { + Text(sourceType.title) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primaryText) + + Spacer() + + if isLoggedOut { + Text(NSLocalizedString("Log In", comment: "Maya")) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.dashBlue) + } + } + + subtitle + } + + Spacer() + + if isLoading { + loadingIndicator + } + } + .padding(.horizontal, 10) + .padding(.vertical, 12) + .contentShape(Rectangle()) + }) + .buttonStyle(.plain) + .disabled(isDisabled) + } + + // MARK: - Subviews + + private var loadingIndicator: some View { + SwiftUI.ProgressView() + } + + @ViewBuilder + private var icon: some View { + switch sourceType { + case .uphold: + Image("maya.uphold.logo") + .resizable() + .aspectRatio(contentMode: .fit) + case .coinbase: + Image("maya.coinbase.logo") + .resizable() + .aspectRatio(contentMode: .fit) + } + } + + @ViewBuilder + private var subtitle: some View { + switch state { + case .available(let address): + Text(address) + .font(.system(size: 14)) + .foregroundColor(.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + case .notAvailable: + Text(NSLocalizedString("Not available", comment: "Maya")) + .font(.system(size: 14)) + .foregroundColor(.tertiaryText) + case .loading: + Text(NSLocalizedString("Loading...", comment: "Maya")) + .font(.system(size: 14)) + .foregroundColor(.tertiaryText) + case .loggedOut: + EmptyView() + } + } + + private var isDisabled: Bool { + switch state { + case .notAvailable, .loading: + return true + default: + return false + } + } +} + diff --git a/DashWallet/Sources/UI/Maya/EnterAddressHostingController.swift b/DashWallet/Sources/UI/Maya/EnterAddressHostingController.swift index 59a409cba..78f8babd1 100644 --- a/DashWallet/Sources/UI/Maya/EnterAddressHostingController.swift +++ b/DashWallet/Sources/UI/Maya/EnterAddressHostingController.swift @@ -17,6 +17,7 @@ // limitations under the License. // +import AuthenticationServices import SwiftUI import UIKit @@ -26,6 +27,7 @@ class EnterAddressHostingController: UIViewController { private let coin: MayaCryptoCurrency private let viewModel: EnterAddressViewModel + private var authSession: ASWebAuthenticationSession? init(coin: MayaCryptoCurrency) { self.coin = coin @@ -55,6 +57,12 @@ class EnterAddressHostingController: UIViewController { DSLogger.log("Maya: Address confirmed for \(self.coin.code): \(address)") #endif self.onAddressConfirmed?(self.coin, address) + }, + onLoginUphold: { [weak self] in + self?.presentUpholdLogin() + }, + onLoginCoinbase: { [weak self] in + self?.presentCoinbaseLogin() } ) @@ -93,4 +101,49 @@ class EnterAddressHostingController: UIViewController { present(scanner, animated: true) } + + // MARK: - Uphold Login + + private func presentUpholdLogin() { + let url = DWUpholdClient.sharedInstance().startAuthRoutineByURL() + let callbackURLScheme = "dashwallet" + + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURLScheme) { [weak self] callbackURL, error in + guard let self, let callbackURL else { return } + + guard callbackURL.absoluteString.contains("uphold") else { return } + + DWUpholdClient.sharedInstance().completeAuthRoutine(with: callbackURL) { [weak self] success in + guard success else { return } + DispatchQueue.main.async { + self?.viewModel.onUpholdLoginCompleted() + } + } + } + + session.presentationContextProvider = self + session.start() + self.authSession = session + } + + // MARK: - Coinbase Login + + private func presentCoinbaseLogin() { + Task { + do { + try await Coinbase.shared.signIn(with: self) + viewModel.onCoinbaseLoginCompleted() + } catch { + DSLogger.log("Maya: Coinbase login failed: \(error)") + } + } + } +} + +// MARK: - ASWebAuthenticationPresentationContextProviding + +extension EnterAddressHostingController: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + view.window ?? ASPresentationAnchor() + } } diff --git a/DashWallet/Sources/UI/Maya/EnterAddressView.swift b/DashWallet/Sources/UI/Maya/EnterAddressView.swift index b7532d486..f14f8f29f 100644 --- a/DashWallet/Sources/UI/Maya/EnterAddressView.swift +++ b/DashWallet/Sources/UI/Maya/EnterAddressView.swift @@ -23,10 +23,14 @@ struct EnterAddressView: View { @ObservedObject var viewModel: EnterAddressViewModel var onScanQR: (() -> Void)? var onContinue: ((String) -> Void)? + var onLoginUphold: (() -> Void)? + var onLoginCoinbase: (() -> Void)? var body: some View { ZStack { - Color.primaryBackground.ignoresSafeArea() + Color.primaryBackground + .ignoresSafeArea() + .onTapGesture { dismissKeyboard() } VStack(spacing: 0) { ScrollView { @@ -41,6 +45,8 @@ struct EnterAddressView: View { .padding(.top, -12) } + addressSourcesMenu + if viewModel.hasClipboardContent { if viewModel.isClipboardRevealed { clipboardSection @@ -51,9 +57,6 @@ struct EnterAddressView: View { } .padding(.horizontal, 20) } - .onTapGesture { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - } continueButton .padding(.horizontal, 20) @@ -61,10 +64,14 @@ struct EnterAddressView: View { } } .onAppear { - viewModel.checkClipboard() + viewModel.loadAddressSources() } } + private func dismissKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + // MARK: - Address Field private var addressField: some View { @@ -92,7 +99,47 @@ struct EnterAddressView: View { .cornerRadius(16) } - // MARK: - Show Clipboard Button + // MARK: - Address Sources Menu + + private var addressSourcesMenu: some View { + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("Paste address from", comment: "Maya")) + .font(.system(size: 13)) + .foregroundColor(.secondaryText) + .padding(.horizontal, 10) + .padding(.vertical, 10) + + AddressSourceView( + sourceType: .uphold, + state: viewModel.upholdState, + onTap: { + if case .loggedOut = viewModel.upholdState { + onLoginUphold?() + } else { + viewModel.selectUpholdAddress() + } + } + ) + + AddressSourceView( + sourceType: .coinbase, + state: viewModel.coinbaseState, + onTap: { + if case .loggedOut = viewModel.coinbaseState { + onLoginCoinbase?() + } else { + viewModel.selectCoinbaseAddress() + } + } + ) + } + .padding(6) + .background(Color.secondaryBackground) + .cornerRadius(12) + .shadow(color: .shadow, radius: 10, x: 0, y: 5) + } + + // MARK: - Clipboard private var showClipboardButton: some View { Button(action: { viewModel.revealClipboard() }) { @@ -114,8 +161,6 @@ struct EnterAddressView: View { .buttonStyle(.plain) } - // MARK: - Clipboard Section (Revealed) - private var clipboardSection: some View { VStack(alignment: .leading, spacing: 10) { Text(NSLocalizedString("Tap the address from the clipboard to paste it", comment: "Maya")) @@ -155,15 +200,15 @@ struct EnterAddressView: View { Button(action: { let address = viewModel.addressText.trimmingCharacters(in: .whitespacesAndNewlines) onContinue?(address) - }) { + }, label: { Text(NSLocalizedString("Continue", comment: "")) .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.white) + .foregroundColor(viewModel.isAddressValid ? .white : Color(UIColor.label.withAlphaComponent(0.4))) .frame(maxWidth: .infinity) .padding(.vertical, 14) - .background(viewModel.isAddressValid ? Color.dashBlue : Color.gray400) + .background(viewModel.isAddressValid ? Color.dashBlue : Color(UIColor.systemFill)) .cornerRadius(12) - } + }) .disabled(!viewModel.isAddressValid) } } diff --git a/DashWallet/Sources/UI/Maya/EnterAddressViewModel.swift b/DashWallet/Sources/UI/Maya/EnterAddressViewModel.swift index 150fe1802..d07bc1c2e 100644 --- a/DashWallet/Sources/UI/Maya/EnterAddressViewModel.swift +++ b/DashWallet/Sources/UI/Maya/EnterAddressViewModel.swift @@ -27,9 +27,25 @@ class EnterAddressViewModel: ObservableObject { @Published var addressText: String = "" @Published var errorMessage: String? + + // MARK: - Address Sources + + @Published var upholdState: AddressSourceState = .loggedOut + @Published var coinbaseState: AddressSourceState = .loggedOut + + // MARK: - Clipboard (two-step: detect → reveal → paste) + @Published var hasClipboardContent: Bool = false @Published var revealedClipboardContent: String? + var isClipboardRevealed: Bool { + revealedClipboardContent != nil + } + + private let addressProvider = MayaExchangeAddressProvider() + private var upholdAddress: String? + private var coinbaseAddress: String? + var placeholderText: String { String(format: NSLocalizedString("%@ address", comment: "Maya"), coin.code) } @@ -38,22 +54,98 @@ class EnterAddressViewModel: ObservableObject { !addressText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - var isClipboardRevealed: Bool { - revealedClipboardContent != nil + /// The currency code to look up on exchanges. + /// Uses the coin code directly — exchanges manage their own currency listings. + /// If the exchange doesn't support the currency, the UI shows "Not available". + private var exchangeCurrencyCode: String { + coin.code } init(coin: MayaCryptoCurrency) { self.coin = coin } + // MARK: - Load Address Sources + + func loadAddressSources() { + loadUpholdState() + loadCoinbaseState() + checkClipboard() + } + + private func loadUpholdState() { + guard addressProvider.isUpholdAuthorized else { + upholdState = .loggedOut + return + } + + upholdState = .loading + + Task { + let address = await addressProvider.fetchUpholdAddress(for: exchangeCurrencyCode) + if let address = address { + upholdAddress = address + upholdState = .available(address) + } else { + // Re-check authorization: if the session was revoked during the fetch, + // show "Log In" instead of "Not available". + if !addressProvider.isUpholdAuthorized { + upholdState = .loggedOut + } else { + upholdState = .notAvailable + } + } + } + } + + private func loadCoinbaseState() { + guard addressProvider.isCoinbaseAuthorized else { + coinbaseState = .loggedOut + return + } + + coinbaseState = .loading + + Task { + // Uses cached address if available, otherwise creates a new one + let address = await addressProvider.fetchCoinbaseAddress(for: exchangeCurrencyCode) + if let address = address { + coinbaseAddress = address + coinbaseState = .available(address) + } else { + // Re-check authorization: if the session was revoked during the fetch + // (e.g., expired token after device restart), show "Log In" instead of + // "Not available" so the user can re-authenticate. + if !addressProvider.isCoinbaseAuthorized { + coinbaseState = .loggedOut + } else { + coinbaseState = .notAvailable + } + } + } + } + + // MARK: - Address Selection + + func selectUpholdAddress() { + guard let address = upholdAddress else { return } + addressText = address + errorMessage = nil + } + + func selectCoinbaseAddress() { + guard let address = coinbaseAddress else { return } + addressText = address + errorMessage = nil + } + + // MARK: - Clipboard + func checkClipboard() { hasClipboardContent = UIPasteboard.general.hasStrings || UIPasteboard.general.hasURLs } func revealClipboard() { - // Read clipboard content (triggers iOS paste permission banner on first access), - // then set the published property so the view re-renders with content ready. - // Animate the transition to prevent flash during system banner dismissal. let content = UIPasteboard.general.url?.absoluteString ?? UIPasteboard.general.string withAnimation(.easeInOut(duration: 0.2)) { revealedClipboardContent = content @@ -66,6 +158,40 @@ class EnterAddressViewModel: ObservableObject { errorMessage = nil } + // MARK: - Post-Login Refresh + + func onUpholdLoginCompleted() { + upholdState = .loading + + Task { + // Login triggers a fresh API fetch, replacing any cached address + let address = await addressProvider.fetchAndCacheUpholdAddress(for: exchangeCurrencyCode) + if let address = address { + upholdAddress = address + upholdState = .available(address) + } else { + upholdState = .notAvailable + } + } + } + + func onCoinbaseLoginCompleted() { + coinbaseState = .loading + + Task { + // Login triggers a fresh address creation (POST), replacing any cached address + let address = await addressProvider.createAndCacheCoinbaseAddress(for: exchangeCurrencyCode) + if let address = address { + coinbaseAddress = address + coinbaseState = .available(address) + } else { + coinbaseState = .notAvailable + } + } + } + + // MARK: - QR / Manual + func setAddress(_ address: String) { addressText = extractAddressFromURI(address) errorMessage = nil