Skip to content
Draft
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
5 changes: 3 additions & 2 deletions Features/Swap/Sources/Scenes/SwapScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ extension SwapScene {
} footer: {
SwapChangeView(
fromId: $model.pairSelectorModel.fromAssetId,
toId: $model.pairSelectorModel.toAssetId
toId: $model.pairSelectorModel.toAssetId,
isLoading: model.isLoading
)
.padding(.top, .small)
.frame(maxWidth: .infinity)
Expand All @@ -110,7 +111,7 @@ extension SwapScene {
SwapTokenView(
model: model.swapTokenModel(type: .receive(chains: [], assetIds: [])),
text: $model.toValue,
showLoading: model.isLoading,
isLoading: model.isLoading,
disabledTextField: true,
onBalanceAction: {},
onSelectAssetAction: model.onSelectAssetReceive
Expand Down
1 change: 1 addition & 0 deletions Features/Swap/Sources/Types/SwapFetchTrigger.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c). Gem Wallet. All rights reserved.

import Components
import SwapService

struct SwapFetchTrigger: DebouncableTrigger {
let input: SwapQuoteInput
Expand Down
13 changes: 2 additions & 11 deletions Features/Swap/Sources/Types/SwapQuoteInput.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
// Copyright (c). Gem Wallet. All rights reserved.

import Foundation
import Primitives
import BigInt

public struct SwapQuoteInput: Hashable, Sendable {
public let fromAsset: Asset
public let toAsset: Asset
public let value: BigInt
public let useMaxAmount: Bool
}

// MARK: - Identifiable
import Primitives
import SwapService

extension SwapQuoteInput: Identifiable {
public var id: String {
Expand Down
61 changes: 49 additions & 12 deletions Features/Swap/Sources/ViewModels/SwapSceneViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import BalanceService
import PriceService
import enum Gemstone.SwapperError
import struct Gemstone.SwapperQuote
import struct Gemstone.SwapperProviderType
import Formatters
import Validators

Expand All @@ -30,6 +31,8 @@ public final class SwapSceneViewModel {
public var swapState: SwapState = .init()
public var isPresentingInfoSheet: SwapSheetType?

private(set) var isStreaming: Bool = false

public let fromAssetQuery: ObservableQuery<AssetRequestOptional>
public let toAssetQuery: ObservableQuery<AssetRequestOptional>

Expand Down Expand Up @@ -113,7 +116,7 @@ public final class SwapSceneViewModel {
}

var isLoading: Bool {
swapState.quotes.isLoading
swapState.quotes.isLoading || isStreaming
}

var assetIds: [AssetId] {
Expand Down Expand Up @@ -339,21 +342,36 @@ extension SwapSceneViewModel {
}
private func performFetch(input: SwapQuoteInput) async {
do {
let preferredProvider = selectedSwapQuote?.data.provider
swapState.swapTransferData = .noData
swapState.quotes = .loading
isStreaming = true
resetToValue()
let swapQuotes = try await swapQuotesProvider.fetchQuotes(
wallet: wallet,
fromAsset: input.fromAsset,
toAsset: input.toAsset,
amount: input.value,
useMaxAmount: input.useMaxAmount
)

swapState.quotes = .data(swapQuotes)
selectedSwapQuote = swapQuotes.first(where: { $0 == selectedSwapQuote }) ?? swapQuotes.first
if let selectedSwapQuote, let asset = toAsset?.asset {
applyQuote(selectedSwapQuote, asset: asset)
var quotes: [SwapperQuote] = []
var errors: [Error] = []

for await result in swapQuotesProvider.fetchQuotes(wallet: wallet, input: input) {
try Task.checkCancellation()

switch result {
case .success(let quote):
quotes.append(quote)
applyQuotes(&quotes)
case .failure(let error):
errors.append(error)
}
}

try Task.checkCancellation()

if quotes.isEmpty {
let swapError = minInputAmountError(errors) ?? SwapperError.NoQuoteAvailable
swapState.quotes = .error(swapError)
selectedSwapQuote = nil
amountInputModel.update(error: nil)
} else {
selectedSwapQuote = quotes.first(where: { $0.data.provider == preferredProvider }) ?? quotes.first
}
} catch {
if !error.isCancelled && !Task.isCancelled {
Expand All @@ -363,6 +381,25 @@ extension SwapSceneViewModel {
debugLog("SwapScene get quotes error: \(error)")
}
}
isStreaming = false
}

private func applyQuotes(_ quotes: inout [SwapperQuote]) {
quotes.sort { BigInt.fromString($0.toValue) > BigInt.fromString($1.toValue) }
swapState.quotes = .data(quotes)
selectedSwapQuote = quotes.first
if let selectedSwapQuote, let asset = toAsset?.asset {
applyQuote(selectedSwapQuote, asset: asset)
}
}

private func minInputAmountError(_ errors: [Error]) -> SwapperError? {
let minAmounts: [BigInt] = errors.compactMap { error in
guard case .InputAmountError(let minAmount) = error as? SwapperError else { return nil }
return minAmount.flatMap { BigInt($0) }
}
guard let min = minAmounts.min() else { return nil }
return .InputAmountError(minAmount: String(min.increase(byPercent: 10)))
}

private func performUpdate(for assetIds: [AssetId]) async {
Expand Down
27 changes: 18 additions & 9 deletions Features/Swap/Sources/Views/SwapChangeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,40 @@

import Foundation
import SwiftUI
import Components
import Style
import Primitives

struct SwapChangeView: View {
@Binding private var fromId: AssetId?
@Binding private var toId: AssetId?
var isLoading: Bool

init(
fromId: Binding<AssetId?> = .constant(.none),
toId: Binding<AssetId?> = .constant(.none)
toId: Binding<AssetId?> = .constant(.none),
isLoading: Bool = false
) {
_fromId = fromId
_toId = toId
self.isLoading = isLoading
}

var body: some View {
Button {
swap(&fromId, &toId)
} label: {
Images.System.arrowSwap
.resizable()
.renderingMode(.template)
.scaledToFit()
if isLoading {
LoadingView(tint: Colors.gray)
.frame(size: .large)
.foregroundStyle(Colors.gray)
} else {
Button {
swap(&fromId, &toId)
} label: {
Images.System.arrowSwap
.resizable()
.renderingMode(.template)
.scaledToFit()
.frame(size: .large)
.foregroundStyle(Colors.gray)
}
}
}
}
21 changes: 8 additions & 13 deletions Features/Swap/Sources/Views/SwapTokenView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Primitives
struct SwapTokenView: View {
let model: SwapTokenViewModel
@Binding var text: String
var showLoading: Bool = false
var isLoading: Bool = false
var disabledTextField: Bool = false
var onBalanceAction: (() -> Void)
var onSelectAssetAction: (() -> Void)
Expand All @@ -26,19 +26,14 @@ struct SwapTokenView: View {
}
}
}

private var inputView: some View {
HStack {
if showLoading {
LoadingView()
}
TextField(showLoading ? "" : String.zero, text: $text)
.keyboardType(.decimalPad)
.foregroundStyle(Colors.black)
.font(.app.title1)
.disabled(disabledTextField)
.multilineTextAlignment(.leading)
}
TextField(String.zero, text: $text)
.keyboardType(.decimalPad)
.foregroundStyle(isLoading ? Colors.gray : Colors.black)
.font(.app.title1)
.disabled(disabledTextField)
.multilineTextAlignment(.leading)
}

private var fiatBalanceView: some View {
Expand Down
1 change: 1 addition & 0 deletions Features/Swap/Tests/SwapTests/SwapQuoteInputTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Testing
import BigInt
import Primitives
import PrimitivesTestKit
import SwapService
@testable import Swap

struct SwapQuoteInputTests {
Expand Down
39 changes: 36 additions & 3 deletions Features/Swap/Tests/SwapTests/SwapSceneViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import SwapServiceTestKit
import BigInt
import protocol Gemstone.GemSwapperProtocol
import enum Gemstone.SwapperError
import struct Gemstone.SwapperQuote
import struct Gemstone.SwapperProviderType
import Keystore
import KeystoreTestKit
import Primitives
Expand Down Expand Up @@ -132,20 +134,50 @@ struct SwapSceneViewModelTests {
#expect(model.fetchTrigger?.isImmediate == true)
}

@Test
func preservesProviderSelectionOnRefresh() async {
let quoteA = SwapperQuote.mock(toValue: "100", data: .mock(provider: .mock(id: .uniswapV3)))
let quoteB = SwapperQuote.mock(toValue: "200", data: .mock(provider: .mock(id: .thorchain)))
let model = SwapSceneViewModel.mock(quotesProvider: SwapQuotesProviderMock(results: [.success(quoteA), .success(quoteB)]))

await model.fetch()
model.onFinishSwapProviderSelection(quoteA)

#expect(model.selectedSwapQuote?.data.provider.id == .uniswapV3)

await model.fetch()

#expect(model.selectedSwapQuote?.data.provider.id == .uniswapV3)
}

@Test
func minAmountErrorPicksSmallest() async {
let model = SwapSceneViewModel.mock(quotesProvider: SwapQuotesProviderMock(results: [
.failure(SwapperError.InputAmountError(minAmount: "2000")),
.failure(SwapperError.InputAmountError(minAmount: "1000")),
]))

await model.fetch()

#expect(model.buttonViewModel.buttonAction == .useMinAmount(amount: "1100", asset: .mockEthereum()))
}

// MARK: - Private methods

private func model(
toValueMock: String = "250000000000"
) async -> SwapSceneViewModel {
let swapper = GemSwapperMock(quotes: [.mock(toValue: toValueMock)])
let swapper = GemSwapperMock(
quoteByProvider: .mock(toValue: toValueMock)
)
let model = SwapSceneViewModel.mock(swapper: swapper)
await model.fetch()
return model
}
}

extension SwapSceneViewModel {
static func mock(swapper: GemSwapperProtocol = GemSwapperMock()) -> SwapSceneViewModel {
static func mock(swapper: GemSwapperProtocol = GemSwapperMock(), quotesProvider: SwapQuotesProvidable? = nil) -> SwapSceneViewModel {
let model = SwapSceneViewModel(
preferences: .mock(),
input: .init(
Expand All @@ -154,7 +186,7 @@ extension SwapSceneViewModel {
),
balanceUpdater: .mock(),
priceUpdater: .mock(),
swapQuotesProvider: SwapQuotesProvider(swapService: .mock(swapper: swapper)),
swapQuotesProvider: quotesProvider ?? SwapQuotesProvider(swapService: .mock(swapper: swapper)),
swapQuoteDataProvider: SwapQuoteDataProvider(keystore: LocalKeystore.mock(), swapService: .mock(swapper: swapper))
)
model.fromAssetQuery.value = .mock(asset: .mockEthereum(), balance: .mock())
Expand All @@ -168,3 +200,4 @@ extension SwapSceneViewModel {
private struct TestError: Error, RetryableError {
var isRetryAvailable: Bool = true
}

18 changes: 18 additions & 0 deletions Packages/FeatureServices/SwapService/SwapQuoteInput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c). Gem Wallet. All rights reserved.

import BigInt
import Primitives

public struct SwapQuoteInput: Hashable, Sendable {
public let fromAsset: Asset
public let toAsset: Asset
public let value: BigInt
public let useMaxAmount: Bool

public init(fromAsset: Asset, toAsset: Asset, value: BigInt, useMaxAmount: Bool) {
self.fromAsset = fromAsset
self.toAsset = toAsset
self.value = value
self.useMaxAmount = useMaxAmount
}
}
Loading
Loading