Skip to content

Commit

Permalink
[ETH-962] New JupiterPriceService
Browse files Browse the repository at this point in the history
  • Loading branch information
Elizaveta Semenova committed Feb 14, 2024
1 parent 80a448d commit e4c191a
Show file tree
Hide file tree
Showing 20 changed files with 228 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public final class EthereumAccountsService: NSObject, AccountsService {

// MARK: - Service

let priceService: PriceService
let priceService: JupiterPriceService

let errorObservable: ErrorObserver

Expand Down Expand Up @@ -38,7 +38,7 @@ public final class EthereumAccountsService: NSObject, AccountsService {
address: String,
web3: Web3,
ethereumTokenRepository: EthereumTokensRepository,
priceService: PriceService,
priceService: JupiterPriceService,
fiat: String,
errorObservable: any ErrorObserver,
enable: Bool
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import BigDecimal
import Combine
import Foundation
import KeyAppKitCore
import KeyAppNetworking
import SolanaSwift
import TokenService

public protocol JupiterPriceService: AnyObject {
/// Get actual price in specific fiat for token.
func getPrice(
token: AnyToken,
fiat: String
) async throws -> TokenPrice?

/// Get actual prices in specific fiat for token.
func getPrices(
tokens: [AnyToken],
fiat: String
) async throws -> [SomeToken: TokenPrice]

/// Emit request event to fetch new price.
var onChangePublisher: AnyPublisher<Void, Never> { get }
}

public final class JupiterPriceServiceImpl: JupiterPriceService {
// MARK: - Inner structure

/// Data structure for caching
enum TokenPriceRecord: Codable, Hashable {
case requested(TokenPrice?)
}

// MARK: - Providers

let client: HTTPClient

// MARK: - Event stream

/// The timer synchronisation
let timerPublisher: Timer.TimerPublisher = .init(interval: 60, runLoop: .main, mode: .default)

public var onChangePublisher: AnyPublisher<Void, Never> {
timerPublisher
.autoconnect()
.map { _ in }
.eraseToAnyPublisher()
}

// MARK: - Constructor

public init(client: HTTPClient) {
self.client = client
}

// MARK: - Methods

public func getPrices(
tokens: [AnyToken], fiat: String
) async throws -> [SomeToken: TokenPrice] {
let fiat = fiat.lowercased()

if tokens.isEmpty {
return [:]
}

var result: [SomeToken: TokenPriceRecord] = [:]

// Filter missing token price
let missingPriceTokenMints: [AnyToken] = tokens.map(\.asSomeToken)

// Request missing prices
let newPrices = try await fetchTokenPrice(tokens: missingPriceTokenMints, fiat: fiat)

// Process missing token prices
for token in missingPriceTokenMints {
let token = token.asSomeToken

if let price = newPrices[token] {
let record = TokenPriceRecord.requested(price)
result[token] = record
} else {
let record = TokenPriceRecord.requested(nil)
result[token] = record
}
}

// Transform values of TokenPriceRecord? to TokenPrice?
let priceResult = result
.compactMapValues { record in
switch record {
case let .requested(value):
return value
}
}

return priceResult
}

public func getPrice(token: AnyToken, fiat: String) async throws -> TokenPrice? {
let result = try await getPrices(tokens: [token], fiat: fiat)
return result.values.first ?? nil
}

/// Method for fetching price from server
private func fetchTokenPrice(tokens: [AnyToken], fiat: String) async throws -> [SomeToken: TokenPrice] {
var result: [SomeToken: TokenPrice] = [:]

// Request token price
let query = tokens.map(\.address).joined(separator: ",")

// Fetch
let newPrices = try await getTokensPrice(ids: query).data
for tokenData in newPrices {
// Token should be from requested list
let token = tokens.first { token in token.addressPriceMapping == tokenData.key }
guard let token = token?.asSomeToken else { continue }

// Parse
let price = parseTokenPrice(token: token, value: tokenData.value.usdPrice, fiat: fiat)
result[token] = price
}

// Transform values of TokenPriceRecord? to TokenPrice?
return result
}

private func getTokensPrice(ids: String) async throws -> JupiterPricesRootResponse {
try await client.request(
endpoint: DefaultHTTPEndpoint(
baseURL: "https://price.jup.ag/",
path: "v4/price?ids=\(ids)",
method: .get,
header: [:]
),
responseModel: JupiterPricesRootResponse.self
)
}

private func parseTokenPrice(token: SomeToken, value: Double, fiat: String) -> TokenPrice {
TokenPrice(
currencyCode: fiat,
value: BigDecimal(floatLiteral: value),
token: token
)
}
}

private struct JupiterPricesRootResponse: Decodable {
let data: [String: JupiterPricesResponse]
}

private struct JupiterPricesResponse: Decodable {
let mintAddress: String
let tokenSymbol: String
let usdPrice: Double // 1 unit of the token worth in USDC

enum CodingKeys: String, CodingKey {
case mintAddress = "id"
case tokenSymbol = "mintSymbol"
case usdPrice = "price"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import SolanaSwift
import TokenService

/// Abstract class for getting exchange rate between token and fiat for any token.
@available(*, deprecated, message: "Use JupiterPriceService instead")
public protocol PriceService: AnyObject {
/// Get actual price in specific fiat for token.
func getPrice(
Expand All @@ -32,6 +33,7 @@ public protocol PriceService: AnyObject {
/// This class service allow client to get exchange rate between token and fiat.
///
/// Each rate has 15 minutes lifetime. When the lifetime is expired, the new rate will be requested.
@available(*, deprecated, message: "Use JupiterPriceService instead")
public class PriceServiceImpl: PriceService {
// MARK: - Inner structure

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public final class SolanaAccountsService: NSObject, AccountsService {

// MARK: - Service

let priceService: PriceService
let priceService: JupiterPriceService

let errorObservable: ErrorObserver

Expand Down Expand Up @@ -55,7 +55,7 @@ public final class SolanaAccountsService: NSObject, AccountsService {
solanaAPIClient: SolanaAPIClient,
realtimeSolanaAccountService: RealtimeSolanaAccountService? = nil,
tokensService: SolanaTokensService,
priceService: PriceService,
priceService: JupiterPriceService,
fiat: String,
proxyConfiguration: ProxyConfiguration?,
errorObservable: any ErrorObserver
Expand Down Expand Up @@ -156,7 +156,10 @@ public final class SolanaAccountsService: NSObject, AccountsService {
for state: AsyncValueState<[Account]>,
fiat: String
) -> Future<AsyncValueState<[Account]>, Never> {
Future<AsyncValueState<[Account]>, Never> { [weak priceService, errorObservable] promise in
Future<AsyncValueState<[Account]>, Never> { [
weak priceService,
errorObservable
] promise in
Task { [weak priceService, errorObservable] in
// Price service is unavailable
guard let priceService else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public final class MoonpaySellDataService: SellDataService {
// MARK: - Dependencies

private let provider: Provider
private let priceProvider: PriceService
private let priceProvider: JupiterPriceService
private let sellTransactionsRepository: SellTransactionsRepository

// MARK: - Properties
Expand Down Expand Up @@ -41,7 +41,7 @@ public final class MoonpaySellDataService: SellDataService {
public init(
userId: String,
provider: Provider,
priceProvider: PriceService,
priceProvider: JupiterPriceService,
sellTransactionsRepository: SellTransactionsRepository
) {
self.userId = userId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ final class EthereumAccountsServiceTests: XCTestCase {
)

let web3 = Web3(provider: web3Provider)
let priceService = PriceServiceImpl(api: MockKeyAppTokenProvider(), errorObserver: MockErrorObservable())
let priceService = MockJupiterPriceService()
let keyAppTokenProvider = MockKeyAppTokenProvider()

let service = EthereumAccountsService(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Combine
import Foundation
import KeyAppBusiness
import KeyAppKitCore

final class MockJupiterPriceService: JupiterPriceService {
func getPrice(token: KeyAppKitCore.AnyToken, fiat: String) async throws -> KeyAppKitCore.TokenPrice? {
let result = try await getPrices(tokens: [token], fiat: fiat)
return result.values.first ?? nil
}

func getPrices(tokens _: [KeyAppKitCore.AnyToken],
fiat _: String) async throws -> [KeyAppKitCore.SomeToken: KeyAppKitCore.TokenPrice]
{
[:]
}

let timerPublisher: Timer.TimerPublisher = .init(interval: 5, runLoop: .main, mode: .default)

var onChangePublisher: AnyPublisher<Void, Never> {
timerPublisher
.autoconnect()
.map { _ in }
.eraseToAnyPublisher()
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import Foundation
import TokenService
import SolanaSwift

struct MockTokensRepository: TokenRepository {
struct MockTokensRepository: TokenRepository, SolanaTokensService {
func getTokenAmount(vs_token: String?, amount: UInt64, mints: [String]) async throws -> [TokenService.SolanaTokenAmountResponse] {
return []
}

func setup() async throws {}

func get(address: String) async throws -> TokenMetadata? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ final class SolanaAccountsServiceTests: XCTestCase {
let keyAppTokenProvider = MockKeyAppTokenProvider()

let tokenService = MockTokensRepository()
let priceService = PriceServiceImpl(api: keyAppTokenProvider, errorObserver: errorObserver, lifetime: 60)
let priceService = MockJupiterPriceService()

let service = SolanaAccountsService(
accountStorage: MockAccountStorage(),
Expand Down Expand Up @@ -47,7 +47,7 @@ final class SolanaAccountsServiceTests: XCTestCase {
let realtimeSolanaAccountService = MockRealtimeSolanaAccountService()

let tokenService = MockTokensRepository()
let priceService = PriceServiceImpl(api: keyAppTokenProvider, errorObserver: errorObserver, lifetime: 60)
let priceService = MockJupiterPriceService()

let service = SolanaAccountsService(
accountStorage: accountStorage,
Expand Down
11 changes: 11 additions & 0 deletions p2p_wallet/Injection/Resolver+registerAllServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import History
import Jupiter
import KeyAppBusiness
import KeyAppKitCore
import KeyAppNetworking
import Moonpay
import NameService
import Onboarding
Expand Down Expand Up @@ -157,6 +158,16 @@ extension Resolver: ResolverRegistering {
.implements(PriceService.self)
.scope(.application)

// Prices
register {
JupiterPriceServiceImpl(client: HTTPClient(
urlSession: URLSession.shared,
decoder: JSONResponseDecoder()
))
}
.implements(JupiterPriceService.self)
.scope(.application)

register { WormholeRPCAPI(endpoint: GlobalAppState.shared.bridgeEndpoint) }
.implements(WormholeAPI.self)
.scope(.session)
Expand Down
7 changes: 0 additions & 7 deletions p2p_wallet/Scenes/DebugMenu/View/DebugMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,6 @@ struct DebugMenuView: View {
try await tokenService.clear()
}
}

Button("Clear price cache") {
let priceService = Resolver.resolve(PriceService.self)
Task {
try await priceService.clear()
}
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion p2p_wallet/Scenes/Main/Buy/BuyViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ final class BuyViewModel: ObservableObject {
@Injected var exchangeService: BuyExchangeService
@Injected var walletsRepository: SolanaAccountsService
@Injected private var analyticsManager: AnalyticsManager
@Injected private var pricesService: PriceService
@Injected private var pricesService: JupiterPriceService

// Defaults
// @SwiftyUserDefault(keyPath: \.buyLastPaymentMethod, options: .cached)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import Wormhole
class HomeAccountsSynchronisationService {
@Injected var solanaAccountsService: SolanaAccountsService
@Injected var ethereumAccountsService: EthereumAccountsService
@Injected var priceService: PriceService
@Injected var userActionService: UserActionService

func refresh() async {
Expand All @@ -15,11 +14,6 @@ class HomeAccountsSynchronisationService {

do {
try await withThrowingTaskGroup(of: Void.self) { group in
// Clear price cache
group.addTask { [weak self] in
try await self?.priceService.clear()
}

// solana
group.addTask { [weak self] in
guard let self else { return }
Expand Down
2 changes: 1 addition & 1 deletion p2p_wallet/Scenes/Main/Sell/SellViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class SellViewModel: BaseViewModel, ObservableObject {
@Injected private var actionService: any SellActionService
@Injected private var analyticsManager: AnalyticsManager
@Injected private var reachability: Reachability
@Injected private var priceService: PriceService
@Injected private var priceService: JupiterPriceService

// MARK: - Properties

Expand Down
Loading

0 comments on commit e4c191a

Please sign in to comment.