Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ final class ConvertAmountViewModel {
/// Destinations offered in the picker: Dollars plus every other held
/// balance, excluding the source. Read by the destination picker sheet.
var destinationOptions: [ExchangedBalance] {
let rate = ratesController.rateForBalanceCurrency()
return session.balances(for: rate)
.filter { $0.stored.mint != sourceBalance.mint }
session.convertDestinations(
from: sourceBalance.mint,
rate: ratesController.rateForBalanceCurrency()
)
}

/// Display name of the current destination — "Dollars" for USDF.
Expand Down Expand Up @@ -88,8 +89,10 @@ final class ConvertAmountViewModel {
// Default to Dollars, except when converting *from* Dollars — then pick
// the largest other holding so the destination is never the source.
if sourceBalance.mint == .usdf {
let rate = ratesController.rateForBalanceCurrency()
let options = session.balances(for: rate).filter { $0.stored.mint != .usdf }
let options = session.convertDestinations(
from: .usdf,
rate: ratesController.rateForBalanceCurrency()
)
self.destinationMint = options
.max(by: { $0.exchangedFiat.nativeAmount.value < $1.exchangedFiat.nativeAmount.value })?
.stored.mint ?? .usdf
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ struct CurrencyInfoContentV2: View {

/// Give — owned community tokens only.
let onGive: () -> Void
/// Convert — sells this (non-USDF) currency into a chosen destination.
/// Convert — sells this currency into a chosen destination.
let onConvert: () -> Void
/// Buy — routes to the buy flow, whether or not the currency is held.
let onBuy: () -> Void
Expand Down Expand Up @@ -302,8 +302,14 @@ struct CurrencyInfoContentV2: View {
if isUSDF {
// Dollars is what the other currencies are bought with, so
// there is nothing to buy more of — it withdraws instead.
// Its Convert goes out via a reserves buy.
actionTile("Convert", icon: .asset("IconArrowBottomTop"), action: onConvert)
// Its Convert goes out via a reserves buy, so it needs
// another holding to buy into.
actionTile(
"Convert",
icon: .asset("IconArrowBottomTop"),
isEnabled: viewModel.canConvert,
action: onConvert
)
actionTile("Withdraw", icon: .asset("IconArrowUp"), action: onWithdraw)
} else {
actionTile("Buy More", icon: .asset("IconArrowDown"), action: onBuy)
Expand All @@ -315,7 +321,12 @@ struct CurrencyInfoContentV2: View {
}
}

private func actionTile(_ title: String, icon: TileIcon, action: @escaping () -> Void) -> some View {
private func actionTile(
_ title: String,
icon: TileIcon,
isEnabled: Bool = true,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 12) {
tileIcon(icon)
Expand All @@ -330,6 +341,8 @@ struct CurrencyInfoContentV2: View {
.clipShape(RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous))
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.opacity(isEnabled ? 1 : 0.4)
}

@ViewBuilder private func tileIcon(_ icon: TileIcon) -> some View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ class CurrencyInfoViewModel {
).nativeAmount
}

/// Whether Convert has somewhere to go. Converting into Dollars is always
/// possible, so only the Dollars screen depends on other holdings — the
/// same list the convert destination picker shows.
var canConvert: Bool {
guard mint == .usdf else { return true }
return !session.convertDestinations(
from: .usdf,
rate: ratesController.rateForBalanceCurrency()
).isEmpty
}

/// The absolute appreciation (or depreciation) of this currency's balance
/// relative to its cost basis, converted to the display currency.
/// Returns zero with `isPositive: true` when
Expand Down
6 changes: 6 additions & 0 deletions Flipcash/Core/Session/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ class Session {
}
}

/// The holdings a conversion out of `sourceMint` can go into: every balance
/// ``balances(for:)`` shows, minus the source itself.
func convertDestinations(from sourceMint: PublicKey, rate: Rate) -> [ExchangedBalance] {
balances(for: rate).filter { $0.stored.mint != sourceMint }
}

/// The balances the wallet shows a card for: every token it holds, and
/// Dollars only once they are worth showing.
static func walletCardBalances(from balances: [ExchangedBalance]) -> [ExchangedBalance] {
Expand Down
101 changes: 101 additions & 0 deletions FlipcashTests/Convert/ConvertDestinationAvailabilityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//
// ConvertDestinationAvailabilityTests.swift
// FlipcashTests
//

import Foundation
import Testing
@testable import FlipcashCore
import FlipcashStore
@testable import Flipcash

@Suite("Convert availability on the currency info screen")
@MainActor
struct ConvertDestinationAvailabilityTests {

private static let jeffySupply: UInt64 = 50_000 * 10_000_000_000
private static let jeffyQuarks: UInt64 = 2_000 * 10_000_000_000 // ≈ $20 of curve value

/// Reserves are seeded in every case so a disabled result comes from the
/// holdings, not from a curve that can't be priced.
private static func makeContainer(holdings: [SessionContainer.Holding]) async throws -> SessionContainer {
let container = try SessionContainer.makeTest(holdings: holdings)

container.ratesController.configureTestRates(
balanceCurrency: .usd,
rates: [Rate(fx: 1, currency: .usd)]
)
await container.ratesController.verifiedProtoService.saveRates([
.freshRate(currencyCode: "USD", rate: 1)
])
await container.ratesController.verifiedProtoService.saveReserveStates([
.freshReserve(mint: .jeffy, supplyFromBonding: jeffySupply)
])

return container
}

private static func canConvert(_ mint: PublicKey, in container: SessionContainer) -> Bool {
CurrencyInfoViewModel(
mint: mint,
session: container.session,
database: container.database,
ratesController: container.ratesController
).canConvert
}

private static let jeffy = MintMetadata.makeLaunchpad(address: .jeffy, supplyFromBonding: jeffySupply)

@Test("Dollars only: Convert on the Dollars screen is disabled")
func dollarsOnly_isDisabled() async throws {
let container = try await Self.makeContainer(holdings: [
SessionContainer.Holding(mint: .usdf, quarks: 10_000_000),
])

#expect(Self.canConvert(.usdf, in: container) == false)
}

@Test("Dollars plus a held currency: Convert is enabled and opens on that currency")
func dollarsAndHeldCurrency_isEnabled() async throws {
let container = try await Self.makeContainer(holdings: [
SessionContainer.Holding(mint: .usdf, quarks: 10_000_000),
SessionContainer.Holding(mint: Self.jeffy, quarks: Self.jeffyQuarks),
])

#expect(Self.canConvert(.usdf, in: container))

let usdf = try #require(container.session.balance(for: .usdf))
let convert = ConvertAmountViewModel(
sourceBalance: usdf,
session: container.session,
ratesController: container.ratesController
)
#expect(convert.destinationMint == .jeffy)
}

@Test("Only other holding is a zero balance: Convert is disabled")
func zeroBalanceOtherHolding_isDisabled() async throws {
let container = try await Self.makeContainer(holdings: [
SessionContainer.Holding(mint: .usdf, quarks: 10_000_000),
SessionContainer.Holding(mint: Self.jeffy, quarks: 0),
])

#expect(Self.canConvert(.usdf, in: container) == false)
}

@Test("No balances yet: Convert on the Dollars screen is disabled")
func noBalances_isDisabled() async throws {
let container = try await Self.makeContainer(holdings: [])

#expect(Self.canConvert(.usdf, in: container) == false)
}

@Test("Another currency's screen keeps Convert enabled with nothing else held")
func nonDollarsScreen_isEnabled() async throws {
let container = try await Self.makeContainer(holdings: [
SessionContainer.Holding(mint: Self.jeffy, quarks: Self.jeffyQuarks),
])

#expect(Self.canConvert(.jeffy, in: container))
}
}
Loading