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

Commit c4ebc9f

Browse files
committed
add basic stream quote support
1 parent 2b7a3ec commit c4ebc9f

10 files changed

Lines changed: 147 additions & 57 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: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -342,18 +342,29 @@ extension SwapSceneViewModel {
342342
swapState.swapTransferData = .noData
343343
swapState.quotes = .loading
344344
resetToValue()
345-
let swapQuotes = try await swapQuotesProvider.fetchQuotes(
346-
wallet: wallet,
347-
fromAsset: input.fromAsset,
348-
toAsset: input.toAsset,
349-
amount: input.value,
350-
useMaxAmount: input.useMaxAmount
351-
)
352345

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)
346+
var accumulated: [SwapperQuote] = []
347+
var errors: [Error] = []
348+
349+
for await result in swapQuotesProvider.fetchQuotes(wallet: wallet, input: input) {
350+
try Task.checkCancellation()
351+
352+
switch result {
353+
case .success(let quote):
354+
accumulated.append(quote)
355+
applyAccumulatedQuotes(&accumulated)
356+
case .failure(let error):
357+
errors.append(error)
358+
}
359+
}
360+
361+
try Task.checkCancellation()
362+
363+
if accumulated.isEmpty {
364+
let swapError = prioritizedSwapError(errors) ?? SwapperError.NoQuoteAvailable
365+
swapState.quotes = .error(swapError)
366+
selectedSwapQuote = nil
367+
amountInputModel.update(error: nil)
357368
}
358369
} catch {
359370
if !error.isCancelled && !Task.isCancelled {
@@ -365,6 +376,28 @@ extension SwapSceneViewModel {
365376
}
366377
}
367378

379+
private func applyAccumulatedQuotes(_ accumulated: inout [SwapperQuote]) {
380+
accumulated.sort { BigInt.fromString($0.toValue) > BigInt.fromString($1.toValue) }
381+
swapState.quotes = .data(accumulated)
382+
selectedSwapQuote = accumulated.first
383+
if let selectedSwapQuote, let asset = toAsset?.asset {
384+
applyQuote(selectedSwapQuote, asset: asset)
385+
}
386+
}
387+
388+
private func prioritizedSwapError(_ errors: [Error]) -> SwapperError? {
389+
let inputErrors: [(BigInt?, String?)] = errors.compactMap { error in
390+
guard let swapperError = error as? SwapperError, case .InputAmountError(let minAmount) = swapperError else { return nil }
391+
return (minAmount.flatMap { BigInt($0) }, minAmount)
392+
}
393+
guard !inputErrors.isEmpty else { return nil }
394+
if let best = inputErrors.filter({ $0.0 != nil }).min(by: { ($0.0 ?? 0) < ($1.0 ?? 0) }) {
395+
let adjusted = best.0.map { String($0.increase(byPercent: 10)) }
396+
return .InputAmountError(minAmount: adjusted)
397+
}
398+
return .InputAmountError(minAmount: nil)
399+
}
400+
368401
private func performUpdate(for assetIds: [AssetId]) async {
369402
await balanceUpdater.updateBalance(for: wallet, assetIds: assetIds)
370403
}

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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ 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+
quoteByProvider: .mock(toValue: toValueMock)
142+
)
141143
let model = SwapSceneViewModel.mock(swapper: swapper)
142144
await model.fetch()
143145
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+
}
Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
// Copyright (c). Gem Wallet. All rights reserved.
22

3-
import Foundation
4-
import BigInt
53
import Foundation
64
import Primitives
75

86
import struct Gemstone.SwapperQuote
7+
import struct Gemstone.SwapperProviderType
98

109
public protocol SwapQuotesProvidable: Sendable {
1110
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]
11+
func fetchQuotes(wallet: Wallet, input: SwapQuoteInput) -> AsyncStream<Result<SwapperQuote, Error>>
1312
}
1413

1514
public struct SwapQuotesProvider: SwapQuotesProvidable {
@@ -23,17 +22,44 @@ public struct SwapQuotesProvider: SwapQuotesProvidable {
2322
swapService.supportedAssets(for: assetId)
2423
}
2524

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) }
25+
public func fetchQuotes(wallet: Wallet, input: SwapQuoteInput) -> AsyncStream<Result<SwapperQuote, Error>> {
26+
AsyncStream { continuation in
27+
let task = Task {
28+
do {
29+
let walletAddress = try wallet.account(for: input.fromAsset.chain).address
30+
let destinationAddress = try wallet.account(for: input.toAsset.chain).address
31+
let providers = try swapService.getProvidersForQuote(input: input, walletAddress: walletAddress, destinationAddress: destinationAddress)
32+
await fetchFromProviders(providers, input: input, walletAddress: walletAddress, destinationAddress: destinationAddress, continuation: continuation)
33+
} catch {
34+
continuation.yield(.failure(error))
35+
}
36+
continuation.finish()
37+
}
38+
continuation.onTermination = { _ in
39+
task.cancel()
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: 22 additions & 11 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,28 +53,37 @@ 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
)
69-
let quotes = try await swapper.fetchQuote(request: swapRequest)
70-
try Task.checkCancellation()
71-
return quotes
7283
}
7384

7485
public func getQuoteData(_ request: SwapperQuote, data: FetchQuoteData) async throws -> GemSwapQuoteData {
75-
let quoteData = try await swapper.fetchQuoteData(quote: request, data: data)
86+
let quoteData = try await swapper.getQuoteData(quote: request, data: data)
7687
try Task.checkCancellation()
7788
return quoteData
7889
}

Packages/FeatureServices/SwapService/TestKit/GemSwapperMock.swift

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import struct Gemstone.SwapperSwapResult
1515

1616
public final class GemSwapperMock: GemSwapperProtocol {
1717
private let permit2ForQuote: Permit2ApprovalData
18-
private let quotes: [SwapperQuote]
1918
private let quoteByProvider: SwapperQuote
2019
private let quoteData: GemSwapQuoteData
2120
private let providers: [SwapperProviderType]
@@ -28,7 +27,6 @@ public final class GemSwapperMock: GemSwapperProtocol {
2827

2928
public init(
3029
permit2ForQuote: Permit2ApprovalData = .mock(),
31-
quotes: [SwapperQuote] = [.mock()],
3230
quoteByProvider: SwapperQuote = .mock(),
3331
quoteData: GemSwapQuoteData = .mock(),
3432
providers: [SwapperProviderType] = [.mock()],
@@ -40,7 +38,6 @@ public final class GemSwapperMock: GemSwapperProtocol {
4038
fetchQuoteError: Error? = nil
4139
) {
4240
self.permit2ForQuote = permit2ForQuote
43-
self.quotes = quotes
4441
self.quoteByProvider = quoteByProvider
4542
self.quoteData = quoteData
4643
self.providers = providers
@@ -56,28 +53,38 @@ public final class GemSwapperMock: GemSwapperProtocol {
5653
permit2ForQuote
5754
}
5855

59-
public func fetchQuote(request: SwapperQuoteRequest) async throws -> [SwapperQuote] {
56+
public func getQuote(request: SwapperQuoteRequest) async throws -> [SwapperQuote] {
6057
if let delay = fetchQuoteDelay {
6158
try await Task.sleep(for: delay)
6259
}
6360
if let error = fetchQuoteError {
6461
throw error
6562
}
66-
return quotes
63+
return [quoteByProvider]
6764
}
6865

6966
public func fetchQuoteByProvider(provider: SwapperProvider, request: SwapperQuoteRequest) async throws -> SwapperQuote {
70-
quoteByProvider
67+
if let delay = fetchQuoteDelay {
68+
try await Task.sleep(for: delay)
69+
}
70+
if let error = fetchQuoteError {
71+
throw error
72+
}
73+
return quoteByProvider
7174
}
7275

73-
public func fetchQuoteData(quote: SwapperQuote, data: FetchQuoteData) async throws -> GemSwapQuoteData {
76+
public func getQuoteData(quote: SwapperQuote, data: FetchQuoteData) async throws -> GemSwapQuoteData {
7477
quoteData
7578
}
7679

7780
public func getProviders() -> [SwapperProviderType] {
7881
providers
7982
}
8083

84+
public func getProvidersForRequest(request: SwapperQuoteRequest) throws -> [SwapperProviderType] {
85+
providers
86+
}
87+
8188
public func getTransactionStatus(chain: Chain, swapProvider: SwapperProvider, transactionHash: String) async throws -> Bool {
8289
transactionStatus
8390
}

0 commit comments

Comments
 (0)