From 303703c936e630507b2bd052450f79d97b1b2c6b Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Thu, 16 Jul 2026 21:23:59 -0400 Subject: [PATCH 1/3] feat: buy currencies with other currencies Adds a payment-currency step to the buy flow: USDF or any launchpad token can fund a buy via the launchpad-to-launchpad StatefulSwap. Fixes multi-lookup-table account ordering in the transaction compiler and the funds gate's cross-rate compare. --- CLAUDE.md | 2 +- .../Screens/Main/AddMoney/AddMoneyGate.swift | 6 - .../Screens/Main/Buy/BuyAmountScreen.swift | 21 +- .../Screens/Main/Buy/BuyAmountViewModel.swift | 171 +++-------- .../Main/Buy/BuyConfirmationScreen.swift | 145 +++++++++ .../Main/Buy/BuyConfirmationViewModel.swift | 189 ++++++++++++ .../Main/Buy/BuyFlowDestinationView.swift | 20 ++ .../Core/Screens/Main/Buy/BuyFlowPath.swift | 20 +- .../Main/Buy/BuyPaymentCurrencyScreen.swift | 52 ++++ .../Buy/BuyPaymentCurrencyViewModel.swift | 123 ++++++++ .../Currency Info/CurrencyInfoScreen.swift | 17 +- .../CurrencySellAmountScreen.swift | 1 + .../CurrencySellConfirmationScreen.swift | 4 +- .../CurrencySellConfirmationViewModel.swift | 23 +- .../SwapProcessingViewModel.swift | 9 +- Flipcash/Core/Session/Session.swift | 49 +++- Flipcash/UI/EnterAmountCalculator.swift | 9 +- Flipcash/Utilities/Events.swift | 2 + .../Payments API/Client+Transaction.swift | 15 + .../Payments API/Services/SwapService.swift | 27 ++ .../Services/TransactionService.swift | 64 ++++ .../ExchangedFiat+LaunchpadSellFee.swift | 48 +++ .../FlipcashCore/Models/SwapModels.swift | 5 + .../CurrencyCreatorProgram.SellTokens.swift | 122 ++++++++ .../Solana/SolanaTransaction.swift | 20 +- .../Solana/SwapInstructionBuilder+Swap.swift | 192 ++++++++++++ .../Solana/TransactionBuilder.swift | 12 + ...urrencyCreatorProgramSellTokensTests.swift | 119 ++++++++ .../LaunchpadSellFeeTests.swift | 85 ++++++ .../SolanaTransactionEncodingTests.swift | 55 ++++ .../SwapInstructionBuilderSwapTests.swift | 213 ++++++++++++++ .../TransactionBuilderSwapErrorTests.swift | 45 +++ FlipcashTests/AddMoneyGateTests.swift | 25 +- .../Buy/BuyAmountViewModelTests.swift | 218 +++++++------- .../Buy/BuyConfirmationViewModelTests.swift | 277 ++++++++++++++++++ .../BuyPaymentCurrencyViewModelTests.swift | 215 ++++++++++++++ .../Buy/SessionBuyWithCurrencyTests.swift | 46 +++ .../EnterAmountCalculatorTests.swift | 15 +- .../Regression_native_amount_mismatch.swift | 64 ++-- .../SwapProcessingViewModelTests.swift | 8 +- .../AddMoneyGateRegressionTests.swift | 19 +- .../BuyApplePayRegressionTests.swift | 23 +- .../BuyDepositRegressionTests.swift | 34 +-- .../BuyPhantomRegressionTests.swift | 31 +- .../BuyReservesRegressionTests.swift | 28 +- .../BuyWithCurrencyRegressionTests.swift | 64 ++++ .../Support/Screens/AmountEntryScreen.swift | 20 -- .../Screens/BuyConfirmationUIScreen.swift | 39 +++ .../Screens/PaymentCurrencyUIScreen.swift | 37 +++ 49 files changed, 2570 insertions(+), 478 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift create mode 100644 Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift create mode 100644 Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyScreen.swift create mode 100644 Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyViewModel.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/ExchangedFiat+LaunchpadSellFee.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Solana/Programs/CurrencyCreatorProgram.SellTokens.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+Swap.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/CurrencyCreatorProgramSellTokensTests.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/LaunchpadSellFeeTests.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderSwapTests.swift create mode 100644 FlipcashTests/Buy/BuyConfirmationViewModelTests.swift create mode 100644 FlipcashTests/Buy/BuyPaymentCurrencyViewModelTests.swift create mode 100644 FlipcashTests/Buy/SessionBuyWithCurrencyTests.swift create mode 100644 FlipcashUITests/Regression/BuyWithCurrencyRegressionTests.swift create mode 100644 FlipcashUITests/Support/Screens/BuyConfirmationUIScreen.swift create mode 100644 FlipcashUITests/Support/Screens/PaymentCurrencyUIScreen.swift diff --git a/CLAUDE.md b/CLAUDE.md index ec2f5444f..4d6909214 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. --- diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift index 6f7fbb312..19adf24cf 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift @@ -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. diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index dd52e9164..3debae249 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -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) @@ -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) } diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift index d8a07e40f..22a2bd8af 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -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" } @@ -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) + )) } } diff --git a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift new file mode 100644 index 000000000..dd9f6e740 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift @@ -0,0 +1,145 @@ +// +// BuyConfirmationScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +struct BuyConfirmationScreen: View { + + @State private var viewModel: BuyConfirmationViewModel + + @Environment(AppRouter.self) private var router + @Environment(Session.self) private var session + + init(targetMint: PublicKey, targetName: String, payment: StoredBalance, paymentAmount: ExchangedFiat, pinnedState: VerifiedState) { + self._viewModel = State(initialValue: BuyConfirmationViewModel( + targetMint: targetMint, + targetName: targetName, + payment: payment, + paymentAmount: paymentAmount, + pinnedState: pinnedState + )) + } + + var body: some View { + @Bindable var viewModel = viewModel + Background(color: .backgroundMain) { + VStack { + Spacer() + + BorderedContainer { + VStack(spacing: 0) { + ConfirmationAmountRow( + title: "You Pay", + currencyName: viewModel.payment.name, + imageURL: viewModel.payment.imageURL, + amount: viewModel.paymentAmount.nativeAmount.formatted() + ) + .padding(.top, 24) + + if !viewModel.isUSDF { + VStack(spacing: 10) { + ConfirmationBreakdownRow( + title: "Amount to buy", + value: viewModel.amountToBuy.nativeAmount.formatted() + ) + ConfirmationBreakdownRow( + title: "Exchange fee", + value: viewModel.feeFormatted + ) + } + .padding() + } + + ConfirmationAmountRow( + title: "You Receive", + currencyName: viewModel.targetName, + imageURL: viewModel.targetImageURL, + amount: viewModel.amountToBuy.nativeAmount.formatted() + ) + .padding(.top, viewModel.isUSDF ? 24 : 0) + .padding(.bottom, 24) + } + } + + Spacer() + + CodeButton( + state: viewModel.actionButtonState, + style: .filled, + title: "Buy", + disabled: !viewModel.canPerformAction, + action: performBuy + ) + .accessibilityIdentifier("buy-confirmation-buy") + } + .padding(20) + } + .navigationTitle("Buy") + .toolbarTitleDisplayMode(.inline) + // A submit is a live money movement — keep the user on this screen + // until it resolves (the sheet's swipe-dismiss is already blocked at + // the stack root while any step is pushed). + .navigationBarBackButtonHidden(viewModel.actionButtonState == .loading) + .dialog(item: $viewModel.dialogItem) + .task { await viewModel.loadTargetImage(session: session) } + } + + // MARK: - Actions - + + private func performBuy() { + Task { await viewModel.buyAction(session: session, router: router) } + } +} + +/// A centered label-over-amount block with the currency's icon, used for the +/// You Pay / You Receive rows. +private struct ConfirmationAmountRow: View { + let title: String + let currencyName: String + let imageURL: URL? + let amount: String + + var body: some View { + VStack(spacing: 8) { + Text(title) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + HStack(spacing: 8) { + if let imageURL { + RemoteImage(url: imageURL) + .frame(width: 24, height: 24) + .clipShape(Circle()) + } + Text(amount) + .font(.appDisplaySmall) + .foregroundStyle(Color.textMain) + } + } + // The icon is the only visual carrier of WHICH currency this is — + // VoiceOver needs the name spoken alongside the amount. + .accessibilityElement(children: .combine) + .accessibilityLabel("\(title): \(amount) of \(currencyName)") + } +} + +/// A leading title / trailing value line for the fee breakdown. +private struct ConfirmationBreakdownRow: View { + let title: String + let value: String + + var body: some View { + HStack { + Text(title) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + Spacer() + Text(value) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift new file mode 100644 index 000000000..8e019e92b --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift @@ -0,0 +1,189 @@ +// +// BuyConfirmationViewModel.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.buy-confirmation") + +@Observable +@MainActor +final class BuyConfirmationViewModel { + + @ObservationIgnored let targetMint: PublicKey + @ObservationIgnored let targetName: String + @ObservationIgnored let payment: StoredBalance + @ObservationIgnored let pinnedState: VerifiedState + + var dialogItem: DialogItem? + private(set) var actionButtonState: ButtonState = .normal + /// Gross debit in the payment token. Mutated in place by Buy Maximum. + private(set) var paymentAmount: ExchangedFiat + /// Icon for the You Receive row, resolved from cached mint metadata. + private(set) var targetImageURL: URL? + + var isUSDF: Bool { payment.mint == .usdf } + + var canPerformAction: Bool { !pinnedState.isStale } + + var feeBps: UInt64 { UInt64(max(0, payment.sellFeeBps ?? 100)) } + + var fee: ExchangedFiat { + paymentAmount.launchpadSellFee(bps: feeBps) + } + + /// Formats the fee, prefixing "~" when non-zero but below display precision. + var feeFormatted: String { + let prefix = fee.isApproximatelyZero() ? "~" : "" + return "\(prefix)\(fee.nativeAmount.formatted())" + } + + /// What the buy leg actually purchases — the gross debit minus the pool fee. + var amountToBuy: ExchangedFiat { + isUSDF ? paymentAmount : paymentAmount.subtractingFee(fee.onChainAmount) + } + + init(targetMint: PublicKey, targetName: String, payment: StoredBalance, paymentAmount: ExchangedFiat, pinnedState: VerifiedState) { + self.targetMint = targetMint + self.targetName = targetName + self.payment = payment + self.paymentAmount = paymentAmount + self.pinnedState = pinnedState + } + + // MARK: - Actions + + func loadTargetImage(session: Session) async { + targetImageURL = try? await session.fetchMintMetadata(mint: targetMint).imageURL + } + + func buyAction(session: Session, router: AppRouter) async { + // Re-entrancy guard: don't rely on the button disabling itself. + guard actionButtonState == .normal else { return } + + // The entry cap is balance-only; the send limit is enforced here. + let sendLimit = session.sendLimitFor(currency: paymentAmount.nativeAmount.currency) ?? .zero + guard paymentAmount.nativeAmount.value <= sendLimit.maxPerDay.value else { + logger.info("Buy rejected: amount exceeds limit", metadata: [ + "amount": "\(paymentAmount.nativeAmount.formatted())", + "max_per_day": "\(sendLimit.maxPerDay.value)", + "currency": "\(paymentAmount.nativeAmount.currency)", + ]) + dialogItem = .error( + title: "Transaction Limit Reached", + subtitle: "You can only buy up to the transaction limit at a time" + ) + return + } + + switch session.hasSufficientFunds(for: paymentAmount) { + case .sufficient: + // Submit the pin-computed amount, not the gate's clamp — quarks + // must stay tied to the pinned proof. A tolerance overshoot is + // clamped against the pin inside `Session.buy`. + await submit(session: session, router: router) + case .insufficient: + logger.info("Buy gated: insufficient balance", metadata: [ + "paymentMint": "\(payment.mint.base58)", + "amountQuarks": "\(paymentAmount.onChainAmount.quarks)", + "balanceQuarks": "\(session.balance(for: payment.mint)?.quarks ?? 0)", + ]) + showInsufficientBalance(session: session) + } + } + + /// Recomputes the summary in place to spend the entire payment balance — + /// the user still confirms with Buy. + func buyMaximum(session: Session) { + guard let live = session.balance(for: payment.mint) else { return } + // USDF needs no reserve supply; bonded mints do (a nil supply would + // value the balance as zero). + let supply = pinnedState.supplyFromBonding + guard isUSDF || supply != nil else { return } + + logger.info("Buy maximum selected", metadata: [ + "paymentMint": "\(payment.mint.base58)", + "previousQuarks": "\(paymentAmount.onChainAmount.quarks)", + "balanceQuarks": "\(live.quarks)", + ]) + + paymentAmount = ExchangedFiat.compute( + onChainAmount: TokenAmount(quarks: live.quarks, mint: payment.mint), + rate: pinnedState.rate, + supplyQuarks: supply + ) + } + + private func submit(session: Session, router: AppRouter) async { + actionButtonState = .loading + do { + let swapId: SwapId + let swapType: SwapType + if isUSDF { + Analytics.buttonTapped(name: .buyWithReserves) + swapId = try await session.buy(amount: paymentAmount, verifiedState: pinnedState, of: targetMint) + swapType = .buyWithReserves + } else { + Analytics.buttonTapped(name: .buyWithCurrency) + swapId = try await session.buy(amount: paymentAmount, with: payment.mint, verifiedState: pinnedState, of: targetMint) + swapType = .buyWithCurrency + } + actionButtonState = .normal + router.pushAny(BuyFlowPath.processing( + swapId: swapId, + currencyName: targetName, + amount: amountToBuy, + swapType: swapType + )) + } 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 { + // Session.assertFresh already logged this. The pin can't refresh + // on this screen (quarks are tied to it), so give the user a way + // out instead of a silently disabled button. + actionButtonState = .normal + dialogItem = .error( + title: "Rate Expired", + subtitle: "This quote is no longer valid. Please go back and select the payment currency again." + ) + } catch { + logger.error("Failed to buy currency from BuyConfirmationScreen", metadata: [ + "targetMint": "\(targetMint.base58)", + "paymentMint": "\(payment.mint.base58)", + "amount": "\(paymentAmount.nativeAmount.formatted())", + "error": "\(error)", + ]) + ErrorReporting.captureError( + error, + reason: "Failed to buy currency from BuyConfirmationScreen", + metadata: ["targetMint": targetMint.base58, "paymentMint": payment.mint.base58] + ) + actionButtonState = .normal + // A submit failure can land after the user popped this screen — + // `session.dialogItem` renders in `DialogWindow` above every + // sheet, so the error survives the view's teardown. + session.dialogItem = .error(title: "Something Went Wrong", subtitle: "Please try again later") + } + } + + // MARK: - Dialogs + + private func showInsufficientBalance(session: Session) { + dialogItem = .info( + title: isUSDF ? "Insufficient Balance" : "Insufficient Balance After Fees", + subtitle: "Switch to maximum amount, or go back and enter a smaller amount" + ) { + .standard("Buy Maximum Amount") { [weak self] in + self?.buyMaximum(session: session) + }; + .dismiss(kind: .subtle) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift index 35b077082..e63b542ec 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift @@ -14,8 +14,28 @@ struct BuyFlowDestinationView: View { let path: BuyFlowPath + @Environment(SessionContainer.self) private var sessionContainer + var body: some View { switch path { + case .selectPaymentCurrency(let targetMint, let targetName, let entered): + BuyPaymentCurrencyScreen( + targetMint: targetMint, + targetName: targetName, + entered: entered, + session: sessionContainer.session, + ratesController: sessionContainer.ratesController + ) + + case .paymentConfirmation(let targetMint, let targetName, let payment, let paymentAmount, let pinnedState): + BuyConfirmationScreen( + targetMint: targetMint, + targetName: targetName, + payment: payment, + paymentAmount: paymentAmount, + pinnedState: pinnedState + ) + case .processing(let swapId, let currencyName, let amount, let swapType): SwapProcessingScreen( swapId: swapId, diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift index e48993e11..be308a9e6 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift @@ -9,14 +9,22 @@ import Foundation import FlipcashCore /// Sub-flow path for the buy stack. The `.buy(mint)` sheet's root is -/// `BuyAmountScreen`; secondary screens (Phantom education/confirm, USDC -/// deposit education/address, post-buy processing) are pushed onto the same -/// stack via `router.pushAny(_:)`. +/// `BuyAmountScreen`; secondary screens (payment currency selection, buy +/// summary, post-buy processing) are pushed onto the same stack via +/// `router.pushAny(_:)`. /// /// Modelled as a Hashable enum (not `AppRouter.Destination` cases) because the -/// associated values include `ExchangedFiat` and `SwapId` — both already -/// Hashable + Sendable. Keeping these out of `Destination` matches the -/// `WithdrawNavigationPath` pattern. +/// associated values include `ExchangedFiat`, `VerifiedState` and `SwapId` — +/// all already Hashable + Sendable. Keeping these out of `Destination` matches +/// the `WithdrawNavigationPath` pattern. enum BuyFlowPath: Hashable, Sendable { + case selectPaymentCurrency(targetMint: PublicKey, targetName: String, entered: FiatAmount) + case paymentConfirmation( + targetMint: PublicKey, + targetName: String, + payment: StoredBalance, + paymentAmount: ExchangedFiat, + pinnedState: VerifiedState + ) case processing(swapId: SwapId, currencyName: String, amount: ExchangedFiat, swapType: SwapType) } diff --git a/Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyScreen.swift new file mode 100644 index 000000000..320c23aa5 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyScreen.swift @@ -0,0 +1,52 @@ +// +// BuyPaymentCurrencyScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +struct BuyPaymentCurrencyScreen: View { + + @State private var viewModel: BuyPaymentCurrencyViewModel + + @Environment(AppRouter.self) private var router + + init(targetMint: PublicKey, targetName: String, entered: FiatAmount, session: Session, ratesController: RatesController) { + self._viewModel = State(initialValue: BuyPaymentCurrencyViewModel( + targetMint: targetMint, + targetName: targetName, + entered: entered, + session: session, + ratesController: ratesController + )) + } + + var body: some View { + @Bindable var viewModel = viewModel + Background(color: .backgroundMain) { + List { + Section { + ForEach(viewModel.rows) { row in + CurrencyBalanceRow( + exchangedBalance: row, + accessibilityIdentifier: row.stored.mint == .usdf ? "payment-currency-row-usdf" : "payment-currency-row", + accessory: .chevron, + action: { + Task { await viewModel.select(row, router: router) } + } + ) + .vSeparator(color: .rowSeparator) + } + } + .listRowInsets(EdgeInsets()) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + .navigationTitle("Select Payment Currency") + .toolbarTitleDisplayMode(.inline) + .dialog(item: $viewModel.dialogItem) + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyViewModel.swift new file mode 100644 index 000000000..72b25d4d6 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyViewModel.swift @@ -0,0 +1,123 @@ +// +// BuyPaymentCurrencyViewModel.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.buy-payment-currency") + +@Observable +@MainActor +final class BuyPaymentCurrencyViewModel { + + var dialogItem: DialogItem? + + @ObservationIgnored let targetMint: PublicKey + @ObservationIgnored let targetName: String + @ObservationIgnored let entered: FiatAmount + + /// Spendable payment sources: the currency being bought is excluded (the + /// server rejects same-mint swaps) and so are zero-value balances — a + /// $0.00 source has no maximum to buy. Underfunded balances stay listed + /// and tappable; their Buy surfaces the insufficient sheet offering the + /// maximum amount instead. + var rows: [ExchangedBalance] { + let rate = ratesController.rateForBalanceCurrency() + return session.balances(for: rate).filter { balance in + balance.stored.mint != targetMint && balance.exchangedFiat.hasDisplayableValue() + } + } + + @ObservationIgnored private let session: Session + @ObservationIgnored private let ratesController: RatesController + /// Double-tap guard around the async pin fetch. + private var isSelecting = false + + init(targetMint: PublicKey, targetName: String, entered: FiatAmount, session: Session, ratesController: RatesController) { + self.targetMint = targetMint + self.targetName = targetName + self.entered = entered + self.session = session + self.ratesController = ratesController + } + + // MARK: - Selection + + func select(_ balance: ExchangedBalance, router: AppRouter) async { + guard !isSelecting else { return } + isSelecting = true + defer { isSelecting = false } + + guard let pin = await ratesController.currentPinnedState(for: entered.currency, mint: balance.stored.mint) else { + logger.warning("No pinned state for payment mint", metadata: [ + "paymentMint": "\(balance.stored.mint.base58)", + "currency": "\(entered.currency.rawValue)", + ]) + showRateUnavailable() + return + } + guard let paymentAmount = computePaymentAmount(for: balance.stored, pin: pin) else { + logger.warning("Payment amount compute failed", metadata: [ + "paymentMint": "\(balance.stored.mint.base58)", + "entered": "\(entered.formatted())", + "hasSupply": "\(pin.supplyFromBonding != nil)", + ]) + showRateUnavailable() + return + } + + router.pushAny(BuyFlowPath.paymentConfirmation( + targetMint: targetMint, + targetName: targetName, + payment: balance.stored, + paymentAmount: paymentAmount, + pinnedState: pin + )) + } + + /// Converts the entered (net) fiat into the payment token's gross debit + /// against the pinned rate + supply. + func computePaymentAmount(for balance: StoredBalance, pin: VerifiedState) -> ExchangedFiat? { + let entered = FiatAmount(value: entered.value, currency: pin.rate.currency) + + if balance.mint == .usdf { + // No fee on the USDF path. Within the displayed balance the + // compute is balance-capped so FX display rounding can't push the + // quarks past the spendable reserves; past it the compute is + // deliberately uncapped so the confirmation's gate can offer + // Buy Maximum instead of silently shrinking the entry. + let displayedBalance = balance.usdf.converting(to: pin.rate).value + .rounded(to: entered.currency.maximumFractionDigits) + let isWithinDisplayedBalance = entered.value <= displayedBalance + return ExchangedFiat.compute( + fromEntered: entered, + rate: pin.rate, + mint: .usdf, + supplyQuarks: 0, // unused on the USDF path + balance: isWithinDisplayedBalance ? session.balance(for: .usdf)?.usdf : nil + ) + } + + guard let supply = pin.supplyFromBonding else { return nil } + + // Deliberately uncapped: when the fee doesn't fit, the gross must + // exceed the balance so the confirmation's gate can offer Buy Maximum + // explicitly instead of silently clamping. + let gross = entered.grossingUpLaunchpadSellFee(bps: UInt64(max(0, balance.sellFeeBps ?? 100))) + return ExchangedFiat.compute( + fromEntered: gross, + rate: pin.rate, + mint: balance.mint, + supplyQuarks: supply + ) + } + + // MARK: - Dialogs + + private func showRateUnavailable() { + dialogItem = .error(title: "Rate Unavailable", subtitle: "Couldn't get a fresh rate. Please try again.") + } +} diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index c53f10708..288c7eabb 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -113,7 +113,7 @@ private struct CurrencyInfoScreenContent: View { marketCapController: marketCapController, onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint)) }, onShowCurrencySelection: { isShowingCurrencySelection = true }, - onBuy: { presentBuyOrAddMoney() }, + onBuy: { router.presentNested(.buy(mint)) }, onGive: { Analytics.buttonTapped(name: .give) router.push(.give(mint)) @@ -156,7 +156,7 @@ private struct CurrencyInfoScreenContent: View { await viewModel.loadMintMetadata() if showBuyOnAppear { - presentBuyOrAddMoney() + router.presentNested(.buy(mint)) } } .sheet(item: $presentedSellViewModel) { sellViewModel in @@ -172,19 +172,6 @@ private struct CurrencyInfoScreenContent: View { // that competes with the `.buy` sheet's presentation queue. } - /// Routes the Buy button (and the buy-on-appear deeplink) to the Add Money - /// flow when the user has no USDF reserves, or straight into the buy amount - /// sheet when they do. - private func presentBuyOrAddMoney() { - if shouldAddMoneyBeforeBuy(session: session) { - session.dialogItem = .noBalance(subtitle: AddMoneyContext.buyCurrency.noBalanceSubtitle) { - router.presentAddMoney(.buyCurrency, source: .buyShortfall) - } - } else { - router.presentNested(.buy(mint)) - } - } - @ViewBuilder private func toolbarContent() -> some View { if let metadata = mintMetadata { if metadata.mint == .usdf { diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellAmountScreen.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellAmountScreen.swift index 3c4e81fbe..bd154169e 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellAmountScreen.swift @@ -52,6 +52,7 @@ struct CurrencySellAmountScreen: View { currencyName: viewModel.currencyMetadata.name, amount: amount, pinnedState: pinnedState, + sellFeeBps: viewModel.currencyMetadata.sellFeeBps, path: $viewModel.path ) .environment(\.dismissParentContainer, { diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationScreen.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationScreen.swift index f124e665b..c200ced66 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationScreen.swift @@ -20,11 +20,11 @@ struct CurrencySellConfirmationScreen: View { // MARK: - Init - - init(mint: PublicKey, currencyName: String, amount: ExchangedFiat, pinnedState: VerifiedState, path: Binding<[CurrencySellPath]>) { + init(mint: PublicKey, currencyName: String, amount: ExchangedFiat, pinnedState: VerifiedState, sellFeeBps: Int?, path: Binding<[CurrencySellPath]>) { self.currencyName = currencyName self.amount = amount self._path = path - self.viewModel = CurrencySellConfirmationViewModel(mint: mint, amount: amount, pinnedState: pinnedState) + self.viewModel = CurrencySellConfirmationViewModel(mint: mint, amount: amount, pinnedState: pinnedState, sellFeeBps: sellFeeBps) } var body: some View { diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift index 6d7cbe542..01f0673bc 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift @@ -14,6 +14,9 @@ class CurrencySellConfirmationViewModel { @ObservationIgnored let mint: PublicKey @ObservationIgnored let amount: ExchangedFiat @ObservationIgnored let pinnedState: VerifiedState + /// The pool's sell fee in basis points; nil falls back to the launchpad + /// default (100). + @ObservationIgnored let sellFeeBps: Int? var dialogItem: DialogItem? private(set) var actionButtonState: ButtonState = .normal @@ -27,22 +30,7 @@ class CurrencySellConfirmationViewModel { } var fee: ExchangedFiat { - let bps: UInt64 = 100 - let feeQuarks = amount.onChainAmount.quarks * bps / 10_000 - let feeOnChain = TokenAmount( - quarks: feeQuarks, - mint: amount.onChainAmount.mint - ) - // Scale native by the *actual* on-chain ratio (not the static bps), - // so a fee that rounds down to 0 quarks also displays as 0 fiat. - let scale: Decimal = amount.onChainAmount.quarks > 0 - ? Decimal(feeQuarks) / Decimal(amount.onChainAmount.quarks) - : 0 - return ExchangedFiat( - onChainAmount: feeOnChain, - nativeAmount: amount.nativeAmount * scale, - currencyRate: amount.currencyRate, - ) + amount.launchpadSellFee(bps: UInt64(max(0, sellFeeBps ?? 100))) } /// Formats the fee for display, prefixing with "~" when the value is @@ -59,10 +47,11 @@ class CurrencySellConfirmationViewModel { // MARK: - Init - - init(mint: PublicKey, amount: ExchangedFiat, pinnedState: VerifiedState) { + init(mint: PublicKey, amount: ExchangedFiat, pinnedState: VerifiedState, sellFeeBps: Int? = nil) { self.mint = mint self.amount = amount self.pinnedState = pinnedState + self.sellFeeBps = sellFeeBps } // MARK: - Actions - diff --git a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift index a428a71da..fcbcbf296 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift @@ -28,7 +28,7 @@ class SwapProcessingViewModel { case .success: if let exchangedFiat { switch swapType { - case .buyWithReserves, .launchWithReserves: + case .buyWithReserves, .buyWithCurrency, .launchWithReserves: return "\(exchangedFiat.nativeAmount.formatted()) of \(currencyName)" case .sell: return "\(exchangedFiat.nativeAmount.formatted()) of USDF" @@ -46,7 +46,7 @@ class SwapProcessingViewModel { return "This transaction typically takes about a minute. You may leave the app while it completes" case .success: switch swapType { - case .buyWithReserves, .launchWithReserves, .sell: + case .buyWithReserves, .buyWithCurrency, .launchWithReserves, .sell: return "was just added to your Flipcash wallet" } case .failed: @@ -69,7 +69,7 @@ class SwapProcessingViewModel { switch displayState { case .processing: switch swapType { - case .buyWithReserves, .launchWithReserves: + case .buyWithReserves, .buyWithCurrency, .launchWithReserves: "Purchasing \(currencyName)" case .sell: "Selling \(currencyName)" @@ -184,6 +184,8 @@ class SwapProcessingViewModel { switch swapType { case .buyWithReserves, .launchWithReserves: Analytics.tokenPurchase(method: .purchaseWithReserves, exchangedFiat: amount, successful: successful) + case .buyWithCurrency: + Analytics.tokenPurchase(method: .purchaseWithCurrency, exchangedFiat: amount, successful: successful) case .sell: Analytics.tokenSell(exchangedFiat: amount, successful: successful) } @@ -210,6 +212,7 @@ enum SwapError: Error { nonisolated enum SwapType: CaseIterable { case buyWithReserves + case buyWithCurrency case launchWithReserves case sell } diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 6d2826918..5674192e5 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -427,8 +427,12 @@ class Session { return .insufficient(shortfall: nil) } - let rate = ratesController.rateForBalanceCurrency() - let exchangedBalance = balance.computeExchangedValue(with: rate) + // Value the balance at the REQUESTED amount's rate, not the live cache. + // Pinned-rate callers (the buy confirmation) would otherwise compare — + // and `subtracting` — across two different rates: a rule-4 violation + // that trips ExchangedFiat's same-rate precondition when the live rate + // drifts after the pin. Live-rate callers are unaffected (same rate). + let exchangedBalance = balance.computeExchangedValue(with: exchangedFiat.currencyRate) if exchangedFiat.onChainAmount <= exchangedBalance.onChainAmount { // Sufficient funds - send the requested amount @@ -647,6 +651,47 @@ class Session { return try await client.buy(amount: amount, verifiedState: verifiedState, of: token.metadata, owner: owner) } + /// Buys `mint` paying with another launchpad currency. `amount` is denominated + /// in the payment token and is the gross debit — the payment pool's sell fee + /// is implicit in the swap. + @discardableResult + func buy(amount: ExchangedFiat, with paymentMint: PublicKey, verifiedState: VerifiedState, of mint: PublicKey) async throws -> SwapId { + try assertFresh(verifiedState, operation: "buyWithCurrency", currency: amount.nativeAmount.currency, mint: paymentMint) + + guard let supply = verifiedState.supplyFromBonding else { + throw Error.missingSupply + } + + let paymentToken = try await fetchMintMetadata(mint: paymentMint) + let token = try await fetchMintMetadata(mint: mint) + + // Cap to the on-chain balance when rounding pushed quarks above it — + // the confirmation gate should have prevented this (sell parity). + let amountForIntent: ExchangedFiat + if let balance = balance(for: paymentMint), amount.onChainAmount.quarks > balance.quarks { + logger.error("Buy-with-currency workaround branch fired — gating should have prevented this", metadata: [ + "amountQuarks": "\(amount.onChainAmount.quarks)", + "balanceQuarks": "\(balance.quarks)", + "paymentMint": "\(paymentMint.base58)", + ]) + amountForIntent = ExchangedFiat.compute( + onChainAmount: TokenAmount(quarks: balance.quarks, mint: paymentMint), + rate: verifiedState.rate, + supplyQuarks: supply + ) + } else { + amountForIntent = amount + } + + logger.info("buying with currency", metadata: [ + "amount": "\(amountForIntent.nativeAmount.formatted())", + "paymentSymbol": "\(paymentToken.symbol)", + "symbol": "\(token.symbol)" + ]) + + return try await client.buy(amount: amountForIntent, with: paymentToken.metadata, verifiedState: verifiedState, of: token.metadata, owner: owner) + } + @discardableResult func launchCurrency( name: String, diff --git a/Flipcash/UI/EnterAmountCalculator.swift b/Flipcash/UI/EnterAmountCalculator.swift index 070149861..3a8b0522f 100644 --- a/Flipcash/UI/EnterAmountCalculator.swift +++ b/Flipcash/UI/EnterAmountCalculator.swift @@ -37,8 +37,13 @@ nonisolated struct EnterAmountCalculator { // Give: effective limit is the lower of per-tx cap and remaining daily guard let limit = sendLimitProvider(currency) else { return nil } return min(limit.maxPerTransaction, limit.nextTransaction) - case .buy, .addMoney: - // Buy / Add Money: per-tx limit is the daily cap (no daily accumulation limit) + case .buy: + // Buy entry is capped by the highest spendable balance, not the + // send limit — the limit check happens at submission on the Buy + // summary. + return nil + case .addMoney: + // Add Money: per-tx limit is the daily cap (no daily accumulation limit) guard let limit = sendLimitProvider(currency) else { return nil } return limit.maxPerDay case .sell, .withdraw: diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index f99970c1b..7e25c1f38 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -29,6 +29,7 @@ extension Analytics { case allowPush = "Button: Allow Push" case skipPush = "Button: Skip Push" case buyWithReserves = "Button: Buy With Reserves" + case buyWithCurrency = "Button: Buy With Currency" case give = "Button: Give" case sell = "Button: Sell" case shareTokenInfo = "Button: Share Token Info" @@ -86,6 +87,7 @@ extension Analytics { enum TokenTransactionEvent: String, AnalyticsEvent { case purchaseWithReserves = "Token Purchase With Reserves" + case purchaseWithCurrency = "Token Purchase With Currency" case sell = "Token Sell" case withdraw = "Token Withdrawal" } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift index d999cf50c..ff66f2f85 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift @@ -131,6 +131,21 @@ extension Client { } } + /// Buy tokens paying with another launchpad currency (Phase 1 + Phase 2). + @discardableResult + public func buy(amount: ExchangedFiat, with paymentToken: MintMetadata, verifiedState: VerifiedState, of token: MintMetadata, owner: AccountCluster) async throws -> SwapId { + // Ensure the timelock vault account exists for the target mint + try await ensureAccountExists( + owner: owner.authority.keyPair, + ownerAuthority: owner.authority, + token: token + ) + + return try await withCheckedThrowingContinuation { c in + transactionService.buy(amount: amount, with: paymentToken, verifiedState: verifiedState, of: token, owner: owner) { c.resume(with: $0) } + } + } + @discardableResult public func sell(amount: ExchangedFiat, verifiedState: VerifiedState, in token: MintMetadata, owner: AccountCluster) async throws -> SwapId { try await withCheckedThrowingContinuation { c in diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/SwapService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/SwapService.swift index c50623c51..53cee0f03 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/SwapService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/SwapService.swift @@ -99,6 +99,25 @@ final class SwapService: Sendable { completion(.failure(.invalidSwap(reasons: []))) return } + case .swap(let sourceMint, let targetMint): + for mint in [sourceMint, targetMint] { + guard mint.vmMetadata != nil else { + logger.error("Swap mint missing VM metadata", metadata: [ + "symbol": "\(mint.symbol)", + "mint": "\(mint.address.base58)" + ]) + completion(.failure(.invalidSwap(reasons: []))) + return + } + guard mint.launchpadMetadata != nil else { + logger.error("Swap mint missing launchpad metadata", metadata: [ + "symbol": "\(mint.symbol)", + "mint": "\(mint.address.base58)" + ]) + completion(.failure(.invalidSwap(reasons: []))) + return + } + } case .withdraw: // No VM / launchpad validation: the destination is USDC (an external // SPL token) and validation is server-side via CoinbaseStableSwapperSwapHandler. @@ -184,6 +203,14 @@ final class SwapService: Sendable { completion(.failure(.unknown)) return } + + // Log the serialized transaction so it can be diffed against + // the server's `expected_transaction` when `signatureError` + // comes back (parity with the new-currency path). + logger.debug("Swap transaction built", metadata: [ + "txBytes": "\(transaction.encode().hexEncodedString())" + ]) + let signatures = transaction.signatures(using: owner, swapAuthority) pendingSwap.withLock { $0.signature = signatures.first } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift index e927beddf..9f55e5044 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift @@ -246,6 +246,70 @@ final class TransactionService: Sendable { } } + /// A buy funded by another launchpad currency — a launchpad→launchpad swap + /// (Phase 1 + Phase 2 via IntentFundSwap). The payment pool's sell fee is + /// implicit in the swap; `fee_amount` stays 0 (server-enforced). + func buy(amount: ExchangedFiat, with paymentToken: MintMetadata, verifiedState: VerifiedState, of token: MintMetadata, owner: AccountCluster, completion: @Sendable @escaping (Result) -> Void) { + let swapId = SwapId.generate() + let fundingIntentID = KeyPair.generate()!.publicKey + let ownerKeyPair = owner.authority.keyPair + + guard let paymentVmAuthority = paymentToken.vmMetadata?.authority else { + logger.error("Failed to find VM authority for payment token", metadata: [ + "symbol": "\(paymentToken.symbol)", + "mint": "\(paymentToken.address.base58)" + ]) + completion(.failure(.unknown)) + return + } + + logger.info("Starting buy with currency", metadata: [ + "amount": "\(amount.nativeAmount.formatted())", + "paymentSymbol": "\(paymentToken.symbol)", + "symbol": "\(token.symbol)" + ]) + + // Phase 1: Create swap state on the server + swapService.swap( + swapId: swapId, + direction: .swap(from: paymentToken, to: token), + amount: amount.onChainAmount, + fundingSource: .submitIntent(id: fundingIntentID), + owner: ownerKeyPair + ) { result in + switch result { + case .success(let metadata): + logger.info("Swap state created", metadata: ["swapId": "\(swapId.publicKey.base58)"]) + + // Phase 2: Fund the payment token's VM swap PDA via IntentFundSwap + let fundingIntent = IntentFundSwap( + intentID: fundingIntentID, + swapId: metadata.swapId, + sourceCluster: owner.use(mint: paymentToken.address, timeAuthority: paymentVmAuthority), + amount: amount, + verifiedState: verifiedState, + fromMint: paymentToken, + toMint: token + ) + + self.submit(intent: fundingIntent, owner: ownerKeyPair) { fundingResult in + switch fundingResult { + case .success: + logger.info("Buy with currency completed", metadata: ["intentId": "\(fundingIntentID.base58)"]) + completion(.success(swapId)) + case .failure(let error): + logger.error("Failed to fund buy-with-currency swap", metadata: ["error": "\(error)"]) + completion(.failure(.fundingIntent(error))) + } + } + + case .failure(let error): + logger.error("Failed to start buy-with-currency swap", metadata: ["error": "\(error)"]) + completion(.failure(error)) + } + } + } + /// Withdraws USDF to a Solana wallet as USDC via Coinbase Stable Swapper. /// Phase 1 + Phase 2 mirroring `buy()`: stateful swap stream → IntentFundSwap submission. func withdrawAsUSDC( diff --git a/FlipcashCore/Sources/FlipcashCore/Models/ExchangedFiat+LaunchpadSellFee.swift b/FlipcashCore/Sources/FlipcashCore/Models/ExchangedFiat+LaunchpadSellFee.swift new file mode 100644 index 000000000..8d2c7ee0a --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/ExchangedFiat+LaunchpadSellFee.swift @@ -0,0 +1,48 @@ +// +// ExchangedFiat+LaunchpadSellFee.swift +// FlipcashCore +// + +import Foundation + +public extension ExchangedFiat { + /// The launchpad pool's sell fee taken from this amount when it is sold. + /// + /// Native is scaled by the *actual* on-chain ratio (not the static bps), + /// so a fee that rounds down to 0 quarks also displays as 0 fiat. + func launchpadSellFee(bps: UInt64) -> ExchangedFiat { + // Split multiply: quarks × bps overflows UInt64 for large launchpad + // balances (10-decimal quarks). Exact for any bps ≤ 10⁴. + let bps = min(bps, 10_000) + let quarks = onChainAmount.quarks + let feeQuarks = (quarks / 10_000) * bps + (quarks % 10_000) * bps / 10_000 + let feeOnChain = TokenAmount( + quarks: feeQuarks, + mint: onChainAmount.mint + ) + let scale: Decimal = onChainAmount.quarks > 0 + ? Decimal(feeQuarks) / Decimal(onChainAmount.quarks) + : 0 + return ExchangedFiat( + onChainAmount: feeOnChain, + nativeAmount: nativeAmount * scale, + currencyRate: currencyRate, + ) + } +} + +public extension FiatAmount { + /// The gross fiat whose launchpad sell proceeds net to this amount after + /// the pool fee: `net / (1 − bps/10⁴)`. + /// + /// Unrounded by design — callers feed the result to + /// `ExchangedFiat.compute(fromEntered:)`, whose fiat→quark boundary owns + /// the rounding. + func grossingUpLaunchpadSellFee(bps: UInt64) -> FiatAmount { + // A 100% fee can't be grossed up — the guard keeps server-sourced bps + // from dividing by zero; the flow gates insufficient downstream anyway. + guard bps < 10_000 else { return self } + let feeFraction = Decimal(bps) / Decimal(10_000) + return FiatAmount(value: value / (1 - feeFraction), currency: currency) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/SwapModels.swift b/FlipcashCore/Sources/FlipcashCore/Models/SwapModels.swift index 33c3bce3b..8e5e9b6f5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/SwapModels.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/SwapModels.swift @@ -651,6 +651,7 @@ extension SwapResponseServerParameters { enum SwapDirection { case buy(mint: MintMetadata) // USDF -> Bonded Token case sell(mint: MintMetadata) // Bonded Token -> USDF + case swap(from: MintMetadata, to: MintMetadata) // Bonded Token -> Bonded Token case withdraw(mint: MintMetadata) // USDF -> USDC via Coinbase Stable Swapper var sourceMint: MintMetadata { @@ -659,6 +660,8 @@ enum SwapDirection { return .usdf case .sell(let mint): return mint + case .swap(let from, _): + return from case .withdraw: return .usdf } @@ -670,6 +673,8 @@ enum SwapDirection { return mint case .sell: return .usdf + case .swap(_, let to): + return to case .withdraw(let mint): return mint } diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CurrencyCreatorProgram.SellTokens.swift b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CurrencyCreatorProgram.SellTokens.swift new file mode 100644 index 000000000..2f5e72b64 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CurrencyCreatorProgram.SellTokens.swift @@ -0,0 +1,122 @@ +// +// CurrencyCreatorProgram.SellTokens.swift +// FlipcashCore +// + +import Foundation + +extension CurrencyCreatorProgram { + + /// Sell target-mint (launchpad currency) tokens for base-mint (USDF) tokens, + /// transferring the proceeds into a pre-existing `sellerBase` ATA. Distinct + /// from `SellAndDepositIntoVm` — no VM deposit step; proceeds land in an ATA. + /// + /// Used by the launchpad→launchpad swap transaction where `sellerBase` is + /// the temporary Core Mint ATA that funds the subsequent `BuyAndDepositIntoVm`. + /// + /// Account structure (mirrors OCP SellTokensInstructionAccounts): + /// 0. [WRITE, SIGNER] Seller + /// 1. [WRITE] Pool PDA + /// 2. [] Target mint + /// 3. [] Base mint + /// 4. [WRITE] Vault target PDA + /// 5. [WRITE] Vault base PDA + /// 6. [WRITE] Seller target token account + /// 7. [WRITE] Seller base token account + /// 8. [] SPL Token program + public struct SellTokens: Equatable, Hashable, Codable { + + public let seller: PublicKey + public let pool: PublicKey + public let targetMint: PublicKey + public let baseMint: PublicKey + public let vaultTarget: PublicKey + public let vaultBase: PublicKey + public let sellerTarget: PublicKey + public let sellerBase: PublicKey + public let inAmount: UInt64 + public let minAmountOut: UInt64 + + public init( + seller: PublicKey, + pool: PublicKey, + targetMint: PublicKey, + baseMint: PublicKey, + vaultTarget: PublicKey, + vaultBase: PublicKey, + sellerTarget: PublicKey, + sellerBase: PublicKey, + inAmount: UInt64, + minAmountOut: UInt64 + ) { + self.seller = seller + self.pool = pool + self.targetMint = targetMint + self.baseMint = baseMint + self.vaultTarget = vaultTarget + self.vaultBase = vaultBase + self.sellerTarget = sellerTarget + self.sellerBase = sellerBase + self.inAmount = inAmount + self.minAmountOut = minAmountOut + } + } +} + +// MARK: - InstructionType - + +extension CurrencyCreatorProgram.SellTokens: InstructionType { + + public init(instruction: Instruction) throws { + let data = try CurrencyCreatorProgram.parse(.sellTokens, instruction: instruction, expectingAccounts: 9) + + guard data.count >= 16 else { + throw CommandParseError.payloadNotFound + } + + let bytes = Array(data) + let inAmount = UInt64(bytes: Array(bytes[0..<8]))! + let minAmountOut = UInt64(bytes: Array(bytes[8..<16]))! + + self.init( + seller: instruction.accounts[0].publicKey, + pool: instruction.accounts[1].publicKey, + targetMint: instruction.accounts[2].publicKey, + baseMint: instruction.accounts[3].publicKey, + vaultTarget: instruction.accounts[4].publicKey, + vaultBase: instruction.accounts[5].publicKey, + sellerTarget: instruction.accounts[6].publicKey, + sellerBase: instruction.accounts[7].publicKey, + inAmount: inAmount, + minAmountOut: minAmountOut + ) + } + + public func instruction() -> Instruction { + let accounts: [AccountMeta] = [ + .writable(publicKey: seller, signer: true), + .writable(publicKey: pool), + .readonly(publicKey: targetMint), + .readonly(publicKey: baseMint), + .writable(publicKey: vaultTarget), + .writable(publicKey: vaultBase), + .writable(publicKey: sellerTarget), + .writable(publicKey: sellerBase), + .readonly(publicKey: TokenProgram.address), + ] + + return Instruction( + program: CurrencyCreatorProgram.address, + accounts: accounts, + data: encode() + ) + } + + public func encode() -> Data { + var data = Data() + data.append(contentsOf: CurrencyCreatorProgram.Command.sellTokens.rawValue.bytes) + data.append(contentsOf: inAmount.bytes) + data.append(contentsOf: minAmountOut.bytes) + return data + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/SolanaTransaction.swift b/FlipcashCore/Sources/FlipcashCore/Solana/SolanaTransaction.swift index bf06fbd02..f7872afcb 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/SolanaTransaction.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/SolanaTransaction.swift @@ -154,8 +154,6 @@ public struct SolanaTransaction: Equatable, Sendable { var readonlyLUTIndexes: [[UInt8]] = Array(repeating: [], count: sortedLUTs.count) var staticAccountKeys: [PublicKey] = [] - var dynamicWritableAccounts: [PublicKey] = [] - var dynamincReadonlyAccounts: [PublicKey] = [] var header = Message.Header( requiredSignatures: 0, @@ -176,10 +174,8 @@ public struct SolanaTransaction: Equatable, Sendable { let byteIndex = UInt8(addressIndex) if accountMeta.isWritable { writableLUTIndexes[lutIndex].append(byteIndex) - dynamicWritableAccounts.append(pk) } else { readonlyLUTIndexes[lutIndex].append(byteIndex) - dynamincReadonlyAccounts.append(pk) } break } @@ -201,9 +197,19 @@ public struct SolanaTransaction: Equatable, Sendable { } } - // Build complete account list for instruction compilation: - // static keys + dynamic writable + dynamic readonly (in that order) - let allAccounts = staticAccountKeys + dynamicWritableAccounts + dynamincReadonlyAccounts + // Build complete account list for instruction compilation. Loaded + // accounts resolve on-chain grouped BY TABLE — every table's writable + // addresses first, then every table's readonly addresses — not in the + // global sort order they were discovered in. With a single table the + // two orderings coincide; with multiple tables only the table-grouped + // order matches the runtime (and the server's expected transaction). + let loadedWritableAccounts = sortedLUTs.enumerated().flatMap { lutIndex, lut in + writableLUTIndexes[lutIndex].map { lut.addresses[Int($0)] } + } + let loadedReadonlyAccounts = sortedLUTs.enumerated().flatMap { lutIndex, lut in + readonlyLUTIndexes[lutIndex].map { lut.addresses[Int($0)] } + } + let allAccounts = staticAccountKeys + loadedWritableAccounts + loadedReadonlyAccounts // Build address table lookups (only include LUTs that are actually used) var addressTableLookups: [MessageAddressTableLookup] = [] diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+Swap.swift b/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+Swap.swift new file mode 100644 index 000000000..0f62bc806 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+Swap.swift @@ -0,0 +1,192 @@ +// +// SwapInstructionBuilder+Swap.swift +// FlipcashCore +// + +extension SwapInstructionBuilder { + // MARK: - Swap Tokens (Launchpad Currency Mint -> Launchpad Currency Mint) + + /// Builds instructions for buying a launchpad currency paying with another + /// launchpad currency: a bounded sell of the source into a temporary Core + /// Mint account, then an unlimited buy of the target funded by it. + /// + /// - Parameters: + /// - serverParameters: Server-provided parameters including compute budget and memo + /// - nonce: The nonce account + /// - authority: The authority signing the transaction + /// - swapAuthority: The swap authority signing the transaction + /// - sourceMintMetadata: Metadata for the payment mint (sold) + /// - targetMintMetadata: Metadata for the mint being bought + /// - coreMintMetadata: Metadata for the Core Mint (intermediate) + /// - amount: Amount of the source mint to swap, in quarks + /// - Returns: Array of instructions in the correct order + public static func buildSwapInstructions( + serverParameters: SwapResponseServerParameters, + nonce: PublicKey, + authority: PublicKey, + swapAuthority: PublicKey, // temporaryHolder + sourceMintMetadata: MintMetadata, + targetMintMetadata: MintMetadata, + coreMintMetadata: MintMetadata, + amount: UInt64, + ) throws -> [Instruction] { + guard let sourceVM = sourceMintMetadata.vmMetadata else { + throw SwapTransactionBuildError.missingMintMetadata(symbol: sourceMintMetadata.symbol) + } + guard let sourceLaunchpad = sourceMintMetadata.launchpadMetadata else { + throw SwapTransactionBuildError.missingMintMetadata(symbol: sourceMintMetadata.symbol) + } + guard let targetVM = targetMintMetadata.vmMetadata else { + throw SwapTransactionBuildError.missingMintMetadata(symbol: targetMintMetadata.symbol) + } + guard let targetLaunchpad = targetMintMetadata.launchpadMetadata else { + throw SwapTransactionBuildError.missingMintMetadata(symbol: targetMintMetadata.symbol) + } + guard let sourceTimelockAccounts = sourceMintMetadata.timelockSwapAccounts(owner: authority) else { + throw SwapTransactionBuildError.missingMintMetadata(symbol: sourceMintMetadata.symbol) + } + + let serverParams = try extractServerParameters(serverParameters) + + guard let memoryIndex = UInt16(exactly: serverParams.memoryIndex) else { + throw SwapTransactionBuildError.invalidServerParameter("memoryIndex") + } + + let temporaryCoreMintAta = AssociatedTokenProgram.CreateIdempotent( + subsidizer: serverParams.payer, + owner: swapAuthority, + mint: coreMintMetadata.address + ) + + let temporarySourceMintAta = AssociatedTokenProgram.CreateIdempotent( + subsidizer: serverParams.payer, + owner: swapAuthority, + mint: sourceMintMetadata.address + ) + + var instructions: [Instruction] = [] + + // 1. System::AdvanceNonce + instructions.append( + SystemProgram.AdvanceNonce( + nonce: nonce, + authority: serverParams.payer + ).instruction() + ) + + // 2. ComputeBudget::SetComputeUnitLimit + instructions.append( + ComputeBudgetProgram.SetComputeUnitLimit( + units: serverParams.computeUnitLimit + ).instruction() + ) + + // 3. ComputeBudget::SetComputeUnitPrice + instructions.append( + ComputeBudgetProgram.SetComputeUnitPrice( + microLamports: serverParams.computeUnitPrice + ).instruction() + ) + + // 4. Memo::Memo + instructions.append( + MemoProgram.Memo( + message: serverParams.memo + ).instruction() + ) + + // 5. AssociatedTokenAccount::CreateIdempotent (open Core Mint temporary account) + instructions.append( + temporaryCoreMintAta.instruction() + ) + + // 6. AssociatedTokenAccount::CreateIdempotent (open source mint temporary account) + instructions.append( + temporarySourceMintAta.instruction() + ) + + // 7. VM::TransferForSwap (source mint VM swap ATA -> source mint temporary account) + instructions.append( + VMProgram.TransferForSwap( + vmAuthority: sourceVM.authority, + vm: sourceVM.vm, + swapper: authority, + swapPda: sourceTimelockAccounts.pda.publicKey, + swapAta: sourceTimelockAccounts.ata.publicKey, + destination: temporarySourceMintAta.address, + amount: amount, + bump: sourceTimelockAccounts.pda.bump, + ).instruction() + ) + + // 8. CurrencyCreator::SellTokens (bounded sell transferring Core Mint into the temporary account) + instructions.append( + CurrencyCreatorProgram.SellTokens( + seller: swapAuthority, + pool: sourceLaunchpad.liquidityPool, + targetMint: sourceMintMetadata.address, + baseMint: coreMintMetadata.address, + vaultTarget: sourceLaunchpad.mintVault, + vaultBase: sourceLaunchpad.coreMintVault, + sellerTarget: temporarySourceMintAta.address, + sellerBase: temporaryCoreMintAta.address, + inAmount: amount, + minAmountOut: 0 + ).instruction() + ) + + // 9. CurrencyCreator::BuyAndDepositIntoVm (unlimited buy depositing target tokens into the target VM) + instructions.append( + CurrencyCreatorProgram.BuyAndDepositIntoVm( + amount: 0, + minOutAmount: 0, + vmMemoryIndex: memoryIndex, + buyer: swapAuthority, + pool: targetLaunchpad.liquidityPool, + targetMint: targetMintMetadata.address, + baseMint: coreMintMetadata.address, + vaultTarget: targetLaunchpad.mintVault, + vaultBase: targetLaunchpad.coreMintVault, + buyerBase: temporaryCoreMintAta.address, + vmAuthority: targetVM.authority, + vm: targetVM.vm, + vmMemory: serverParams.memoryAccount, + vmOmnibus: targetVM.omnibus, + vtaOwner: authority + ).instruction() + ) + + // 10. Token::CloseAccount (closes Core Mint temporary account) + instructions.append( + TokenProgram.CloseAccount( + account: temporaryCoreMintAta.address, + destination: serverParams.payer, + owner: swapAuthority, + ).instruction() + ) + + // 11. Token::CloseAccount (closes source mint temporary account) + instructions.append( + TokenProgram.CloseAccount( + account: temporarySourceMintAta.address, + destination: serverParams.payer, + owner: swapAuthority, + ).instruction() + ) + + // 12. VM::CloseSwapAccountIfEmpty (closes source mint VM swap ATA if empty) + instructions.append( + VMProgram.CloseSwapAccountIfEmpty( + vmAuthority: sourceVM.authority, + vm: sourceVM.vm, + swapper: authority, + swapPda: sourceTimelockAccounts.pda.publicKey, + swapAta: sourceTimelockAccounts.ata.publicKey, + destination: serverParams.payer, + bump: sourceTimelockAccounts.pda.bump, + ).instruction() + ) + + return instructions + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/TransactionBuilder.swift b/FlipcashCore/Sources/FlipcashCore/Solana/TransactionBuilder.swift index 671305d77..9511f0445 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/TransactionBuilder.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/TransactionBuilder.swift @@ -59,6 +59,18 @@ extension TransactionBuilder { minOutput: minOutput, ) + case .swap(let sourceMint, let targetMint): + try SwapInstructionBuilder.buildSwapInstructions( + serverParameters: responseParams, + nonce: metadata.serverParameters.nonce, + authority: authority, + swapAuthority: swapAuthority, + sourceMintMetadata: sourceMint, + targetMintMetadata: targetMint, + coreMintMetadata: coreMint, + amount: amount, + ) + case .withdraw: // Stablecoin (USDF → USDC) withdraws go through swapUsdfToUsdc. throw SwapTransactionBuildError.unsupportedServerParameters diff --git a/FlipcashCore/Tests/FlipcashCoreTests/CurrencyCreatorProgramSellTokensTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/CurrencyCreatorProgramSellTokensTests.swift new file mode 100644 index 000000000..117e84179 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/CurrencyCreatorProgramSellTokensTests.swift @@ -0,0 +1,119 @@ +// +// CurrencyCreatorProgramSellTokensTests.swift +// FlipcashCoreTests +// + +import Foundation +import Testing +@testable import FlipcashCore + +@Suite("CurrencyCreatorProgram.SellTokens") +struct CurrencyCreatorProgramSellTokensTests { + + private static func key(_ seed: UInt8) -> PublicKey { + try! PublicKey([UInt8](repeating: seed, count: 32)) + } + + private static let seller = key(1) + private static let pool = key(2) + private static let targetMint = key(3) + private static let baseMint = key(4) + private static let vaultTarget = key(5) + private static let vaultBase = key(6) + private static let sellerTarget = key(7) + private static let sellerBase = key(8) + + private func makeInstance(inAmount: UInt64, minAmountOut: UInt64) -> CurrencyCreatorProgram.SellTokens { + CurrencyCreatorProgram.SellTokens( + seller: Self.seller, + pool: Self.pool, + targetMint: Self.targetMint, + baseMint: Self.baseMint, + vaultTarget: Self.vaultTarget, + vaultBase: Self.vaultBase, + sellerTarget: Self.sellerTarget, + sellerBase: Self.sellerBase, + inAmount: inAmount, + minAmountOut: minAmountOut + ) + } + + @Test("Opcode is 5 and data layout is opcode + inAmount + minAmountOut") + func encoding_dataLayout() { + let data = makeInstance(inAmount: 1_000_000, minAmountOut: 0).encode() + + #expect(data.count == 17) + #expect(data[0] == CurrencyCreatorProgram.Command.sellTokens.rawValue) + #expect(UInt64(bytes: Array(data[1..<9])) == 1_000_000) + #expect(UInt64(bytes: Array(data[9..<17])) == 0) + } + + @Test("Round-trip encode/decode preserves every field") + func roundTrip() throws { + let original = makeInstance(inAmount: 42, minAmountOut: 7) + let parsed = try CurrencyCreatorProgram.SellTokens(instruction: original.instruction()) + #expect(parsed == original) + } + + @Test("Account order matches OCP SellTokensInstructionAccounts") + func accountOrder() { + let accounts = makeInstance(inAmount: 1, minAmountOut: 0).instruction().accounts + + #expect(accounts.count == 9) + + #expect(accounts[0].publicKey == Self.seller) + #expect(accounts[0].isSigner == true) + #expect(accounts[0].isWritable == true) + + #expect(accounts[1].publicKey == Self.pool) + #expect(accounts[1].isWritable == true) + #expect(accounts[1].isSigner == false) + + #expect(accounts[2].publicKey == Self.targetMint) + #expect(accounts[2].isWritable == false) + + #expect(accounts[3].publicKey == Self.baseMint) + #expect(accounts[3].isWritable == false) + + #expect(accounts[4].publicKey == Self.vaultTarget) + #expect(accounts[4].isWritable == true) + + #expect(accounts[5].publicKey == Self.vaultBase) + #expect(accounts[5].isWritable == true) + + #expect(accounts[6].publicKey == Self.sellerTarget) + #expect(accounts[6].isWritable == true) + + #expect(accounts[7].publicKey == Self.sellerBase) + #expect(accounts[7].isWritable == true) + + #expect(accounts[8].publicKey == TokenProgram.address) + #expect(accounts[8].isWritable == false) + #expect(accounts[8].isSigner == false) + } + + @Test("Program address is the CurrencyCreator program") + func programAddress() { + #expect(makeInstance(inAmount: 1, minAmountOut: 0).instruction().program == CurrencyCreatorProgram.address) + } + + @Test("Decoding rejects a truncated payload") + func decode_truncatedPayload_throws() { + var instruction = makeInstance(inAmount: 1, minAmountOut: 0).instruction() + instruction.data = instruction.data.prefix(9) // opcode + half of inAmount + + #expect(throws: (any Error).self) { + try CurrencyCreatorProgram.SellTokens(instruction: instruction) + } + } + + @Test("Decoding rejects a wrong account count") + func decode_wrongAccountCount_throws() { + var instruction = makeInstance(inAmount: 1, minAmountOut: 0).instruction() + instruction.accounts = Array(instruction.accounts.dropLast()) + + #expect(throws: (any Error).self) { + try CurrencyCreatorProgram.SellTokens(instruction: instruction) + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/LaunchpadSellFeeTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/LaunchpadSellFeeTests.swift new file mode 100644 index 000000000..78b0afac4 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/LaunchpadSellFeeTests.swift @@ -0,0 +1,85 @@ +// +// LaunchpadSellFeeTests.swift +// FlipcashCoreTests +// + +import Foundation +import Testing +@testable import FlipcashCore + +@Suite("Launchpad sell fee math") +struct LaunchpadSellFeeTests { + + @Test("Fee is bps of the on-chain amount with native scaled by the actual ratio") + func launchpadSellFee_basic() { + let gross = ExchangedFiat(nativeAmount: FiatAmount.usd(20.20), rate: .oneToOne) + + let fee = gross.launchpadSellFee(bps: 100) + + #expect(fee.onChainAmount.quarks == gross.onChainAmount.quarks / 100) + #expect(fee.nativeAmount.formatted() == "$0.20") + } + + @Test("A fee that rounds to 0 quarks also displays as 0 fiat") + func launchpadSellFee_zeroQuarks() { + let tiny = ExchangedFiat( + onChainAmount: TokenAmount(quarks: 50, mint: .usdf), + nativeAmount: FiatAmount.usd(0.00005), + currencyRate: .oneToOne + ) + + let fee = tiny.launchpadSellFee(bps: 100) + + #expect(fee.onChainAmount.quarks == 0) + #expect(fee.nativeAmount.value == 0) + } + + @Test("Grossing up nets back to the original after the fee", arguments: [ + (net: Decimal(20), grossFormatted: "$20.20"), + (net: Decimal(10), grossFormatted: "$10.10"), + (net: Decimal(string: "0.01")!, grossFormatted: "$0.01"), + ]) + func grossingUp_roundTrip(net: Decimal, grossFormatted: String) { + let gross = FiatAmount.usd(net).grossingUpLaunchpadSellFee(bps: 100) + + #expect(gross.formatted() == grossFormatted) + + // net = gross × (1 − f): the displayed pair must be self-consistent. + let grossExchanged = ExchangedFiat(nativeAmount: gross, rate: .oneToOne) + let netted = grossExchanged.subtractingFee(grossExchanged.launchpadSellFee(bps: 100).onChainAmount) + #expect(netted.nativeAmount.value.rounded(to: 2) == net.rounded(to: 2)) + } + + @Test("Buy Maximum shape: full balance nets to balance × (1 − f)") + func buyMaximum_netting() { + let balance = ExchangedFiat(nativeAmount: FiatAmount.usd(20), rate: .oneToOne) + + let fee = balance.launchpadSellFee(bps: 100) + let net = balance.subtractingFee(fee.onChainAmount) + + #expect(fee.nativeAmount.formatted() == "$0.20") + #expect(net.nativeAmount.formatted() == "$19.80") + } + + @Test("Fee math stays exact at the max launchpad supply — quarks × bps would overflow UInt64") + func launchpadSellFee_maxSupply_noOverflow() { + // 21M tokens at 10 decimals: a naive quarks × 100 exceeds UInt64.max. + let maxSupplyQuarks: UInt64 = 21_000_000 * 10_000_000_000 + let holding = ExchangedFiat( + onChainAmount: TokenAmount(quarks: maxSupplyQuarks, mint: .usdf), + nativeAmount: FiatAmount.usd(1), + currencyRate: .oneToOne + ) + + let fee = holding.launchpadSellFee(bps: 100) + + #expect(fee.onChainAmount.quarks == 2_100_000_000_000_000) + } + + @Test("Grossing up a 100% fee returns the amount unchanged instead of dividing by zero") + func grossingUp_fullFeeBps_returnsSelf() { + let net = FiatAmount.usd(20) + + #expect(net.grossingUpLaunchpadSellFee(bps: 10_000) == net) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SolanaTransactionEncodingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SolanaTransactionEncodingTests.swift index c0f7c41b4..7ed5f3f63 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/SolanaTransactionEncodingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/SolanaTransactionEncodingTests.swift @@ -50,6 +50,61 @@ struct SolanaTransactionEncodingTests { #expect(transaction.encode() == Self.expectedBytes) } + @Test("Multi-table lookups compile instruction indexes in table-grouped order") + func multiTableLookups_compileInTableGroupedOrder() throws { + // Loaded accounts resolve on-chain grouped BY TABLE (every table's + // writables, then every table's readonlys) — not in global sort + // order. Arrange keys so the two orderings differ: the second table's + // accounts sort lexicographically BEFORE the first table's. + func key(_ seed: UInt8) -> PublicKey { try! PublicKey([UInt8](repeating: seed, count: 32)) } + + let payer = key(1) + let program = key(2) + let writableA = key(40) // in table A (sorts after writableB) + let writableB = key(30) // in table B + let readonlyA = key(41) // in table A (sorts after readonlyB) + let readonlyB = key(31) // in table B + + let tableA = AddressLookupTable(publicKey: key(10), addresses: [writableA, readonlyA]) + let tableB = AddressLookupTable(publicKey: key(11), addresses: [writableB, readonlyB]) + + let instruction = Instruction( + program: program, + accounts: [ + .writable(publicKey: writableA), + .writable(publicKey: writableB), + .readonly(publicKey: readonlyA), + .readonly(publicKey: readonlyB), + ], + data: Data([7]) + ) + + let transaction = SolanaTransaction( + payer: payer, + recentBlockhash: Self.blockhash, + addressLookupTables: [tableA, tableB], + instructions: [instruction] + ) + + guard case .versionedV0(let message) = transaction.message else { + Issue.record("Expected a v0 message when lookup tables are provided") + return + } + + // Static: payer(0), program(1). Loaded: tableA.writable(2), + // tableB.writable(3), tableA.readonly(4), tableB.readonly(5). + #expect(message.staticAccountKeys == [payer, program]) + #expect(message.instructions[0].accountIndexes == [2, 3, 4, 5]) + + #expect(message.addressTableLookups.count == 2) + #expect(message.addressTableLookups[0].publicKey == tableA.publicKey) + #expect(message.addressTableLookups[0].writableIndexes == [0]) + #expect(message.addressTableLookups[0].readonlyIndexes == [1]) + #expect(message.addressTableLookups[1].publicKey == tableB.publicKey) + #expect(message.addressTableLookups[1].writableIndexes == [0]) + #expect(message.addressTableLookups[1].readonlyIndexes == [1]) + } + @Test("Round-trip: encoded bytes decode back into an equivalent transaction") func encodeDecode_roundTrips() throws { let instructions = SwapInstructionBuilder.buildUsdcToUsdfSwapInstructions( diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderSwapTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderSwapTests.swift new file mode 100644 index 000000000..07cb4cc41 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderSwapTests.swift @@ -0,0 +1,213 @@ +// +// SwapInstructionBuilderSwapTests.swift +// FlipcashCoreTests +// + +import Foundation +import Testing +@testable import FlipcashCore + +@Suite("SwapInstructionBuilder launchpad→launchpad swap") +struct SwapInstructionBuilderSwapTests { + + private static func key(_ seed: UInt8) -> PublicKey { + try! PublicKey([UInt8](repeating: seed, count: 32)) + } + + private static let payer = key(1) + private static let nonce = key(2) + private static let authority = key(4) + private static let swapAuthority = key(5) + private static let memoryAccount = key(6) + + private static func makeLaunchpadMint(base: UInt8, symbol: String) -> MintMetadata { + MintMetadata( + address: key(base), + decimals: 10, + name: symbol, + symbol: symbol, + description: "", + imageURL: nil, + vmMetadata: VMMetadata( + vm: key(base + 1), + authority: key(base + 2), + lockDurationInDays: 21 + ), + launchpadMetadata: LaunchpadMetadata( + currencyConfig: key(base + 3), + liquidityPool: key(base + 4), + seed: key(base + 5), + authority: key(base + 2), + mintVault: key(base + 6), + coreMintVault: key(base + 7), + coreMintFees: nil, + supplyFromBonding: 50_000, + sellFeeBps: 100 + ) + ) + } + + private static let sourceMint = makeLaunchpadMint(base: 10, symbol: "PAY") + private static let targetMint = makeLaunchpadMint(base: 30, symbol: "BUY") + + private static func makeServerParameters(memoryIndex: UInt32 = 3) -> SwapResponseServerParameters { + SwapResponseServerParameters( + kind: .stateful(.init( + payer: payer, + alts: [], + computeUnitLimit: 250_000, + computeUnitPrice: 10_000, + memoValue: "buy_sell_v0", + memoryAccount: memoryAccount, + memoryIndex: memoryIndex + )) + ) + } + + private func build(amount: UInt64 = 1_000_000) throws -> [Instruction] { + try SwapInstructionBuilder.buildSwapInstructions( + serverParameters: Self.makeServerParameters(), + nonce: Self.nonce, + authority: Self.authority, + swapAuthority: Self.swapAuthority, + sourceMintMetadata: Self.sourceMint, + targetMintMetadata: Self.targetMint, + coreMintMetadata: .usdf, + amount: amount + ) + } + + @Test("Builds the 12-instruction launchpad→launchpad format in server order") + func instructionOrder() throws { + let instructions = try build() + + #expect(instructions.count == 12) + #expect(instructions[0].program == SystemProgram.address) + #expect(instructions[1].program == ComputeBudgetProgram.address) + #expect(instructions[2].program == ComputeBudgetProgram.address) + #expect(instructions[3].program == MemoProgram.address) + #expect(instructions[4].program == AssociatedTokenProgram.address) + #expect(instructions[5].program == AssociatedTokenProgram.address) + #expect(instructions[6].program == VMProgram.address) + #expect(instructions[7].program == CurrencyCreatorProgram.address) + #expect(instructions[8].program == CurrencyCreatorProgram.address) + #expect(instructions[9].program == TokenProgram.address) + #expect(instructions[10].program == TokenProgram.address) + #expect(instructions[11].program == VMProgram.address) + } + + @Test("Temp ATA creations are core mint first, then source mint") + func tempAtaOrder() throws { + let instructions = try build() + + let coreAta = try AssociatedTokenProgram.CreateIdempotent(instruction: instructions[4]) + #expect(coreAta.mint == MintMetadata.usdf.address) + #expect(coreAta.owner == Self.swapAuthority) + #expect(coreAta.subsidizer == Self.payer) + + let sourceAta = try AssociatedTokenProgram.CreateIdempotent(instruction: instructions[5]) + #expect(sourceAta.mint == Self.sourceMint.address) + #expect(sourceAta.owner == Self.swapAuthority) + } + + @Test("TransferForSwap moves the swap amount into the temp source ATA") + func transferLeg() throws { + let instructions = try build(amount: 777) + + let sourceAta = try AssociatedTokenProgram.CreateIdempotent(instruction: instructions[5]) + let transfer = try VMProgram.TransferForSwap(instruction: instructions[6]) + + #expect(transfer.amount == 777) + #expect(transfer.vm == Self.sourceMint.vmMetadata?.vm) + #expect(transfer.swapper == Self.authority) + #expect(transfer.destination == sourceAta.address) + } + + @Test("SellTokens sells the full amount with minAmountOut 0 into the temp core ATA") + func sellLeg() throws { + let instructions = try build(amount: 777) + + let coreAta = try AssociatedTokenProgram.CreateIdempotent(instruction: instructions[4]) + let sourceAta = try AssociatedTokenProgram.CreateIdempotent(instruction: instructions[5]) + let sell = try CurrencyCreatorProgram.SellTokens(instruction: instructions[7]) + + #expect(sell.inAmount == 777) + #expect(sell.minAmountOut == 0) + #expect(sell.seller == Self.swapAuthority) + #expect(sell.pool == Self.sourceMint.launchpadMetadata?.liquidityPool) + #expect(sell.targetMint == Self.sourceMint.address) + #expect(sell.baseMint == MintMetadata.usdf.address) + #expect(sell.vaultTarget == Self.sourceMint.launchpadMetadata?.mintVault) + #expect(sell.vaultBase == Self.sourceMint.launchpadMetadata?.coreMintVault) + #expect(sell.sellerTarget == sourceAta.address) + #expect(sell.sellerBase == coreAta.address) + } + + @Test("BuyAndDepositIntoVm is the unlimited-buy form funded by the temp core ATA") + func buyLeg() throws { + let instructions = try build() + + let coreAta = try AssociatedTokenProgram.CreateIdempotent(instruction: instructions[4]) + let buy = try CurrencyCreatorProgram.BuyAndDepositIntoVm(instruction: instructions[8]) + + #expect(buy.amount == 0) + #expect(buy.minOutAmount == 0) + #expect(buy.vmMemoryIndex == 3) + #expect(buy.buyer == Self.swapAuthority) + #expect(buy.pool == Self.targetMint.launchpadMetadata?.liquidityPool) + #expect(buy.targetMint == Self.targetMint.address) + #expect(buy.baseMint == MintMetadata.usdf.address) + #expect(buy.buyerBase == coreAta.address) + #expect(buy.vmAuthority == Self.targetMint.vmMetadata?.authority) + #expect(buy.vm == Self.targetMint.vmMetadata?.vm) + #expect(buy.vmMemory == Self.memoryAccount) + #expect(buy.vtaOwner == Self.authority) + } + + @Test("Close order is temp core ATA, temp source ATA, then source swap account") + func closeOrder() throws { + let instructions = try build() + + let coreAta = try AssociatedTokenProgram.CreateIdempotent(instruction: instructions[4]) + let sourceAta = try AssociatedTokenProgram.CreateIdempotent(instruction: instructions[5]) + + let closeCore = try TokenProgram.CloseAccount(instruction: instructions[9]) + #expect(closeCore.account == coreAta.address) + #expect(closeCore.destination == Self.payer) + #expect(closeCore.owner == Self.swapAuthority) + + let closeSource = try TokenProgram.CloseAccount(instruction: instructions[10]) + #expect(closeSource.account == sourceAta.address) + + let closeSwap = try VMProgram.CloseSwapAccountIfEmpty(instruction: instructions[11]) + #expect(closeSwap.vm == Self.sourceMint.vmMetadata?.vm) + #expect(closeSwap.swapper == Self.authority) + } + + @Test("Missing launchpad metadata on either mint throws missingMintMetadata") + func missingMetadata_throws() { + let bareTarget = MintMetadata( + address: Self.targetMint.address, + decimals: 10, + name: "BUY", + symbol: "BUY", + description: "", + imageURL: nil, + vmMetadata: Self.targetMint.vmMetadata, + launchpadMetadata: nil + ) + + #expect(throws: SwapTransactionBuildError.missingMintMetadata(symbol: "BUY")) { + try SwapInstructionBuilder.buildSwapInstructions( + serverParameters: Self.makeServerParameters(), + nonce: Self.nonce, + authority: Self.authority, + swapAuthority: Self.swapAuthority, + sourceMintMetadata: Self.sourceMint, + targetMintMetadata: bareTarget, + coreMintMetadata: .usdf, + amount: 1 + ) + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransactionBuilderSwapErrorTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransactionBuilderSwapErrorTests.swift index 766ac3d61..ec797e3ce 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransactionBuilderSwapErrorTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransactionBuilderSwapErrorTests.swift @@ -167,4 +167,49 @@ struct TransactionBuilderSwapErrorTests { #expect(transaction.message.instructions.count == 9) } + + @Test("Stateful parameters with a swap direction build a 12-instruction transaction") + func statefulParameters_swapDirection_buildsTransaction() throws { + let paymentMint = MintMetadata( + address: Self.testKey(20), + decimals: 10, + name: "Pay Token", + symbol: "PAY", + description: "", + imageURL: nil, + vmMetadata: VMMetadata( + vm: Self.testKey(21), + authority: Self.testKey(22), + lockDurationInDays: 21 + ), + launchpadMetadata: LaunchpadMetadata( + currencyConfig: Self.testKey(23), + liquidityPool: Self.testKey(24), + seed: Self.testKey(25), + authority: Self.testKey(22), + mintVault: Self.testKey(26), + coreMintVault: Self.testKey(27), + coreMintFees: nil, + supplyFromBonding: 50_000, + sellFeeBps: 100 + ) + ) + + let transaction = try buildSwap( + responseParams: Self.makeStatefulParameters(), + direction: .swap(from: paymentMint, to: Self.launchpadMint) + ) + + #expect(transaction.message.instructions.count == 12) + } + + @Test("New-currency parameters with a swap direction throw unsupportedServerParameters") + func newCurrencyParameters_swapDirection_throwsUnsupported() { + #expect(throws: SwapTransactionBuildError.unsupportedServerParameters) { + try self.buildSwap( + responseParams: Self.makeNewCurrencyParameters(), + direction: .swap(from: Self.launchpadMint, to: Self.launchpadMint) + ) + } + } } diff --git a/FlipcashTests/AddMoneyGateTests.swift b/FlipcashTests/AddMoneyGateTests.swift index a37f90aae..5644a9ad8 100644 --- a/FlipcashTests/AddMoneyGateTests.swift +++ b/FlipcashTests/AddMoneyGateTests.swift @@ -27,28 +27,9 @@ struct AddMoneyGateTests { ) } - // MARK: - Buy - - @Test("Buy pre-check adds money when there is no USDF balance") - func buy_noBalance_addsMoney() { - let session = MockSession() - session.usdfReserveBalance = nil - #expect(shouldAddMoneyBeforeBuy(session: session) == true) - } - - @Test("Buy pre-check adds money when the USDF balance is zero") - func buy_zeroBalance_addsMoney() throws { - let session = MockSession() - session.usdfReserveBalance = try makeUSDFBalance(quarks: 0) - #expect(shouldAddMoneyBeforeBuy(session: session) == true) - } - - @Test("Buy pre-check opens the buy sheet when USDF reserves exist") - func buy_positiveBalance_opensBuy() throws { - let session = MockSession() - session.usdfReserveBalance = try makeUSDFBalance(quarks: 5_000_000) - #expect(shouldAddMoneyBeforeBuy(session: session) == false) - } + // Buy has no pre-gate anymore: the amount screen always opens and its + // action button becomes an Add Money CTA when nothing is spendable + // (covered by BuyAmountViewModelTests). // MARK: - Launch diff --git a/FlipcashTests/Buy/BuyAmountViewModelTests.swift b/FlipcashTests/Buy/BuyAmountViewModelTests.swift index 95d506ba8..1c13fe3b3 100644 --- a/FlipcashTests/Buy/BuyAmountViewModelTests.swift +++ b/FlipcashTests/Buy/BuyAmountViewModelTests.swift @@ -8,69 +8,41 @@ import Testing @testable import FlipcashCore @testable import Flipcash -@Suite("BuyAmountViewModel — USDF gate") +@Suite("BuyAmountViewModel — balance cap") @MainActor struct BuyAmountViewModelTests { - // MARK: - amountEnteredAction gating - - /// Server-provided per-day limit that the gate must clear before any - /// submission. Set high enough that the test entered amounts ($1–$20) all - /// pass; the only thing varying between tests is the USDF balance. private static let testSendLimit = SendLimit( nextTransaction: FiatAmount(value: 1000, currency: .usd), maxPerTransaction: FiatAmount(value: 1000, currency: .usd), maxPerDay: FiatAmount(value: 1000, currency: .usd) ) - /// Builds a `SessionContainer` with the given USDF balance and seeds the - /// fresh verified state + send limits the viewmodel needs to reach the - /// USDF gate. Without these, `prepareSubmission` returns nil and the flow - /// short-circuits at the "Rate Unavailable" dialog. - /// - /// `currency`/`fx` select the balance currency — pass a non-USD rate to - /// reproduce FX display rounding (see the CAD regression below). - /// - /// Pass a `maxPerDay` below the entered amount when the test drives - /// `amountEnteredAction` past the gate: the flow then stops at the limit - /// check and can never reach the real `session.buy` — which submits a - /// live `IntentCreateAccount` to production that the antispam guard - /// denies. + /// Builds a `SessionContainer` with the given holdings and seeds fresh + /// rates so the cap and pin lookups work. private static func makeContainer( - usdfQuarks: UInt64, + holdings: [SessionContainer.Holding], currency: CurrencyCode = .usd, - fx: Double = 1.0, - maxPerDay: Decimal = 1_000 + fx: Double = 1.0 ) async throws -> SessionContainer { - let holdings: [SessionContainer.Holding] = usdfQuarks == 0 - ? [] - : [.init(mint: MintMetadata.usdf, quarks: usdfQuarks)] - - var sendLimits: [CurrencyCode: SendLimit] = [.usd: testSendLimit] - sendLimits[currency] = SendLimit( - nextTransaction: FiatAmount(value: 1000, currency: currency), - maxPerTransaction: FiatAmount(value: 1000, currency: currency), - maxPerDay: FiatAmount(value: maxPerDay, currency: currency) - ) - let container = try SessionContainer.makeTest( holdings: holdings, limits: Limits( sinceDate: .now, fetchDate: .now, - sendLimits: sendLimits + sendLimits: [currency: SendLimit( + nextTransaction: FiatAmount(value: 1000, currency: currency), + maxPerTransaction: FiatAmount(value: 1000, currency: currency), + maxPerDay: FiatAmount(value: 1000, currency: currency) + )] ) ) - // Force the balance currency. Without this the rates controller reads - // `LocalDefaults.balanceCurrency`, which can be polluted by other - // suites that called `configureTestRates(balanceCurrency: .cad, ...)`. container.ratesController.configureTestRates( balanceCurrency: currency, rates: [Rate(fx: Decimal(fx), currency: currency)] ) - // Pin a fresh verified state so prepareSubmission() succeeds. await container.ratesController.verifiedProtoService.saveRates([ .freshRate(currencyCode: currency.rawValue.uppercased(), rate: fx) ]) @@ -79,7 +51,7 @@ struct BuyAmountViewModelTests { } private static func makeViewModel( - mint: PublicKey = .usdf, + mint: PublicKey = .jeffy, currencyName: String = "Jeffy", container: SessionContainer ) -> BuyAmountViewModel { @@ -91,99 +63,125 @@ struct BuyAmountViewModelTests { ) } - @Test( - "Insufficient USDF surfaces the No Balance dialog instead of buying", - arguments: [ - (usdfQuarks: UInt64(0), enteredAmount: "1"), - (usdfQuarks: UInt64(5_000_000), enteredAmount: "20"), - ] - ) - func insufficientBalance_showsNoBalanceDialog(usdfQuarks: UInt64, enteredAmount: String) async throws { - let container = try await Self.makeContainer(usdfQuarks: usdfQuarks) + @Test("Cap is the highest eligible balance") + func cap_isHighestBalance() async throws { + // USDF $30 dwarfs the small launchpad holding. + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 30_000_000), + .init(mint: .makeLaunchpad(address: .jeffy), quarks: 10_000_000_000), // 1 token ≈ $0.01 + ]) + let viewModel = Self.makeViewModel(mint: .usdcAuthority, container: container) + + #expect(viewModel.maxPossibleAmount.nativeAmount.formatted() == "$30.00") + #expect(!viewModel.isBalanceEmpty) + #expect(viewModel.actionTitle == "Next") + } + + @Test("The target currency is excluded from the cap") + func cap_excludesTarget() async throws { + // Jeffy is the largest holding but is also the buy target — the cap + // must fall back to the USDF balance. + let jeffyQuarks: UInt64 = 2_000 * 10_000_000_000 // ≈ $20 of curve value + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 5_000_000), + .init(mint: .makeLaunchpad(address: .jeffy), quarks: jeffyQuarks), + ]) + let viewModel = Self.makeViewModel(mint: .jeffy, container: container) + + #expect(viewModel.maxPossibleAmount.nativeAmount.formatted() == "$5.00") + } + + @Test("A launchpad balance above USDF drives the cap when it isn't the target") + func cap_launchpadCanExceedUSDF() async throws { + let jeffyQuarks: UInt64 = 2_000 * 10_000_000_000 + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 5_000_000), + .init(mint: .makeLaunchpad(address: .jeffy), quarks: jeffyQuarks), + ]) + let viewModel = Self.makeViewModel(mint: .usdcAuthority, container: container) + + let jeffyValue = try #require(container.session.balance(for: .jeffy)) + .computeExchangedValue(with: container.ratesController.rateForBalanceCurrency()) + #expect(viewModel.maxPossibleAmount.nativeAmount == jeffyValue.nativeAmount) + #expect(viewModel.maxPossibleAmount.nativeAmount.value > 5) + } + + @Test("Zero eligible balance flips the button to Add Money") + func zeroBalance_addMoneyCTA() async throws { + let container = try await Self.makeContainer(holdings: []) + let viewModel = Self.makeViewModel(container: container) + + #expect(viewModel.isBalanceEmpty) + #expect(viewModel.actionTitle == "Add Money") + #expect(viewModel.actionEnabled("") == true) + } + + @Test("Holding only the target currency flips the button to Add Money") + func onlyTargetHolding_addMoneyCTA() async throws { + // The buy target can't pay for itself, so the sole holding leaves no + // eligible source — the flow never reaches the payment selector. + let jeffyQuarks: UInt64 = 2_000 * 10_000_000_000 + let container = try await Self.makeContainer(holdings: [ + .init(mint: .makeLaunchpad(address: .jeffy), quarks: jeffyQuarks), + ]) + let viewModel = Self.makeViewModel(mint: .jeffy, container: container) + + #expect(viewModel.isBalanceEmpty) + #expect(viewModel.actionTitle == "Add Money") + } + + @Test("Add Money CTA presents the Add Money sheet") + func zeroBalance_primaryActionPresentsAddMoney() async throws { + let container = try await Self.makeContainer(holdings: []) let viewModel = Self.makeViewModel(container: container) let router = AppRouter() router.present(.balance) - viewModel.enteredAmount = enteredAmount - await viewModel.amountEnteredAction(router: router) + viewModel.primaryAction(router: router) - // The standard "No Balance Yet" dialog is surfaced; its "Add Money" - // action (not fired here) is what presents the deposit picker, so the - // sheet isn't on the stack yet. - #expect(container.session.dialogItem?.title == "No Balance Yet") - #expect(!router.presentedSheets.contains(.addMoney(.buyCurrency))) + #expect(router.presentedSheets.contains(.addMoney(.buyCurrency))) } - @Test("Empty entered amount does nothing on submit") - func emptyAmount_noop() async throws { - let container = try await Self.makeContainer(usdfQuarks: 50_000_000) + @Test("Next pushes the payment-currency step with the validated amount") + func next_pushesSelectPaymentCurrency() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 30_000_000), + ]) let viewModel = Self.makeViewModel(container: container) let router = AppRouter() router.present(.balance) + router.presentNested(.buy(.jeffy)) - viewModel.enteredAmount = "" - await viewModel.amountEnteredAction(router: router) + viewModel.enteredAmount = "10" + viewModel.primaryAction(router: router) - #expect(!router.presentedSheets.contains(.addMoney(.buyCurrency))) - #expect(viewModel.dialogItem == nil) - // Loading flicker on an empty submit would be a regression. - #expect(viewModel.actionButtonState == .normal) + #expect(router[.buy].count == 1) } - // MARK: - FX rounding regression (deposit 1 CAD, buy 1 CAD) - - /// Regression: a CAD user deposits USDF that *displays* as 1.00 CAD, then - /// buys 1.00 CAD — the gate must treat that as sufficient. The old raw - /// `Decimal` compare (`usdfBalance.value >= amount.usdfValue.value`) - /// rejected it, because 1.00 CAD converts to fractionally more USD than - /// the truncated 6-decimal balance. The gate must follow - /// `Session.hasSufficientFunds` (quarks compare + half-denomination - /// max-send tolerance) like every other spend flow. - /// - /// Two balances straddle the quark rounding boundary at fx 1.37/1.36: - /// one where the entered amount's quarks equal the balance exactly, one - /// where they land 1 quark over and only the tolerance saves the buy. - @Test( - "Displayed-balance max buy in a non-USD currency passes the gate", - arguments: [ - (usdfQuarks: UInt64(729_927), fx: 1.37), - (usdfQuarks: UInt64(735_293), fx: 1.36), - ] - ) - func maxBuy_nonUSDCurrency_passesGate(usdfQuarks: UInt64, fx: Double) async throws { - // The daily limit sits below the entered $1.00 so the flow stops at - // the limit check right after the gate, never reaching the real buy. - let container = try await Self.makeContainer( - usdfQuarks: usdfQuarks, - currency: .cad, - fx: fx, - maxPerDay: 0.5 - ) + @Test("An invalid entered amount does not push") + func invalidAmount_noop() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 30_000_000), + ]) let viewModel = Self.makeViewModel(container: container) let router = AppRouter() router.present(.balance) + router.presentNested(.buy(.jeffy)) - viewModel.enteredAmount = "1" - await viewModel.amountEnteredAction(router: router) - - // The gate must NOT route a covered max-buy to Add Money... - #expect(container.session.dialogItem == nil) + viewModel.enteredAmount = "" + viewModel.primaryAction(router: router) - // ...and the flow must have cleared the gate, stopping at the limit - // check rather than reaching the network buy. - #expect(viewModel.dialogItem?.title == "Transaction Limit Reached") + #expect(router[.buy].isEmpty) } - @Test("Submission quarks are capped to the USDF balance for a max buy") - func maxBuy_submissionCappedToBalance() async throws { - let usdfQuarks: UInt64 = 729_927 // displays as 1.00 CAD at 1.37 - let container = try await Self.makeContainer(usdfQuarks: usdfQuarks, currency: .cad, fx: 1.37) + @Test("Entering beyond the cap disables Next; the cap itself is allowed") + func overCap_disabled() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 30_000_000), + ]) let viewModel = Self.makeViewModel(container: container) - viewModel.enteredAmount = "1" - let submission = await viewModel.prepareSubmission() - - let quarks = try #require(submission).amount.onChainAmount.quarks - #expect(quarks == usdfQuarks, "A max buy must spend exactly the balance, not overshoot it") + #expect(viewModel.actionEnabled("30") == true) + #expect(viewModel.actionEnabled("31") == false) } } diff --git a/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift b/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift new file mode 100644 index 000000000..b529537e7 --- /dev/null +++ b/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift @@ -0,0 +1,277 @@ +// +// BuyConfirmationViewModelTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import FlipcashCore +@testable import Flipcash + +@Suite("BuyConfirmationViewModel") +@MainActor +struct BuyConfirmationViewModelTests { + + 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 + + private static func makeContainer( + holdings: [SessionContainer.Holding], + currency: CurrencyCode = .usd, + fx: Double = 1.0, + maxPerDay: Decimal = 1_000 + ) async throws -> SessionContainer { + let container = try SessionContainer.makeTest( + holdings: holdings, + limits: Limits( + sinceDate: .now, + fetchDate: .now, + sendLimits: [currency: SendLimit( + nextTransaction: FiatAmount(value: 1000, currency: currency), + maxPerTransaction: FiatAmount(value: 1000, currency: currency), + maxPerDay: FiatAmount(value: maxPerDay, currency: currency) + )] + ) + ) + + container.ratesController.configureTestRates( + balanceCurrency: currency, + rates: [Rate(fx: Decimal(fx), currency: currency)] + ) + + await container.ratesController.verifiedProtoService.saveRates([ + .freshRate(currencyCode: currency.rawValue.uppercased(), rate: fx) + ]) + await container.ratesController.verifiedProtoService.saveReserveStates([ + .freshReserve(mint: .jeffy, supplyFromBonding: jeffySupply) + ]) + + return container + } + + /// Runs the selector's real compute so the confirmation sees the same + /// gross amount production would. + private static func makePaymentAmount( + entered: Decimal, + balance: StoredBalance, + pin: VerifiedState, + container: SessionContainer, + currency: CurrencyCode = .usd + ) throws -> ExchangedFiat { + let selector = BuyPaymentCurrencyViewModel( + targetMint: .usdcAuthority, + targetName: "Moony", + entered: FiatAmount(value: entered, currency: currency), + session: container.session, + ratesController: container.ratesController + ) + return try #require(selector.computePaymentAmount(for: balance, pin: pin)) + } + + private static func makeViewModel( + payment: StoredBalance, + paymentAmount: ExchangedFiat, + pin: VerifiedState + ) -> BuyConfirmationViewModel { + BuyConfirmationViewModel( + targetMint: .usdcAuthority, + targetName: "Moony", + payment: payment, + paymentAmount: paymentAmount, + pinnedState: pin + ) + } + + // MARK: - Boundary gate + + @Test("Entering the full token balance surfaces the insufficient-after-fees sheet, not a submit") + func boundary_showsSheet() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .makeLaunchpad(address: .jeffy, supplyFromBonding: Self.jeffySupply), quarks: Self.jeffyQuarks), + ]) + let rate = container.ratesController.rateForBalanceCurrency() + let jeffyBalance = try #require(container.session.balance(for: .jeffy)) + let displayed = jeffyBalance.computeExchangedValue(with: rate) + .nativeAmount.value.rounded(to: CurrencyCode.usd.maximumFractionDigits) + let pin = try #require(await container.ratesController.currentPinnedState(for: .usd, mint: .jeffy)) + let paymentAmount = try Self.makePaymentAmount(entered: displayed, balance: jeffyBalance, pin: pin, container: container) + + let viewModel = Self.makeViewModel(payment: jeffyBalance, paymentAmount: paymentAmount, pin: pin) + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(.usdcAuthority)) + + await viewModel.buyAction(session: container.session, router: router) + + #expect(viewModel.dialogItem?.title == "Insufficient Balance After Fees") + #expect(viewModel.actionButtonState == .normal) + #expect(router[.buy].isEmpty, "A short buy must never reach the processing push") + } + + @Test("Buy Maximum recomputes the summary from the full balance and passes the gate") + func buyMaximum_recomputesAndPasses() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .makeLaunchpad(address: .jeffy, supplyFromBonding: Self.jeffySupply), quarks: Self.jeffyQuarks), + ]) + let rate = container.ratesController.rateForBalanceCurrency() + let jeffyBalance = try #require(container.session.balance(for: .jeffy)) + let displayed = jeffyBalance.computeExchangedValue(with: rate) + .nativeAmount.value.rounded(to: CurrencyCode.usd.maximumFractionDigits) + let pin = try #require(await container.ratesController.currentPinnedState(for: .usd, mint: .jeffy)) + let paymentAmount = try Self.makePaymentAmount(entered: displayed, balance: jeffyBalance, pin: pin, container: container) + + let viewModel = Self.makeViewModel(payment: jeffyBalance, paymentAmount: paymentAmount, pin: pin) + + viewModel.buyMaximum(session: container.session) + + #expect(viewModel.paymentAmount.onChainAmount.quarks == jeffyBalance.quarks) + + // The recomputed summary must be internally consistent at display + // precision: amount to buy + fee == you pay. + let pay = viewModel.paymentAmount.nativeAmount.value + let net = viewModel.amountToBuy.nativeAmount.value.rounded(to: 2) + let fee = viewModel.fee.nativeAmount.value.rounded(to: 2) + #expect((net + fee).rounded(to: 2) == pay.rounded(to: 2)) + + // And the canonical gate must now pass. + switch container.session.hasSufficientFunds(for: viewModel.paymentAmount) { + case .sufficient: + break + case .insufficient: + Issue.record("Buy Maximum must satisfy the funds gate") + } + } + + // MARK: - USDF variant + + @Test("USDF payments show no fee and amountToBuy equals the payment") + func usdfVariant_noFee() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 30_000_000), + ]) + let usdfBalance = try #require(container.session.balance(for: .usdf)) + let pin = try #require(await container.ratesController.currentPinnedState(for: .usd, mint: .usdf)) + let paymentAmount = try Self.makePaymentAmount(entered: 10, balance: usdfBalance, pin: pin, container: container) + + let viewModel = Self.makeViewModel(payment: usdfBalance, paymentAmount: paymentAmount, pin: pin) + + #expect(viewModel.isUSDF) + #expect(viewModel.amountToBuy == viewModel.paymentAmount) + } + + @Test("An underfunded USDF payment surfaces the insufficient sheet, without the fee wording") + func usdfUnderfunded_showsSheet() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 630_000), // $0.63 + ]) + let usdfBalance = try #require(container.session.balance(for: .usdf)) + let pin = try #require(await container.ratesController.currentPinnedState(for: .usd, mint: .usdf)) + let paymentAmount = try Self.makePaymentAmount(entered: Decimal(string: "0.74")!, balance: usdfBalance, pin: pin, container: container) + + let viewModel = Self.makeViewModel(payment: usdfBalance, paymentAmount: paymentAmount, pin: pin) + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(.usdcAuthority)) + + await viewModel.buyAction(session: container.session, router: router) + + #expect(viewModel.dialogItem?.title == "Insufficient Balance") + #expect(router[.buy].isEmpty) + + // Buy Maximum recomputes to the full USDF balance (no reserve supply needed). + viewModel.buyMaximum(session: container.session) + #expect(viewModel.paymentAmount.onChainAmount.quarks == usdfBalance.quarks) + } + + /// Regression: the funds gate must value the balance at the REQUESTED + /// amount's rate. The confirmation feeds it a pinned-rate amount; with a + /// drifted live cache the old live-rate compare tripped + /// `ExchangedFiat.subtracting`'s same-rate precondition and crashed at the + /// exact boundary this feature is built around. + @Test("A rate drift between pin and Buy gates as insufficient instead of crashing") + func rateDrift_boundaryGatesInsufficient() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .makeLaunchpad(address: .jeffy, supplyFromBonding: Self.jeffySupply), quarks: Self.jeffyQuarks), + ], currency: .cad, fx: 1.35) + let rate = container.ratesController.rateForBalanceCurrency() + let jeffyBalance = try #require(container.session.balance(for: .jeffy)) + let displayed = jeffyBalance.computeExchangedValue(with: rate) + .nativeAmount.value.rounded(to: CurrencyCode.cad.maximumFractionDigits) + let pin = try #require(await container.ratesController.currentPinnedState(for: .cad, mint: .jeffy)) + let paymentAmount = try Self.makePaymentAmount( + entered: displayed, + balance: jeffyBalance, + pin: pin, + container: container, + currency: .cad + ) + + // The live cache drifts after the pin was captured. + container.ratesController.configureTestRates( + balanceCurrency: .cad, + rates: [Rate(fx: Decimal(1.37), currency: .cad)] + ) + + let viewModel = Self.makeViewModel(payment: jeffyBalance, paymentAmount: paymentAmount, pin: pin) + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(.usdcAuthority)) + + await viewModel.buyAction(session: container.session, router: router) + + #expect(viewModel.dialogItem?.title == "Insufficient Balance After Fees") + #expect(router[.buy].isEmpty) + } + + /// Regression port (deposit 1.00 CAD, buy 1.00 CAD): a covered max-buy in + /// a non-USD currency must clear the canonical funds gate. Asserted + /// directly against `hasSufficientFunds` — the old "stops at the limit + /// check" proxy no longer proves it since the limit now runs first. + /// Previously covered by `BuyAmountViewModelTests.maxBuy_nonUSDCurrency_passesGate`. + @Test( + "Displayed-balance max buy in a non-USD currency passes the gate", + arguments: [ + (usdfQuarks: UInt64(729_927), fx: 1.37), + (usdfQuarks: UInt64(735_293), fx: 1.36), + ] + ) + func maxBuy_nonUSDCurrency_passesGate(usdfQuarks: UInt64, fx: Double) async throws { + let container = try await Self.makeContainer( + holdings: [.init(mint: .usdf, quarks: usdfQuarks)], + currency: .cad, + fx: fx + ) + let usdfBalance = try #require(container.session.balance(for: .usdf)) + let pin = try #require(await container.ratesController.currentPinnedState(for: .cad, mint: .usdf)) + let paymentAmount = try Self.makePaymentAmount( + entered: 1, + balance: usdfBalance, + pin: pin, + container: container, + currency: .cad + ) + + switch container.session.hasSufficientFunds(for: paymentAmount) { + case .sufficient: + break + case .insufficient: + Issue.record("A displayed-balance max buy must pass the funds gate") + } + } + + @Test("A stale pin disables the Buy button") + func stalePin_disables() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 30_000_000), + ]) + let usdfBalance = try #require(container.session.balance(for: .usdf)) + + let viewModel = Self.makeViewModel( + payment: usdfBalance, + paymentAmount: ExchangedFiat(nativeAmount: FiatAmount.usd(1), rate: .oneToOne), + pin: .stale() + ) + + #expect(viewModel.canPerformAction == false) + } +} diff --git a/FlipcashTests/Buy/BuyPaymentCurrencyViewModelTests.swift b/FlipcashTests/Buy/BuyPaymentCurrencyViewModelTests.swift new file mode 100644 index 000000000..dacd8f976 --- /dev/null +++ b/FlipcashTests/Buy/BuyPaymentCurrencyViewModelTests.swift @@ -0,0 +1,215 @@ +// +// BuyPaymentCurrencyViewModelTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import FlipcashCore +@testable import Flipcash + +@Suite("BuyPaymentCurrencyViewModel") +@MainActor +struct BuyPaymentCurrencyViewModelTests { + + 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 + + private static func makeContainer( + holdings: [SessionContainer.Holding], + currency: CurrencyCode = .usd, + fx: Double = 1.0 + ) async throws -> SessionContainer { + let container = try SessionContainer.makeTest(holdings: holdings) + + container.ratesController.configureTestRates( + balanceCurrency: currency, + rates: [Rate(fx: Decimal(fx), currency: currency)] + ) + + await container.ratesController.verifiedProtoService.saveRates([ + .freshRate(currencyCode: currency.rawValue.uppercased(), rate: fx) + ]) + await container.ratesController.verifiedProtoService.saveReserveStates([ + .freshReserve(mint: .jeffy, supplyFromBonding: jeffySupply) + ]) + + return container + } + + private static func makeViewModel( + targetMint: PublicKey = .usdcAuthority, + entered: Decimal, + currency: CurrencyCode = .usd, + container: SessionContainer + ) -> BuyPaymentCurrencyViewModel { + BuyPaymentCurrencyViewModel( + targetMint: targetMint, + targetName: "Moony", + entered: FiatAmount(value: entered, currency: currency), + session: container.session, + ratesController: container.ratesController + ) + } + + // MARK: - Row membership + + @Test("The target currency never appears in the payment list") + func targetRow_removed() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 30_000_000), + .init(mint: .makeLaunchpad(address: .jeffy, supplyFromBonding: Self.jeffySupply), quarks: Self.jeffyQuarks), + ]) + let viewModel = Self.makeViewModel(targetMint: .jeffy, entered: 1, container: container) + + #expect(viewModel.rows.allSatisfy { $0.stored.mint != .jeffy }) + #expect(viewModel.rows.contains { $0.stored.mint == .usdf }) + } + + @Test("An underfunded balance stays listed and tappable") + func underfunded_staysListed() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 30_000_000), + .init(mint: .makeLaunchpad(address: .jeffy, supplyFromBonding: Self.jeffySupply), quarks: Self.jeffyQuarks), + ]) + + let rate = container.ratesController.rateForBalanceCurrency() + let jeffyDisplayed = try #require(container.session.balance(for: .jeffy)) + .computeExchangedValue(with: rate) + .nativeAmount.value + .rounded(to: CurrencyCode.usd.maximumFractionDigits) + + // Entered well above the balance: the row must remain selectable so + // the confirmation's Buy can offer Buy Maximum Amount. + let overBalance = Self.makeViewModel(entered: jeffyDisplayed + 5, container: container) + #expect(overBalance.rows.contains { $0.stored.mint == .jeffy }) + } + + @Test("A zero-value USDF balance is not offered as a payment source") + func zeroValueUSDF_removed() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 0), + .init(mint: .makeLaunchpad(address: .jeffy, supplyFromBonding: Self.jeffySupply), quarks: Self.jeffyQuarks), + ]) + let viewModel = Self.makeViewModel(entered: 1, container: container) + + // The jeffy anchor keeps this from passing vacuously on an empty list. + #expect(viewModel.rows.contains { $0.stored.mint == .jeffy }) + #expect(viewModel.rows.allSatisfy { $0.stored.mint != .usdf }) + } + + @Test("Selecting a funded row pins its state and pushes the confirmation") + func select_success_pushesConfirmation() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 30_000_000), + ]) + let viewModel = Self.makeViewModel(entered: 10, container: container) + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(.usdcAuthority)) + + let usdfRow = try #require(viewModel.rows.first { $0.stored.mint == .usdf }) + await viewModel.select(usdfRow, router: router) + + #expect(router[.buy].count == 1) + #expect(viewModel.dialogItem == nil) + } + + // MARK: - Payment compute + + @Test("USDF payment computes a balance-capped amount with no fee") + func usdfCompute_capped() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 30_000_000), + ]) + let viewModel = Self.makeViewModel(entered: 10, container: container) + let usdfBalance = try #require(container.session.balance(for: .usdf)) + let pin = try #require(await container.ratesController.currentPinnedState(for: .usd, mint: .usdf)) + + let amount = try #require(viewModel.computePaymentAmount(for: usdfBalance, pin: pin)) + + #expect(amount.onChainAmount.quarks == 10_000_000) + #expect(amount.mint == .usdf) + } + + @Test("USDF entered above the displayed balance is deliberately uncapped so the gate can offer Buy Maximum") + func usdfCompute_aboveDisplayedBalance_uncapped() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 630_000), // $0.63 + ]) + let viewModel = Self.makeViewModel(entered: Decimal(string: "0.74")!, container: container) + let usdfBalance = try #require(container.session.balance(for: .usdf)) + let pin = try #require(await container.ratesController.currentPinnedState(for: .usd, mint: .usdf)) + + let amount = try #require(viewModel.computePaymentAmount(for: usdfBalance, pin: pin)) + + // The summary must show the true entered amount, not a silently + // shrunken one — the confirmation's gate then surfaces the sheet. + #expect(amount.onChainAmount.quarks == 740_000) + #expect(amount.onChainAmount.quarks > usdfBalance.quarks) + } + + /// Regression port (deposit 1.00 CAD, buy 1.00 CAD): the USDF compute must + /// cap the quarks to the balance so FX display rounding can't overshoot + /// the spendable reserves. Previously covered by + /// `BuyAmountViewModelTests.maxBuy_submissionCappedToBalance`. + @Test( + "Displayed-balance max buy in a non-USD currency stays within the balance", + arguments: [ + (usdfQuarks: UInt64(729_927), fx: 1.37), + (usdfQuarks: UInt64(735_293), fx: 1.36), + ] + ) + func usdfCompute_nonUSDMaxBuy_cappedToBalance(usdfQuarks: UInt64, fx: Double) async throws { + let container = try await Self.makeContainer( + holdings: [.init(mint: .usdf, quarks: usdfQuarks)], + currency: .cad, + fx: fx + ) + let viewModel = Self.makeViewModel(entered: 1, currency: .cad, container: container) + let usdfBalance = try #require(container.session.balance(for: .usdf)) + let pin = try #require(await container.ratesController.currentPinnedState(for: .cad, mint: .usdf)) + + let amount = try #require(viewModel.computePaymentAmount(for: usdfBalance, pin: pin)) + + #expect(amount.onChainAmount.quarks == usdfQuarks, "A max buy must spend exactly the balance, not overshoot or shrink") + } + + @Test("Token payment grosses up by the pool fee and is deliberately uncapped") + func tokenCompute_grossedUp() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .usdf, quarks: 30_000_000), + .init(mint: .makeLaunchpad(address: .jeffy, supplyFromBonding: Self.jeffySupply), quarks: Self.jeffyQuarks), + ]) + + let rate = container.ratesController.rateForBalanceCurrency() + let jeffyBalance = try #require(container.session.balance(for: .jeffy)) + let jeffyDisplayed = jeffyBalance + .computeExchangedValue(with: rate) + .nativeAmount.value + .rounded(to: CurrencyCode.usd.maximumFractionDigits) + + let viewModel = Self.makeViewModel(entered: jeffyDisplayed, container: container) + let pin = try #require(await container.ratesController.currentPinnedState(for: .usd, mint: .jeffy)) + + let amount = try #require(viewModel.computePaymentAmount(for: jeffyBalance, pin: pin)) + + // Entering the full displayed balance must overshoot it by the fee — + // that overshoot is what drives the insufficient-after-fees sheet. + #expect(amount.onChainAmount.quarks > jeffyBalance.quarks) + #expect(amount.mint == .jeffy) + } + + @Test("A pin without reserve supply fails the token compute") + func missingSupply_nilCompute() async throws { + let container = try await Self.makeContainer(holdings: [ + .init(mint: .makeLaunchpad(address: .jeffy, supplyFromBonding: Self.jeffySupply), quarks: Self.jeffyQuarks), + ]) + let viewModel = Self.makeViewModel(entered: 1, container: container) + let jeffyBalance = try #require(container.session.balance(for: .jeffy)) + + let rateOnly = VerifiedState.fresh(bonded: false) + + #expect(viewModel.computePaymentAmount(for: jeffyBalance, pin: rateOnly) == nil) + } +} diff --git a/FlipcashTests/Buy/SessionBuyWithCurrencyTests.swift b/FlipcashTests/Buy/SessionBuyWithCurrencyTests.swift new file mode 100644 index 000000000..4b7556499 --- /dev/null +++ b/FlipcashTests/Buy/SessionBuyWithCurrencyTests.swift @@ -0,0 +1,46 @@ +// +// SessionBuyWithCurrencyTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import FlipcashCore +@testable import Flipcash + +@Suite("Session.buy(with:) — pre-RPC guards") +@MainActor +struct SessionBuyWithCurrencyTests { + + private static func makeAmount() -> ExchangedFiat { + ExchangedFiat(nativeAmount: FiatAmount.usd(1), rate: .oneToOne) + } + + @Test("A stale pin throws verifiedStateStale before any network call") + func stalePin_throws() async throws { + let container = try SessionContainer.makeTest(holdings: []) + + await #expect(throws: Session.Error.verifiedStateStale) { + try await container.session.buy( + amount: Self.makeAmount(), + with: .jeffy, + verifiedState: .stale(), + of: PublicKey.usdcAuthority + ) + } + } + + @Test("A pin without reserve supply throws missingSupply") + func missingSupply_throws() async throws { + let container = try SessionContainer.makeTest(holdings: []) + + await #expect(throws: Session.Error.missingSupply) { + try await container.session.buy( + amount: Self.makeAmount(), + with: .jeffy, + verifiedState: .fresh(bonded: false), + of: PublicKey.usdcAuthority + ) + } + } +} diff --git a/FlipcashTests/EnterAmountCalculatorTests.swift b/FlipcashTests/EnterAmountCalculatorTests.swift index ae25933ba..e4880e22b 100644 --- a/FlipcashTests/EnterAmountCalculatorTests.swift +++ b/FlipcashTests/EnterAmountCalculatorTests.swift @@ -169,10 +169,10 @@ import FlipcashCore // MARK: - Buy-Style Mode Limit Tests static let buyStyleModes: [EnterAmountView.Mode] = [ - .buy, + .addMoney, ] - @Test("Buy-style modes use maxPerDay as per-transaction limit", arguments: buyStyleModes) + @Test("Add-Money-style modes use maxPerDay as per-transaction limit", arguments: buyStyleModes) func maxTransactionAmount_buyStyleModes_usesMaxPerDay(mode: EnterAmountView.Mode) { let sendLimit = Self.sendLimit(nextTransaction: 100_000_000, maxPerTransaction: 250_000_000, maxPerDay: 1_000_000_000) @@ -333,10 +333,11 @@ import FlipcashCore #expect(result.currency == .cad) } - @Test("maxEnterAmount for buy mode caps at maxPerDay, not maxPerTransaction") - func maxEnterAmount_buyMode_capsAtMaxPerDay() { + @Test("maxEnterAmount for buy mode caps at the balance, ignoring send limits") + func maxEnterAmount_buyMode_capsAtBalance() { let balance = Self.createExchangedFiat(usdQuarks: 2_000_000) - // $0.10 remaining, $0.25 per-tx cap, $1.00 daily (used as buy per-tx) + // Limits far below the balance must not constrain buy entry — the + // send limit is enforced at submission on the Buy summary instead. let sendLimit = Self.sendLimit(nextTransaction: 100_000, maxPerTransaction: 250_000, maxPerDay: 1_000_000) let calculator = EnterAmountCalculator( @@ -345,8 +346,8 @@ import FlipcashCore sendLimitProvider: { _ in return sendLimit } ) - // Buy mode should use maxPerDay ($1.00), not maxPerTransaction ($0.25) - #expect(calculator.maxEnterAmount(maxBalance: balance) == sendLimit.maxPerDay) + #expect(calculator.maxTransactionAmount == nil) + #expect(calculator.maxEnterAmount(maxBalance: balance) == balance.nativeAmount) } } diff --git a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift index 2b5b96051..7a8a3aeb2 100644 --- a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift +++ b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift @@ -25,21 +25,14 @@ struct Regression_native_amount_mismatch { // MARK: - Scenario D (buy) - @Test("Scenario D (buy): amountEnteredAction computes quarks from the PINNED rate, not the live cache") - func scenarioD_buyAmountEnteredActionUsesPinnedRate() async throws { - // Pinned rate: 1 USD = 1.35 CAD. Live cache drifted to 1.37 after the pin was captured. - // prepareSubmission pins the rate and computes quarks against it, independent of balance. + @Test("Scenario D (buy): the payment compute uses the PINNED rate, not the live cache") + func scenarioD_buyPaymentComputeUsesPinnedRate() async throws { + // Pinned rate: 1 USD = 1.35 CAD. Live cache drifted to 1.37 after the + // pin was captured. The payment-currency step pins the rate on row + // selection and computes quarks against it; the balance is ample so + // the USDF cap can't interfere. let sessionContainer = try SessionContainer.makeTest( - holdings: [], - limits: Limits( - sinceDate: .now, - fetchDate: .now, - sendLimits: [.cad: SendLimit( - nextTransaction: FiatAmount(value: 1000, currency: .cad), - maxPerTransaction: FiatAmount(value: 1000, currency: .cad), - maxPerDay: FiatAmount(value: 1000, currency: .cad) - )] - ) + holdings: [.init(mint: MintMetadata.usdf, quarks: 50_000_000)] ) sessionContainer.ratesController.configureTestRates( balanceCurrency: .cad, @@ -49,22 +42,24 @@ struct Regression_native_amount_mismatch { .freshRate(currencyCode: "CAD", rate: 1.35) ]) - let vm = BuyAmountViewModel( - mint: .usdf, - currencyName: "USDF", + let vm = BuyPaymentCurrencyViewModel( + targetMint: .jeffy, + targetName: "Jeffy", + entered: FiatAmount(value: 1, currency: .cad), session: sessionContainer.session, ratesController: sessionContainer.ratesController ) - vm.enteredAmount = "1" - let submission = try #require(await vm.prepareSubmission()) + let usdfBalance = try #require(sessionContainer.session.balance(for: .usdf)) + let pin = try #require(await sessionContainer.ratesController.currentPinnedState(for: .cad, mint: .usdf)) + let amount = try #require(vm.computePaymentAmount(for: usdfBalance, pin: pin)) // $1 CAD / 1.35 × 10^6, HALF_UP rounded via scaleUpInt → 740_741 USDF quarks. // The buggy live path (1.37) would round to 729_927 quarks — the value // the server rejected in production. - #expect(submission.amount.onChainAmount.quarks == 740_741) - #expect(submission.amount.currencyRate.fx == Decimal(1.35)) - #expect(submission.pinnedState.exchangeRate == 1.35) + #expect(amount.onChainAmount.quarks == 740_741) + #expect(amount.currencyRate.fx == Decimal(1.35)) + #expect(pin.exchangeRate == 1.35) } // MARK: - Scenario D (sell) @@ -143,11 +138,11 @@ struct Regression_native_amount_mismatch { // MARK: - Scenario E (buy) - @Test("Scenario E (buy): amountEnteredAction surfaces .staleRate when no fresh pin is cached") - func scenarioE_buyAmountEnteredActionSurfacesStaleRateWhenNoPin() async throws { - // The account holds ample USDF so the balance gate passes; a live rate - // is configured, but nothing is seeded in the verified proto service — - // the submit path has no pin to use and must bail. + @Test("Scenario E (buy): selecting a payment currency surfaces Rate Unavailable when no fresh pin is cached") + func scenarioE_buyPaymentSelectionSurfacesRateUnavailableWhenNoPin() async throws { + // The account holds ample USDF; a live rate is configured, but nothing + // is seeded in the verified proto service — the selection path has no + // pin to carry to the summary and must bail without pushing. let sessionContainer = try SessionContainer.makeTest( holdings: [.init(mint: MintMetadata.usdf, quarks: 50_000_000)] ) @@ -156,20 +151,23 @@ struct Regression_native_amount_mismatch { rates: [Rate(fx: 1.35, currency: .cad)] ) - let vm = BuyAmountViewModel( - mint: .usdf, - currencyName: "USDF", + let vm = BuyPaymentCurrencyViewModel( + targetMint: .jeffy, + targetName: "Jeffy", + entered: FiatAmount(value: 1, currency: .cad), session: sessionContainer.session, ratesController: sessionContainer.ratesController ) - vm.enteredAmount = "1" let router = AppRouter() router.present(.balance) - await vm.amountEnteredAction(router: router) + router.presentNested(.buy(.jeffy)) + + let usdfRow = try #require(vm.rows.first { $0.stored.mint == .usdf }) + await vm.select(usdfRow, router: router) #expect(vm.dialogItem?.title == "Rate Unavailable") - #expect(!router.presentedSheets.contains(.addMoney(.buyCurrency))) + #expect(router[.buy].isEmpty, "A pinless selection must not push the summary") } // MARK: - Scenario E (sell) diff --git a/FlipcashTests/SwapProcessingViewModelTests.swift b/FlipcashTests/SwapProcessingViewModelTests.swift index 5b48ecb65..aee67ffd7 100644 --- a/FlipcashTests/SwapProcessingViewModelTests.swift +++ b/FlipcashTests/SwapProcessingViewModelTests.swift @@ -12,10 +12,11 @@ import FlipcashCore @MainActor struct SwapProcessingViewModelTests { - @Test("SwapType exposes only the reserves + sell cases after funding decoupling") - func swapType_hasOnlyReservesAndSellCases() { - #expect(SwapType.allCases.count == 3) + @Test("SwapType exposes the reserves, currency-paid, launch, and sell cases") + func swapType_exposesExpectedCases() { + #expect(SwapType.allCases.count == 4) #expect(SwapType.allCases.contains(.buyWithReserves)) + #expect(SwapType.allCases.contains(.buyWithCurrency)) #expect(SwapType.allCases.contains(.launchWithReserves)) #expect(SwapType.allCases.contains(.sell)) } @@ -31,6 +32,7 @@ struct SwapProcessingViewModelTests { ) } #expect(makeViewModel(.buyWithReserves).navigationTitle == "Purchasing TestCoin") + #expect(makeViewModel(.buyWithCurrency).navigationTitle == "Purchasing TestCoin") #expect(makeViewModel(.launchWithReserves).navigationTitle == "Purchasing TestCoin") #expect(makeViewModel(.sell).navigationTitle == "Selling TestCoin") } diff --git a/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift b/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift index 6b1f4746a..2eb7e6905 100644 --- a/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift +++ b/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift @@ -11,7 +11,7 @@ import XCTest /// no auth keys are required. final class AddMoneyGateRegressionTests: BaseUITestCase { - func testBuyWithNoAssets_gatesOnAddMoneyBeforeAmountEntry() { + func testBuyWithNoAssets_offersAddMoneyOnAmountEntry() { let addMoney = AddMoneyStartScreen(app: app) let currencyInfo = CurrencyInfoUIScreen(app: app) @@ -25,20 +25,17 @@ final class AddMoneyGateRegressionTests: BaseUITestCase { ) currencyInfo.assertReached() - // Buy on a $0 account must gate immediately — no amount sheet. + // Buy always opens the amount sheet; on a $0 account the action + // button becomes an Add Money CTA instead of Next. waitAndTap(currencyInfo.buyButton) - addMoney.assertNoBalanceReached() XCTAssertTrue( - app.staticTexts["Add money to buy currencies"].exists, - "Expected the buy-context subtitle on the No Balance prompt" - ) - XCTAssertFalse( - app.navigationBars["Amount"].exists, - "The buy amount sheet must not open when the account has no USDF" + app.navigationBars["Amount"].waitForExistence(timeout: 10), + "The buy amount sheet must open even when the account has no balance" ) - // Add Money → the Select Method picker. - addMoney.tapAddMoney(from: self) + // Add Money → the Select Method picker. This flow enters from + // Discover, so the sheet's CTA is the only Add Money button on screen. + waitUntilHittableAndTap(app.buttons["Add Money"].firstMatch) addMoney.assertSelectMethodReached() } diff --git a/FlipcashUITests/Regression/BuyApplePayRegressionTests.swift b/FlipcashUITests/Regression/BuyApplePayRegressionTests.swift index a91a9ff34..8de0c7fda 100644 --- a/FlipcashUITests/Regression/BuyApplePayRegressionTests.swift +++ b/FlipcashUITests/Regression/BuyApplePayRegressionTests.swift @@ -12,6 +12,10 @@ import XCTest /// state, so the test accepts either. Stops short of completing /// verification — SMS / email links are out of scope for the simulator. /// +/// Entry is the Balance screen's own Add Money button — buy entry is capped +/// at the highest spendable balance, so the old buy-shortfall vehicle into +/// Add Money no longer exists. +/// /// **Prerequisites:** /// - `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` /// - The account behind the access key must have **no verified email**. A @@ -25,33 +29,20 @@ final class BuyApplePayRegressionTests: BaseUITestCase { func testApplePay_unverifiedAccount_showsVerificationSheet() { let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) let amountEntry = AmountEntryScreen(app: app) let addMoney = AddMoneyStartScreen(app: app) let verifyInfo = VerifyInfoUIScreen(app: app) assertMainScreenReached() - // Navigate: Main → Wallet → first currency → CurrencyInfoScreen → Buy + // Navigate: Main → Wallet → Add Money → Select Method → Pay. wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - waitAndTap(currencyInfo.buyButton) - - // Enter an amount above the USDF balance so the buy shortfall routes - // into the Add Money flow. - amountEntry.enterPickerTriggeringAmount() - waitUntilHittableAndTap(amountEntry.buyActionButton) - - // No Balance Yet → Add Money → Select Method → Pay. - addMoney.assertNoBalanceReached() - addMoney.tapAddMoney(from: self) + waitUntilHittableAndTap(app.buttons["Add Money"].firstMatch) addMoney.assertSelectMethodReached() addMoney.selectPayDebitCard(from: self) // Amount to Add → enter $10 → the verified-contact gate opens the - // verification sheet. The buy keypad was navigated away, so only one - // keypad is in the hierarchy. + // verification sheet. addMoney.assertAmountToAddReached() amountEntry.keypadButton("1").tap() amountEntry.keypadButton("0").tap() diff --git a/FlipcashUITests/Regression/BuyDepositRegressionTests.swift b/FlipcashUITests/Regression/BuyDepositRegressionTests.swift index d711214d8..4186249c3 100644 --- a/FlipcashUITests/Regression/BuyDepositRegressionTests.swift +++ b/FlipcashUITests/Regression/BuyDepositRegressionTests.swift @@ -6,50 +6,38 @@ import XCTest /// Regression test for the "Other Wallet" deposit path in the Add Money flow. -/// Buying is reserves-only; funding is the standalone Add Money flow. The old -/// "Deposit USDC" picker row is now the "Other Wallet" method, which opens the -/// USDC education pre-flight; Next pushes the deposit-address screen. Exercises: +/// The old "Deposit USDC" picker row is the "Other Wallet" method, which opens +/// the USDC education pre-flight; Next pushes the deposit-address screen. +/// Exercises: /// -/// - The buy nested sheet opens on top of CurrencyInfoScreen. -/// - An amount above the USDF balance routes to the Add Money flow. +/// - The Balance screen's Add Money button opens "Select Method". /// - Selecting Other Wallet shows the USDC education screen; Next pushes the /// deposit-address screen with the Copy Address button hittable. The address /// is derived from the session's owner key — its exact value isn't asserted, /// just that the CTA renders. /// +/// Entry is the Balance screen's own Add Money button — buy entry is capped +/// at the highest spendable balance, so the old buy-shortfall vehicle into +/// Add Money no longer exists. +/// /// **Prerequisites:** /// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` -/// - The test account must have at least one non-USDF currency visible in -/// Wallet (the first row is used as the buy target). final class BuyDepositRegressionTests: BaseUITestCase { override var requiresAuthentication: Bool { true } func testOtherWallet_pushesUSDCDepositAddress() { let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - let amountEntry = AmountEntryScreen(app: app) let addMoney = AddMoneyStartScreen(app: app) let education = USDCDepositEducationScreen(app: app) let depositAddress = USDCDepositAddressScreen(app: app) assertMainScreenReached() - // Navigate: Main → Wallet → first currency → CurrencyInfoScreen → Buy - wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - waitAndTap(currencyInfo.buyButton) - - // Enter an amount above the USDF balance so the buy shortfall routes - // into the Add Money flow. - amountEntry.enterPickerTriggeringAmount() - waitUntilHittableAndTap(amountEntry.buyActionButton) - - // No Balance Yet → Add Money → Select Method → Other Wallet → + // Navigate: Main → Wallet → Add Money → Select Method → Other Wallet → // USDC education pre-flight → Next → USDC deposit-address screen. - addMoney.assertNoBalanceReached() - addMoney.tapAddMoney(from: self) + wallet.open(from: self) + waitUntilHittableAndTap(app.buttons["Add Money"].firstMatch) addMoney.assertSelectMethodReached() addMoney.selectOtherWallet(from: self) diff --git a/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift b/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift index 717c98491..642b99e7f 100644 --- a/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift +++ b/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift @@ -5,13 +5,11 @@ import XCTest -/// Regression test for the Phantom deposit path. Buying is now reserves-only; -/// funding is the standalone Add Money flow. Exercises the in-app flow as far -/// as can be tested without a real Phantom install: +/// Regression test for the Phantom deposit path in the Add Money flow. +/// Exercises the in-app flow as far as can be tested without a real Phantom +/// install: /// -/// - The buy nested sheet opens on top of CurrencyInfoScreen. -/// - An amount above the USDF balance routes to the Add Money flow -/// ("No Balance Yet" → "Select Method"). +/// - The Balance screen's Add Money button opens "Select Method". /// - Selecting Phantom opens the "Add Money With Phantom" education screen /// with the "Connect Your Phantom Wallet" CTA. /// @@ -19,6 +17,10 @@ import XCTest /// deeplink, and only a successful connect pushes "Amount to Add" — out of /// scope for the local simulator without a real Phantom install. /// +/// Entry is the Balance screen's own Add Money button — buy entry is capped +/// at the highest spendable balance, so the old buy-shortfall vehicle into +/// Add Money no longer exists. +/// /// **Prerequisites:** /// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` final class BuyPhantomRegressionTests: BaseUITestCase { @@ -27,26 +29,13 @@ final class BuyPhantomRegressionTests: BaseUITestCase { func testPhantomFlow_showsEducationScreen() { let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - let amountEntry = AmountEntryScreen(app: app) let addMoney = AddMoneyStartScreen(app: app) assertMainScreenReached() - // Navigate: Main → Wallet → first currency → CurrencyInfoScreen → Buy + // Navigate: Main → Wallet → Add Money → Select Method → Phantom. wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - waitAndTap(currencyInfo.buyButton) - - // Enter an amount above the USDF balance so the buy shortfall routes - // into the Add Money flow. - amountEntry.enterPickerTriggeringAmount() - waitUntilHittableAndTap(amountEntry.buyActionButton) - - // No Balance Yet → Add Money → Select Method → Phantom → education. - addMoney.assertNoBalanceReached() - addMoney.tapAddMoney(from: self) + waitUntilHittableAndTap(app.buttons["Add Money"].firstMatch) addMoney.assertSelectMethodReached() addMoney.selectPhantom(from: self) diff --git a/FlipcashUITests/Regression/BuyReservesRegressionTests.swift b/FlipcashUITests/Regression/BuyReservesRegressionTests.swift index 21dde8f96..d8b79adc8 100644 --- a/FlipcashUITests/Regression/BuyReservesRegressionTests.swift +++ b/FlipcashUITests/Regression/BuyReservesRegressionTests.swift @@ -5,12 +5,13 @@ import XCTest -/// Regression test for the full buy flow using the user's USDF reserve as -/// the funding source. Asserts that: +/// Regression test for the full buy flow paying with USDF. Asserts that: /// /// - The buy nested sheet opens on top of CurrencyInfoScreen. -/// - A sub-cent entry the USDF balance can cover routes straight to the -/// swap-processing screen (the Add Money "Select Method" sheet never appears). +/// - Next pushes the Select Payment Currency step; picking USDF lands on the +/// Buy summary in its simple (no fee breakdown) variant. +/// - A covered entry routes straight to the swap-processing screen (the Add +/// Money "Select Method" sheet never appears). /// - After OK on the processing screen, the user lands back on /// CurrencyInfoScreen — not the Wallet root, not the Scanner. /// @@ -27,6 +28,8 @@ final class BuyReservesRegressionTests: BaseUITestCase { let wallet = WalletScreen(app: app) let currencyInfo = CurrencyInfoUIScreen(app: app) let amountEntry = AmountEntryScreen(app: app) + let paymentCurrency = PaymentCurrencyUIScreen(app: app) + let confirmation = BuyConfirmationUIScreen(app: app) let processing = SwapProcessingUIScreen(app: app) assertMainScreenReached() @@ -36,11 +39,22 @@ final class BuyReservesRegressionTests: BaseUITestCase { wallet.selectFirstCurrency() currencyInfo.assertReached() - // Buy → enter $0.01 → submit. The amount is well below any plausible - // USDF balance, so the USDF gate routes straight to the swap. + // Buy → enter $0.01 → Next → USDF → summary → Buy. The amount is well + // below any plausible USDF balance, so USDF is always eligible. waitAndTap(currencyInfo.buyButton) amountEntry.enterMinimumAmount() - waitUntilHittableAndTap(amountEntry.buyActionButton) + waitUntilHittableAndTap(amountEntry.nextButton) + + paymentCurrency.assertReached() + waitAndTap(paymentCurrency.usdfRow) + + confirmation.assertReached() + // The USDF variant is the simple summary — no fee breakdown. + XCTAssertFalse( + confirmation.exchangeFeeRow.exists, + "USDF-paid buys must not show an Exchange fee row" + ) + waitUntilHittableAndTap(confirmation.buyButton) // A covered amount must not detour through the Add Money flow — the // "Select Method" sheet must never appear. diff --git a/FlipcashUITests/Regression/BuyWithCurrencyRegressionTests.swift b/FlipcashUITests/Regression/BuyWithCurrencyRegressionTests.swift new file mode 100644 index 000000000..f50788d26 --- /dev/null +++ b/FlipcashUITests/Regression/BuyWithCurrencyRegressionTests.swift @@ -0,0 +1,64 @@ +// +// BuyWithCurrencyRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Regression test for buying a currency paying with another launchpad +/// currency: the multi-step flow (amount → Select Payment Currency → summary +/// with fee breakdown → processing) with a real $0.01 swap. The insufficient +/// sheet and Buy Maximum math are covered deterministically by +/// `BuyConfirmationViewModelTests` — a UI rendition proved too balance- +/// dependent to keep stable. +/// +/// **Prerequisites:** +/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` +/// - The account holds USDF and at least TWO launchpad currencies +final class BuyWithCurrencyRegressionTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + /// Buys $0.01 of the first wallet currency paying with another launchpad + /// token, all the way through the processing screen. Moves ~$0.01 of real + /// dev-environment value per run. + func testBuyCurrency_payingWithToken_fullFlow() { + let wallet = WalletScreen(app: app) + let currencyInfo = CurrencyInfoUIScreen(app: app) + let amountEntry = AmountEntryScreen(app: app) + let paymentCurrency = PaymentCurrencyUIScreen(app: app) + let confirmation = BuyConfirmationUIScreen(app: app) + let processing = SwapProcessingUIScreen(app: app) + + assertMainScreenReached() + + // Target = first wallet currency; payment = first eligible token row + // on the selector (the target's own row renders disabled and carries a + // different identifier, so it can never be matched). + wallet.open(from: self) + wallet.selectFirstCurrency() + currencyInfo.assertReached() + + waitAndTap(currencyInfo.buyButton) + amountEntry.enterMinimumAmount() + waitUntilHittableAndTap(amountEntry.nextButton) + + paymentCurrency.assertReached() + XCTAssertTrue( + paymentCurrency.firstTokenRow.waitForExistence(timeout: 10), + "Fixture requires a second launchpad currency with a spendable balance" + ) + waitAndTap(paymentCurrency.firstTokenRow) + + confirmation.assertReached() + XCTAssertTrue( + confirmation.exchangeFeeRow.waitForExistence(timeout: 5), + "Token-paid buys must show the Exchange fee breakdown" + ) + waitUntilHittableAndTap(confirmation.buyButton) + + processing.assertReached() + processing.waitForCompletionAndDismiss() + currencyInfo.assertReached() + } +} diff --git a/FlipcashUITests/Support/Screens/AmountEntryScreen.swift b/FlipcashUITests/Support/Screens/AmountEntryScreen.swift index c325d4cbf..379a3959a 100644 --- a/FlipcashUITests/Support/Screens/AmountEntryScreen.swift +++ b/FlipcashUITests/Support/Screens/AmountEntryScreen.swift @@ -21,14 +21,6 @@ struct AmountEntryScreen { var keypadDecimal: XCUIElement { app.buttons["."] } var nextButton: XCUIElement { app.buttons["Next"] } - /// The "Buy" `CodeButton` on the amount entry screen. - /// When the BuyAmountScreen sheet is presented, there are two "Buy" buttons in - /// the hierarchy — the footer button on CurrencyInfoScreen (index 0, behind the sheet) - /// and the action button on the amount entry sheet (index 1, on top). - var buyActionButton: XCUIElement { - app.buttons.matching(identifier: "Buy").element(boundBy: 1) - } - func keypadButton(_ digit: String) -> XCUIElement { app.buttons[digit] } // MARK: - Actions @@ -42,16 +34,4 @@ struct AmountEntryScreen { keypadButton("0").tap() keypadButton("1").tap() } - - /// Enters an amount near the per-transaction cap so the USDF gate routes - /// to the funding picker regardless of the test account's USDF balance. - /// The single-transaction limit is $1,000.00; entering "999" stays inside - /// it while exceeding any plausible test-account reserve. - func enterPickerTriggeringAmount() { - XCTAssertTrue(keypadZero.waitForExistence(timeout: 5), "Expected keypad to be visible") - - keypadButton("9").tap() - keypadButton("9").tap() - keypadButton("9").tap() - } } diff --git a/FlipcashUITests/Support/Screens/BuyConfirmationUIScreen.swift b/FlipcashUITests/Support/Screens/BuyConfirmationUIScreen.swift new file mode 100644 index 000000000..7c64b2fa7 --- /dev/null +++ b/FlipcashUITests/Support/Screens/BuyConfirmationUIScreen.swift @@ -0,0 +1,39 @@ +// +// BuyConfirmationUIScreen.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the buy flow's summary (confirmation) step. +@MainActor +struct BuyConfirmationUIScreen { + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + /// The summary's Buy button, targeted by identifier so the CurrencyInfo + /// footer's "Buy" (behind the sheet) can never shift the match. + var buyButton: XCUIElement { app.buttons["buy-confirmation-buy"] } + /// The You Pay block is a combined accessibility element ("You Pay: $X of + /// "), so match by label prefix across element types. + var youPayLabel: XCUIElement { + app.descendants(matching: .any) + .matching(NSPredicate(format: "label BEGINSWITH %@", "You Pay")) + .firstMatch + } + var exchangeFeeRow: XCUIElement { app.staticTexts["Exchange fee"] } + + // MARK: - Assertions + + func assertReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + youPayLabel.waitForExistence(timeout: timeout), + "Expected the Buy summary screen" + ) + } +} diff --git a/FlipcashUITests/Support/Screens/PaymentCurrencyUIScreen.swift b/FlipcashUITests/Support/Screens/PaymentCurrencyUIScreen.swift new file mode 100644 index 000000000..ea5c176c4 --- /dev/null +++ b/FlipcashUITests/Support/Screens/PaymentCurrencyUIScreen.swift @@ -0,0 +1,37 @@ +// +// PaymentCurrencyUIScreen.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the buy flow's Select Payment Currency step. +@MainActor +struct PaymentCurrencyUIScreen { + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var title: XCUIElement { app.staticTexts["Select Payment Currency"] } + + /// The USDF payment row (always present, eligible unless underfunded). + var usdfRow: XCUIElement { app.buttons["payment-currency-row-usdf"] } + + /// First non-USDF payment row. The buy target never appears in the list, + /// and underfunded rows stay tappable (their Buy surfaces the Buy Maximum + /// sheet). + var firstTokenRow: XCUIElement { app.buttons.matching(identifier: "payment-currency-row").firstMatch } + + // MARK: - Assertions + + func assertReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + title.waitForExistence(timeout: timeout), + "Expected the Select Payment Currency screen" + ) + } +} From 33d739001d9f64fadef2ca35ab682dd4fd2623a9 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Fri, 17 Jul 2026 10:41:45 -0400 Subject: [PATCH 2/3] fix: match the payment selector to the existing currency picker design --- .../Screens/Main/Buy/BuyPaymentCurrencyScreen.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyScreen.swift index 320c23aa5..adb2db89a 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyPaymentCurrencyScreen.swift @@ -26,6 +26,8 @@ struct BuyPaymentCurrencyScreen: View { var body: some View { @Bindable var viewModel = viewModel Background(color: .backgroundMain) { + // Mirrors DepositCurrencyListScreen's construction — the two + // pickers must stay visually interchangeable. List { Section { ForEach(viewModel.rows) { row in @@ -33,14 +35,15 @@ struct BuyPaymentCurrencyScreen: View { exchangedBalance: row, accessibilityIdentifier: row.stored.mint == .usdf ? "payment-currency-row-usdf" : "payment-currency-row", accessory: .chevron, - action: { - Task { await viewModel.select(row, router: router) } - } - ) - .vSeparator(color: .rowSeparator) + amountStyle: .pill, + usesSymbol: row.stored.mint == .usdf + ) { + Task { await viewModel.select(row, router: router) } + } } } .listRowInsets(EdgeInsets()) + .listSectionSeparator(.hidden, edges: .top) } .listStyle(.plain) .scrollContentBackground(.hidden) From e0d7b016bd954a318f0c72d33b414c565576a42a Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Fri, 17 Jul 2026 12:15:58 -0400 Subject: [PATCH 3/3] refactor: drop the currency info screen's unused wallet connection dependency --- .../Main/Currency Info/CurrencyInfoScreen.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 288c7eabb..1dc83ed38 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -51,8 +51,6 @@ private struct CurrencyInfoScreenContent: View { mintMetadata?.mint == .usdf } - @Environment(WalletConnection.self) private var walletConnection - private let mint: PublicKey private let ratesController: RatesController private let marketCapController: MarketCapController @@ -165,11 +163,11 @@ private struct CurrencyInfoScreenContent: View { .sheet(isPresented: $isShowingCurrencySelection) { CurrencySelectionScreen(ratesController: ratesController) } - // `walletConnection.dialogItem` is forwarded to `session.dialogItem` - // from inside the `.buy` nested sheet (see BuyAmountScreen) so it - // surfaces in `DialogWindow` rather than fighting the sheet stack - // here. Binding `.dialog(item:)` on this screen would mount a sheet - // that competes with the `.buy` sheet's presentation queue. + // Dialogs originating in the buy flow route through + // `session.dialogItem` so they surface in `DialogWindow` rather than + // fighting the sheet stack here. Binding `.dialog(item:)` on this + // screen would mount a sheet that competes with the `.buy` sheet's + // presentation queue. } @ViewBuilder private func toolbarContent() -> some View {