Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.

Commit f1b4759

Browse files
committed
Stream swap quotes and move SwapQuoteInput to service
Move SwapQuoteInput into the SwapService package and change quote fetching to a streamed, provider-based flow. SwapQuotesProvider now returns an AsyncStream<Result<SwapperQuote, Error>> and concurrently queries providers; SwapService exposes getProvidersForQuote and getQuoteByProvider and builds requests from SwapQuoteInput. SwapSceneViewModel is updated to consume incremental quotes, accumulate/sort them, apply the best quote as it arrives, and prioritize InputAmountError variants when no quotes are returned. Updated GemSwapperMock and tests to match the new APIs and behavior, and adjusted imports accordingly.
1 parent 1a79f59 commit f1b4759

9 files changed

Lines changed: 135 additions & 39 deletions

File tree

Features/Swap/Sources/Types/SwapFetchTrigger.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c). Gem Wallet. All rights reserved.
22

33
import Components
4+
import SwapService
45

56
struct SwapFetchTrigger: DebouncableTrigger {
67
let input: SwapQuoteInput

Features/Swap/Sources/Types/SwapQuoteInput.swift

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
// Copyright (c). Gem Wallet. All rights reserved.
22

3-
import Foundation
4-
import Primitives
53
import BigInt
6-
7-
public struct SwapQuoteInput: Hashable, Sendable {
8-
public let fromAsset: Asset
9-
public let toAsset: Asset
10-
public let value: BigInt
11-
public let useMaxAmount: Bool
12-
}
13-
14-
// MARK: - Identifiable
4+
import Primitives
5+
import SwapService
156

167
extension SwapQuoteInput: Identifiable {
178
public var id: String {

Features/Swap/Sources/ViewModels/SwapSceneViewModel.swift

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -342,18 +342,41 @@ extension SwapSceneViewModel {
342342
swapState.swapTransferData = .noData
343343
swapState.quotes = .loading
344344
resetToValue()
345-
let swapQuotes = try await swapQuotesProvider.fetchQuotes(
345+
346+
var accumulated: [SwapperQuote] = []
347+
var errors: [Error] = []
348+
349+
for await result in swapQuotesProvider.fetchQuotes(
346350
wallet: wallet,
347351
fromAsset: input.fromAsset,
348352
toAsset: input.toAsset,
349353
amount: input.value,
350354
useMaxAmount: input.useMaxAmount
351-
)
355+
) {
356+
try Task.checkCancellation()
357+
358+
switch result {
359+
case .success(let quote):
360+
accumulated.append(quote)
361+
let sorted = try? accumulated.sorted { try BigInt.from(string: $0.toValue) > BigInt.from(string: $1.toValue) }
362+
accumulated = sorted ?? accumulated
363+
swapState.quotes = .data(accumulated)
364+
selectedSwapQuote = accumulated.first
365+
if let selectedSwapQuote, let asset = toAsset?.asset {
366+
applyQuote(selectedSwapQuote, asset: asset)
367+
}
368+
case .failure(let error):
369+
errors.append(error)
370+
}
371+
}
372+
373+
try Task.checkCancellation()
352374

353-
swapState.quotes = .data(swapQuotes)
354-
selectedSwapQuote = swapQuotes.first(where: { $0 == selectedSwapQuote }) ?? swapQuotes.first
355-
if let selectedSwapQuote, let asset = toAsset?.asset {
356-
applyQuote(selectedSwapQuote, asset: asset)
375+
if accumulated.isEmpty {
376+
let swapError = prioritizedSwapError(errors) ?? SwapperError.NoQuoteAvailable
377+
swapState.quotes = .error(swapError)
378+
selectedSwapQuote = nil
379+
amountInputModel.update(error: nil)
357380
}
358381
} catch {
359382
if !error.isCancelled && !Task.isCancelled {
@@ -365,6 +388,19 @@ extension SwapSceneViewModel {
365388
}
366389
}
367390

391+
private func prioritizedSwapError(_ errors: [Error]) -> SwapperError? {
392+
let inputErrors: [(BigInt?, String?)] = errors.compactMap { error in
393+
guard let swapperError = error as? SwapperError, case .InputAmountError(let minAmount) = swapperError else { return nil }
394+
return (minAmount.flatMap { BigInt($0) }, minAmount)
395+
}
396+
guard !inputErrors.isEmpty else { return nil }
397+
if let best = inputErrors.filter({ $0.0 != nil }).min(by: { ($0.0 ?? 0) < ($1.0 ?? 0) }) {
398+
let adjusted = best.0.map { String($0 * 11 / 10) }
399+
return .InputAmountError(minAmount: adjusted)
400+
}
401+
return .InputAmountError(minAmount: nil)
402+
}
403+
368404
private func performUpdate(for assetIds: [AssetId]) async {
369405
await balanceUpdater.updateBalance(for: wallet, assetIds: assetIds)
370406
}

Features/Swap/Tests/SwapTests/SwapQuoteInputTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Testing
44
import BigInt
55
import Primitives
66
import PrimitivesTestKit
7+
import SwapService
78
@testable import Swap
89

910
struct SwapQuoteInputTests {

Features/Swap/Tests/SwapTests/SwapSceneViewModelTests.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,10 @@ struct SwapSceneViewModelTests {
137137
private func model(
138138
toValueMock: String = "250000000000"
139139
) async -> SwapSceneViewModel {
140-
let swapper = GemSwapperMock(quotes: [.mock(toValue: toValueMock)])
140+
let swapper = GemSwapperMock(
141+
quotes: [.mock(toValue: toValueMock)],
142+
quoteByProvider: .mock(toValue: toValueMock)
143+
)
141144
let model = SwapSceneViewModel.mock(swapper: swapper)
142145
await model.fetch()
143146
return model
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c). Gem Wallet. All rights reserved.
2+
3+
import BigInt
4+
import Primitives
5+
6+
public struct SwapQuoteInput: Hashable, Sendable {
7+
public let fromAsset: Asset
8+
public let toAsset: Asset
9+
public let value: BigInt
10+
public let useMaxAmount: Bool
11+
12+
public init(fromAsset: Asset, toAsset: Asset, value: BigInt, useMaxAmount: Bool) {
13+
self.fromAsset = fromAsset
14+
self.toAsset = toAsset
15+
self.value = value
16+
self.useMaxAmount = useMaxAmount
17+
}
18+
}

Packages/FeatureServices/SwapService/SwapQuotesProvider.swift

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import Foundation
66
import Primitives
77

88
import struct Gemstone.SwapperQuote
9+
import struct Gemstone.SwapperProviderType
910

1011
public protocol SwapQuotesProvidable: Sendable {
1112
func supportedAssets(for assetId: AssetId) -> ([Primitives.Chain], [Primitives.AssetId])
12-
func fetchQuotes(wallet: Wallet, fromAsset: Asset, toAsset: Asset, amount: BigInt, useMaxAmount: Bool) async throws -> [Gemstone.SwapperQuote]
13+
func fetchQuotes(wallet: Wallet, fromAsset: Asset, toAsset: Asset, amount: BigInt, useMaxAmount: Bool) -> AsyncStream<Result<SwapperQuote, Error>>
1314
}
1415

1516
public struct SwapQuotesProvider: SwapQuotesProvidable {
@@ -23,17 +24,42 @@ public struct SwapQuotesProvider: SwapQuotesProvidable {
2324
swapService.supportedAssets(for: assetId)
2425
}
2526

26-
public func fetchQuotes(wallet: Wallet, fromAsset: Asset, toAsset: Asset, amount: BigInt, useMaxAmount: Bool) async throws -> [Gemstone.SwapperQuote] {
27-
let walletAddress = try wallet.account(for: fromAsset.chain).address
28-
let destinationAddress = try wallet.account(for: toAsset.chain).address
29-
let quotes = try await swapService.getQuotes(
30-
fromAsset: fromAsset,
31-
toAsset: toAsset,
32-
value: amount.description,
33-
walletAddress: walletAddress,
34-
destinationAddress: destinationAddress,
35-
useMaxAmount: useMaxAmount
36-
)
37-
return try quotes.sorted { try BigInt.from(string: $0.toValue) > BigInt.from(string: $1.toValue) }
27+
public func fetchQuotes(wallet: Wallet, fromAsset: Asset, toAsset: Asset, amount: BigInt, useMaxAmount: Bool) -> AsyncStream<Result<SwapperQuote, Error>> {
28+
AsyncStream { continuation in
29+
Task {
30+
do {
31+
let input = SwapQuoteInput(fromAsset: fromAsset, toAsset: toAsset, value: amount, useMaxAmount: useMaxAmount)
32+
let walletAddress = try wallet.account(for: fromAsset.chain).address
33+
let destinationAddress = try wallet.account(for: toAsset.chain).address
34+
let providers = try swapService.getProvidersForQuote(input: input, walletAddress: walletAddress, destinationAddress: destinationAddress)
35+
await fetchFromProviders(providers, input: input, walletAddress: walletAddress, destinationAddress: destinationAddress, continuation: continuation)
36+
} catch {
37+
continuation.yield(.failure(error))
38+
}
39+
continuation.finish()
40+
}
41+
}
42+
}
43+
44+
private func fetchFromProviders(_ providers: [SwapperProviderType], input: SwapQuoteInput, walletAddress: String, destinationAddress: String, continuation: AsyncStream<Result<SwapperQuote, Error>>.Continuation) async {
45+
await withTaskGroup(of: Result<SwapperQuote, Error>?.self) { group in
46+
for provider in providers {
47+
group.addTask { [swapService] in
48+
guard !Task.isCancelled else { return nil }
49+
do {
50+
let quote = try await swapService.getQuoteByProvider(provider: provider.id, input: input, walletAddress: walletAddress, destinationAddress: destinationAddress)
51+
return .success(quote)
52+
} catch {
53+
guard !Task.isCancelled else { return nil }
54+
return .failure(error)
55+
}
56+
}
57+
}
58+
for await result in group {
59+
if let result {
60+
continuation.yield(result)
61+
}
62+
}
63+
}
3864
}
3965
}

Packages/FeatureServices/SwapService/SwapService.swift

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import GemstonePrimitives
1818
import NativeProviderService
1919
import Primitives
2020
import enum Primitives.AnyError
21+
import enum Gemstone.SwapperProvider
22+
import struct Gemstone.SwapperProviderType
2123
import enum Primitives.Chain
2224
import enum Primitives.EVMChain
2325

@@ -51,19 +53,31 @@ public final class SwapService: Sendable, SwappableChainsProvider {
5153
)
5254
}
5355

54-
public func getQuotes(fromAsset: Asset, toAsset: Asset, value: String, walletAddress: String, destinationAddress: String, useMaxAmount: Bool) async throws -> [SwapperQuote] {
55-
let swapRequest = SwapperQuoteRequest(
56-
fromAsset: SwapperQuoteAsset(asset: fromAsset),
57-
toAsset: SwapperQuoteAsset(asset: toAsset),
56+
public func getProvidersForQuote(input: SwapQuoteInput, walletAddress: String, destinationAddress: String) throws -> [SwapperProviderType] {
57+
let request = buildRequest(input: input, walletAddress: walletAddress, destinationAddress: destinationAddress)
58+
return try swapper.getProvidersForRequest(request: request)
59+
}
60+
61+
public func getQuoteByProvider(provider: SwapperProvider, input: SwapQuoteInput, walletAddress: String, destinationAddress: String) async throws -> SwapperQuote {
62+
let request = buildRequest(input: input, walletAddress: walletAddress, destinationAddress: destinationAddress)
63+
let quote = try await swapper.fetchQuoteByProvider(provider: provider, request: request)
64+
try Task.checkCancellation()
65+
return quote
66+
}
67+
68+
private func buildRequest(input: SwapQuoteInput, walletAddress: String, destinationAddress: String) -> SwapperQuoteRequest {
69+
SwapperQuoteRequest(
70+
fromAsset: SwapperQuoteAsset(asset: input.fromAsset),
71+
toAsset: SwapperQuoteAsset(asset: input.toAsset),
5872
walletAddress: walletAddress,
5973
destinationAddress: destinationAddress,
60-
value: value,
74+
value: input.value.description,
6175
mode: .exactIn,
6276
options: SwapperOptions(
63-
slippage: getDefaultSlippage(chain: fromAsset.id.chain.rawValue),
77+
slippage: getDefaultSlippage(chain: input.fromAsset.id.chain.rawValue),
6478
fee: getReferralFees(),
6579
preferredProviders: [],
66-
useMaxAmount: useMaxAmount
80+
useMaxAmount: input.useMaxAmount
6781
)
6882
)
6983
let quotes = try await swapper.getQuote(request: swapRequest)

Packages/FeatureServices/SwapService/TestKit/GemSwapperMock.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,13 @@ public final class GemSwapperMock: GemSwapperProtocol {
6767
}
6868

6969
public func fetchQuoteByProvider(provider: SwapperProvider, request: SwapperQuoteRequest) async throws -> SwapperQuote {
70-
quoteByProvider
70+
if let delay = fetchQuoteDelay {
71+
try await Task.sleep(for: delay)
72+
}
73+
if let error = fetchQuoteError {
74+
throw error
75+
}
76+
return quoteByProvider
7177
}
7278

7379
public func getQuoteData(quote: SwapperQuote, data: FetchQuoteData) async throws -> GemSwapQuoteData {

0 commit comments

Comments
 (0)