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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand Down
50 changes: 49 additions & 1 deletion DashWallet/Sources/Models/Swap/ExchangeAddressProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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<String> = [
"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 {
Expand All @@ -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")
Expand All @@ -300,6 +345,7 @@ class ExchangeAddressProvider {
guard (200...299).contains(statusCode) else {
let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8>"
DWLogger.log("Maya Uphold: Fetch cards failed (HTTP \(statusCode)): \(responseBody)")
invalidateSessionIfRejected(statusCode)
return []
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -377,6 +424,7 @@ class ExchangeAddressProvider {
// Log the full response for debugging
let responseBody = String(data: data, encoding: .utf8) ?? "<non-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)")
Expand Down
5 changes: 3 additions & 2 deletions DashWallet/Sources/Models/Swap/SwapExecutionData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
17 changes: 17 additions & 0 deletions DashWallet/Sources/Models/Swap/SwapKitErrorCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: ":")
Expand All @@ -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.",
Expand Down Expand Up @@ -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 ?? "<nil>")")
return NSLocalizedString(
"Something went wrong setting up your swap. Please try again.",
comment: "Dash DEX / dex_error_generic"
Expand Down
4 changes: 2 additions & 2 deletions DashWallet/Sources/Models/Swap/SwapTrackingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
12 changes: 10 additions & 2 deletions DashWallet/Sources/Models/SwapKit/SwapKitConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading