Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
}
12 changes: 12 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
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
85 changes: 57 additions & 28 deletions DashWallet/Sources/Models/SwapKit/SwapKitSwapProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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
)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
)

Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading