Skip to content

Commit

Permalink
Merge pull request #1700 from p2p-org/feature/pwn-962
Browse files Browse the repository at this point in the history
[ETH-962] New JupiterPriceService
  • Loading branch information
lisemyon authored Feb 15, 2024
2 parents 80a448d + 9705755 commit e5ade8f
Show file tree
Hide file tree
Showing 19 changed files with 250 additions and 32 deletions.
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(\.jupiterAddressMaping).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.jupiterAddressMaping == 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"
}
}
23 changes: 23 additions & 0 deletions Packages/KeyAppKit/Sources/KeyAppBusiness/Price/PriceService.swift
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 Expand Up @@ -264,6 +266,7 @@ public class PriceServiceImpl: PriceService {

extension AnyToken {
/// Map token to requested primary key in backend.
@available(*, deprecated, message: "Use jupiterAddressMaping instead")
var addressPriceMapping: String {
switch network {
case .solana:
Expand All @@ -283,4 +286,24 @@ extension AnyToken {
}
}
}

var jupiterAddressMaping: String {
switch network {
case .solana:
switch primaryKey {
case .native:
return Token.nativeSolana.mintAddress
case let .contract(address):
return address
}

case .ethereum:
switch primaryKey {
case .native:
return Token.eth.mintAddress
case let .contract(address):
return address
}
}
}
}
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,14 @@
import Foundation
import SolanaSwift
import TokenService

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

struct MockTokensRepository: TokenRepository {
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
Loading

0 comments on commit e5ade8f

Please sign in to comment.