Skip to content
Closed
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ Every router mutation logs one INFO entry under `flipcash.router` — filter by
2. **ExchangedFiat** - Wraps underlying currency + converted display value
3. **BondingCurve** - Pricing for custom currencies
4. **AccountCluster** - Manages keys per mint
5. **VerifiedState** - Bundles server-signed exchange rate proof (`rateProto`) and optional reserve state proof (`reserveProto`). Required when submitting any payment intent. For **launchpad currencies**, `reserveProto` is mandatory — the server rejects intents without it. **Pin-at-compute invariant**: amount-entry flows (`CurrencyBuyViewModel`, `CurrencySellViewModel`, `WithdrawViewModel`, `GiveViewModel`) fetch the pin at the commit moment via `prepareSubmission()` and compute `ExchangedFiat.quarks` against that same pin. The pin is then carried through `Session.showCashBill` → `BillDescription.verifiedState` → `SendCashOperation` / `createCashLink` so face-to-face transfer and cash-link submission both use the proof the quarks were derived from. Fetching twice or pinning at flow-open reintroduces the "native amount and quark value mismatch" reject.
5. **VerifiedState** - Bundles server-signed exchange rate proof (`rateProto`) and optional reserve state proof (`reserveProto`). Required when submitting any payment intent. For **launchpad currencies**, `reserveProto` is mandatory — the server rejects intents without it. **Pin-at-compute invariant**: amount-entry flows (`CurrencySellViewModel`, `WithdrawViewModel`, `GiveViewModel`) fetch the pin at the commit moment via `prepareSubmission()` and compute `ExchangedFiat.quarks` against that same pin. The buy flow pins the **payment mint's** verified state when a payment currency is selected (`BuyPaymentCurrencyViewModel.select`) and carries it through `BuyConfirmationViewModel` to submission — for launchpad-paid buys the swap amount is denominated in the payment token, so the payment mint's rate + reserve proof are the ones that must match the quarks. The pin is then carried through `Session.showCashBill` → `BillDescription.verifiedState` → `SendCashOperation` / `createCashLink` so face-to-face transfer and cash-link submission both use the proof the quarks were derived from. Fetching twice or pinning at flow-open reintroduces the "native amount and quark value mismatch" reject.
6. **SendCashOperation** - Orchestrates peer-to-peer transfers via a rendezvous handshake. Has two concurrent paths: Path 1 (advertise bill with verified state) and Path 2 (listen for grab, then transfer). Both paths share a resolved `VerifiedState`.

---
Expand Down
6 changes: 0 additions & 6 deletions Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,6 @@ protocol USDFReserveReading: AnyObject {

extension Session: USDFReserveReading {}

/// True when the user holds no USDF reserves to spend on a buy.
@MainActor
func shouldAddMoneyBeforeBuy(session: some USDFReserveReading) -> Bool {
guard let balance = session.balance(for: .usdf) else { return true }
return balance.usdf.value == 0
}

/// True when the user's USDF reserves can't cover `launchCost` (purchase +
/// fee). Must agree with the wizard's `reserveBalance` affordability check.
Expand Down
21 changes: 12 additions & 9 deletions Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,14 @@ struct BuyAmountScreen: View {
EnterAmountView(
mode: .buy,
enteredAmount: $viewModel.enteredAmount,
subtitle: .singleTransactionLimit,
actionState: $viewModel.actionButtonState,
actionEnabled: { _ in viewModel.canPerformAction },
action: {
Task { await viewModel.amountEnteredAction(router: router) }
},
currencySelectionAction: showCurrencySelection
subtitle: .balanceWithLimit(viewModel.maxPossibleAmount),
// Submission moved to the Buy summary; the amount step's
// button never leaves `.normal`.
actionState: .constant(.normal),
actionEnabled: { viewModel.actionEnabled($0) },
action: { viewModel.primaryAction(router: router) },
currencySelectionAction: showCurrencySelection,
actionTitle: viewModel.actionTitle
)
.foregroundStyle(.textMain)
.padding(20)
Expand All @@ -65,11 +66,13 @@ struct BuyAmountScreen: View {
.navigationDestination(for: BuyFlowPath.self) { path in
// Env value must be set on the destination view itself — modifiers
// on the source view don't propagate to navigation destinations
// (they live in a separate SwiftUI context).
// (they live in a separate SwiftUI context). `.id(path)` forces a
// fresh view identity per path value so init-seeded @State can't
// survive a same-depth value swap (the DestinationView convention).
BuyFlowDestinationView(path: path)
.environment(\.dismissParentContainer, router.dismissSheet)
.id(path)
}
.dialog(item: $viewModel.dialogItem)
.sheet(isPresented: $isShowingCurrencySelection) {
CurrencySelectionScreen(ratesController: ratesController)
}
Expand Down
171 changes: 36 additions & 135 deletions Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,42 +9,32 @@ import SwiftUI
import FlipcashCore
import FlipcashUI

private let logger = Logger(label: "flipcash.buy-amount")

@Observable
@MainActor
final class BuyAmountViewModel: Identifiable {
var actionButtonState: ButtonState = .normal
var enteredAmount: String = ""
var dialogItem: DialogItem?

@ObservationIgnored let mint: PublicKey
@ObservationIgnored let currencyName: String

var enteredFiat: ExchangedFiat? {
computeAmount(using: ratesController.rateForBalanceCurrency())
/// Highest spendable balance among eligible payment sources — USDF plus
/// displayable launchpad tokens, excluding the currency being bought.
/// The keypad gate and the "Enter up to" subtitle both read this value.
var maxPossibleAmount: ExchangedFiat {
let rate = ratesController.rateForBalanceCurrency()
let eligible = session.balances(for: rate).filter { $0.stored.mint != mint }
let zero = ExchangedFiat.compute(onChainAmount: .zero(mint: .usdf), rate: rate, supplyQuarks: nil)
return eligible.max { $0.exchangedFiat.nativeAmount.value < $1.exchangedFiat.nativeAmount.value }?.exchangedFiat ?? zero
}

var canPerformAction: Bool {
guard enteredFiat != nil else { return false }
return EnterAmountCalculator.isWithinDisplayLimit(
enteredAmount: enteredAmount,
max: maxPossibleAmount.nativeAmount
)
/// True when no eligible source has a displayable value — the action
/// button becomes an Add Money CTA.
var isBalanceEmpty: Bool {
!maxPossibleAmount.nativeAmount.hasDisplayableValue
}

/// Single-transaction cap exposed by the server via `Limits.sendLimitFor`.
/// Matches what `EnterAmountView`'s subtitle renders for `.buy` mode, so
/// the in-view cap and the view-model gate stay aligned.
///
/// This is the daily send limit, not a balance-derived cap: the buy flow
/// must allow amounts that exceed the user's current USDF balance, since
/// funding (Apple Pay / Phantom / etc.) tops it up.
var maxPossibleAmount: ExchangedFiat {
let rate = ratesController.rateForBalanceCurrency()
let maxNative = session.sendLimitFor(currency: rate.currency)?.maxPerDay
?? FiatAmount.zero(in: rate.currency)
return ExchangedFiat(nativeAmount: maxNative, rate: rate)
var actionTitle: String {
isBalanceEmpty ? "Add Money" : "Next"
}

var screenTitle: String { "Amount" }
Expand All @@ -60,120 +50,31 @@ final class BuyAmountViewModel: Identifiable {
self.ratesController = ratesController
}

// MARK: - Submission

/// Single source of truth for amount submission — gates balance and
/// limits, then submits the buy from reserves; a shortfall routes to the
/// Add Money flow.
func amountEnteredAction(router: AppRouter) async {
guard let enteredFiat else { return }
// MARK: - Actions

switch session.hasSufficientFunds(for: enteredFiat) {
case .sufficient:
break
case .insufficient:
session.dialogItem = .noBalance(subtitle: AddMoneyContext.buyCurrency.noBalanceSubtitle) {
router.presentAddMoney(.buyCurrency, source: .buyShortfall)
}
return
}

actionButtonState = .loading

guard let (amount, pin) = await prepareSubmission() else {
actionButtonState = .normal
dialogItem = .error(title: "Rate Unavailable", subtitle: "Couldn't get a fresh rate. Please try again.")
return
}

let sendLimit = session.sendLimitFor(currency: amount.nativeAmount.currency) ?? .zero
guard amount.nativeAmount.value <= sendLimit.maxPerDay.value else {
logger.info("Buy rejected: amount exceeds limit", metadata: [
"amount": "\(amount.nativeAmount.formatted())",
"max_per_day": "\(sendLimit.maxPerDay.value)",
"currency": "\(amount.nativeAmount.currency)",
])
actionButtonState = .normal
showLimitsError()
return
}

await performBuy(amount: amount, pin: pin, router: router)
}

private func performBuy(amount: ExchangedFiat, pin: VerifiedState, router: AppRouter) async {
do {
Analytics.buttonTapped(name: .buyWithReserves)
let swapId = try await session.buy(
amount: amount,
verifiedState: pin,
of: mint
)
actionButtonState = .normal
router.pushAny(BuyFlowPath.processing(
swapId: swapId,
currencyName: currencyName,
amount: amount,
swapType: .buyWithReserves
))
} catch Session.Error.insufficientBalance {
// Race: the balance gate said OK but the reserves buy disagreed.
actionButtonState = .normal
session.dialogItem = .noBalance(subtitle: AddMoneyContext.buyCurrency.noBalanceSubtitle) {
router.presentAddMoney(.buyCurrency, source: .buyShortfall)
}
} catch Session.Error.verifiedStateStale {
actionButtonState = .normal
} catch {
logger.error("Failed to buy currency from BuyAmountScreen", metadata: [
"mint": "\(mint.base58)",
"amount": "\(amount.nativeAmount.formatted())",
"error": "\(error)",
])
ErrorReporting.captureError(
error,
reason: "Failed to buy currency from BuyAmountScreen",
metadata: ["mint": mint.base58, "amount": amount.nativeAmount.formatted()]
)
actionButtonState = .normal
dialogItem = .error(title: "Something Went Wrong", subtitle: "Please try again later")
}
}

/// Pin verified state once, compute amount against the pin so quarks
/// stay tied to the rate the server is about to verify. The entered
/// amount is capped to the USDF balance so FX display rounding can't push
/// the quarks past the spendable reserves.
func prepareSubmission() async -> (amount: ExchangedFiat, pinnedState: VerifiedState)? {
let currency = ratesController.balanceCurrency
guard let pin = await ratesController.currentPinnedState(for: currency, mint: .usdf) else {
return nil
}
guard let entered = amountValidator.validate(enteredAmount) else { return nil }
guard let amount = ExchangedFiat.compute(
fromEntered: FiatAmount(value: entered, currency: pin.rate.currency),
rate: pin.rate,
mint: .usdf,
supplyQuarks: 0, // unused on the USDF path
balance: session.balance(for: .usdf)?.usdf
) else { return nil }
return (amount, pin)
}

private func computeAmount(using rate: Rate) -> ExchangedFiat? {
guard let amount = amountValidator.validate(enteredAmount) else { return nil }
return ExchangedFiat(
nativeAmount: FiatAmount(value: amount, currency: rate.currency),
rate: rate
func actionEnabled(_ entered: String) -> Bool {
// One balances scan per call: the cap feeds both the empty check and
// the display-limit gate (it's re-read on every keystroke).
let cap = maxPossibleAmount.nativeAmount
guard cap.hasDisplayableValue else { return true }
return EnterAmountCalculator.isWithinDisplayLimit(
enteredAmount: entered,
max: cap
)
}

// MARK: - Dialogs

private func showLimitsError() {
dialogItem = .error(
title: "Transaction Limit Reached",
subtitle: "You can only buy up to the transaction limit at a time"
)
/// Next pushes the payment-currency step; with nothing to spend the same
/// button routes to Add Money instead.
func primaryAction(router: AppRouter) {
if isBalanceEmpty {
router.presentAddMoney(.buyCurrency, source: .buyShortfall)
return
}
guard let entered = amountValidator.validate(enteredAmount) else { return }
router.pushAny(BuyFlowPath.selectPaymentCurrency(
targetMint: mint,
targetName: currencyName,
entered: FiatAmount(value: entered, currency: ratesController.balanceCurrency)
))
}
}
Loading