diff --git a/CLAUDE.md b/CLAUDE.md index 65206ff47..b426920a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,13 +235,15 @@ let stream = service.openMessageStream(request, callOptions: .streaming) { respo All navigation flows through `AppRouter` — a single `@Observable @MainActor` class on `SessionContainer`, injected via `@Environment(AppRouter.self)`. **Don't add screen-level `@State` sheet flags or `selectedXxx` bindings for navigation** — mutate the router instead. Deeplinks and push notifications call `router.navigate(to:)`; in-screen pushes call `router.push(_:on:)`. -Top-level sheets (`Balance`, `Settings`, `Give`, `Discover`) each own a `NavigationStack(path: $router[.])` and register destinations via the `.appRouterDestinations(...)` modifier on their root content. Per-stack paths are `NavigationPath` (type-erased), so sub-flow destinations (e.g., `WithdrawNavigationPath`) coexist with top-level `Destination` cases on the same stack — register `.navigationDestination(for: SubFlowPath.self)` on the sub-flow root view and push via `router.pushAny(_:on:)`. **Don't nest a `NavigationStack` inside another stack's destination** — push/pop/push corrupts SwiftUI's stack state with `comparisonTypeMismatch`. +Top-level sheets (`Balance`, `Settings`, `Give`, `Discover`) each own a `NavigationStack(path: $router[.])` and register destinations via the `.appRouterDestinations(...)` modifier on their root content. Per-stack paths are `NavigationPath` (type-erased), so sub-flow destinations (e.g., `WithdrawNavigationPath`, `BuyFlowPath`) coexist with top-level `Destination` cases on the same stack — register `.navigationDestination(for: SubFlowPath.self)` on the sub-flow root view and push via `router.pushAny(_:on:)`. **Don't nest a `NavigationStack` inside another stack's destination** — push/pop/push corrupts SwiftUI's stack state with `comparisonTypeMismatch`. -**Local interaction sheets stay local.** Transient pickers (currency selection, buy/sell amount, funding selection) and operation-bound modals (swap/launch processing covers) belong on the screen that owns them as `.sheet(...)` / `.fullScreenCover(...)` modifiers — they're interactions or in-flight status, not navigation. +**Nested sheets.** `presentedSheets` is an ordered stack: `.first` is the root sheet (mounted at app root) and any entries above visually stack on top. Use `router.presentNested(.x(...))` to stack a sheet on top of the current top — required for "sheet over sheet" UX like buy-from-currency-info. SwiftUI requires nested sheets to be mounted from **inside** the parent sheet's content tree (sibling `.sheet` modifiers at the root can't stack), so each top-level sheet's content applies the `.appRouterNestedSheet(...)` modifier — that's the convention. New top-level sheets must remember to apply it; the modifier handles all nested levels via env-injected `nestedSheetDepth`. The buy flow is the only nested sheet today (`.buy(mint)`); sell/give/etc. are migrating opt-in. + +**Local interaction sheets stay local.** Transient pickers (currency selection, funding selection) belong on the screen that owns them as `.sheet(...)` / `.fullScreenCover(...)` modifiers — they're interactions, not navigation. Operation-bound modals (swap/launch processing covers) similarly belong locally, *unless* they're part of a router-managed sheet's flow — in that case prefer pushing onto the sheet's stack as a `BuyFlowPath.processing` (or similar) so the sheet's dismiss tears down the whole chain. **The test:** if a deeplink could reasonably land the user here, it's a destination — route through `AppRouter`. If not, keep it local. -**Sheet path lifecycle.** `dismissSheet` leaves the dismissed sheet's `NavigationPath` populated so the closing animation runs with its current contents. The path is cleared on the next `present(_:)` of that same sheet, so re-opens land at root. Sheet swaps (presenting another sheet without dismissing first) preserve both paths for swap-back. Don't add manual `popToRoot` calls around your own dismissal — let the router handle it. +**Sheet path lifecycle.** `dismissSheet` pops the topmost sheet and leaves its `NavigationPath` populated so the closing animation runs with current contents. The path is cleared on the next `present(_:)` or `presentNested(_:)` of that same sheet value, so re-opens land at root. Sheet swaps at root (`present(.different)` without an intervening `dismissSheet`) preserve the swapped-out root's path for swap-back; nested sheets above a swapped root are always dismissed (and their paths cleared on re-open). `present(.sameRoot)` while a nested sheet is up pops the nested and keeps the root path. Don't add manual `popToRoot` calls around your own dismissal — let the router handle it. Every router mutation logs one INFO entry under `flipcash.router` — filter by that label to trace any navigation interaction. @@ -511,6 +513,7 @@ Navigation: - Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift (top-level sheets) - Flipcash/Core/Navigation/AppRouter+Stack.swift (per-sheet stacks) - Flipcash/Core/Navigation/AppRouter+DestinationView.swift (destination → view map) +- Flipcash/Core/Navigation/AppRouter+NestedSheet.swift (nested sheet modifier + root views) Session & Auth: - Flipcash/Core/Session/Session.swift diff --git a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift index c4abc829e..13d162a0d 100644 --- a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift +++ b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift @@ -18,79 +18,41 @@ public final class WalletConnection { var isShowingAmountEntry: Bool = false + /// General wallet-related dialogs (connect failures, key restoration). + /// Swap-flow dialogs are owned by `PhantomCoordinator`. var dialogItem: DialogItem? - /// Single source of truth for external-wallet processing state. Invalid - /// combinations — "cancelled flag set with no active context", "both - /// buy-existing and launch contexts populated simultaneously" — are - /// unrepresentable by construction. - var state: WalletProcessingState = .idle - - /// Buy-existing context, exposed for `.fullScreenCover(item:)`. Writing - /// `nil` (SwiftUI dismiss) transitions to `.idle` only if currently buying. - var processing: ExternalSwapProcessing? { - get { - if case .buying(let p, _) = state { return p } - return nil - } - set { - if newValue == nil, case .buying = state { - state = .idle - } - } - } - - /// Launch context, exposed for `.fullScreenCover(item:)`. Writing `nil` - /// (SwiftUI dismiss) transitions to `.idle` only if currently launching. - var launchProcessing: ExternalLaunchProcessing? { - get { - if case .launching(let l, _) = state { return l } - return nil - } - set { - if newValue == nil, case .launching = state { - state = .idle - } - } - } - - /// True iff the current context is marked failed. `SwapProcessingScreen` - /// observes this via `.onChange(of:)` to flip its display to cancelled. - var isProcessingCancelled: Bool { - switch state { - case .idle: return false - case .buying(_, let isFailed), .launching(_, let isFailed): return isFailed - } - } - private(set) var session: ConnectedWalletSession? var publicKey: FlipcashCore.PublicKey { box.publicKey } - private let box: Box - private let owner: AccountCluster - private let client: Client + /// Stream of deeplink events from Phantom — signed transactions and + /// errors. Consumed by `PhantomCoordinator` (constructed once per + /// session); finishes in `deinit` so the consumer Task exits cleanly. + let deeplinkEvents: AsyncStream + private let deeplinkContinuation: AsyncStream.Continuation + + /// Event yielded onto `deeplinkEvents` for each deeplink return. + enum DeeplinkEvent: Sendable { + /// Phantom returned with a signed transaction (base58-encoded). + case signed(String) + /// Phantom returned with code 4001 (user-cancel). + case userCancelled + /// Phantom returned with a non-cancel error code. + case failed(code: String) + } + private let box: Box + let owner: AccountCluster private let rpc: any SolanaRPC - /// Pending swap info to use when Phantom returns with signed transaction - private var pendingSwap: PendingSwap? - - /// Awaiting continuation for `connect()`. Resumed by `didConnect` on - /// success or by the errorCode branch of `didReceiveURL` on failure. + /// Awaiting continuation for `connect()` / `handshake()`. Resumed by + /// `didConnect` on success or by the errorCode branch of `didReceiveURL` + /// on failure. private var pendingConnect: CheckedContinuation? - /// What the signed-transaction handler should do after the user returns - struct PendingSwap { - /// Funding-leg swap id (USDC→USDF) — used in the Phantom memo. - let fundingSwapId: SwapId - let amount: ExchangedFiat - let displayName: String - let onCompleted: @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult - } - /// Whether the user has previously linked a Phantom wallet via /// `connectToPhantom`. Once true it stays true for the lifetime of the /// keychain entry; users can clear by uninstalling Phantom or wiping app data. @@ -100,11 +62,14 @@ public final class WalletConnection { // MARK: - Init - - init(owner: AccountCluster, client: Client, rpc: any SolanaRPC = SolanaJSONRPCClient()) { + init(owner: AccountCluster, rpc: any SolanaRPC = SolanaJSONRPCClient()) { self.owner = owner - self.client = client self.rpc = rpc + var continuation: AsyncStream.Continuation! + self.deeplinkEvents = AsyncStream { continuation = $0 } + self.deeplinkContinuation = continuation + if let connectedWalletSession = Keychain.connectedWalletSession { self.session = connectedWalletSession self.box = try! Box(secretKey: connectedWalletSession.secretKey) @@ -114,13 +79,18 @@ public final class WalletConnection { logger.info("New encryption box", metadata: ["publicKey": "\(box.publicKey.base58)"]) } } + + deinit { + // Closing the stream lets the coordinator's consumer Task exit cleanly + // when the SessionContainer (and thus this connection) tears down. + deeplinkContinuation.finish() + } // MARK: - Receive - func didReceiveURL(url: URL) { if let code = url.queryItemValue(for: "errorCode") { Analytics.track(event: Analytics.WalletEvent.cancel) - pendingSwap = nil let isUserCancel = code == "4001" if !isUserCancel { @@ -139,34 +109,27 @@ public final class WalletConnection { // If the error came from the connect deeplink and an async // awaiter is present, propagate the failure to them instead of - // showing the transaction-oriented dialog. 4001 (user-cancel) - // becomes a CancellationError so callers can silently abort; - // every other code becomes `connectFailed` so callers can - // surface an error. + // yielding a swap event. 4001 (user-cancel) becomes a + // CancellationError so callers can silently abort; every other + // code becomes `connectFailed` so callers can surface an error. if url.pathComponents.last == "walletConnected", let continuation = pendingConnect { pendingConnect = nil if isUserCancel { - continuation.resume(throwing: CancellationError()) + // Distinct from CancellationError so the coordinator can + // surface a "Connection Cancelled" dialog. CancellationError + // would route through the silent local-cancel path. + continuation.resume(throwing: WalletConnectionError.userCancelledConnect) } else { continuation.resume(throwing: WalletConnectionError.connectFailed(code: code)) } return } - if case .idle = state { - dialogItem = .init( - style: .destructive, - title: isUserCancel ? "Transaction Cancelled" : "Transaction Failed", - subtitle: isUserCancel - ? "The transaction was cancelled in your wallet" - : "Your wallet returned an error. Please try again.", - dismissable: true - ) { - .okay(kind: .destructive) - } - } else { - markCurrentStateFailed() - } + // Swap-flow error. Forward to the coordinator via the stream; + // the coordinator decides whether to mark the active context + // failed or surface a dialog (e.g., user tapped Phantom but + // pending state had cleared). + deeplinkContinuation.yield(isUserCancel ? .userCancelled : .failed(code: code)) return } @@ -251,248 +214,18 @@ public final class WalletConnection { } } + /// Yields the signed transaction to the deeplink stream. The + /// `PhantomCoordinator` consumes the stream and runs simulation + + /// server-notify + chain submission against its `pendingSwap` context. private func didSignTransaction(_ signedTx: String) { - let pending = pendingSwap - pendingSwap = nil - completeSwap(signedTx: signedTx, pending: pending) + deeplinkContinuation.yield(.signed(signedTx)) } - /// `pending` is passed explicitly (rather than read inside the Task) so - /// callers read+clear `self.pendingSwap` synchronously before the async - /// work begins — avoids a race if a second deep-link callback arrives - /// while the first is mid-flight. - @discardableResult - func completeSwap( - signedTx: String, - pending: PendingSwap? - ) -> Task { - Task { [rpc, weak self] in - guard let pending, let self else { - logger.warning("Received signed transaction but no pending swap context") - return - } - - let swapMetadata: [String: String] = [ - "swapId": pending.fundingSwapId.publicKey.base58, - "amount": pending.amount.nativeAmount.formatted(), - "name": pending.displayName, - ] - - let rawData = Data(Base58.toBytes(signedTx)) - guard let tx = SolanaTransaction(data: rawData) else { - logger.error("Failed to decode signed transaction") - ErrorReporting.captureError(Error.invalidURL, reason: "Failed to decode signed transaction", metadata: swapMetadata) - return - } - - // Preflight before server-notify + chain-submit so a tx that can't - // land (stale account state, unpayable fee) surfaces a user dialog - // instead of burning through the full flow. - let txBase64 = rawData.base64EncodedString() - switch await self.simulateSignedTransaction(txBase64, swapMetadata: swapMetadata) { - case .proceed: - break - case .blocked(let dialog): - self.dialogItem = dialog - return - } - - // Present the generic processing screen. The server callback below - // transitions to a launch context when the caller is launching a - // new currency; early-exit failures transition back to `.idle` so no - // cover presents for a swap the server never recorded. - self.state = .buying( - ExternalSwapProcessing( - swapId: pending.fundingSwapId, - currencyName: pending.displayName, - amount: pending.amount - ), - isFailed: false - ) - - // Notify server before submitting to chain — if the server rejects, - // skip chain submission entirely so no USDC is spent without a swap state. - do { - let result = try await pending.onCompleted(tx.identifier, pending.amount) - switch result { - case .buyExisting(let swapId): - if swapId != pending.fundingSwapId { - self.state = .buying( - ExternalSwapProcessing( - swapId: swapId, - currencyName: pending.displayName, - amount: pending.amount - ), - isFailed: false - ) - } - case .launch(let swapId, let mint): - self.state = .launching( - ExternalLaunchProcessing( - swapId: swapId, - launchedMint: mint, - currencyName: pending.displayName, - amount: pending.amount - ), - isFailed: false - ) - } - logger.info("Server notified of swap funding") - } catch { - logger.error("Server notification failed", metadata: ["error": "\(error)"]) - ErrorReporting.captureError(error, reason: "Server notification failed", metadata: swapMetadata) - self.state = .idle - return - } - - // Server accepted — submit transaction to chain. The swap id is - // server-recorded at this point, so on failure we keep the context - // and flip `isFailed` so the processing screen surfaces the error. - do { - let signature = try await rpc.sendTransaction( - txBase64, - configuration: SolanaSendTransactionConfig() - ) - logger.info("Transaction sent", metadata: ["signature": "\(signature.base58)"]) - Analytics.track(event: Analytics.WalletEvent.transactionsSubmitted) - } catch { - logger.error("Chain submission failed", metadata: ["error": "\(error)"]) - ErrorReporting.captureError(error, reason: "Chain submission failed", metadata: swapMetadata) - self.markCurrentStateFailed() - } - } - } - /// Opens the external wallet via deep link. private func openExternalWallet(_ url: URL) { url.openWithApplication() } - /// Transport failures (URL errors, decode errors) pass through as - /// `.proceed` — a flaky RPC blip must not block a user with valid funds. - /// Only explicit RPC rejections block. - func simulateSignedTransaction( - _ txBase64: String, - swapMetadata: [String: String] - ) async -> SimulationOutcome { - do { - _ = try await rpc.simulateTransaction( - txBase64, - configuration: SolanaSimulateTransactionConfig( - commitment: .confirmed, - encoding: .base64, - replaceRecentBlockhash: true - ) - ) - return .proceed - } catch SolanaRPCError.transactionSimulationError(let logs) { - return blockedOutcome( - reason: "Phantom signed transaction failed simulation", - logs: logs, - extraMetadata: ["kind": "simulationErr"], - swapMetadata: swapMetadata - ) - } catch SolanaRPCError.responseError(let response) { - var extra: [String: String] = ["kind": "preflightRejection"] - if let code = response.code { extra["code"] = "\(code)" } - if let message = response.message { extra["message"] = message } - return blockedOutcome( - reason: "Phantom signed transaction rejected at preflight", - logs: response.data?.logs ?? [], - extraMetadata: extra, - swapMetadata: swapMetadata - ) - } catch { - logger.warning("Simulation RPC failed, proceeding to submit", metadata: [ - "error": "\(error)", - ]) - return .proceed - } - } - - private func blockedOutcome( - reason: String, - logs: [String], - extraMetadata: [String: String], - swapMetadata: [String: String] - ) -> SimulationOutcome { - logger.error("Blocking signed transaction after RPC rejection", metadata: [ - "kind": "\(extraMetadata["kind"] ?? "unknown")", - "code": "\(extraMetadata["code"] ?? "")", - "message": "\(extraMetadata["message"] ?? "")", - "logs": "\(logs.suffix(5).joined(separator: " | "))", - ]) - ErrorReporting.captureError( - Error.simulationFailed(logs: logs), - reason: reason, - metadata: swapMetadata.merging(extraMetadata) { current, _ in current } - ) - return .blocked(.init( - style: .destructive, - title: "Transaction Failed", - subtitle: "The Solana network wouldn't accept this transaction from your wallet. No funds were moved. Please try again.", - dismissable: true - ) { - .okay(kind: .destructive) - }) - } - - enum SimulationOutcome { - case proceed - case blocked(DialogItem) - } - - /// Requests an external swap to fund a buy of an existing launchpad currency. - /// The processing screen is deferred until the user returns with a signed transaction. - func requestSwap(usdc: FlipcashCore.TokenAmount, token: MintMetadata) async throws { - let fundingSwapId = SwapId.generate() - try await requestUsdcToUsdfSwap( - fundingSwapId: fundingSwapId, - usdc: usdc, - displayName: token.name, - onCompleted: { [client, owner] signature, amount in - try await client.buyWithExternalFunding( - swapId: fundingSwapId, - amount: amount, - of: token, - owner: owner, - transactionSignature: signature - ) - return .buyExisting(swapId: fundingSwapId) - } - ) - isShowingAmountEntry = false - } - - /// Requests an external USDC→USDF swap for the currency-creation launch flow. - /// `onCompleted` runs after Phantom signs and must execute - /// `Session.launchCurrency` + `Session.buyNewCurrencyWithExternalFunding`, - /// returning the swap id from the buy so the processing screen polls the - /// right swap state (the funding swap id is unrelated). - func requestSwapForLaunch( - usdc: FlipcashCore.TokenAmount, - displayName: String, - onCompleted: @escaping @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult - ) async throws { - try await requestUsdcToUsdfSwap( - fundingSwapId: SwapId.generate(), - usdc: usdc, - displayName: displayName, - onCompleted: onCompleted - ) - } - - /// Dismisses the processing screen and clears any pending wallet dialogs. - func dismissProcessing() { - dialogItem = nil - state = .idle - } - - /// Flips the current active context's `isFailed` flag. No-op for `.idle`. - private func markCurrentStateFailed() { - state = state.markedFailed() - } - // MARK: - Actions - /// Async wrapper around `connectToPhantom()`. Returns immediately if @@ -500,15 +233,52 @@ public final class WalletConnection { /// connect deeplink arrives (success) or an errorCode callback does /// (throws `WalletConnectionError.connectFailed`). A second call while /// the first is in flight cancels the first waiter. + /// + /// Task cancellation is propagated through `withTaskCancellationHandler` + /// so callers that abandon the connect (e.g. `PhantomEducationScreen` + /// being popped) don't leak the underlying `CheckedContinuation`. func connect() async throws { guard !isConnected else { return } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - pendingConnect?.resume(throwing: CancellationError()) - pendingConnect = continuation - connectToPhantom() + try await openConnectDeeplink() + } + + /// Always deeplinks Phantom for connect, even when a Keychain session + /// already exists. Used by `PhantomCoordinator.start(_:)` to verify the + /// session is live before requesting a signature — Phantom auto-approves + /// when it still trusts our `dapp_encryption_public_key` (~sub-second + /// round-trip), and shows the connect prompt when it doesn't. + /// + /// Skipping this verification leaves us vulnerable to "first transaction + /// silently fails" when the user revoked us in Phantom between flows. + func handshake() async throws { + try await openConnectDeeplink() + } + + /// Shared deeplink-and-await primitive. Suspends until `didConnect` or an + /// errorCode callback resolves the continuation. Cancellation propagates + /// through `withTaskCancellationHandler`. + private func openConnectDeeplink() async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + pendingConnect?.resume(throwing: CancellationError()) + pendingConnect = continuation + connectToPhantom() + } + } onCancel: { [weak self] in + Task { @MainActor [weak self] in + self?.cancelPendingConnect() + } } } + /// Resumes any in-flight `pendingConnect` with a CancellationError and + /// clears the slot. Safe to call when nothing is pending. + private func cancelPendingConnect() { + guard let continuation = pendingConnect else { return } + pendingConnect = nil + continuation.resume(throwing: CancellationError()) + } + func connectToPhantom() { let nonce = UUID().uuidString @@ -525,14 +295,15 @@ public final class WalletConnection { openExternalWallet(c.url!) } - /// Builds + sends a USDC→USDF transaction to Phantom for signing. Stashes - /// the supplied `onCompleted` closure so `didSignTransaction` can run it - /// once the signed transaction comes back via deeplink. - private func requestUsdcToUsdfSwap( - fundingSwapId: SwapId, + /// Builds + sends a USDC→USDF transaction to Phantom for signing. The + /// signed transaction comes back via `didReceiveURL` → `deeplinkEvents`, + /// where `PhantomCoordinator` matches it against its own `pendingSwap` + /// context. This method has no pending-state side effects of its own — + /// it's a pure "send sign request" service. + func sendUsdcToUsdfSignRequest( usdc: FlipcashCore.TokenAmount, - displayName: String, - onCompleted: @escaping @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult + fundingSwapId: SwapId, + displayName: String ) async throws { guard let connectedSession = Keychain.connectedWalletSession else { throw Error.noSession @@ -563,13 +334,6 @@ public final class WalletConnection { instructions: instructions ) - pendingSwap = PendingSwap( - fundingSwapId: fundingSwapId, - amount: amount, - displayName: displayName, - onCompleted: onCompleted - ) - let txEncoded = Base58.fromBytes(Array(transaction.encode())) let payload: [String: Any] = [ @@ -592,7 +356,6 @@ public final class WalletConnection { ] guard let url = c.url else { - pendingSwap = nil logger.error("Failed to construct signTransaction URL") throw Error.invalidURL } @@ -724,6 +487,10 @@ public enum WalletConnectionError: Error, LocalizedError { case jsonDecodingFailed(underlying: Error) case noSession case connectFailed(code: String) + /// User cancelled the connect prompt in Phantom (Phantom code 4001 on the + /// `walletConnected` deeplink). Distinct from `CancellationError`, which + /// signals a local Task cancellation (view dismissed, coordinator reset). + case userCancelledConnect public var errorDescription: String? { switch self { @@ -732,6 +499,7 @@ public enum WalletConnectionError: Error, LocalizedError { case .jsonDecodingFailed(let e): return "Failed to decode JSON: \(e.localizedDescription)" case .noSession: return "No connected wallet session." case .connectFailed(let code): return "Wallet connection failed (code: \(code))." + case .userCancelledConnect: return "You cancelled the connection in your wallet." } } } @@ -745,29 +513,6 @@ extension WalletConnection { } } -/// External-wallet processing state. Variants differ by flow (buy-existing vs -/// currency launch) and each carries an `isFailed` flag that drives the -/// processing screen's "cancelled" display without requiring a separate flag -/// that could drift out of sync with the active context. -enum WalletProcessingState: Hashable { - case idle - case buying(ExternalSwapProcessing, isFailed: Bool) - case launching(ExternalLaunchProcessing, isFailed: Bool) - - /// Returns the state with the active context's `isFailed` flipped to true. - /// `.idle` is a fixed point. Idempotent on already-failed states. - func markedFailed() -> WalletProcessingState { - switch self { - case .idle: - return .idle - case .buying(let context, _): - return .buying(context, isFailed: true) - case .launching(let context, _): - return .launching(context, isFailed: true) - } - } -} - // MARK: - SealedData - public struct SealedData { @@ -820,5 +565,5 @@ private extension FlipcashCore.Keychain { // MARK: - Mock - extension WalletConnection { - static let mock = WalletConnection(owner: .mock, client: .mock) + static let mock = WalletConnection(owner: .mock) } diff --git a/Flipcash/Core/Controllers/Onramp/ApplePayOverlay.swift b/Flipcash/Core/Controllers/Onramp/ApplePayOverlay.swift new file mode 100644 index 000000000..b26adc96c --- /dev/null +++ b/Flipcash/Core/Controllers/Onramp/ApplePayOverlay.swift @@ -0,0 +1,29 @@ +// +// ApplePayOverlay.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore + +/// Invisible overlay that hosts the Coinbase Apple Pay WKWebView. The view is +/// rendered at zero opacity (it exists only to drive Apple Pay's JS payment +/// flow in the background) and explicitly excluded from hit testing and +/// accessibility so the covered region of the amount keypad remains tappable +/// and VoiceOver users don't land on a silent 300×300 zone. +struct ApplePayOverlay: View { + + let order: OnrampOrderResponse? + let onEvent: (ApplePayEvent) -> Void + + var body: some View { + if let order { + ApplePayWebView(url: order.paymentLink.url, onMessage: onEvent) + .frame(width: 300, height: 300) + .opacity(0) + .allowsHitTesting(false) + .accessibilityHidden(true) + .id(order.id) + } + } +} diff --git a/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift b/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift index 0ed5016f4..99a739691 100644 --- a/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift +++ b/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift @@ -13,6 +13,10 @@ private let logger = Logger(label: "flipcash.onramp-coordinator") @Observable final class OnrampCoordinator { + /// Coinbase Onramp's USD floor — orders below this amount fail with a + /// generic error, so the buy flow gates ahead of the Apple Pay sheet. + static let minimumPurchaseUSD: Decimal = 5 + // MARK: - State - /// Apple Pay order — drives the invisible WebView overlay hosted at root. @@ -667,7 +671,7 @@ final class OnrampCoordinator { } Analytics.onrampInvokePayment(amount: amount.usdfValue) - + let id = UUID() let userRef = session.ownerKeyPair.publicKey.base58 let orderRef = "\(userRef):\(id)" @@ -698,16 +702,39 @@ final class OnrampCoordinator { )) logger.info("Coinbase order created", metadata: [ - "order_id": "\(response.id)" + "order_id": "\(response.id)", + "recorded_purchase": "\(response.order.purchaseAmount ?? "")", ]) + // Coinbase applies its own USD→USDF rate (~0.9994) when recording + // the order, so the USDF quarks they'll fund differ from what we + // requested by 1-2 quarks. The server requires the swap amount to + // exactly equal the Coinbase-recorded purchase, otherwise it + // denies with "purchase amount does not match swap amount". + // Rebuild the ExchangedFiat from Coinbase's reported amount so + // the swap quarks line up with the funding side. + guard let recorded = response.order.purchaseAmount, + let recordedDecimal = Decimal(string: recorded) else { + logger.error("Coinbase response missing purchaseAmount", metadata: [ + "order_id": "\(response.id)", + ]) + clearPendingState() + showBuyFailedDialog() + return + } + let fundedAmount = ExchangedFiat.compute( + onChainAmount: TokenAmount(wholeTokens: recordedDecimal, mint: .usdf), + rate: amount.currencyRate, + supplyQuarks: nil + ) + // Server must be watching the Coinbase order before Apple Pay // commits — otherwise the user pays into a swap the backend // hasn't registered. do { pendingSwapId = try await initiateCoinbaseOnrampSwap( for: operation, - amount: amount, + amount: fundedAmount, orderId: response.id ) } catch { @@ -913,6 +940,10 @@ final class OnrampCoordinator { // MARK: - Supporting types - +enum OnrampError: Error { + case missingCoinbaseApiKey +} + private enum Origin: Int { case root case info diff --git a/Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift b/Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift new file mode 100644 index 000000000..1d8065514 --- /dev/null +++ b/Flipcash/Core/Controllers/Onramp/OnrampDeeplinkInbox.swift @@ -0,0 +1,18 @@ +// +// OnrampDeeplinkInbox.swift +// Flipcash +// + +import SwiftUI + +/// Long-lived store for Onramp email verification deeplinks. `DeepLinkController` +/// drops incoming verifications here; `OnrampHostModifier` observes the value +/// with `.onChange(initial: true)` and hands it off to `OnrampCoordinator`, so +/// whether the link arrived before or after the sheet opened the verification +/// is picked up through the same entry point. Lives on `SessionContainer` so +/// it survives sheet dismissal but not logout. +@Observable +@MainActor +final class OnrampDeeplinkInbox { + var pendingEmailVerification: VerificationDescription? +} diff --git a/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift b/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift index 6e8554dbc..c7bbcb0ee 100644 --- a/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift +++ b/Flipcash/Core/Controllers/Onramp/OnrampHostModifier.swift @@ -13,10 +13,12 @@ import FlipcashCore /// /// The verification sheet is NOT hosted here — SwiftUI only allows one modal /// sheet per presentation context, and when the user is several sheets deep -/// (Wallet → Discovery → Wizard → FundingSelection) the root-level sheet slot -/// is already taken. Screens that initiate an onramp (`OnrampAmountScreen`, -/// `CurrencyCreationWizardScreen`) host the verification sheet themselves so -/// it presents on top of whatever sheet stack the user is already in. +/// (Wallet → Discovery → Wizard → funding picker) the root-level sheet slot +/// is already taken. Each screen that owns the user's path into onramp hosts +/// the verification sheet itself so it presents on top of whatever sheet +/// stack the user is in: `BuyAmountScreen` for the buy flow (verification +/// fires when the user taps Apple Pay in `PurchaseMethodSheet`), and +/// `CurrencyCreationWizardScreen` for the launch flow. struct OnrampHostModifier: ViewModifier { @Environment(OnrampCoordinator.self) private var onrampCoordinator diff --git a/Flipcash/Core/Controllers/Onramp/OnrampVerificationPath.swift b/Flipcash/Core/Controllers/Onramp/OnrampVerificationPath.swift new file mode 100644 index 000000000..1305b2a22 --- /dev/null +++ b/Flipcash/Core/Controllers/Onramp/OnrampVerificationPath.swift @@ -0,0 +1,21 @@ +// +// OnrampVerificationPath.swift +// Flipcash +// + +import FlipcashCore + +/// Navigation path for `VerifyInfoScreen`'s NavigationStack. +enum OnrampVerificationPath: Hashable { + case info + case enterPhoneNumber + case confirmPhoneNumberCode + case enterEmail + case confirmEmailCode +} + +extension Profile { + var canCreateCoinbaseOrder: Bool { + phone != nil && email?.isEmpty == false + } +} diff --git a/Flipcash/Core/Controllers/Phantom/PhantomCoordinator.swift b/Flipcash/Core/Controllers/Phantom/PhantomCoordinator.swift new file mode 100644 index 000000000..3dec0de86 --- /dev/null +++ b/Flipcash/Core/Controllers/Phantom/PhantomCoordinator.swift @@ -0,0 +1,559 @@ +// +// PhantomCoordinator.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-14. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.phantom-coordinator") + +/// Owns the Phantom-funded operation lifecycle from picker tap through to +/// post-signing chain submission. Mirrors `OnrampCoordinator` so funding +/// paths follow the same shape (operation + state + completion). +/// +/// Always-connect policy: `start(_:)` unconditionally invokes the Phantom +/// connect handshake. We never trust a cached Keychain session — the user +/// could have revoked us in Phantom since last time. A warm session returns +/// inside ~1 second; a cold session prompts the user. Either way we know we +/// have a live session before requesting a signature. +@Observable +@MainActor +final class PhantomCoordinator { + + // MARK: - Public state + + /// The operation being funded. Set by `start(_:)`, cleared by `cancel()` + /// or when the operation completes. + private(set) var operation: PaymentOperation? + + /// Drives picker / education / confirm UI. + private(set) var state: State = .idle + + /// Single source of truth for external-wallet processing state. Invalid + /// combinations — "cancelled flag set with no active context", "both + /// buy-existing and launch contexts populated simultaneously" — are + /// unrepresentable by construction. + private(set) var processingState: WalletProcessingState = .idle + + /// Buy-existing context, exposed for `.fullScreenCover(item:)`. Writing + /// `nil` (SwiftUI dismiss) transitions back to `.idle` only if currently + /// buying. + var processing: ExternalSwapProcessing? { + get { + if case .buying(let p, _) = processingState { return p } + return nil + } + set { + if newValue == nil, case .buying = processingState { + processingState = .idle + } + } + } + + /// Launch context, exposed for `.fullScreenCover(item:)`. + var launchProcessing: ExternalLaunchProcessing? { + get { + if case .launching(let l, _) = processingState { return l } + return nil + } + set { + if newValue == nil, case .launching = processingState { + processingState = .idle + } + } + } + + /// True iff the active processing context has been marked failed. + /// `SwapProcessingScreen` observes this via `.onChange(of:initial:)`. + var isProcessingCancelled: Bool { + switch processingState { + case .idle: return false + case .buying(_, let isFailed), .launching(_, let isFailed): return isFailed + } + } + + /// True while a signing request has been sent and the user hasn't yet + /// returned with a signed transaction. Used by the buy nested sheet to + /// block swipe-dismissal so the user can't accidentally lose the + /// in-flight signature. + var isAwaitingExternalSwap: Bool { pendingSwap != nil } + + /// Launch flows assign a handler before opening the picker. Coordinator + /// invokes it after Phantom signs to chain `launchCurrency` + + /// `buyNewCurrencyWithExternalFunding` and return a `SignedSwapResult`. + /// Buy flows don't need a handler — buy completion is built in. + var launchHandler: (@MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult)? + + enum State: Equatable { + /// No operation in flight. + case idle + /// Handshake deeplink sent; waiting for Phantom return. Always runs, + /// even if a Keychain session exists. + case connecting + /// Phantom is connected. Waiting for the user to tap Confirm on the + /// `PhantomConfirmScreen`. + case awaitingConfirm + /// Signing deeplink sent; waiting for Phantom return. + case signing + /// Connect or signing failed. Surfaces a dialog; user can retry. + case failed(reason: String) + } + + // MARK: - Dependencies + + private let walletConnection: WalletConnection + private let session: Session + private let client: Client + private let rpc: any SolanaRPC + + // MARK: - Init + + init(walletConnection: WalletConnection, session: Session, client: Client, rpc: any SolanaRPC = SolanaJSONRPCClient()) { + self.walletConnection = walletConnection + self.session = session + self.client = client + self.rpc = rpc + startEventStreamConsumer() + } + + // No `deinit` task cancellation: Swift 6 makes `deinit` nonisolated and + // it can't touch `eventTask` (main-actor isolated). The consumer Task + // exits when `WalletConnection.deinit` calls `deeplinkContinuation + // .finish()`, which ends the `for await` loop cleanly. Both objects are + // session-scoped on `SessionContainer`, so the stream and the task + // share a lifetime. + + // MARK: - Lifecycle + + /// Entry point — picker calls this when the user taps Phantom. + /// Unconditionally deeplinks Phantom for connect. + func start(_ operation: PaymentOperation) { + cancelInternalTasks() + pendingSwap = nil + // Drop any stale launch handler when starting a buy. The wizard sets + // `launchHandler` right before pushing into the picker for `.launch`; + // a `.buy` start must not inherit a leftover handler from a prior + // wizard attempt that didn't run to completion. + if case .buy = operation { + launchHandler = nil + } + self.operation = operation + state = .connecting + connectTask = Task { [weak self] in + guard let self else { return } + await self.runHandshake() + } + } + + /// Called from `PhantomConfirmScreen` when the user taps Confirm. + func confirm() { + guard state == .awaitingConfirm, let operation else { return } + cancelInternalTasks() + state = .signing + signTask = Task { [weak self] in + guard let self else { return } + await self.runSwapRequest(for: operation) + } + } + + /// Discards in-flight pre-signing state. Safe to call from any + /// `.onDisappear` — no-ops when nothing is pending. Active processing + /// contexts (`processingState != .idle`) are owned by the cover and + /// untouched here; only the pre-sign lifecycle is reset. + func cancel() { + cancelInternalTasks() + pendingSwap = nil + operation = nil + launchHandler = nil + state = .idle + } + + /// Dismisses the processing cover. Callers (BuyAmountScreen, wizard) + /// invoke this from the cover's `dismissParentContainer` env value. + func dismissProcessing() { + processingState = .idle + operation = nil + launchHandler = nil + state = .idle + } + + // MARK: - Pending swap state + + /// Context for resolving a signed transaction. Set in `runSwapRequest`, + /// consumed in `handleSignedTransaction`. + private var pendingSwap: PendingSwap? + + /// Captures everything `handleSignedTransaction` needs to dispatch the + /// post-sign chain submission. The `onCompleted` closure is set by the + /// coordinator itself (buy uses a built-in `client.buyWithExternalFunding` + /// call; launch defers to `launchHandler`). + struct PendingSwap { + let fundingSwapId: SwapId + let amount: ExchangedFiat + let displayName: String + let onCompleted: @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult + } + + // MARK: - Internal tasks + + private var connectTask: Task? + private var signTask: Task? + private var eventTask: Task? + + private func cancelInternalTasks() { + connectTask?.cancel() + connectTask = nil + signTask?.cancel() + signTask = nil + } + + /// Subscribes to `WalletConnection.deeplinkEvents` for the lifetime of + /// this coordinator. Exits when the stream finishes (in + /// `WalletConnection.deinit`). + private func startEventStreamConsumer() { + let stream = walletConnection.deeplinkEvents + eventTask = Task { [weak self] in + for await event in stream { + await self?.handle(event) + } + } + } + + // MARK: - Handshake + sign request + + private func runHandshake() async { + do { + try await walletConnection.handshake() + try Task.checkCancellation() + state = .awaitingConfirm + } catch is CancellationError { + // Local cancel — view dismissed, coordinator reset, etc. + // Silent reset is correct here; the user didn't act, the app did. + await reset() + } catch WalletConnectionError.userCancelledConnect { + // User dismissed the connect prompt in Phantom. Surface a dialog + // so the education screen shows feedback; user can tap Connect + // again to retry. + state = .failed(reason: "You cancelled the connection in your wallet. Tap Connect again to retry.") + } catch { + logger.error("Phantom handshake failed", metadata: ["error": "\(error)"]) + state = .failed(reason: "We couldn't connect to your Phantom wallet. Please try again.") + } + } + + private func runSwapRequest(for operation: PaymentOperation) async { + let fundingSwapId = SwapId.generate() + let onCompleted: @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult + + switch operation { + case .buy(let payload): + let token: MintMetadata + do { + token = try await session.fetchMintMetadata(mint: payload.mint).metadata + try Task.checkCancellation() + } catch is CancellationError { + await reset() + return + } catch { + logger.error("Failed to load mint metadata for Phantom buy", metadata: ["error": "\(error)"]) + state = .failed(reason: "We couldn't load the currency. Please try again.") + return + } + let client = self.client + let owner = walletConnection.owner + onCompleted = { signature, amount in + try await client.buyWithExternalFunding( + swapId: fundingSwapId, + amount: amount, + of: token, + owner: owner, + transactionSignature: signature + ) + return .buyExisting(swapId: fundingSwapId) + } + + case .launch: + guard let launchHandler else { + logger.error("Launch confirm reached without a registered launchHandler") + state = .failed(reason: "We couldn't start the launch. Please try again.") + return + } + // The caller-provided handler chains launchCurrency + + // buyNewCurrencyWithExternalFunding and returns the buy swap id. + onCompleted = launchHandler + } + + let usdc: FlipcashCore.TokenAmount = operation.displayAmount.onChainAmount + pendingSwap = PendingSwap( + fundingSwapId: fundingSwapId, + amount: operation.displayAmount, + displayName: operation.currencyName, + onCompleted: onCompleted + ) + + do { + try await walletConnection.sendUsdcToUsdfSignRequest( + usdc: usdc, + fundingSwapId: fundingSwapId, + displayName: operation.currencyName + ) + } catch is CancellationError { + pendingSwap = nil + await reset() + } catch { + pendingSwap = nil + logger.error("Phantom sign request failed", metadata: ["error": "\(error)"]) + state = .failed(reason: "We couldn't initiate the transaction. Please try again.") + } + } + + // MARK: - Deeplink event handling + + private func handle(_ event: WalletConnection.DeeplinkEvent) async { + switch event { + case .signed(let signedTx): + let pending = pendingSwap + pendingSwap = nil + await handleSignedTransaction(signedTx: signedTx, pending: pending) + case .userCancelled: + pendingSwap = nil + // Cancel happened during the sign step (pre-`buying`/`launching`) + // → re-enable the confirm button so the user can retry. If we'd + // already entered processing, flip the active context to failed + // so the processing screen surfaces the error. + if case .idle = processingState { + if operation != nil { + state = .awaitingConfirm + } + session.dialogItem = .init( + style: .destructive, + title: "Transaction Cancelled", + subtitle: "The transaction was cancelled in your wallet", + dismissable: true + ) { .okay(kind: .destructive) } + } else { + processingState = processingState.markedFailed() + } + case .failed: + pendingSwap = nil + if case .idle = processingState { + if operation != nil { + state = .awaitingConfirm + } + session.dialogItem = .init( + style: .destructive, + title: "Transaction Failed", + subtitle: "Your wallet returned an error. Please try again.", + dismissable: true + ) { .okay(kind: .destructive) } + } else { + processingState = processingState.markedFailed() + } + } + } + + /// Decodes the signed transaction, runs preflight simulation, + /// notifies the server, transitions to the processing context, and + /// submits to the chain. Server-notify is intentionally before chain + /// submit: a server rejection skips submission so no USDC moves without + /// a recorded swap. + private func handleSignedTransaction(signedTx: String, pending: PendingSwap?) async { + guard let pending else { + logger.warning("Received signed transaction but no pending swap context") + return + } + + let swapMetadata: [String: String] = [ + "swapId": pending.fundingSwapId.publicKey.base58, + "amount": pending.amount.nativeAmount.formatted(), + "name": pending.displayName, + ] + + let rawData = Data(Base58.toBytes(signedTx)) + guard let tx = SolanaTransaction(data: rawData) else { + logger.error("Failed to decode signed transaction") + ErrorReporting.captureError(WalletConnection.Error.invalidURL, reason: "Failed to decode signed transaction", metadata: swapMetadata) + return + } + + let txBase64 = rawData.base64EncodedString() + switch await simulateSignedTransaction(txBase64, swapMetadata: swapMetadata) { + case .proceed: + break + case .blocked(let dialog): + session.dialogItem = dialog + return + } + + // Present the generic processing screen via the .buying context. + // The server callback may transition this to .launching when the + // caller is launching a new currency; early-exit failures transition + // back to .idle so no cover presents for a swap the server never + // recorded. + processingState = .buying( + ExternalSwapProcessing( + swapId: pending.fundingSwapId, + currencyName: pending.displayName, + amount: pending.amount + ), + isFailed: false + ) + + // Notify server before submitting to chain — if the server rejects, + // skip chain submission so no USDC is spent without a swap state. + do { + let result = try await pending.onCompleted(tx.identifier, pending.amount) + switch result { + case .buyExisting(let swapId): + if swapId != pending.fundingSwapId { + processingState = .buying( + ExternalSwapProcessing( + swapId: swapId, + currencyName: pending.displayName, + amount: pending.amount + ), + isFailed: false + ) + } + case .launch(let swapId, let mint): + processingState = .launching( + ExternalLaunchProcessing( + swapId: swapId, + launchedMint: mint, + currencyName: pending.displayName, + amount: pending.amount + ), + isFailed: false + ) + } + logger.info("Server notified of swap funding") + } catch { + logger.error("Server notification failed", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Server notification failed", metadata: swapMetadata) + processingState = .idle + return + } + + // Server accepted — submit to chain. Failure here keeps the context + // and flips `isFailed` so the processing screen surfaces the error. + do { + let signature = try await rpc.sendTransaction( + txBase64, + configuration: SolanaSendTransactionConfig() + ) + logger.info("Transaction sent", metadata: ["signature": "\(signature.base58)"]) + Analytics.track(event: Analytics.WalletEvent.transactionsSubmitted) + } catch { + logger.error("Chain submission failed", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Chain submission failed", metadata: swapMetadata) + processingState = processingState.markedFailed() + } + } + + // MARK: - Simulation + + private enum SimulationOutcome { + case proceed + case blocked(DialogItem) + } + + /// Transport failures (URL errors, decode errors) pass through as + /// `.proceed` — a flaky RPC blip must not block a user with valid funds. + /// Only explicit RPC rejections block. + private func simulateSignedTransaction( + _ txBase64: String, + swapMetadata: [String: String] + ) async -> SimulationOutcome { + do { + _ = try await rpc.simulateTransaction( + txBase64, + configuration: SolanaSimulateTransactionConfig( + commitment: .confirmed, + encoding: .base64, + replaceRecentBlockhash: true + ) + ) + return .proceed + } catch SolanaRPCError.transactionSimulationError(let logs) { + return blockedOutcome( + reason: "Phantom signed transaction failed simulation", + logs: logs, + extraMetadata: ["kind": "simulationErr"], + swapMetadata: swapMetadata + ) + } catch SolanaRPCError.responseError(let response) { + var extra: [String: String] = ["kind": "preflightRejection"] + if let code = response.code { extra["code"] = "\(code)" } + if let message = response.message { extra["message"] = message } + return blockedOutcome( + reason: "Phantom signed transaction rejected at preflight", + logs: response.data?.logs ?? [], + extraMetadata: extra, + swapMetadata: swapMetadata + ) + } catch { + logger.warning("Simulation RPC failed, proceeding to submit", metadata: ["error": "\(error)"]) + return .proceed + } + } + + private func blockedOutcome( + reason: String, + logs: [String], + extraMetadata: [String: String], + swapMetadata: [String: String] + ) -> SimulationOutcome { + logger.error("Blocking signed transaction after RPC rejection", metadata: [ + "kind": "\(extraMetadata["kind"] ?? "unknown")", + "code": "\(extraMetadata["code"] ?? "")", + "message": "\(extraMetadata["message"] ?? "")", + "logs": "\(logs.suffix(5).joined(separator: " | "))", + ]) + ErrorReporting.captureError( + WalletConnection.Error.simulationFailed(logs: logs), + reason: reason, + metadata: swapMetadata.merging(extraMetadata) { current, _ in current } + ) + return .blocked(.init( + style: .destructive, + title: "Transaction Failed", + subtitle: "The Solana network wouldn't accept this transaction from your wallet. No funds were moved. Please try again.", + dismissable: true + ) { + .okay(kind: .destructive) + }) + } + + private func reset() async { + operation = nil + launchHandler = nil + pendingSwap = nil + state = .idle + } +} + +// MARK: - WalletProcessingState + +/// External-wallet processing state. Variants differ by flow (buy-existing vs +/// currency launch) and each carries an `isFailed` flag that drives the +/// processing screen's "cancelled" display without requiring a separate flag +/// that could drift out of sync with the active context. +enum WalletProcessingState: Hashable { + case idle + case buying(ExternalSwapProcessing, isFailed: Bool) + case launching(ExternalLaunchProcessing, isFailed: Bool) + + /// Returns a new state with the same context but `isFailed = true`. + /// No-op for `.idle`. + func markedFailed() -> WalletProcessingState { + switch self { + case .idle: return .idle + case .buying(let context, _): return .buying(context, isFailed: true) + case .launching(let context, _): return .launching(context, isFailed: true) + } + } +} diff --git a/Flipcash/Core/Models/PaymentOperation.swift b/Flipcash/Core/Models/PaymentOperation.swift new file mode 100644 index 000000000..58d8b296f --- /dev/null +++ b/Flipcash/Core/Models/PaymentOperation.swift @@ -0,0 +1,78 @@ +// +// PaymentOperation.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-14. +// + +import Foundation +import FlipcashCore + +/// Funding-time payload shared by buy-existing and launch-new-currency flows. +/// `PurchaseMethodSheet` and the per-funding-path coordinators (`PhantomCoordinator`, +/// `OnrampCoordinator`) accept this so the picker UI is identical regardless +/// of what the user is funding. +/// +/// Marked `nonisolated` so the type and its computed-property unwraps are +/// reachable from `AppRouter.Destination` (which is itself `nonisolated` for +/// cross-actor logging metadata). +nonisolated enum PaymentOperation: Hashable, Sendable, Identifiable { + + case buy(BuyPayload) + case launch(LaunchPayload) + + struct BuyPayload: Hashable, Sendable { + let id: UUID + let mint: PublicKey + let currencyName: String + let amount: ExchangedFiat + let verifiedState: VerifiedState + + init(mint: PublicKey, currencyName: String, amount: ExchangedFiat, verifiedState: VerifiedState) { + self.id = UUID() + self.mint = mint + self.currencyName = currencyName + self.amount = amount + self.verifiedState = verifiedState + } + } + + struct LaunchPayload: Hashable, Sendable { + let id: UUID + let currencyName: String + let total: ExchangedFiat + let launchAmount: ExchangedFiat + let launchFee: ExchangedFiat + + init(currencyName: String, total: ExchangedFiat, launchAmount: ExchangedFiat, launchFee: ExchangedFiat) { + self.id = UUID() + self.currencyName = currencyName + self.total = total + self.launchAmount = launchAmount + self.launchFee = launchFee + } + } + + var id: UUID { + switch self { + case .buy(let payload): return payload.id + case .launch(let payload): return payload.id + } + } + + /// Amount displayed on the picker / funding screens. For buy this is the + /// purchase amount; for launch this is the total (purchase + fee). + var displayAmount: ExchangedFiat { + switch self { + case .buy(let payload): return payload.amount + case .launch(let payload): return payload.total + } + } + + var currencyName: String { + switch self { + case .buy(let payload): return payload.currencyName + case .launch(let payload): return payload.currencyName + } + } +} diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 6848f7eac..b8750c8c7 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -16,7 +16,7 @@ extension AppRouter { // Wallet flow case currencyInfo(PublicKey) - /// Same screen as `currencyInfo` but auto-presents the funding-selection + /// Same screen as `currencyInfo` but auto-presents the buy nested /// sheet on appear. Modelled as a sibling case rather than an /// associated-value flag so the trace shows "user wanted to deposit" /// distinctly from "user opened currency info". @@ -26,6 +26,25 @@ extension AppRouter { case currencyCreationWizard case transactionHistory(PublicKey) case give(PublicKey) + /// Skips the picker step in the withdraw flow and lands the user on + /// `WithdrawIntroScreen` with the currency pre-selected. Pushed from + /// USDF Currency Info on the Wallet sheet. + case withdrawCurrency(PublicKey) + /// USDC → USDF deposit education screen. Reached from USDF Currency + /// Info on the Wallet sheet and from the buy flow's Other Wallet path + /// on the `.buy` sheet — same screen, different entry points. + case usdcDepositEducation + /// USDC → USDF deposit address screen (shows the per-user timelock + /// swap PDA's USDC ATA). Reached as the next step after + /// `.usdcDepositEducation`. + case usdcDepositAddress + /// Phantom education screen. Shared by buy + launch via the carried + /// `PaymentOperation`. The `PhantomCoordinator` already owns the + /// in-flight state; the payload here keeps the screen renderable + /// against a stale-coordinator nil-operation race on back-stack reuse. + case phantomEducation(PaymentOperation) + /// Phantom confirm screen, paired with `.phantomEducation`. + case phantomConfirm(PaymentOperation) // Settings flow case settingsMyAccount @@ -45,7 +64,9 @@ extension AppRouter { switch self { case .currencyInfo, .currencyInfoForDeposit, .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, - .transactionHistory, .give: + .transactionHistory, .give, .withdrawCurrency, + .usdcDepositEducation, .usdcDepositAddress, + .phantomEducation, .phantomConfirm: return .balance case .settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings, .settingsBetaFlags, .settingsAccountSelection, @@ -68,6 +89,11 @@ extension AppRouter { case .currencyCreationWizard: "currencyCreationWizard" case .transactionHistory: "transactionHistory" case .give: "give" + case .withdrawCurrency: "withdrawCurrency" + case .usdcDepositEducation: "usdcDepositEducation" + case .usdcDepositAddress: "usdcDepositAddress" + case .phantomEducation: "phantomEducation" + case .phantomConfirm: "phantomConfirm" case .settingsMyAccount: "settingsMyAccount" case .settingsAdvancedFeatures: "settingsAdvancedFeatures" case .settingsAppSettings: "settingsAppSettings" @@ -90,9 +116,13 @@ extension AppRouter { .currencyInfoForDeposit(let mint), .transactionHistory(let mint), .give(let mint), + .withdrawCurrency(let mint), .deposit(let mint): return mint.base58 + case .phantomEducation(let operation), .phantomConfirm(let operation): + return operation.currencyName case .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, + .usdcDepositEducation, .usdcDepositAddress, .settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings, .settingsBetaFlags, .settingsAccountSelection, .settingsApplicationLogs, .accessKey, .depositCurrencyList, .withdraw: diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index ebfa1e0d9..b55e3113d 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -41,7 +41,7 @@ struct DestinationView: View { mint: mint, container: container, sessionContainer: sessionContainer, - showFundingOnAppear: true + showBuyOnAppear: true ) .id(mint) @@ -138,6 +138,25 @@ struct DestinationView: View { container: container, sessionContainer: sessionContainer ) + + case .withdrawCurrency(let mint): + PreselectedWithdrawRoot( + mint: mint, + container: container, + sessionContainer: sessionContainer + ) + + case .usdcDepositEducation: + USDCDepositEducationScreen() + + case .usdcDepositAddress: + USDCDepositAddressScreen() + + case .phantomEducation(let operation): + PhantomEducationScreen(operation: operation) + + case .phantomConfirm(let operation): + PhantomConfirmScreen(operation: operation) } } } diff --git a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift new file mode 100644 index 000000000..c5e29717e --- /dev/null +++ b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift @@ -0,0 +1,126 @@ +// +// AppRouter+NestedSheet.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore + +extension View { + + /// Mounts the nested sheet (one level deeper than this view's depth) when + /// `AppRouter.presentedSheets` has one. Apply on every sheet's content + /// view tree so a nested sheet at that depth can render — SwiftUI requires + /// nested sheets to be presented from within the parent sheet's content, + /// not as siblings at the app root. + /// + /// Supports one level of nesting; depth-3+ would need a presentedSheets- + /// aware conditional mount. A previous recursive version mounted a dormant + /// inner `.sheet(item:)` that swallowed `interactiveDismissDisabled` from + /// descendants, re-enabling swipe-dismiss on screens that opted out. + func appRouterNestedSheet(container: Container, sessionContainer: SessionContainer) -> some View { + modifier(AppRouterNestedSheetModifier(container: container, sessionContainer: sessionContainer)) + } +} + +private struct AppRouterNestedSheetModifier: ViewModifier { + + let container: Container + let sessionContainer: SessionContainer + + @Environment(AppRouter.self) private var router + @Environment(\.nestedSheetDepth) private var depth + + func body(content: Content) -> some View { + // Each level binds to its own slot in `presentedSheets`. The setter + // forwards user-driven dismissal (swipe-down) to `dismissSheet`, but + // SwiftUI ALSO calls the setter with nil after a programmatic dismiss + // completes. Without the in-bounds guard the setter would re-enter + // `dismissSheet` and pop the parent sheet, cascading the dismissal. + let myDepth = depth + 1 + let binding = Binding( + get: { + guard router.presentedSheets.indices.contains(myDepth) else { return nil } + return router.presentedSheets[myDepth] + }, + set: { newValue in + guard newValue == nil else { return } + guard router.presentedSheets.indices.contains(myDepth) else { return } + router.dismissSheet() + } + ) + return content.sheet(item: binding) { nested in + NestedSheetRootView( + sheet: nested, + container: container, + sessionContainer: sessionContainer + ) + .environment(\.nestedSheetDepth, depth + 1) + } + } +} + +/// Dispatches the active nested `SheetPresentation` to its root view. Lives +/// as a `View` (not a `@ViewBuilder` function) so SwiftUI tracks identity +/// per case — per CLAUDE.md "no view functions" rule. +private struct NestedSheetRootView: View { + + let sheet: AppRouter.SheetPresentation + let container: Container + let sessionContainer: SessionContainer + + var body: some View { + switch sheet { + case .buy(let mint): + BuySheetRoot( + mint: mint, + container: container, + sessionContainer: sessionContainer + ) + + case .balance, .settings, .give, .discover: + // Root-only sheets — they shouldn't be presented as nested. If + // they ever are, we fall through to an empty view; the warning + // in `presentNested` logs the mistake. + EmptyView() + } + } +} + +/// Root view for the `.buy(mint)` nested sheet. Owns the `NavigationStack` +/// bound to `router[.buy]`. `BuyAmountScreen` registers the +/// `.navigationDestination(for: BuyFlowPath.self)` modifier itself, so +/// sub-screens push naturally on this stack. +private struct BuySheetRoot: View { + + let mint: PublicKey + let container: Container + let sessionContainer: SessionContainer + + @Environment(AppRouter.self) private var router + + var body: some View { + @Bindable var router = router + NavigationStack(path: $router[.buy]) { + BuyAmountScreen( + mint: mint, + currencyName: sessionContainer.session.balance(for: mint)?.name ?? "this currency", + session: sessionContainer.session, + ratesController: sessionContainer.ratesController + ) + .id(mint) + // Sub-flow screens (Phantom, USDC deposit, processing) call + // `dismissParentContainer` to close the whole `.buy` sheet on + // success. BuyAmountScreen itself dismisses via the same env value + // through its toolbar close button. + .environment(\.dismissParentContainer, { router.dismissSheet() }) + // Top-level `AppRouter.Destination` cases (e.g. `.usdcDepositEducation`, + // `.usdcDepositAddress`) are pushed from the Other Wallet path. They + // share the same screens reached from the Wallet sheet, so register + // the app-wide destination map here too. + .appRouterDestinations(container: container, sessionContainer: sessionContainer) + } + } +} diff --git a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift index 295cbaff3..ff3986d5a 100644 --- a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -6,16 +6,19 @@ // import Foundation +import FlipcashCore extension AppRouter { - /// Identifies the top-level modal sheet currently overlaying `ScanScreen`. - /// One sheet at a time; switching sheets dismisses the previous. + /// Identifies a top-level modal sheet. The router can present multiple at + /// once — the bottom of the stack is the root sheet (overlays `ScanScreen`) + /// and any subsequent entries are nested sheets that visually stack on top. nonisolated enum SheetPresentation: Identifiable, Hashable, Sendable, CustomStringConvertible { case balance case settings case give case discover + case buy(PublicKey) var id: Self { self } @@ -28,15 +31,38 @@ extension AppRouter { case .settings: .settings case .give: .give case .discover: .discover + case .buy: .buy } } + /// Payload-free case discriminator. Used by `presentNested` to detect + /// "same case, different payload" (e.g. `.buy(A)` → `.buy(B)`) without + /// comparing the stringly-typed `description`. + var caseKind: CaseKind { + switch self { + case .balance: .balance + case .settings: .settings + case .give: .give + case .discover: .discover + case .buy: .buy + } + } + + enum CaseKind: Hashable, Sendable { + case balance + case settings + case give + case discover + case buy + } + var description: String { switch self { case .balance: "balance" case .settings: "settings" case .give: "give" case .discover: "discover" + case .buy: "buy" } } } diff --git a/Flipcash/Core/Navigation/AppRouter+Stack.swift b/Flipcash/Core/Navigation/AppRouter+Stack.swift index 4a64355f2..b21846489 100644 --- a/Flipcash/Core/Navigation/AppRouter+Stack.swift +++ b/Flipcash/Core/Navigation/AppRouter+Stack.swift @@ -17,15 +17,21 @@ extension AppRouter { case settings case give case discover + case buy /// The sheet a stack is presented in. Cross-stack navigation uses /// this to know which top-level modal to surface. - var sheet: SheetPresentation { + /// + /// `.buy` returns `nil` — its sheet carries a mint payload that can't + /// be synthesized from the stack alone. Buy is always entered via + /// `router.presentNested(.buy(mint))` directly, never via `navigate(to:)`. + var sheet: SheetPresentation? { switch self { case .balance: .balance case .settings: .settings case .give: .give case .discover: .discover + case .buy: nil } } @@ -35,6 +41,7 @@ extension AppRouter { case .settings: "settings" case .give: "give" case .discover: "discover" + case .buy: "buy" } } } diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index e2754cd2e..570a02ad0 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -23,10 +23,18 @@ private let logger = Logger(label: "flipcash.router") /// This avoids nested `NavigationStack`s, which crash with /// `comparisonTypeMismatch` on push/pop/push cycles. /// +/// The router supports nested sheets: `presentedSheets` is an ordered stack +/// where the bottom entry is the root sheet (mounted at app root) and any +/// entries above visually stack on top via `.appRouterNestedSheet` modifiers +/// applied inside each parent sheet's content tree. SwiftUI requires nested +/// sheets to be presented from within the parent's view tree — they cannot +/// be siblings at the root. +/// /// All mutators log at INFO via `flipcash.router`. The bindable subscript /// funnels SwiftUI's automatic writes (e.g., swipe-back) through `setPath`, /// so every observable state change produces exactly one log line. @Observable +@MainActor final class AppRouter { /// Approximate duration of a SwiftUI `.sheet` / `.fullScreenCover` dismiss @@ -36,16 +44,26 @@ final class AppRouter { /// mutating bill state so it enters fresh on the revealed ScanScreen. static let dismissAnimationDuration: Duration = .milliseconds(400) - private(set) var presentedSheet: SheetPresentation? + /// Stack of presented sheets, bottom-first. `.first` is the root sheet + /// (mounted at app root); `.last` is topmost (visible). Empty when no + /// sheet is presented. + private(set) var presentedSheets: [SheetPresentation] = [] + + /// Topmost (currently visible) sheet, or nil if no sheet is presented. + var presentedSheet: SheetPresentation? { presentedSheets.last } + + /// Root sheet — the one mounted at the app root. Distinct from + /// `presentedSheet` when nested sheets are present. + var rootSheet: SheetPresentation? { presentedSheets.first } private var paths: [Stack: NavigationPath] = [:] /// Sheets the user has explicitly dismissed (close button, swipe-down, or /// programmatic `dismissSheet`) since their last presentation. The next - /// `present(_:)` of an entry in this set clears the sheet's stack path so - /// re-opening starts at root. Sheet swaps don't add to the set, so swap-back - /// preserves the prior path. Bounded by the number of `SheetPresentation` - /// cases. + /// `present(_:)` or `presentNested(_:)` of an entry in this set clears the + /// sheet's stack path so re-opening starts at root. Sheet swaps don't add + /// to the set, so swap-back preserves the prior path. Bounded by the + /// number of `SheetPresentation` cases (per-value for cases with payload). private var dismissedSheets: Set = [] init() {} @@ -60,7 +78,10 @@ final class AppRouter { // MARK: - Stack mutators - /// Pushes onto whatever stack is currently presented (`presentedSheet?.stack`). + /// Pushes onto whatever stack is topmost (`presentedSheet?.stack`). With + /// nested sheets, that's the nested sheet's stack — pushes always land on + /// the visible NavigationStack, never on a stack hidden underneath. + /// /// No-op with a warning if no sheet is presented — pushes onto a hidden /// stack would silently corrupt that stack's path until the user later /// presents that sheet. @@ -77,11 +98,11 @@ final class AppRouter { logger.info("Push", metadata: navigationMetadata(stack: stack, destination: destination)) } - /// Pushes any Hashable value onto the currently-presented stack. Used by - /// sub-flows whose destination types live outside `AppRouter.Destination` - /// (e.g., `WithdrawNavigationPath`), so a single stack can carry mixed - /// types without nesting `NavigationStack`s. No-op with a warning if no - /// sheet is presented. + /// Pushes any Hashable value onto the topmost stack. Used by sub-flows + /// whose destination types live outside `AppRouter.Destination` (e.g., + /// `WithdrawNavigationPath`, `BuyFlowPath`), so a single stack can carry + /// mixed types without nesting `NavigationStack`s. No-op with a warning + /// if no sheet is presented. func pushAny(_ value: H) { guard let stack = presentedSheet?.stack else { logger.warning("Push (sub-flow) attempted with no sheet presented", metadata: [ @@ -150,69 +171,154 @@ final class AppRouter { // MARK: - Sheet mutators - /// Presents `sheet`. Idempotent: no-op if already presenting `sheet`. + /// Presents `sheet` as the root sheet. /// - /// If `sheet` was previously dismissed (sits in `dismissedSheets`), its - /// stack path is cleared synchronously *before* the sheet mounts — so a - /// re-open lands at root. A sheet swap (presenting a different sheet + /// Semantics: + /// - Already at this exact state (`presentedSheets == [sheet]`) → idempotent. + /// - Same root with nested sheets above → pop the nested(s), keep root. + /// `present(.balance)` while `[.balance, .buy(mint)]` is up → `[.balance]`. + /// - Different root (with or without nested above) → dismiss everything, + /// present new root. + /// + /// If the new root was previously dismissed (sits in `dismissedSheets`), + /// its stack path is cleared synchronously *before* the sheet mounts — so + /// a re-open lands at root. A sheet swap (presenting a different sheet /// without going through `dismissSheet` first) leaves both paths intact, /// preserving the original "swap-and-return" behaviour. - /// - /// Doing the clear here instead of inside `dismissSheet` avoids the - /// "push back, then dismiss" animation: dismissal lets the sheet's - /// snapshot slide off-screen with its current contents intact, and the - /// clear runs only when the user actively chooses to re-open. func present(_ sheet: SheetPresentation) { - guard sheet != presentedSheet else { return } - let previous = presentedSheet + if presentedSheets == [sheet] { return } + + let previousTop = presentedSheet + + if presentedSheets.first == sheet { + // Same root, nested above → pop nested(s), keep root path intact. + for nested in presentedSheets.dropFirst() { + dismissedSheets.insert(nested) + } + presentedSheets = [sheet] + logger.info("Presented sheet (popped nested above same root)", metadata: [ + "sheet": "\(sheet)", + "previousSheet": "\(previousTop.map(String.init(describing:)) ?? "")", + ]) + return + } + + // Different root: nested sheets above (if any) are dismissed — their + // paths clear on next re-open. The replaced root is left out of + // `dismissedSheets` so a future `present(_:)` of the old root restores + // its path (swap-back semantics). + for nested in presentedSheets.dropFirst() { + dismissedSheets.insert(nested) + } + if dismissedSheets.remove(sheet) != nil { paths[sheet.stack] = NavigationPath() } - presentedSheet = sheet + + presentedSheets = [sheet] logger.info("Presented sheet", metadata: [ "sheet": "\(sheet)", - "previousSheet": "\(previous.map(String.init(describing:)) ?? "")", + "previousSheet": "\(previousTop.map(String.init(describing:)) ?? "")", + ]) + } + + /// Appends `sheet` on top of the current top, stacking visually. Requires + /// at least one sheet already presented (no-op + warning otherwise — the + /// caller should `present(_:)` a root first, not promote-implicitly). + /// + /// Semantics: + /// - Stack empty → no-op + warning. + /// - Same sheet already on top → idempotent. + /// - Same case different payload on top (e.g., `.buy(A)` → `.buy(B)`) → + /// swap the top entry. The displaced value is added to `dismissedSheets` + /// so its path is cleared on next re-open. + /// - Otherwise → append. + /// + /// Path-clear-on-reopen applies identically to nested sheets: if the + /// presented sheet sits in `dismissedSheets`, its path is cleared + /// synchronously before mount. + func presentNested(_ sheet: SheetPresentation) { + guard !presentedSheets.isEmpty else { + logger.warning("presentNested attempted with no sheet presented; call present(_:) first", metadata: [ + "sheet": "\(sheet)", + ]) + return + } + + if presentedSheets.last == sheet { return } + + if let top = presentedSheets.last, + top != sheet, + top.caseKind == sheet.caseKind { + // Same case kind on top with a different payload — swap. + dismissedSheets.insert(top) + if dismissedSheets.remove(sheet) != nil { + paths[sheet.stack] = NavigationPath() + } + presentedSheets[presentedSheets.count - 1] = sheet + logger.info("Presented nested sheet (swapped same-case top)", metadata: [ + "sheet": "\(sheet)", + "displaced": "\(top)", + ]) + return + } + + if dismissedSheets.remove(sheet) != nil { + paths[sheet.stack] = NavigationPath() + } + + presentedSheets.append(sheet) + logger.info("Presented nested sheet", metadata: [ + "sheet": "\(sheet)", + "depth": "\(presentedSheets.count)", ]) } - /// Dismisses the active sheet and marks it as "explicitly closed" so the - /// next `present(_:)` of the same sheet clears its stack path. The path - /// itself is left untouched here — the dismissing sheet keeps its current + /// Dismisses the topmost sheet. If only the root remains, this dismisses + /// the root (same as the pre-nested behaviour). With nested sheets, only + /// the topmost is popped — the root stays presented. + /// + /// The dismissed sheet is marked "explicitly closed" so the next + /// presentation of the same value clears its stack path. The path itself + /// is left untouched here — the dismissing sheet keeps its current /// contents through the slide-down animation, and the clear happens on /// re-open instead. func dismissSheet() { - guard let dismissing = presentedSheet else { return } - presentedSheet = nil + guard let dismissing = presentedSheets.popLast() else { return } dismissedSheets.insert(dismissing) - logger.info("Dismissed sheet", metadata: ["sheet": "\(dismissing)"]) + logger.info("Dismissed sheet", metadata: [ + "sheet": "\(dismissing)", + "remainingDepth": "\(presentedSheets.count)", + ]) } - /// Global navigation reset: dismisses the active sheet (if any) and - /// clears every stack's `NavigationPath`. The user lands on the - /// Scanner — the unconditional root rendered behind all sheets. + /// Global navigation reset: dismisses every presented sheet and clears + /// every stack's `NavigationPath`. The user lands on the Scanner — the + /// unconditional root rendered behind all sheets. /// /// Distinct from ``popToRoot(on:)``, which is a per-stack pop. This - /// method is the all-stacks + sheet variant, used by the + /// method is the all-sheets + all-stacks variant, used by the /// auto-return-on-background trigger when the user has been away long /// enough that any in-flight navigation should be discarded. /// - /// The active sheet's slide-down animation runs with its current contents - /// — the same behaviour as ``dismissSheet()``. Inactive stacks have no - /// on-screen UI so their paths are cleared synchronously. + /// The dismissing root sheet's slide-down animation runs with its current + /// contents — the same behaviour as ``dismissSheet()``. Inactive stacks + /// have no on-screen UI so their paths are cleared synchronously. func dismissAll() { - let dismissingSheet = presentedSheet - presentedSheet = nil - if let dismissingSheet { - dismissedSheets.insert(dismissingSheet) + let dismissing = presentedSheets + let dismissingRoot = presentedSheets.first + presentedSheets = [] + for sheet in dismissing { + dismissedSheets.insert(sheet) } - // Clear every other stack's path now. The dismissing stack keeps its - // path through the slide-off animation; the existing dismissedSheets - // mechanism clears it on the next present(_:) for that sheet. - for stack in Stack.allCases where stack != dismissingSheet?.stack { + // Clear every stack's path except the dismissing root's — that stack + // keeps its path through the slide-off animation, cleared on next + // present of that root via the dismissedSheets mechanism. + for stack in Stack.allCases where stack != dismissingRoot?.stack { paths[stack] = NavigationPath() } logger.info("Dismiss all", metadata: [ - "dismissedSheet": "\(dismissingSheet.map(String.init(describing:)) ?? "")", + "dismissedSheets": "\(dismissing.map(String.init(describing:)))", ]) } @@ -235,9 +341,10 @@ final class AppRouter { // MARK: - Cross-stack navigation - /// Cross-stack navigation. Presents the destination's `owningStack` (swapping - /// the current sheet if different) and sets `[destination]` as the only - /// path entry on that stack. Other stacks' paths are preserved underneath. + /// Cross-stack navigation. Presents the destination's `owningStack` as the + /// root sheet (dismissing any nested sheets above) and sets `[destination]` + /// as the only path entry on that stack. Other stacks' paths are preserved + /// underneath. /// /// Call this from deeplinks, push notifications, and any programmatic /// redirect that should land the user *on* the destination regardless of @@ -249,14 +356,24 @@ final class AppRouter { /// > view's view model. func navigate(to destination: Destination) { let targetStack = destination.owningStack - let targetSheet = targetStack.sheet + // `Destination.owningStack` only ever names a root stack + // (balance/settings/give/discover) — `.buy` is nested-only and never + // an owning stack — so the optional `Stack.sheet` is never nil here. + guard let targetSheet = targetStack.sheet else { + logger.warning("navigate(to:) hit a nested-only stack — destination is misrouted", metadata: [ + "stack": "\(targetStack)", + "destination": "\(destination)", + ]) + return + } var expected = NavigationPath() expected.append(destination) - let alreadyThere = presentedSheet == targetSheet + let alreadyThere = presentedSheets == [targetSheet] && paths[targetStack, default: NavigationPath()] == expected guard !alreadyThere else { return } + // present(_:) handles dismiss-nested-then-swap-root semantics. present(targetSheet) setPath([destination], on: targetStack) } diff --git a/Flipcash/Core/Screens/Main/BalanceScreen.swift b/Flipcash/Core/Screens/Main/BalanceScreen.swift index 6064bea68..1f616d324 100644 --- a/Flipcash/Core/Screens/Main/BalanceScreen.swift +++ b/Flipcash/Core/Screens/Main/BalanceScreen.swift @@ -28,19 +28,12 @@ struct BalanceScreen: View { /// positions instead of popping when the sort order shuffles. @Namespace private var balanceRowNamespace - /// USDF is surfaced separately via `reservesBalance`. - private var currencyBalances: [ExchangedBalance] { - sortedBalances.filter { $0.stored.mint != .usdf } - } - - /// `nil` for a zero balance so the reserves row is hidden when there's - /// nothing to show. - private var reservesBalance: ExchangedBalance? { - sortedBalances.first { $0.stored.mint == .usdf && $0.stored.quarks > 0 } - } - + /// USDF is always present in `session.balances(for:)` after sync, so an + /// account with no purchased currencies still has USDF in the list. Gate + /// on a non-USDF holding so the empty state ("Buy your first currency to + /// get started") still appears for that brand-new user. private var hasBalances: Bool { - !currencyBalances.isEmpty || reservesBalance != nil + sortedBalances.contains { $0.stored.mint != .usdf } } private var balance: ExchangedFiat { @@ -55,7 +48,7 @@ struct BalanceScreen: View { } /// Takes balances by parameter so callers can pass a cached snapshot and - /// avoid re-filtering `currencyBalances` on every body evaluation. + /// avoid re-iterating `sortedBalances` on every body evaluation. private func computeAppreciation(for balances: [ExchangedBalance]) -> (amount: FiatAmount, isPositive: Bool) { var totalAppreciation: Decimal = 0 @@ -133,7 +126,7 @@ struct BalanceScreen: View { } @ViewBuilder private func list() -> some View { - let appreciation = computeAppreciation(for: currencyBalances) + let appreciation = computeAppreciation(for: sortedBalances) // ScrollView ignores the bottom safe area so the section footer pins to // the very bottom of the screen — the gradient can then fade out @@ -153,24 +146,17 @@ struct BalanceScreen: View { .padding(.vertical, 30) if hasBalances { - ForEach(currencyBalances) { balance in - CurrencyBalanceRow(exchangedBalance: balance) { + ForEach(sortedBalances) { balance in + CurrencyBalanceRow( + exchangedBalance: balance, + accessibilityIdentifier: balance.stored.mint == .usdf ? "currency-row-usdf" : "currency-row" + ) { Analytics.tokenInfoOpened(from: .openedFromWallet, mint: balance.stored.mint) router.push(.currencyInfo(balance.stored.mint)) } .vSeparator(color: .rowSeparator) .matchedGeometryEffect(id: balance.id, in: balanceRowNamespace) } - - if let reservesBalance, reservesBalance.exchangedFiat.hasDisplayableValue() { - CashReservesRow( - reservesBalance: reservesBalance, - showTopDivider: currencyBalances.isEmpty, - onTap: { - router.push(.currencyInfo(reservesBalance.stored.mint)) - } - ) - } } else { emptyState() } @@ -220,6 +206,12 @@ struct ExchangedBalance: Identifiable, Hashable { } } +extension StoredBalance { + func exchanged(with rate: Rate) -> ExchangedBalance { + ExchangedBalance(stored: self, exchangedFiat: computeExchangedValue(with: rate)) + } +} + private struct BalanceHeaderButton: View { let balance: ExchangedFiat diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift new file mode 100644 index 000000000..9c56366a0 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -0,0 +1,156 @@ +// +// BuyAmountScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +struct BuyAmountScreen: View { + + @State private var viewModel: BuyAmountViewModel + @State private var isShowingCurrencySelection: Bool = false + + @Environment(AppRouter.self) private var router + @Environment(OnrampCoordinator.self) private var coordinator + @Environment(PhantomCoordinator.self) private var phantomCoordinator + @Environment(RatesController.self) private var ratesController + @Environment(WalletConnection.self) private var walletConnection + @Environment(Session.self) private var session + + init(mint: PublicKey, currencyName: String, session: Session, ratesController: RatesController) { + self._viewModel = State(initialValue: BuyAmountViewModel( + mint: mint, + currencyName: currencyName, + session: session, + ratesController: ratesController + )) + } + + private var isDismissBlocked: Bool { + // Any pushed sub-flow screen (Phantom confirm, USDC deposit address, + // processing) — destination-level `interactiveDismissDisabled(true)` + // does NOT propagate through the nested-sheet binding, so gate at + // the NavigationStack root by checking the path is non-empty. + if !router[.buy].isEmpty { return true } + return coordinator.isProcessingPayment || phantomCoordinator.isAwaitingExternalSwap + } + + var body: some View { + @Bindable var viewModel = viewModel + @Bindable var coordinator = coordinator + Background(color: .backgroundMain) { + EnterAmountView( + mode: .buy, + enteredAmount: $viewModel.enteredAmount, + subtitle: .singleTransactionLimit, + // Surface coordinator's in-flight Apple Pay/Coinbase setup as + // a spinner on the Buy button — the picker dismisses fast and + // the Apple Pay sheet can take a beat to open. + actionState: Binding( + get: { coordinator.isProcessingPayment ? .loading : viewModel.actionButtonState }, + set: { viewModel.actionButtonState = $0 } + ), + actionEnabled: { _ in viewModel.canPerformAction }, + action: { + Task { await viewModel.amountEnteredAction(router: router) } + }, + currencySelectionAction: showCurrencySelection + ) + .foregroundStyle(.textMain) + .padding(20) + } + .navigationTitle(viewModel.screenTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !isDismissBlocked { + ToolbarItem(placement: .topBarTrailing) { + CloseButton(action: router.dismissSheet) + } + } + } + .interactiveDismissDisabled(isDismissBlocked) + .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). + // + // SwapProcessingScreen at the leaf observes + // `walletConnection.isProcessingCancelled` for failure flips, so + // clearing the wallet state happens on OK (here), not at push + // time. For Coinbase and direct-USDF paths walletConnection state + // is already idle, so `dismissProcessing()` is a no-op. + BuyFlowDestinationView(path: path) + .environment(\.dismissParentContainer, { + phantomCoordinator.dismissProcessing() + router.dismissSheet() + }) + } + .dialog(item: $viewModel.dialogItem) + // Coordinator surfaces Coinbase/Apple Pay failures (e.g. order + // creation rejected, swap-amount mismatch) via its own dialog item; + // bind it here so the user sees the error instead of a silent flicker. + .dialog(item: $coordinator.dialogItem) + .sheet(item: $viewModel.pendingOperation) { operation in + PurchaseMethodSheet( + operation: operation, + sources: [.applePay, .phantom, .otherWallet], + applePayAction: nil, + onDismiss: { viewModel.pendingOperation = nil } + ) + } + .sheet(isPresented: $coordinator.isShowingVerificationFlow) { + VerifyInfoScreen(onrampCoordinator: coordinator) + } + .sheet(isPresented: $isShowingCurrencySelection) { + CurrencySelectionScreen(ratesController: ratesController) + } + .onChange(of: coordinator.completion) { _, newValue in + guard case .buyProcessing(let swapId, let currencyName, let amount) = newValue else { return } + router.pushAny(BuyFlowPath.processing( + swapId: swapId, + currencyName: currencyName, + amount: amount, + swapType: .buyWithCoinbase + )) + coordinator.completion = nil + } + // Phantom-funded buy: when the signed tx returns and the coordinator + // transitions to .buying(...), push the processing screen onto the + // `.buy` stack. Gated on nil → non-nil so we don't double-push when + // the server callback reassigns the buying context to a new swap id. + .onChange(of: phantomCoordinator.processing) { oldValue, newValue in + guard oldValue == nil, let newValue else { return } + router.pushAny(BuyFlowPath.processing( + swapId: newValue.swapId, + currencyName: newValue.currencyName, + amount: newValue.amount, + swapType: .buyWithPhantom + )) + } + // Forward wallet-connection dialogs (Phantom cancel during signing, + // simulate failures, etc.) to `session.dialogItem` so they render in + // `DialogWindow` at alert level instead of trying to mount a sheet + // on `CurrencyInfoScreen` — which fights the `.buy` sheet's + // presentation queue and dismisses it. + // `DialogItem` isn't Equatable, so observe the id (UUID) and read the + // current value in the handler. + .onChange(of: walletConnection.dialogItem?.id) { _, newId in + guard newId != nil, let dialog = walletConnection.dialogItem else { return } + session.dialogItem = dialog + // Defer the clear past the current observation tick so we don't + // mutate the observed value in the same update cycle that fired + // this handler. + Task { @MainActor in + walletConnection.dialogItem = nil + } + } + } + + private func showCurrencySelection() { + isShowingCurrencySelection.toggle() + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift new file mode 100644 index 000000000..62e434ba6 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -0,0 +1,178 @@ +// +// BuyAmountViewModel.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +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? + var pendingOperation: PaymentOperation? + + @ObservationIgnored let mint: PublicKey + @ObservationIgnored let currencyName: String + + var enteredFiat: ExchangedFiat? { + computeAmount(using: ratesController.rateForBalanceCurrency()) + } + + var canPerformAction: Bool { + guard enteredFiat != nil else { return false } + return EnterAmountCalculator.isWithinDisplayLimit( + enteredAmount: enteredAmount, + max: maxPossibleAmount.nativeAmount + ) + } + + /// 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 screenTitle: String { "Amount" } + + @ObservationIgnored private let session: Session + @ObservationIgnored private let ratesController: RatesController + + init(mint: PublicKey, currencyName: String, session: Session, ratesController: RatesController) { + self.mint = mint + self.currencyName = currencyName + self.session = session + self.ratesController = ratesController + } + + // MARK: - Submission + + /// Single source of truth for amount submission. Pin verified state, compute + /// quarks against the pin, run limit + balance gates, then either submit + /// the buy directly (when USDF covers the amount) or hand off to the + /// funding picker via ``pendingMethodSelection``. + func amountEnteredAction(router: AppRouter) async { + guard enteredFiat != nil else { return } + actionButtonState = .loading + + guard let (amount, pin) = await prepareSubmission() else { + actionButtonState = .normal + dialogItem = .staleRate + 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 + } + + if usdfBalanceCovers(amount) { + await performBuy(amount: amount, pin: pin, router: router) + } else { + actionButtonState = .normal + routeToPicker(amount: amount, pin: pin) + } + } + + private func routeToPicker(amount: ExchangedFiat, pin: VerifiedState) { + pendingOperation = .buy(.init( + mint: mint, + currencyName: currencyName, + amount: amount, + verifiedState: pin + )) + } + + private func usdfBalanceCovers(_ amount: ExchangedFiat) -> Bool { + guard let balance = session.balance(for: .usdf) else { return false } + return balance.usdf.value >= amount.usdfValue.value + } + + 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: balance gate said OK but session.buy disagreed. Route to picker. + actionButtonState = .normal + routeToPicker(amount: amount, pin: pin) + } 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 = .somethingWentWrong + } + } + + /// Pin verified state once, compute amount against the pin so quarks + /// stay tied to the rate the server is about to verify. + private 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 amount = computeAmount(using: pin.rate) else { return nil } + return (amount, pin) + } + + private func computeAmount(using rate: Rate) -> ExchangedFiat? { + guard !enteredAmount.isEmpty else { return nil } + // Use Decimal(string:) — the keypad always emits "." regardless of locale. + // NumberFormatter.decimal(from:) breaks on non-"." locales (CLAUDE.md pitfall). + guard let amount = Decimal(string: enteredAmount) else { return nil } + return ExchangedFiat( + nativeAmount: FiatAmount(value: amount, currency: rate.currency), + rate: rate + ) + } + + // MARK: - Dialogs + + private func showLimitsError() { + dialogItem = .init( + style: .destructive, + title: "Transaction Limit Reached", + subtitle: "You can only buy up to the transaction limit at a time", + dismissable: true + ) { .okay(kind: .destructive) } + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift new file mode 100644 index 000000000..35b077082 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowDestinationView.swift @@ -0,0 +1,28 @@ +// +// BuyFlowDestinationView.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore + +/// Sub-flow dispatcher for the `.buy` stack. Registered via +/// `.navigationDestination(for: BuyFlowPath.self)` on the stack root. +struct BuyFlowDestinationView: View { + + let path: BuyFlowPath + + var body: some View { + switch path { + case .processing(let swapId, let currencyName, let amount, let swapType): + SwapProcessingScreen( + swapId: swapId, + swapType: swapType, + currencyName: currencyName, + amount: amount + ) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift new file mode 100644 index 000000000..e48993e11 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift @@ -0,0 +1,22 @@ +// +// BuyFlowPath.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +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(_:)`. +/// +/// 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. +enum BuyFlowPath: Hashable, Sendable { + case processing(swapId: SwapId, currencyName: String, amount: ExchangedFiat, swapType: SwapType) +} diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift new file mode 100644 index 000000000..0e268132a --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/PhantomConfirmScreen.swift @@ -0,0 +1,87 @@ +// +// PhantomConfirmScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Post-handshake confirmation. Tapping Confirm calls +/// `PhantomCoordinator.confirm()`, which dispatches the right +/// `WalletConnection.request*` for the carried operation kind. +/// +/// The completion routing (push processing for buy, fullScreenCover for +/// launch) is observed by the picker's caller — `BuyAmountScreen` watches +/// `coordinator.processing`, the wizard watches `coordinator.launchProcessing`. +/// This screen just kicks off the sign request. +struct PhantomConfirmScreen: View { + + let operation: PaymentOperation + + @Environment(PhantomCoordinator.self) private var coordinator + + private var isSigning: Bool { + coordinator.state == .signing + } + + var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 24) { + Spacer() + + BadgedIcon( + icon: Image.asset(.buyPhantom), + badge: Image.asset(.buyCheckmark) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Phantom connected") + + VStack(spacing: 8) { + Text("Connected") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + + Text("Confirm the transaction in Phantom to continue") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 40) + + Spacer() + + Button(action: coordinator.confirm) { + if isSigning { + HStack(spacing: 8) { + ProgressView().progressViewStyle(.circular) + Text("Waiting for Phantom…") + } + } else { + HStack(spacing: 6) { + Text("Confirm in your") + Image.asset(.phantom) + .renderingMode(.template) + .resizable() + .frame(width: 18, height: 18) + Text("Phantom") + } + } + } + .buttonStyle(.filled) + .disabled(isSigning) + } + .padding(20) + } + .navigationTitle("Confirmation") + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + // Backing out before Phantom returns cancels the pending swap so + // a future unrelated deeplink doesn't complete a stale operation. + coordinator.cancel() + } + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift new file mode 100644 index 000000000..7614f1378 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/PhantomEducationScreen.swift @@ -0,0 +1,123 @@ +// +// PhantomEducationScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// "Buy / Launch With Phantom" pre-flight. The connect deeplink fires when +/// the user taps the CTA on this screen — not when Phantom is selected from +/// the picker — so the user sees the education copy before being kicked over +/// to Phantom. On return, `coordinator.state` flips to `.awaitingConfirm` and +/// we push `.phantomConfirm`. +struct PhantomEducationScreen: View { + + let operation: PaymentOperation + + @Environment(AppRouter.self) private var router + @Environment(PhantomCoordinator.self) private var coordinator + @Environment(Session.self) private var session + + /// Tracks whether `.phantomConfirm` has been pushed for this view's + /// lifetime. Necessary because `coordinator.state` re-enters + /// `.awaitingConfirm` after a sign-cancel (so the user can retry), which + /// would otherwise stack a second confirm screen via `.onChange`. + @State private var hasPushedConfirm = false + + private var isConnecting: Bool { + coordinator.state == .connecting + } + + var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 24) { + Spacer() + + HeroGraphic() + .accessibilityElement(children: .ignore) + .accessibilityLabel("Buy with Phantom using Solana USDC") + + VStack(spacing: 8) { + Text("Buy With Phantom") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + + Text("Purchase using Solana USDC in Phantom. Simply connect your wallet and confirm the transaction.") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 40) + + Spacer() + + Button { + coordinator.start(operation) + } label: { + if isConnecting { + HStack(spacing: 8) { + ProgressView().progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect Your Phantom Wallet") + } + } + .buttonStyle(.filled) + .disabled(isConnecting) + } + .padding(20) + } + .navigationTitle("Purchase") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: coordinator.state) { _, newState in + switch newState { + case .awaitingConfirm where !hasPushedConfirm: + hasPushedConfirm = true + router.push(.phantomConfirm(operation)) + case .failed(let reason): + // Surface the failure as a destructive dialog so the user knows + // why the connect didn't go through. They can dismiss and tap + // "Connect Your Phantom Wallet" again to retry. + session.dialogItem = .init( + style: .destructive, + title: "Couldn't Connect", + subtitle: reason, + dismissable: true + ) { .okay(kind: .destructive) } + case .awaitingConfirm, .idle, .connecting, .signing: + break + } + } + .onDisappear { + // Backing out while the connect deeplink is in flight cancels the + // coordinator so a stale Phantom return doesn't push confirm onto + // a stack the user just dismissed. + if coordinator.state == .connecting { + coordinator.cancel() + } + } + } +} + +private struct HeroGraphic: View { + var body: some View { + HStack(spacing: 16) { + BadgedIcon(icon: Image.asset(.buyPhantom)) + + Image(systemName: "plus") + .foregroundStyle(Color.textMain) + .font(.system(size: 20, weight: .medium)) + + BadgedIcon( + icon: Image.asset(.buyUSDC), + badge: Image.asset(.buySolana) + ) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift new file mode 100644 index 000000000..a89544b9e --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/PurchaseMethodSheet.swift @@ -0,0 +1,216 @@ +// +// PurchaseMethodSheet.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Half-sheet picker shown when a funding intent cannot be filled from the +/// USDF reserve alone. Shared by buy-existing and currency-launch flows via +/// `PaymentOperation`. Callers compose the `sources` array to control which +/// methods render — buy passes all three, launch omits `.otherWallet`. +struct PurchaseMethodSheet: View { + + let operation: PaymentOperation + let sources: [Method] + /// Callers whose Apple Pay flow needs preflight work before invoking + /// `OnrampCoordinator.start(_:amount:)` provide this override. The buy + /// flow passes `nil` so the picker dispatches directly. + let applePayAction: (() -> Void)? + let onDismiss: () -> Void + + @Environment(AppRouter.self) private var router + @Environment(Session.self) private var session + + enum Method: Hashable { + case applePay + case phantom + case otherWallet + } + + var body: some View { + PartialSheet { + VStack(spacing: 12) { + HStack { + Text("Select Purchase Method") + .font(.appBarButton) + .foregroundStyle(Color.textMain) + Spacer() + } + .padding(.vertical, 20) + + // Apple Pay is hidden if the caller didn't request it OR the + // session can't actually use Coinbase. + ForEach(visibleSources, id: \.self) { method in + MethodButton( + method: method, + operation: operation, + applePayAction: applePayAction, + onDismiss: onDismiss + ) + } + + Button("Dismiss", action: onDismiss) + .buttonStyle(.subtle) + } + .padding() + } + } + + private var visibleSources: [Method] { + Self.visibleSources(from: sources, session: session) + } + + /// Pure function exposing the visibility filter so it can be unit-tested + /// without instantiating a SwiftUI view. Apple Pay drops out when the + /// session can't actually use Coinbase, regardless of whether the caller + /// requested it. + static func visibleSources(from sources: [Method], session: Session) -> [Method] { + sources.filter { method in + switch method { + case .applePay: + return session.hasCoinbaseOnramp + case .phantom, .otherWallet: + return true + } + } + } +} + +private struct MethodButton: View { + let method: PurchaseMethodSheet.Method + let operation: PaymentOperation + let applePayAction: (() -> Void)? + let onDismiss: () -> Void + + var body: some View { + switch method { + case .applePay: + ApplePayMethodButton( + operation: operation, + applePayAction: applePayAction, + onDismiss: onDismiss + ) + case .phantom: + PhantomMethodButton(operation: operation, onDismiss: onDismiss) + case .otherWallet: + OtherWalletMethodButton(onDismiss: onDismiss) + } + } +} + +/// Dismisses the sheet, then waits for the system's dismiss animation +/// before invoking `action`. Without the wait, pushing onto a navigation +/// stack while the sheet is still mid-dismiss racing causes SwiftUI to +/// drop the push. +@MainActor +private func dismissThenDispatch( + onDismiss: () -> Void, + action: @escaping @MainActor @Sendable () -> Void +) { + onDismiss() + Task { @MainActor in + try? await Task.sleep(for: AppRouter.dismissAnimationDuration) + action() + } +} + +private struct ApplePayMethodButton: View { + let operation: PaymentOperation + let applePayAction: (() -> Void)? + let onDismiss: () -> Void + + @Environment(OnrampCoordinator.self) private var coordinator + @Environment(Session.self) private var session + + var body: some View { + Button { + // Coinbase Onramp rejects USD purchases under the minimum — gate + // before the Apple Pay sheet round-trip. Use the USDF (1:1 USD) + // value since `nativeAmount` is in the user's display currency. + let minimumUSD = OnrampCoordinator.minimumPurchaseUSD + guard operation.displayAmount.usdfValue.value >= minimumUSD else { + let minimum = FiatAmount.usd(minimumUSD) + .converting(to: operation.displayAmount.currencyRate) + .formatted() + session.dialogItem = .applePayMinimumPurchase(minimum: minimum) + return + } + Analytics.buttonTapped(name: .buyWithCoinbase) + if let applePayAction { + // Caller-provided dispatch — used by the launch flow which + // needs to run preflight (launchCurrency) before starting + // the coordinator. + dismissThenDispatch(onDismiss: onDismiss) { + applePayAction() + } + return + } + // Default buy dispatch. + guard case .buy(let payload) = operation else { + // Defensive — launch should always pass applePayAction. + return + } + let amount = payload.amount + let mint = payload.mint + let displayName = payload.currencyName + dismissThenDispatch(onDismiss: onDismiss) { [coordinator] in + coordinator.start(.buy(mint: mint, displayName: displayName), amount: amount) + } + } label: { + Text("\u{F8FF}Pay") + .font(.body.bold()) + } + .buttonStyle(.filled) + .accessibilityIdentifier("apple-pay-method-button") + } +} + +private struct PhantomMethodButton: View { + let operation: PaymentOperation + let onDismiss: () -> Void + + @Environment(AppRouter.self) private var router + + var body: some View { + Button { + Analytics.buttonTapped(name: .buyWithPhantom) + let operation = self.operation + // Just push the education destination — the Phantom connect + // deeplink fires from the education screen's "Connect Your + // Phantom Wallet" button, not here. This keeps the connect + // prompt off-screen until the user has read the education copy. + dismissThenDispatch(onDismiss: onDismiss) { [router] in + router.push(.phantomEducation(operation)) + } + } label: { + HStack(spacing: 4) { + Image.asset(.phantom) + .renderingMode(.template) + .resizable() + .frame(width: 20, height: 20) + Text("Phantom") + } + } + .buttonStyle(.filled) + } +} + +private struct OtherWalletMethodButton: View { + let onDismiss: () -> Void + + @Environment(AppRouter.self) private var router + + var body: some View { + Button("Other Wallet") { + dismissThenDispatch(onDismiss: onDismiss) { [router] in + router.push(.usdcDepositEducation) + } + } + .buttonStyle(.filled) + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift new file mode 100644 index 000000000..6cc96a1cd --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositAddressScreen.swift @@ -0,0 +1,75 @@ +// +// USDCDepositAddressScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Displays the per-user USDC deposit address — the USDF swap PDA itself, +/// matching the address Coinbase Onramp sends to. Anything received here is +/// auto-converted 1:1 to USDF on receipt by the server-side watcher. Mirrors +/// the established settings `DepositScreen` pattern (`ImmutableField` + +/// `CodeButton` with `.successText("Copied")`). +struct USDCDepositAddressScreen: View { + + @Environment(Session.self) private var session + @State private var buttonState: ButtonState = .normal + @State private var depositAddress: String? + + var body: some View { + Background(color: .backgroundMain) { + VStack(alignment: .leading, spacing: 20) { + Text("Deposit funds into your wallet by sending USDC to your deposit address below. Tap to copy.") + .font(.appTextMedium) + .foregroundStyle(.textSecondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + + if let depositAddress { + Button { + copy(depositAddress) + } label: { + ImmutableField(depositAddress) + } + } else { + Text("Deposit address unavailable") + .font(.appTextMedium) + .foregroundStyle(.textSecondary) + } + + Spacer() + + if let depositAddress { + CodeButton( + state: buttonState, + style: .filled, + title: "Copy Address", + action: { copy(depositAddress) } + ) + } + } + .padding(20) + } + .navigationTitle("Deposit USDC") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + // PDA derivation runs Ed25519 isOnCurve checks against up to 256 + // candidate seeds, so resolve once on appear instead of on every + // body evaluation (`buttonState` flips to `.successText("Copied")` + // on tap, which would re-derive otherwise). The work is synchronous + // so `.onAppear` is the right tool — `.task` would be misleading. + depositAddress = MintMetadata.usdf + .timelockSwapAccounts(owner: session.owner.authorityPublicKey)? + .pda.publicKey.base58 + } + } + + private func copy(_ value: String) { + UIPasteboard.general.string = value + buttonState = .successText("Copied") + } +} diff --git a/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift new file mode 100644 index 000000000..139ff2b72 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Buy/USDCDepositEducationScreen.swift @@ -0,0 +1,73 @@ +// +// USDCDepositEducationScreen.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Pre-flight for the USDC → USDF conversion: explains that incoming Solana +/// USDC is auto-converted 1:1 to USDF on receipt. Reached from the buy flow's +/// "Other Wallet" path and from the USDF Currency Info screen's "Deposit" +/// button — same screen, same destination address regardless of entry point. +struct USDCDepositEducationScreen: View { + + @Environment(AppRouter.self) private var router + + var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 24) { + Spacer() + + ConversionGraphic() + .accessibilityElement(children: .ignore) + .accessibilityLabel("Convert USDC to USDF") + + VStack(spacing: 8) { + Text("Deposit USDC") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + + Text("Your Solana USDC will be converted 1:1 to USD on Flipcash (USDF)") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 40) + + Spacer() + + Button("Next") { + router.push(.usdcDepositAddress) + } + .buttonStyle(.filled) + } + .padding(20) + } + .navigationTitle("Deposit") + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct ConversionGraphic: View { + var body: some View { + HStack(spacing: 16) { + BadgedIcon( + icon: Image.asset(.buyUSDC), + badge: Image.asset(.buySolana) + ) + + Image.system(.arrowRight) + .foregroundStyle(Color.textSecondary) + + Image.asset(.buyFlipcash) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + } + } +} diff --git a/Flipcash/Core/Screens/Main/CashReservesRow.swift b/Flipcash/Core/Screens/Main/CashReservesRow.swift deleted file mode 100644 index b5dbc50ae..000000000 --- a/Flipcash/Core/Screens/Main/CashReservesRow.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// CashReservesRow.swift -// Flipcash -// - -import SwiftUI -import FlipcashCore -import FlipcashUI - -struct CashReservesRow: View { - let reservesBalance: ExchangedBalance - let showTopDivider: Bool - let onTap: () -> Void - - var body: some View { - Button { - Analytics.tokenInfoOpened(from: .openedFromWallet, mint: reservesBalance.stored.mint) - onTap() - } label: { - HStack(spacing: 8) { - Text("USDF") - .font(.appBarButton) - .foregroundStyle(Color.textSecondary) - - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.textSecondary) - - Spacer() - - Text(reservesBalance.exchangedFiat.nativeAmount.formatted()) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - .contentTransition(.numericText()) - .animation(.default, value: reservesBalance.exchangedFiat.nativeAmount) - } - } - .padding(20) - .vSeparator(color: .rowSeparator, position: showTopDivider ? [.top, .bottom] : .bottom) - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift index 510c1e660..152da2a24 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift @@ -30,13 +30,16 @@ struct CurrencyCreationWizardScreen: View { @State private var compressTask: Task? @State private var validationTask: Task? @State private var isValidating: Bool = false - @State private var pendingCoinbaseLaunch: Bool = false @State private var errorDialog: DialogItem? @FocusState private var focusedField: Field? @State private var isShowingPhotoPicker = false @State private var isShowingFilePicker = false - @State private var isShowingFundingSheet = false + /// Drives the `PurchaseMethodSheet` when the user picks "Pay to Create" + /// and USDF doesn't cover the launch cost. + @State private var pendingOperation: PaymentOperation? + + @Environment(PhantomCoordinator.self) private var phantomCoordinator /// Non-nil while the Reserves-funded launch is in flight. Drives a /// `fullScreenCover` that presents `CurrencyLaunchProcessingScreen`. @State private var reservesLaunchContext: ReservesLaunchContext? @@ -184,7 +187,7 @@ struct CurrencyCreationWizardScreen: View { previewFiat: previewFiat, totalLaunchCost: totalLaunchCost, isValidating: isValidating, - onBuy: { isShowingFundingSheet = true } + onBuy: onPayToCreateTap ) .transition(direction.slide) } @@ -232,54 +235,12 @@ struct CurrencyCreationWizardScreen: View { ) { result in handleFileImport(result) } - .sheet(isPresented: $isShowingFundingSheet, onDismiss: { - // SwiftUI allows only one modal sheet at a time. If the user picked - // Coinbase and they're unverified, the coordinator needs to present - // its own verification sheet — defer the kickoff until the funding - // sheet has fully dismissed so the two sheets don't collide. - guard pendingCoinbaseLaunch else { return } - pendingCoinbaseLaunch = false - - // Launch the currency upfront so the stateful swap stream can be - // opened with the new mint before Apple Pay is presented. - let displayName = state.currencyName - let launchAmount = self.launchAmount - let launchFee = self.launchFee - let totalLaunchCost = self.totalLaunchCost - validationTask?.cancel() - validationTask = Task { - guard let mint = await launchCurrencyWithPreflightRouting() else { - isValidating = false - return - } - onrampCoordinator.start( - .launch( - mint: mint, - displayName: displayName, - launchAmount: launchAmount, - launchFee: launchFee - ), - amount: totalLaunchCost - ) - } - }) { - FundingSelectionSheet( - reserveBalance: reserveBalance, - isCoinbaseAvailable: session.hasCoinbaseOnramp, - onSelectReserves: { - isShowingFundingSheet = false - launchAndBuyWithReserves() - }, - onSelectCoinbase: { - pendingCoinbaseLaunch = true - isValidating = true - isShowingFundingSheet = false - }, - onSelectPhantom: { - isShowingFundingSheet = false - beginPhantomLaunch() - }, - onDismiss: { isShowingFundingSheet = false } + .sheet(item: $pendingOperation) { operation in + PurchaseMethodSheet( + operation: operation, + sources: [.applePay, .phantom], + applePayAction: { startApplePayLaunch() }, + onDismiss: { pendingOperation = nil } ) } .fullScreenCover(item: $reservesLaunchContext) { context in @@ -331,8 +292,9 @@ struct CurrencyCreationWizardScreen: View { } } // The wizard only hosts launch covers — buy-existing Phantom flows - // present their own cover from `CurrencyInfoScreen`. - .fullScreenCover(item: Bindable(walletConnection).launchProcessing) { processing in + // present their own cover from `BuyAmountScreen`. State forwards + // from `WalletConnection.launchProcessing` via the coordinator. + .fullScreenCover(item: Bindable(phantomCoordinator).launchProcessing) { processing in NavigationStack { CurrencyLaunchProcessingScreen( swapId: processing.swapId, @@ -343,14 +305,14 @@ struct CurrencyCreationWizardScreen: View { ) .environment(\.dismissParentContainer, { // Single-motion dismiss: sheet animation carries the - // cover off. Cleanup of shared wallet state is deferred + // cover off. Cleanup of coordinator state is deferred // past the animation so it doesn't trigger a separate // cover-dismiss in front of the sheet. `isValidating` // is @State and self-cleans on unmount. router.dismissSheet() Task { @MainActor in try? await Task.sleep(for: AppRouter.dismissAnimationDuration) - walletConnection.dismissProcessing() + phantomCoordinator.dismissProcessing() } }) } @@ -747,46 +709,57 @@ struct CurrencyCreationWizardScreen: View { return .launch(swapId: swapId, mint: mint) } - /// Kicks off the Phantom launch flow. Phantom signing happens out of - /// process (deeplink), so we don't toggle `isValidating` — once Phantom - /// returns, `WalletConnection.processing` becomes non-nil and the - /// `fullScreenCover` takes over the UI. Any failure while *requesting* - /// the swap surfaces a generic dialog. - private func beginPhantomLaunch() { + // MARK: - Pay-to-Create dispatch + + /// "Pay X to Create" tap handler. USDF gate first — if reserves cover the + /// total launch cost, run the reserves flow immediately. Otherwise wire + /// the Phantom coordinator's launch handler and open the picker. + private func onPayToCreateTap() { + if reserveBalance != nil { + launchAndBuyWithReserves() + return + } + + // Wire the Phantom launch handler now (before the picker opens) so the + // coordinator has it when the user taps Phantom and confirms. Buy + // operations on `PhantomCoordinator` don't use this handler — only + // launch does. + phantomCoordinator.launchHandler = { signature, _ in + try await launchAfterExternalFunding(signature: signature, source: .phantom) + } + + pendingOperation = .launch(.init( + currencyName: state.currencyName, + total: totalLaunchCost, + launchAmount: launchAmount, + launchFee: launchFee + )) + } + + /// Apple Pay dispatch closure passed into `PurchaseMethodSheet`. Runs the + /// Launch RPC preflight (creates the mint) before starting Coinbase, so + /// the destination address is valid when the Apple Pay sheet opens. + private func startApplePayLaunch() { + let displayName = state.currencyName + let launchAmount = self.launchAmount + let launchFee = self.launchFee + let totalLaunchCost = self.totalLaunchCost + isValidating = true validationTask?.cancel() validationTask = Task { - let displayName = state.currencyName - do { - if !walletConnection.isConnected { - try await walletConnection.connect() - // Returning from Phantom drops us through a brief - // background → inactive → active scene transition. - // UIApplication.shared.open is silently suppressed - // until the scene is fully active, so the follow-up - // sign request is no-op'd without this yield. - try await Task.sleep(for: .seconds(1)) - } - try await walletConnection.requestSwapForLaunch( - usdc: totalLaunchCost.onChainAmount, - displayName: displayName, - onCompleted: { signature, _ in - try await launchAfterExternalFunding(signature: signature, source: .phantom) - } - ) - } catch is CancellationError { - // User declined the connect request in Phantom. Surface a - // dialog so the wizard shows visible feedback instead of - // silently returning to the confirmation screen. - errorDialog = makeDestructiveDialog( - title: "Wallet Connection Cancelled", - subtitle: "You cancelled the connection in your wallet. Tap Phantom again to retry." - ) - } catch { - if Task.isCancelled { return } - logger.error("Failed to request Phantom swap", metadata: ["error": "\(error)"]) - ErrorReporting.captureError(error) - presentGenericErrorDialog() + guard let mint = await launchCurrencyWithPreflightRouting() else { + isValidating = false + return } + onrampCoordinator.start( + .launch( + mint: mint, + displayName: displayName, + launchAmount: launchAmount, + launchFee: launchFee + ), + amount: totalLaunchCost + ) } } diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 9280d613f..17016a3b4 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -15,21 +15,8 @@ struct CurrencyInfoScreen: View { @Environment(\.dismiss) private var dismiss @Environment(AppRouter.self) private var router - @State private var isShowingFundingSelection: Bool = false - @State private var presentedBuyViewModel: CurrencyBuyViewModel? @State private var presentedSellViewModel: CurrencySellViewModel? @State private var isShowingCurrencySelection: Bool = false - /// Non-nil while the Onramp sheet is presented. Setting it presents the - /// sheet with a fresh `OnrampViewModel`; nil'ing it dismisses. - @State private var onrampDestination: BuyTarget? - @State private var pendingOnrampTarget: BuyTarget? - - /// Identifying data for the Coinbase onramp sheet trigger. - private struct BuyTarget: Identifiable, Hashable { - let mint: PublicKey - let displayName: String - var id: String { mint.base58 } - } let session: Session @@ -42,14 +29,12 @@ struct CurrencyInfoScreen: View { } @Environment(WalletConnection.self) private var walletConnection - @Environment(OnrampCoordinator.self) private var onrampCoordinator private let mint: PublicKey private let container: Container private let ratesController: RatesController - private let sessionContainer: SessionContainer private let marketCapController: MarketCapController - private let showFundingOnAppear: Bool + private let showBuyOnAppear: Bool // MARK: - Init - @@ -58,14 +43,13 @@ struct CurrencyInfoScreen: View { viewModel: CurrencyInfoViewModel, container: Container, sessionContainer: SessionContainer, - showFundingOnAppear: Bool + showBuyOnAppear: Bool ) { self.mint = mint self.container = container self.ratesController = sessionContainer.ratesController self.session = sessionContainer.session - self.sessionContainer = sessionContainer - self.showFundingOnAppear = showFundingOnAppear + self.showBuyOnAppear = showBuyOnAppear self.viewModel = viewModel self.marketCapController = MarketCapController( @@ -77,7 +61,7 @@ struct CurrencyInfoScreen: View { /// Creates the screen by mint address. Metadata is loaded from the database /// (fast path) or fetched from the network, showing a loading state until ready. - init(mint: PublicKey, container: Container, sessionContainer: SessionContainer, showFundingOnAppear: Bool = false) { + init(mint: PublicKey, container: Container, sessionContainer: SessionContainer, showBuyOnAppear: Bool = false) { self.init( mint: mint, viewModel: CurrencyInfoViewModel( @@ -88,7 +72,7 @@ struct CurrencyInfoScreen: View { ), container: container, sessionContainer: sessionContainer, - showFundingOnAppear: showFundingOnAppear + showBuyOnAppear: showBuyOnAppear ) } @@ -108,7 +92,7 @@ struct CurrencyInfoScreen: View { marketCapController: marketCapController, onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint)) }, onShowCurrencySelection: { isShowingCurrencySelection = true }, - onBuy: { isShowingFundingSelection = true }, + onBuy: { router.presentNested(.buy(mint)) }, onGive: { Analytics.buttonTapped(name: .give) router.push(.give(mint)) @@ -120,7 +104,9 @@ struct CurrencyInfoScreen: View { session: session, ratesController: ratesController ) - } + }, + onDeposit: { router.push(.usdcDepositEducation) }, + onWithdraw: { router.push(.withdrawCurrency(mint)) } ) case .error(let error): CurrencyInfoErrorView(error: error) { @@ -149,116 +135,21 @@ struct CurrencyInfoScreen: View { ratesController.ensureMintSubscribed(mint) await viewModel.loadMintMetadata() - if showFundingOnAppear { - isShowingFundingSelection = true - } - } - .fullScreenCover(item: Bindable(walletConnection).processing) { processing in - NavigationStack { - SwapProcessingScreen( - swapId: processing.swapId, - swapType: .buyWithPhantom, - currencyName: processing.currencyName, - amount: processing.amount - ) - .environment(\.dismissParentContainer, { - walletConnection.dismissProcessing() - }) + if showBuyOnAppear { + router.presentNested(.buy(mint)) } } - .fullScreenCover(item: onrampCoordinator.buyCompletionBinding) { completion in - if case .buyProcessing(let swapId, let name, let amount) = completion { - NavigationStack { - SwapProcessingScreen( - swapId: swapId, - swapType: .buyWithCoinbase, - currencyName: name, - amount: amount - ) - .environment(\.dismissParentContainer, { - onrampCoordinator.completion = nil - }) - } - } - } - .sheet(isPresented: Bindable(walletConnection).isShowingAmountEntry) { - if let metadata = viewModel.mintMetadata { - NavigationStack { - EnterWalletAmountScreen { quarks in - try await walletConnection.requestSwap( - usdc: quarks, - token: metadata.metadata - ) - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - CloseButton { walletConnection.isShowingAmountEntry = false } - } - } - } - } - } - .sheet(item: $presentedBuyViewModel) { buyViewModel in - CurrencyBuyAmountScreen(viewModel: buyViewModel) - } .sheet(item: $presentedSellViewModel) { sellViewModel in CurrencySellAmountScreen(viewModel: sellViewModel) } .sheet(isPresented: $isShowingCurrencySelection) { CurrencySelectionScreen(ratesController: ratesController) } - .sheet(isPresented: $isShowingFundingSelection, onDismiss: { - // SwiftUI allows only one modal sheet at a time, so we can't set - // `onrampDestination` in the same frame as dismissing the funding - // sheet — the second sheet gets swallowed. Defer the handoff until - // the funding sheet has fully dismissed. - guard let target = pendingOnrampTarget else { return } - pendingOnrampTarget = nil - onrampDestination = target - }) { - if let metadata = viewModel.mintMetadata { - FundingSelectionSheet( - reserveBalance: viewModel.reserveBalance, - isCoinbaseAvailable: session.hasCoinbaseOnramp, - onSelectReserves: { - Analytics.buttonTapped(name: .buyWithReserves) - presentedBuyViewModel = CurrencyBuyViewModel( - currencyPublicKey: metadata.mint, - currencyName: metadata.name, - session: session, - ratesController: ratesController - ) - isShowingFundingSelection = false - }, - onSelectCoinbase: { - Analytics.buttonTapped(name: .buyWithCoinbase) - pendingOnrampTarget = BuyTarget( - mint: metadata.mint, - displayName: metadata.name - ) - isShowingFundingSelection = false - }, - onSelectPhantom: { - Analytics.buttonTapped(name: .buyWithPhantom) - walletConnection.connectToPhantom() - isShowingFundingSelection = false - }, - onDismiss: { - isShowingFundingSelection = false - } - ) - } - } - .sheet(item: $onrampDestination) { target in - OnrampAmountScreen.forBuying( - mint: target.mint, - displayName: target.displayName, - session: sessionContainer.session, - onrampCoordinator: onrampCoordinator, - onDismiss: { onrampDestination = nil } - ) - } - .dialog(item: Bindable(walletConnection).dialogItem) + // `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. } @ViewBuilder private func toolbarContent() -> some View { @@ -300,6 +191,8 @@ private struct LoadedContent: View { let onBuy: () -> Void let onGive: () -> Void let onSell: () -> Void + let onDeposit: () -> Void + let onWithdraw: () -> Void private var isUSDF: Bool { metadata.mint == .usdf @@ -359,16 +252,29 @@ private struct LoadedContent: View { currencyCode: ratesController.balanceCurrency, marketCapController: marketCapController ) - - Color - .clear - .padding(.bottom, 100) } + + // Reserve space so the floating footer doesn't overlap + // scrolled content. + Color + .clear + .padding(.bottom, 100) } } // Floating Footer - if !isUSDF { + if isUSDF { + CurrencyInfoFooter { + Button("Deposit") { + onDeposit() + } + .buttonStyle(.filled) + + CodeButton(style: .filledSecondary, title: "Withdraw") { + onWithdraw() + } + } + } else { CurrencyInfoFooter { Button("Buy") { onBuy() diff --git a/Flipcash/Core/Screens/Main/Currency Info/FundingSelectionSheet.swift b/Flipcash/Core/Screens/Main/Currency Info/FundingSelectionSheet.swift deleted file mode 100644 index aa70f891d..000000000 --- a/Flipcash/Core/Screens/Main/Currency Info/FundingSelectionSheet.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// FundingSelectionSheet.swift -// Code -// -// Created by Claude on 2025-02-04. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -struct FundingSelectionSheet: View { - let reserveBalance: ExchangedFiat? - let isCoinbaseAvailable: Bool - let onSelectReserves: () -> Void - let onSelectCoinbase: () -> Void - let onSelectPhantom: () -> Void - let onDismiss: () -> Void - - var body: some View { - PartialSheet { - VStack { - HStack { - Text("Select Purchase Method") - .font(.appBarButton) - .foregroundStyle(Color.textMain) - Spacer() - } - .padding(.vertical, 20) - - if isCoinbaseAvailable { - Button { - onSelectCoinbase() - } label: { - HStack(spacing: 4) { - Text("Debit Card with") - Text("Pay") - .font(.body.bold()) - } - } - .buttonStyle(.filled) - } - - if let reserveBalance, reserveBalance.hasDisplayableValue() { - Button("USDF (\(reserveBalance.nativeAmount.formatted()))") { - onSelectReserves() - } - .buttonStyle(.filled) - } - - Button { - onSelectPhantom() - } label: { - HStack(spacing: 4) { - Text("Solana USDC With") - Image.asset(.phantom) - .renderingMode(.template) - .resizable() - .frame(width: 20, height: 20) - Text("Phantom") - } - } - .buttonStyle(.filled) - - Button("Dismiss", action: onDismiss) - .buttonStyle(.subtle) - } - .padding() - } - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyAmountScreen.swift deleted file mode 100644 index db4691169..000000000 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyAmountScreen.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// CurrencyBuyAmountScreen.swift -// Code -// -// Created by Raul Riera on 2025-12-18. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -struct CurrencyBuyAmountScreen: View { - @Bindable private var viewModel: CurrencyBuyViewModel - @Environment(\.dismiss) var dismissAction: DismissAction - @Environment(RatesController.self) private var ratesController - - @State private var isShowingCurrencySelection: Bool = false - - // MARK: - Init - - - init(viewModel: CurrencyBuyViewModel) { - self.viewModel = viewModel - } - - // MARK: - Body - - - var body: some View { - NavigationStack(path: $viewModel.path) { - Background(color: .backgroundMain) { - EnterAmountView( - mode: .buy, - enteredAmount: $viewModel.enteredAmount, - subtitle: .balanceWithLimit(viewModel.maxPossibleAmount), - actionState: $viewModel.actionButtonState, - actionEnabled: { _ in - viewModel.canPerformAction - }, - action: viewModel.amountEnteredAction, - currencySelectionAction: showCurrencySelection - ) - .foregroundStyle(.textMain) - .padding(20) - } - .navigationTitle(viewModel.screenTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationDestination(for: CurrencyBuyPath.self) { step in - switch step { - case .processing(let swapId, let currencyName, let amount): - SwapProcessingScreen(swapId: swapId, swapType: .buyWithReserves, currencyName: currencyName, amount: amount) - .environment(\.dismissParentContainer, { - dismissAction() - }) - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - CloseButton { dismissAction() } - } - } - .dialog(item: $viewModel.dialogItem) - .sheet(isPresented: $isShowingCurrencySelection) { - CurrencySelectionScreen(ratesController: ratesController) - } - } - } - - private func showCurrencySelection() { - isShowingCurrencySelection.toggle() - } -} diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyViewModel.swift deleted file mode 100644 index 26c33bc63..000000000 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyViewModel.swift +++ /dev/null @@ -1,217 +0,0 @@ -// -// CurrencyBuyViewModel.swift -// Code -// -// Created by Raul Riera on 2025-12-18. -// - -import SwiftUI -import FlipcashCore -import FlipcashUI -import Logging - -private let logger = Logger(label: "flipcash.swap-service") - -@Observable -class CurrencyBuyViewModel: Identifiable { - var actionButtonState: ButtonState = .normal - var enteredAmount: String = "" - var dialogItem: DialogItem? - var path: [CurrencyBuyPath] = [] - - var enteredFiat: ExchangedFiat? { - computeAmount(using: ratesController.rateForBalanceCurrency()) - } - - var canPerformAction: Bool { - guard enteredFiat != nil else { - return false - } - - return EnterAmountCalculator.isWithinDisplayLimit( - enteredAmount: enteredAmount, - max: maxPossibleAmount.nativeAmount - ) - } - - var screenTitle: String { - return "Amount To Buy" - } - - var maxPossibleAmount: ExchangedFiat { - let rate = ratesController.rateForBalanceCurrency() - let zero = ExchangedFiat.compute( - onChainAmount: .zero(mint: .usdf), - rate: rate, - supplyQuarks: nil - ) - - guard let balance = session.balance(for: .usdf) else { - return zero - } - - return balance.computeExchangedValue(with: rate) - } - - @ObservationIgnored private let session: Session - @ObservationIgnored private let ratesController: RatesController - @ObservationIgnored private let destination: PublicKey - @ObservationIgnored private let currencyName: String - - // MARK: - Init - - - init(currencyPublicKey: PublicKey, currencyName: String, session: Session, ratesController: RatesController) { - self.destination = currencyPublicKey - self.currencyName = currencyName - self.session = session - self.ratesController = ratesController - } - - // MARK: - Actions - - - func amountEnteredAction() { - guard enteredFiat != nil else { - return - } - - performBuy() - } - - /// Resolves the pin and computes the submission amount against it — one - /// fetch for both, so quarks can't drift from the submitted pin. - 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 amount = computeAmount(using: pin.rate) else { - return nil - } - return (amount, pin) - } - - /// Used by the display preview (live rate) and the submit path (pinned rate). - private func computeAmount(using rate: Rate) -> ExchangedFiat? { - guard !enteredAmount.isEmpty else { - return nil - } - - guard let amount = NumberFormatter.decimal(from: enteredAmount) else { - return nil - } - - let mint: PublicKey = .usdf - let entered = ExchangedFiat( - nativeAmount: FiatAmount(value: amount, currency: rate.currency), - rate: rate - ) - - // Cap to balance to handle rounding differences between display and entry. Since our display rounds HALF_UP - guard let balance = session.balance(for: .usdf) else { - return entered - } - - // If entered USDF value exceeds balance, cap it to the balance - if entered.usdfValue.value > balance.usdf.value { - return ExchangedFiat.compute( - onChainAmount: TokenAmount(quarks: balance.quarks, mint: mint), - rate: rate, - supplyQuarks: nil - ) - } - - return entered - } - - private func performBuy() { - actionButtonState = .loading - - Task { - guard let (buyAmount, pin) = await prepareSubmission() else { - actionButtonState = .normal - dialogItem = .staleRate - return - } - - let sendLimit = session.sendLimitFor(currency: buyAmount.nativeAmount.currency) ?? .zero - - guard buyAmount.nativeAmount.value <= sendLimit.maxPerDay.value else { - logger.info("Buy rejected: amount exceeds limit", metadata: [ - "amount": "\(buyAmount.nativeAmount.formatted())", - "max_per_day": "\(sendLimit.maxPerDay.value)", - "currency": "\(buyAmount.nativeAmount.currency)", - ]) - actionButtonState = .normal - showLimitsError() - return - } - - do { - let swapId = try await session.buy(amount: buyAmount, verifiedState: pin, of: destination) - path.append(.processing(swapId: swapId, currencyName: currencyName, amount: buyAmount)) - } catch Session.Error.insufficientBalance { - actionButtonState = .normal - showInsufficientBalanceError() - } catch Session.Error.verifiedStateStale { - // Session.assertFresh already logged; reset the button so the user can retry. - actionButtonState = .normal - } catch { - ErrorReporting.captureError( - error, - reason: "Failed to buy currency", - metadata: [ - "mint": destination.base58, - "amount": buyAmount.nativeAmount.formatted(), - ] - ) - actionButtonState = .normal - showGenericError() - } - } - } - - // MARK: - Reset - - - private func resetEnteredAmount() { - enteredAmount = "" - } - - // MARK: - Dialogs - - - private func showInsufficientBalanceError() { - dialogItem = .init( - style: .destructive, - title: "Insufficient Balance", - subtitle: "Please enter a lower amount and try again", - dismissable: true - ) { - .okay(kind: .destructive) - } - } - - private func showLimitsError() { - dialogItem = .init( - style: .destructive, - title: "Transaction Limit Reached", - subtitle: "You can only buy up to the transaction limit at a time", - dismissable: true - ) { - .okay(kind: .destructive) - } - } - - private func showGenericError() { - dialogItem = .init( - style: .destructive, - title: "Something Went Wrong", - subtitle: "Please try again later", - dismissable: true - ) { - .okay(kind: .destructive) - } - } -} - -enum CurrencyBuyPath: Hashable { - case processing(swapId: SwapId, currencyName: String, amount: ExchangedFiat) -} diff --git a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingScreen.swift b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingScreen.swift index 78a038d80..42a20fc02 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingScreen.swift @@ -16,7 +16,7 @@ struct SwapProcessingScreen: View { @Environment(Session.self) private var session @Environment(PushController.self) private var pushController @Environment(\.dismissParentContainer) private var dismissParentContainer - @Environment(WalletConnection.self) private var walletConnection + @Environment(PhantomCoordinator.self) private var phantomCoordinator // MARK: - Init - @@ -92,7 +92,7 @@ struct SwapProcessingScreen: View { .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .interactiveDismissDisabled(true) - .onChange(of: walletConnection.isProcessingCancelled, initial: true) { _, cancelled in + .onChange(of: phantomCoordinator.isProcessingCancelled, initial: true) { _, cancelled in if cancelled { viewModel.cancel() } diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index 47243bb4b..7eb2cc8a2 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -139,8 +139,10 @@ struct ScanScreen: View { // Swipe-to-dismiss writes nil through this binding; route through // `dismissSheet()` so the dismissal is logged. Programmatic presentations // go through `router.present(_:)` directly and never write through here. + // Bound to `rootSheet` (bottom of the sheet stack) — nested sheets mount + // inside this root sheet's content via `.appRouterNestedSheet`. .sheet(item: Binding( - get: { router.presentedSheet }, + get: { router.rootSheet }, set: { newValue in if newValue == nil { router.dismissSheet() @@ -152,6 +154,7 @@ struct ScanScreen: View { container: container, sessionContainer: sessionContainer ) + .appRouterNestedSheet(container: container, sessionContainer: sessionContainer) } // Dismiss all presented sheets when a bill is about to appear. // Bills render in ScanScreen's ZStack, so any sheet on top @@ -381,6 +384,12 @@ private struct RoutedSheet: View { } } } + case .buy: + // `.buy` is a nested-only sheet — it should never be presented at + // root. `presentNested(.buy(mint))` is the intended entry point. + // Rendering EmptyView is a defensive no-op; the misuse is already + // logged by the router when the stack is empty. + EmptyView() } } } diff --git a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift index 45ba77a61..6e9274488 100644 --- a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift +++ b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift @@ -92,17 +92,24 @@ extension SelectCurrencyScreen { } struct CurrencyBalanceRow: View { - + let exchangedBalance: ExchangedBalance + let accessibilityIdentifier: String let action: (() -> Void)? let showSelected: Bool? - - init(exchangedBalance: ExchangedBalance, showSelected: Bool? = nil, action: (() -> Void)? = nil) { + + init( + exchangedBalance: ExchangedBalance, + accessibilityIdentifier: String = "currency-row", + showSelected: Bool? = nil, + action: (() -> Void)? = nil + ) { self.exchangedBalance = exchangedBalance + self.accessibilityIdentifier = accessibilityIdentifier self.action = action self.showSelected = showSelected } - + var body: some View { Button { action?() @@ -114,7 +121,7 @@ struct CurrencyBalanceRow: View { isSelected: showSelected, ) } - .accessibilityIdentifier("currency-row") + .accessibilityIdentifier(accessibilityIdentifier) .disabled(action == nil) .listRowBackground(Color.clear) .padding(.horizontal, 20) diff --git a/Flipcash/Core/Screens/Onramp/OnrampAmountScreen.swift b/Flipcash/Core/Screens/Onramp/OnrampAmountScreen.swift deleted file mode 100644 index 4b2e30281..000000000 --- a/Flipcash/Core/Screens/Onramp/OnrampAmountScreen.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// OnrampAmountScreen.swift -// Code -// -// Created by Dima Bart on 2025-04-17. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -struct OnrampAmountScreen: View { - - @State private var viewModel: OnrampViewModel - @Environment(OnrampCoordinator.self) private var onrampCoordinator - - private let onDismiss: () -> Void - - // MARK: - Init - - - static func forBuying( - mint: PublicKey, - displayName: String, - session: Session, - onrampCoordinator: OnrampCoordinator, - onDismiss: @escaping () -> Void - ) -> OnrampAmountScreen { - OnrampAmountScreen( - viewModel: .forBuying( - mint: mint, - displayName: displayName, - session: session, - onrampCoordinator: onrampCoordinator - ), - onDismiss: onDismiss - ) - } - - private init( - viewModel: OnrampViewModel, - onDismiss: @escaping () -> Void - ) { - _viewModel = State(wrappedValue: viewModel) - self.onDismiss = onDismiss - } - - // MARK: - Body - - - var body: some View { - @Bindable var viewModel = viewModel - @Bindable var onrampCoordinator = onrampCoordinator - NavigationStack { - Background(color: .backgroundMain) { - EnterAmountView( - mode: .onramp, - enteredAmount: $viewModel.enteredAmount, - subtitle: .singleTransactionLimit, - actionState: .constant(onrampCoordinator.isProcessingPayment ? .loading : .normal), - actionEnabled: { _ in viewModel.enteredFiat != nil }, - action: viewModel.customAmountEnteredAction, - currencySelectionAction: nil, - ) - .foregroundStyle(.textMain) - .padding(20) - } - .navigationTitle("Amount to Add") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if !onrampCoordinator.isProcessingPayment { - ToolbarItem(placement: .topBarTrailing) { - CloseButton(action: onDismiss) - } - } - } - .interactiveDismissDisabled(onrampCoordinator.isProcessingPayment) - } - .dialog(item: $viewModel.dialogItem) - .dialog(item: $onrampCoordinator.dialogItem) - .sheet(isPresented: $onrampCoordinator.isShowingVerificationFlow) { - VerifyInfoScreen(onrampCoordinator: onrampCoordinator) - } - .onChange(of: onrampCoordinator.completion) { _, completion in - guard case .buyProcessing = completion else { return } - onDismiss() - } - } -} - -/// Invisible overlay that hosts the Coinbase Apple Pay WKWebView. The view is -/// rendered at zero opacity (it exists only to drive Apple Pay's JS payment -/// flow in the background) and explicitly excluded from hit testing and -/// accessibility so the covered region of the amount keypad remains tappable -/// and VoiceOver users don't land on a silent 300×300 zone. -struct ApplePayOverlay: View { - - let order: OnrampOrderResponse? - let onEvent: (ApplePayEvent) -> Void - - var body: some View { - if let order { - ApplePayWebView(url: order.paymentLink.url, onMessage: onEvent) - .frame(width: 300, height: 300) - .opacity(0) - .allowsHitTesting(false) - .accessibilityHidden(true) - .id(order.id) - } - } -} diff --git a/Flipcash/Core/Screens/Onramp/OnrampViewModel.swift b/Flipcash/Core/Screens/Onramp/OnrampViewModel.swift deleted file mode 100644 index 02df554bb..000000000 --- a/Flipcash/Core/Screens/Onramp/OnrampViewModel.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// OnrampViewModel.swift -// Code -// -// Created by Dima Bart on 2025-08-11. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -private let logger = Logger(label: "flipcash.onramp") - -/// Long-lived store for Onramp email verification deeplinks. `DeepLinkController` -/// drops incoming verifications here; `OnrampHostModifier` observes the value -/// with `.onChange(initial: true)` and hands it off to `OnrampCoordinator`, so -/// whether the link arrived before or after the sheet opened the verification -/// is picked up through the same entry point. Lives on `SessionContainer` so -/// it survives sheet dismissal but not logout. -@Observable -final class OnrampDeeplinkInbox { - var pendingEmailVerification: VerificationDescription? -} - -@Observable -class OnrampViewModel { - - var enteredAmount: String = "" - - var dialogItem: DialogItem? - - let displayName: String - - var enteredFiat: ExchangedFiat? { - guard !enteredAmount.isEmpty else { - return nil - } - - guard let amount = NumberFormatter.decimal(from: enteredAmount) else { - return nil - } - - return ExchangedFiat( - nativeAmount: FiatAmount(value: amount, currency: .usd), - rate: .oneToOne - ) - } - - @ObservationIgnored private let session: Session - @ObservationIgnored private let mint: PublicKey - @ObservationIgnored private let onrampCoordinator: OnrampCoordinator - - // MARK: - Init - - - static func forBuying( - mint: PublicKey, - displayName: String, - session: Session, - onrampCoordinator: OnrampCoordinator - ) -> OnrampViewModel { - OnrampViewModel( - displayName: displayName, - mint: mint, - session: session, - onrampCoordinator: onrampCoordinator - ) - } - - private init( - displayName: String, - mint: PublicKey, - session: Session, - onrampCoordinator: OnrampCoordinator - ) { - self.displayName = displayName - self.mint = mint - self.session = session - self.onrampCoordinator = onrampCoordinator - } - - // MARK: - Actions - - - func customAmountEnteredAction() { - guard let exchangedFiat = enteredFiat else { - return - } - - guard let maxPerDay = session.sendLimitFor(currency: exchangedFiat.nativeAmount.currency)?.maxPerDay else { - return - } - - guard exchangedFiat.nativeAmount.value <= maxPerDay.value else { - logger.info("Onramp rejected: amount exceeds limit", metadata: [ - "amount": "\(exchangedFiat.nativeAmount.formatted())", - "max_per_day": "\(maxPerDay.value)", - "currency": "\(exchangedFiat.nativeAmount.currency)", - ]) - showAmountTooLargeError() - return - } - - guard exchangedFiat.nativeAmount.value >= 5.00 else { - showAmountTooSmallError() - return - } - - onrampCoordinator.start( - .buy(mint: mint, displayName: displayName), - amount: exchangedFiat - ) - } - - // MARK: - Dialog Factories - - - private func presentDestructiveDialog( - title: String, - subtitle: String, - action: @escaping DialogAction.DialogActionHandler = {} - ) { - dialogItem = .init( - style: .destructive, - title: title, - subtitle: subtitle, - dismissable: true, - ) { - .okay(kind: .destructive, action: action) - } - } - - // MARK: - Errors - - - private func showAmountTooSmallError() { - presentDestructiveDialog( - title: "$5 Minimum Purchase", - subtitle: "Please enter an amount of $5 or higher" - ) - } - - private func showAmountTooLargeError() { - presentDestructiveDialog( - title: "Amount Too Large", - subtitle: "Please enter a smaller amount" - ) - } -} - -// MARK: - Paths - - -/// Navigation path for `VerifyInfoScreen`'s NavigationStack. -enum OnrampVerificationPath: Hashable { - case info - case enterPhoneNumber - case confirmPhoneNumberCode - case enterEmail - case confirmEmailCode -} - -// MARK: - Profile - - -extension Profile { - var canCreateCoinbaseOrder: Bool { - phone != nil && email?.isEmpty == false - } -} - -// MARK: - OnrampError - - -enum OnrampError: Error { - case missingCoinbaseApiKey -} - diff --git a/Flipcash/Core/Screens/Settings/Withdraw/PreselectedWithdrawRoot.swift b/Flipcash/Core/Screens/Settings/Withdraw/PreselectedWithdrawRoot.swift new file mode 100644 index 000000000..b41826006 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/Withdraw/PreselectedWithdrawRoot.swift @@ -0,0 +1,41 @@ +// +// PreselectedWithdrawRoot.swift +// Flipcash +// +// Created by Raul Riera on 2026-05-13. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// Withdraw flow entry point that skips the "Select Currency" picker. +/// Configures `WithdrawViewModel` with the mint before `@State` boxes it, +/// so the intro screen renders with `kind` already populated. +struct PreselectedWithdrawRoot: View { + + @Environment(AppRouter.self) private var router + @State private var viewModel: WithdrawViewModel + + init(mint: PublicKey, container: Container, sessionContainer: SessionContainer) { + let vm = WithdrawViewModel(container: container, sessionContainer: sessionContainer) + if let stored = sessionContainer.session.balance(for: mint) { + let rate = sessionContainer.ratesController.rateForBalanceCurrency() + vm.selectCurrency(stored.exchanged(with: rate)) + } + self._viewModel = State(wrappedValue: vm) + } + + var body: some View { + WithdrawIntroScreen() + .withdrawSubstepDestinations(viewModel: viewModel) + .onAppear { + viewModel.pushSubstep = { step in + router.pushAny(step) + } + viewModel.onComplete = { + router.popToRoot(on: .balance) + } + } + } +} diff --git a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift index 29ddc020f..30c1d2d10 100644 --- a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift +++ b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawIntroScreen.swift @@ -50,7 +50,7 @@ struct WithdrawIntroScreen: View { private struct ConversionGraphic: View { var body: some View { HStack(spacing: 16) { - Image.asset(.flipcash) + Image.asset(.buyFlipcash) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 100, height: 100) @@ -58,18 +58,10 @@ private struct ConversionGraphic: View { Image.system(.arrowRight) .foregroundStyle(Color.textSecondary) - // The solanaUSDC asset's viewBox is 143×145 because the Solana - // hex badge sits down-right of the main USDC circle (which is - // 128.3 wide within the viewBox). Frame ratio 111×113 scales the - // viewBox so the main circle renders at ≈100×100, matching the - // Flipcash icon. The offset pulls the asset down-right so the - // main circle's center aligns with the Flipcash circle's center - // (the asset's geometric center is offset by the badge weight). - Image.asset(.solanaUSDC) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 111, height: 113) - .offset(x: 6, y: 7) + BadgedIcon( + icon: Image.asset(.buyUSDC), + badge: Image.asset(.buySolana) + ) } } } diff --git a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawScreen.swift b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawScreen.swift index d87e47f27..c85173a0a 100644 --- a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawScreen.swift +++ b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawScreen.swift @@ -56,37 +56,7 @@ struct WithdrawScreen: View { } .navigationTitle("Select Currency") .navigationBarTitleDisplayMode(.inline) - .navigationDestination(for: WithdrawNavigationPath.self) { path in - switch path { - case .intro: - WithdrawIntroScreen() - .dialog(item: $viewModel.dialogItem) - case .enterAmount: - WithdrawAmountScreen( - title: "Withdraw", - enteredAmount: $viewModel.enteredAmount, - subtitle: viewModel.amountSubtitle, - canProceed: viewModel.canProceedToAddress, - onProceed: viewModel.amountEnteredAction, - showsCurrencySelection: true - ) - .dialog(item: $viewModel.dialogItem) - case .enterAddress: - WithdrawAddressScreen( - promptCurrencyName: viewModel.kind?.destinationCurrencyName ?? "funds", - enteredAddress: $viewModel.enteredAddress, - destinationMetadata: viewModel.destinationMetadata, - acceptsTokenAccount: viewModel.kind?.acceptsTokenAccount ?? true, - canCompleteWithdrawal: viewModel.canCompleteWithdrawal, - onPasteFromClipboard: viewModel.pasteFromClipboardAction, - onNext: viewModel.addressEnteredAction - ) - .dialog(item: $viewModel.dialogItem) - case .confirmation: - WithdrawSummaryScreen(viewModel: viewModel) - .dialog(item: $viewModel.dialogItem) - } - } + .withdrawSubstepDestinations(viewModel: viewModel) .onAppear { viewModel.pushSubstep = { step in router.pushAny(step) @@ -100,3 +70,54 @@ struct WithdrawScreen: View { } } } + +extension View { + + /// Registers the four `WithdrawNavigationPath` substeps on the enclosing + /// `NavigationStack`. Shared between `WithdrawScreen` (Settings entry — + /// picker first) and `PreselectedWithdrawRoot` (Wallet entry — picker + /// skipped), so both code paths render identical substep screens. + func withdrawSubstepDestinations(viewModel: WithdrawViewModel) -> some View { + navigationDestination(for: WithdrawNavigationPath.self) { path in + WithdrawSubstepDestination(path: path, viewModel: viewModel) + } + } +} + +private struct WithdrawSubstepDestination: View { + + let path: WithdrawNavigationPath + @Bindable var viewModel: WithdrawViewModel + + var body: some View { + switch path { + case .intro: + WithdrawIntroScreen() + .dialog(item: $viewModel.dialogItem) + case .enterAmount: + WithdrawAmountScreen( + title: "Withdraw", + enteredAmount: $viewModel.enteredAmount, + subtitle: viewModel.amountSubtitle, + canProceed: viewModel.canProceedToAddress, + onProceed: viewModel.amountEnteredAction, + showsCurrencySelection: true + ) + .dialog(item: $viewModel.dialogItem) + case .enterAddress: + WithdrawAddressScreen( + promptCurrencyName: viewModel.kind?.destinationCurrencyName ?? "funds", + enteredAddress: $viewModel.enteredAddress, + destinationMetadata: viewModel.destinationMetadata, + acceptsTokenAccount: viewModel.kind?.acceptsTokenAccount ?? true, + canCompleteWithdrawal: viewModel.canCompleteWithdrawal, + onPasteFromClipboard: viewModel.pasteFromClipboardAction, + onNext: viewModel.addressEnteredAction + ) + .dialog(item: $viewModel.dialogItem) + case .confirmation: + WithdrawSummaryScreen(viewModel: viewModel) + .dialog(item: $viewModel.dialogItem) + } + } +} diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 3705aae0b..62c027520 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -238,11 +238,12 @@ final class SessionAuthenticator { userID: initializedAccount.userID ) - let walletConnection = WalletConnection(owner: owner, client: container.client) + let walletConnection = WalletConnection(owner: owner) return SessionContainer( session: session, database: database, + client: container.client, walletConnection: walletConnection, ratesController: ratesController, historyController: historyController, @@ -417,11 +418,13 @@ struct SessionContainer { let flipClient: FlipClient let onrampDeeplinkInbox: OnrampDeeplinkInbox let onrampCoordinator: OnrampCoordinator + let phantomCoordinator: PhantomCoordinator let appRouter: AppRouter init( session: Session, database: Database, + client: Client, walletConnection: WalletConnection, ratesController: RatesController, historyController: HistoryController, @@ -437,6 +440,11 @@ struct SessionContainer { self.flipClient = flipClient self.onrampDeeplinkInbox = OnrampDeeplinkInbox() self.onrampCoordinator = OnrampCoordinator(session: session, flipClient: flipClient) + self.phantomCoordinator = PhantomCoordinator( + walletConnection: walletConnection, + session: session, + client: client + ) self.appRouter = AppRouter() } @@ -449,6 +457,7 @@ struct SessionContainer { .environment(pushController) .environment(walletConnection) .environment(onrampCoordinator) + .environment(phantomCoordinator) .environment(onrampDeeplinkInbox) } diff --git a/Flipcash/Extensions/EnvironmentValues.swift b/Flipcash/Extensions/EnvironmentValues.swift index e4d00496e..cbf77f30e 100644 --- a/Flipcash/Extensions/EnvironmentValues.swift +++ b/Flipcash/Extensions/EnvironmentValues.swift @@ -48,4 +48,11 @@ extension EnvironmentValues { /// In this example, `ChildView` dismisses `ParentView`, even though it is /// not the currently presented view. @Entry var dismissParentContainer: () -> Void = {} + + /// Depth of the current sheet in `AppRouter.presentedSheets`. 0 = root sheet + /// (mounted at app root), 1 = first nested, etc. Driven by + /// `.appRouterNestedSheet` so each level can read its own depth without + /// the caller having to pass it explicitly. Default 0 is safe for any + /// view not inside a router-driven sheet. + @Entry var nestedSheetDepth: Int = 0 } diff --git a/Flipcash/UI/DialogItem+CashFlows.swift b/Flipcash/UI/DialogItem+CashFlows.swift index 61a594f8c..0492aa7bf 100644 --- a/Flipcash/UI/DialogItem+CashFlows.swift +++ b/Flipcash/UI/DialogItem+CashFlows.swift @@ -79,4 +79,19 @@ extension DialogItem { .cancel() } } + + /// Coinbase Onramp rejects orders below `OnrampCoordinator.minimumPurchaseUSD` + /// with a generic error; surface the constraint up-front instead of letting + /// the user round-trip to Apple Pay. `minimum` is the USD floor already + /// formatted in the user's selected display currency. + static func applePayMinimumPurchase(minimum: String) -> DialogItem { + .init( + style: .destructive, + title: "\(minimum) Minimum Purchase", + subtitle: "Please enter an amount of \(minimum) or higher", + dismissable: true + ) { + .okay(kind: .destructive) + } + } } diff --git a/FlipcashTests/Buy/BuyAmountViewModelTests.swift b/FlipcashTests/Buy/BuyAmountViewModelTests.swift new file mode 100644 index 000000000..55ef9c659 --- /dev/null +++ b/FlipcashTests/Buy/BuyAmountViewModelTests.swift @@ -0,0 +1,155 @@ +// +// BuyAmountViewModelTests.swift +// FlipcashTests +// +// Created by Raul Riera on 2026-05-12. +// + +import Foundation +import Testing +@testable import FlipcashCore +@testable import Flipcash + +@Suite("BuyAmountViewModel — USDF gate") +@MainActor +struct BuyAmountViewModelTests { + + // MARK: - Test fixtures + + /// 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 `dialogItem = .staleRate`. + private static func makeContainer(usdfQuarks: UInt64) async throws -> SessionContainer { + let holdings: [SessionContainer.Holding] = usdfQuarks == 0 + ? [] + : [.init(mint: MintMetadata.usdf, quarks: usdfQuarks)] + + let container = try SessionContainer.makeTest( + holdings: holdings, + limits: Limits( + sinceDate: .now, + fetchDate: .now, + sendLimits: [.usd: testSendLimit] + ) + ) + + // Force USD as the balance currency. Without this the rates controller + // reads `LocalDefaults.balanceCurrency`, which can be polluted by other + // suites that called `configureTestRates(balanceCurrency: .cad, ...)` — + // the persisted `.cad` then makes `currentPinnedState(for: .cad)` return + // nil here (no CAD pin seeded), routing through `.staleRate` instead + // of the picker. + container.ratesController.configureTestRates( + balanceCurrency: .usd, + rates: [Rate(fx: 1.0, currency: .usd)] + ) + + // Pin a fresh USD verified state so prepareSubmission() succeeds. + await container.ratesController.verifiedProtoService.saveRates([ + .freshRate(currencyCode: "USD", rate: 1.0) + ]) + + return container + } + + private static func makeViewModel( + mint: PublicKey = .usdf, + currencyName: String = "Jeffy", + container: SessionContainer + ) -> BuyAmountViewModel { + BuyAmountViewModel( + mint: mint, + currencyName: currencyName, + session: container.session, + ratesController: container.ratesController + ) + } + + // MARK: - Gate decision tests + + @Test("Sufficient USDF balance does not surface the funding picker") + func sufficientBalance_doesNotOpenPicker() async throws { + // $50 USDF (6 decimals) → balance covers $20 entry. + let container = try await Self.makeContainer(usdfQuarks: 50_000_000) + let viewModel = Self.makeViewModel(container: container) + let router = AppRouter() + router.present(.balance) + + viewModel.enteredAmount = "20" + await viewModel.amountEnteredAction(router: router) + + // Gate routed to reserve-funded buy, not the picker. The subsequent + // session.buy network call is out of scope for this unit test — + // here we only assert the gate decision. + #expect(viewModel.pendingOperation == nil) + } + + @Test( + "Balance below the entered amount opens the funding picker", + arguments: [ + (usdfQuarks: UInt64(0), enteredAmount: "1"), + (usdfQuarks: UInt64(5_000_000), enteredAmount: "20"), + ] + ) + func insufficientBalance_opensPicker(usdfQuarks: UInt64, enteredAmount: String) async throws { + let container = try await Self.makeContainer(usdfQuarks: usdfQuarks) + let viewModel = Self.makeViewModel(container: container) + let router = AppRouter() + router.present(.balance) + + viewModel.enteredAmount = enteredAmount + await viewModel.amountEnteredAction(router: router) + + let operation = try #require(viewModel.pendingOperation) + #expect(operation.displayAmount.nativeAmount.value > 0) + // No push fired — picker is a local sheet, not a stack destination. + #expect(router[.balance].count == 0) + } + + @Test("Pinned amount is carried into the PaymentOperation buy payload") + func pinPropagation() async throws { + let container = try await Self.makeContainer(usdfQuarks: 0) + let viewModel = Self.makeViewModel(container: container) + let router = AppRouter() + router.present(.balance) + + viewModel.enteredAmount = "10" + await viewModel.amountEnteredAction(router: router) + + let operation = try #require(viewModel.pendingOperation) + guard case .buy(let payload) = operation else { + Issue.record("Expected .buy operation, got \(operation)") + return + } + // Native USD amount round-trips through the pin into the payload. + #expect(payload.amount.nativeAmount.value == 10) + #expect(payload.amount.nativeAmount.currency == .usd) + } + + @Test("Empty entered amount does nothing on submit") + func emptyAmount_noop() async throws { + let container = try await Self.makeContainer(usdfQuarks: 50_000_000) + let viewModel = Self.makeViewModel(container: container) + let router = AppRouter() + router.present(.balance) + + viewModel.enteredAmount = "" + await viewModel.amountEnteredAction(router: router) + + #expect(viewModel.pendingOperation == nil) + #expect(router[.balance].count == 0) + #expect(viewModel.dialogItem == nil) + // Loading flicker on an empty submit would be a regression. + #expect(viewModel.actionButtonState == .normal) + } +} diff --git a/FlipcashTests/Buy/PurchaseMethodSheetTests.swift b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift new file mode 100644 index 000000000..009876623 --- /dev/null +++ b/FlipcashTests/Buy/PurchaseMethodSheetTests.swift @@ -0,0 +1,91 @@ +// +// PurchaseMethodSheetTests.swift +// FlipcashTests +// +// Created by Raul Riera on 2026-05-12. +// + +import Foundation +import Testing +@testable import FlipcashCore +@testable import Flipcash + +/// Runs serialized — both test cases mutate the `BetaFlags.shared` +/// singleton (UserDefaults-backed), so parallel execution with any other +/// suite that reads `enableCoinbase` would race on the global flag. +@Suite("PurchaseMethodSheet — visibility", .serialized) +@MainActor +struct PurchaseMethodSheetTests { + + /// Runs `body` with `BetaFlags.enableCoinbase` forced to the requested + /// state, restoring the original value (which is persisted to UserDefaults + /// by `BetaFlags.set`) when `body` returns. Without restoration the suite + /// leaks the disabled flag to whichever suite runs next. + private static func withCoinbaseBetaFlag(enabled: Bool, _ body: () throws -> R) rethrows -> R { + let original = BetaFlags.shared.hasEnabled(.enableCoinbase) + BetaFlags.shared.set(.enableCoinbase, enabled: enabled) + defer { BetaFlags.shared.set(.enableCoinbase, enabled: original) } + return try body() + } + + /// Builds a `SessionContainer` whose `userFlags.hasCoinbase` is `true`. + /// Tests that need Apple Pay visible reuse this fixture. + private static func makeContainerWithCoinbase() throws -> SessionContainer { + let container = try SessionContainer.makeTest(holdings: []) + container.session.userFlags = UserFlags( + isRegistered: true, + isStaff: false, + onrampProviders: [.coinbaseVirtual], + preferredOnrampProvider: .coinbaseVirtual, + minBuildNumber: 0, + billExchangeDataTimeout: nil, + newCurrencyPurchaseAmount: .zero(mint: .usdf), + newCurrencyFeeAmount: .zero(mint: .usdf), + withdrawalFeeAmount: TokenAmount(quarks: 0, mint: .usdf) + ) + return container + } + + @Test("Apple Pay row hidden when hasCoinbaseOnramp is false; remaining order is Phantom then Other Wallet") + func applePayHiddenWhenNoCoinbase() throws { + try Self.withCoinbaseBetaFlag(enabled: false) { + let container = try SessionContainer.makeTest(holdings: []) + // Default test session has no userFlags set, so hasCoinbase is false. + + let visible = PurchaseMethodSheet.visibleSources( + from: [.applePay, .phantom, .otherWallet], + session: container.session + ) + + #expect(visible == [.phantom, .otherWallet]) + } + } + + @Test("Apple Pay row visible and ordered first when hasCoinbaseOnramp is true; full order is Apple Pay → Phantom → Other Wallet") + func applePayVisibleAndFirst() throws { + try Self.withCoinbaseBetaFlag(enabled: false) { + let container = try Self.makeContainerWithCoinbase() + + let visible = PurchaseMethodSheet.visibleSources( + from: [.applePay, .phantom, .otherWallet], + session: container.session + ) + + #expect(visible == [.applePay, .phantom, .otherWallet]) + } + } + + @Test("Launch flow's `sources` array omits Other Wallet — picker reflects exactly that") + func launchSources_excludeOtherWallet() throws { + try Self.withCoinbaseBetaFlag(enabled: false) { + let container = try Self.makeContainerWithCoinbase() + + let visible = PurchaseMethodSheet.visibleSources( + from: [.applePay, .phantom], + session: container.session + ) + + #expect(visible == [.applePay, .phantom]) + } + } +} diff --git a/FlipcashTests/Concurrency/AppRouterStressTests.swift b/FlipcashTests/Concurrency/AppRouterStressTests.swift index 1ebedc610..12bc666b9 100644 --- a/FlipcashTests/Concurrency/AppRouterStressTests.swift +++ b/FlipcashTests/Concurrency/AppRouterStressTests.swift @@ -50,7 +50,9 @@ struct AppRouterStressTests { @Test("100 rounds across all sheet cases converge on empty state") func cyclingAllSheets_convergesOnEmptyState() { let router = AppRouter() - let sheets = AppRouter.Stack.allCases.map(\.sheet) + // `compactMap` skips nested-only stacks (`.buy`) — they can't be + // a root sheet, so they're outside this stress test's scope. + let sheets = AppRouter.Stack.allCases.compactMap(\.sheet) for i in 0..<100 { let sheet = sheets[i % sheets.count] diff --git a/FlipcashTests/CurrencyBuyViewModelTests.swift b/FlipcashTests/CurrencyBuyViewModelTests.swift deleted file mode 100644 index de5abc36f..000000000 --- a/FlipcashTests/CurrencyBuyViewModelTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// CurrencyBuyViewModelTests.swift -// FlipcashTests -// -// Created by Raul Riera on 2026-01-02. -// - -import Foundation -import Testing -import SwiftUI -import FlipcashUI -@testable import FlipcashCore -@testable import Flipcash - -@MainActor -struct CurrencyBuyViewModelTests { - - // MARK: - Test Helpers - - - /// CAD rate: 1 USD = 1.35 CAD - static let cadRate = Rate(fx: 1.35, currency: .cad) - - /// Helper to create a test view model with CAD as the selected currency. - /// Uses the mock SessionContainer which has no seeded balance. - static func createViewModel() -> CurrencyBuyViewModel { - let sessionContainer = SessionContainer.mock - - sessionContainer.ratesController.configureTestRates(balanceCurrency: .cad, rates: [cadRate]) - - return CurrencyBuyViewModel( - currencyPublicKey: .usdf, - currencyName: "USDF", - session: sessionContainer.session, - ratesController: sessionContainer.ratesController - ) - } - - /// Helper to create a view model backed by a real database seeded with USDF balance, - /// so `canPerformAction` can reach the display-limit check. - static func createViewModelWithBalance() throws -> CurrencyBuyViewModel { - // 10 USDF (10_000_000 quarks at 6 decimals) - let container = try SessionContainer.makeTest(holdings: [ - .init(mint: MintMetadata.usdf, quarks: 10_000_000) - ]) - - container.ratesController.configureTestRates(balanceCurrency: .cad, rates: [cadRate]) - - return CurrencyBuyViewModel( - currencyPublicKey: .jeffy, - currencyName: "Test", - session: container.session, - ratesController: container.ratesController - ) - } - - // MARK: - Initialization Tests - - - @Test - func testInitialization_DefaultValues() { - // Given/When: Creating a new view model - let viewModel = Self.createViewModel() - - // Then: Initial state should be correct - #expect(viewModel.actionButtonState == .normal) - #expect(viewModel.enteredAmount == "") - #expect(viewModel.dialogItem == nil) - #expect(viewModel.canPerformAction == false) - } - - // MARK: - Entered Fiat Direction Tests - - - @Test - func testEnteredFiat_WithCADEntry_NativeIsCAD_USDFValueIsUSD() throws { - // Given: A view model with 1 CAD entered - // Rate is 1.35 (1 USD = 1.35 CAD), so 1 CAD = ~0.74 USD usdf value - let viewModel = Self.createViewModel() - viewModel.enteredAmount = "1" - - // When: Getting the enteredFiat from the viewModel - let exchangedFiat = try #require(viewModel.enteredFiat) - - // Then: Native should be in CAD (the entry currency) - #expect(exchangedFiat.nativeAmount.currency == .cad) - - // Then: USDF value should be in USD (the base currency) - #expect(exchangedFiat.usdfValue.currency == .usd) - - // Then: Rate should match our configured CAD rate - #expect(exchangedFiat.currencyRate.currency == .cad) - #expect(exchangedFiat.currencyRate.fx == Self.cadRate.fx) - - // Then: The USD value should be less than the native CAD value - // because 1 CAD < 1 USD (1 CAD ≈ 0.74 USD at 1.35 rate) - #expect(exchangedFiat.usdfValue.value < exchangedFiat.nativeAmount.value) - } - - // MARK: - canPerformAction - - - @Test("canPerformAction is false when enteredAmount is empty") - func canPerformAction_emptyAmount_returnsFalse() { - let viewModel = Self.createViewModel() - - #expect(viewModel.canPerformAction == false) - } - - @Test("canPerformAction is true when a valid amount is within the display cap") - func canPerformAction_validAmount_returnsTrue() throws { - let viewModel = try Self.createViewModelWithBalance() - viewModel.enteredAmount = "1" - - #expect(viewModel.canPerformAction == true) - } -} diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift index 8de2e96fe..871ff4201 100644 --- a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -92,6 +92,9 @@ struct AppRouterCrossStackTests { (AppRouter.Destination.currencyCreationWizard, AppRouter.Stack.balance), (AppRouter.Destination.transactionHistory(.usdc), AppRouter.Stack.balance), (AppRouter.Destination.give(.usdc), AppRouter.Stack.balance), + (AppRouter.Destination.withdrawCurrency(.usdc), AppRouter.Stack.balance), + (AppRouter.Destination.usdcDepositEducation, AppRouter.Stack.balance), + (AppRouter.Destination.usdcDepositAddress, AppRouter.Stack.balance), (AppRouter.Destination.settingsMyAccount, AppRouter.Stack.settings), (AppRouter.Destination.settingsAdvancedFeatures, AppRouter.Stack.settings), (AppRouter.Destination.settingsAppSettings, AppRouter.Stack.settings), @@ -111,6 +114,24 @@ struct AppRouterCrossStackTests { #expect(destination.owningStack == expected) } + @Test( + "Destination.payload exposes the mint for mint-bearing cases", + arguments: [ + (AppRouter.Destination.withdrawCurrency(.usdc), PublicKey.usdc.base58 as String?), + (AppRouter.Destination.deposit(.usdc), PublicKey.usdc.base58 as String?), + (AppRouter.Destination.currencyInfo(.usdf), PublicKey.usdf.base58 as String?), + (AppRouter.Destination.usdcDepositEducation, nil), + (AppRouter.Destination.usdcDepositAddress, nil), + (AppRouter.Destination.withdraw, nil), + ] + ) + func destination_payloadIsCorrect( + _ destination: AppRouter.Destination, + expected: String? + ) { + #expect(destination.payload == expected) + } + @Test( "Stack maps to its sheet presentation", arguments: [ @@ -122,4 +143,9 @@ struct AppRouterCrossStackTests { func stack_mapsToSheet(_ stack: AppRouter.Stack, expected: AppRouter.SheetPresentation) { #expect(stack.sheet == expected) } + + @Test(".buy stack has no owning root sheet — it's nested-only") + func buyStack_sheet_isNil() { + #expect(AppRouter.Stack.buy.sheet == nil) + } } diff --git a/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift new file mode 100644 index 000000000..f0a1626d2 --- /dev/null +++ b/FlipcashTests/Navigation/AppRouterNestedSheetTests.swift @@ -0,0 +1,302 @@ +// +// AppRouterNestedSheetTests.swift +// FlipcashTests +// +// Created by Raul Riera on 2026-05-12. +// + +import SwiftUI +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("AppRouter Nested Sheets") +struct AppRouterNestedSheetTests { + + private static let mintA: PublicKey = .usdc + private static let mintB: PublicKey = .usdf + + // MARK: - State model + + @Test("presentedSheets starts empty") + func presentedSheets_startsEmpty() { + let router = AppRouter() + #expect(router.presentedSheets.isEmpty) + #expect(router.presentedSheet == nil) + #expect(router.rootSheet == nil) + } + + @Test("present sets root, topmost == root when no nesting") + func present_setsRoot() { + let router = AppRouter() + router.present(.balance) + #expect(router.presentedSheets == [.balance]) + #expect(router.presentedSheet == .balance) + #expect(router.rootSheet == .balance) + } + + @Test("presentNested appends on top of root") + func presentNested_appendsOnRoot() { + let router = AppRouter() + router.present(.balance) + + router.presentNested(.buy(Self.mintA)) + + #expect(router.presentedSheets == [.balance, .buy(Self.mintA)]) + #expect(router.presentedSheet == .buy(Self.mintA)) + #expect(router.rootSheet == .balance) + } + + @Test("presentNested with empty stack is a no-op") + func presentNested_onEmpty_isNoop() { + let router = AppRouter() + router.presentNested(.buy(Self.mintA)) + #expect(router.presentedSheets.isEmpty) + } + + @Test("presentNested idempotent when same sheet already on top") + func presentNested_idempotent_onSameTop() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.presentNested(.buy(Self.mintA)) + + #expect(router.presentedSheets == [.balance, .buy(Self.mintA)]) + } + + @Test("presentNested same case different payload swaps the top") + func presentNested_sameCaseDifferentPayload_swaps() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + router.push(.usdcDepositEducation) + + router.presentNested(.buy(Self.mintB)) + + // Same case different payload → swap (not stack). + #expect(router.presentedSheets == [.balance, .buy(Self.mintB)]) + // Displaced sheet's path is cleared because it landed in dismissedSheets + // and the new value sits in a different SheetPresentation hash bucket. + // Either way, the new top should be at root of the buy stack via + // the dismissed-path-clear contract (verified separately). + } + + @Test("presentNested same case different payload — no prior pushed content — swaps cleanly") + func presentNested_sameCaseDifferentPayload_noPriorPath_swaps() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.presentNested(.buy(Self.mintB)) + + #expect(router.presentedSheets == [.balance, .buy(Self.mintB)]) + #expect(router[.buy].isEmpty, "no path was set; the new top should sit at root of the buy stack") + } + + // MARK: - dismissSheet + + @Test("dismissSheet pops topmost when nested is up") + func dismissSheet_withNested_popsTopmost() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.dismissSheet() + + #expect(router.presentedSheets == [.balance]) + #expect(router.presentedSheet == .balance) + } + + @Test("dismissSheet pops root when only root remains") + func dismissSheet_onRootOnly_clearsAll() { + let router = AppRouter() + router.present(.balance) + + router.dismissSheet() + + #expect(router.presentedSheets.isEmpty) + #expect(router.presentedSheet == nil) + } + + @Test("dismissSheet sequence pops one level at a time") + func dismissSheet_sequence_popsLevels() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.dismissSheet() + #expect(router.presentedSheets == [.balance]) + + router.dismissSheet() + #expect(router.presentedSheets.isEmpty) + } + + // MARK: - present semantics with nested up + + @Test("present(.differentRoot) when nested is up clears everything and sets new root") + func present_differentRoot_clearsAll() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.present(.settings) + + #expect(router.presentedSheets == [.settings]) + } + + @Test("present(.sameRoot) when nested is up pops the nested and keeps root") + func present_sameRoot_popsNestedKeepsRoot() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(Self.mintA)) + router.presentNested(.buy(Self.mintA)) + + router.present(.balance) + + #expect(router.presentedSheets == [.balance]) + // Root path is preserved because root wasn't dismissed. + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(Self.mintA))) + } + + @Test("present(.differentRoot) when nested is up clears the new root's stale path") + func present_differentRoot_clearsNewRootPath() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(Self.mintA)) + router.dismissSheet() // .balance now in dismissedSheets + + router.present(.settings) + router.presentNested(.buy(Self.mintA)) + + // Now re-present .balance — should clear its stale path. + router.present(.balance) + + #expect(router.presentedSheets == [.balance]) + #expect(router[.balance].isEmpty, + "presenting a previously dismissed root after nesting still clears its path") + } + + // MARK: - Path clear on reopen at nested level + + @Test("dismissSheet + presentNested(.same) clears the nested sheet's path") + func dismissNested_thenPresentNestedSame_clearsPath() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + router.push(.usdcDepositEducation) + + router.dismissSheet() // Pop .buy(mintA); its path is still populated. + router.presentNested(.buy(Self.mintA)) + + #expect(router[.buy].isEmpty, + "re-opening a dismissed nested sheet must land at root") + } + + @Test("nested swipe-down + reopen still clears path") + func dismissNested_thenReopenAfterIntermediate_stillClearsPath() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + router.push(.usdcDepositEducation) + router.dismissSheet() // .buy dismissed + router.dismissSheet() // .balance dismissed + + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + #expect(router[.buy].isEmpty) + } + + // MARK: - dismissAll + + @Test("dismissAll clears every level") + func dismissAll_clearsEveryLevel() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.dismissAll() + + #expect(router.presentedSheets.isEmpty) + #expect(router.presentedSheet == nil) + } + + @Test("dismissAll marks every dismissed sheet for path clear on reopen") + func dismissAll_clearsPathsOnReopen() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(Self.mintA)) + router.presentNested(.buy(Self.mintA)) + router.push(.usdcDepositEducation) + + router.dismissAll() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + #expect(router[.balance].isEmpty) + #expect(router[.buy].isEmpty) + } + + // MARK: - navigate with nested up + + @Test("navigate(to:) when nested is up dismisses nested and sets target root") + func navigate_dismissesNestedAndSetsRoot() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.navigate(to: .settingsApplicationLogs) + + #expect(router.presentedSheets == [.settings]) + #expect(router[.settings] == AppRouter.navigationPath(.settingsApplicationLogs)) + } + + @Test("navigate(to:) on same-root destination while nested is up pops nested") + func navigate_sameRoot_popsNested() { + let router = AppRouter() + router.present(.balance) + router.push(.currencyInfo(Self.mintA)) + router.presentNested(.buy(Self.mintA)) + + router.navigate(to: .currencyInfo(Self.mintB)) + + #expect(router.presentedSheets == [.balance]) + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(Self.mintB))) + } + + // MARK: - Push lands on topmost + + @Test("push lands on the nested sheet's stack when nested is up") + func push_landsOnNestedStack() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.push(.usdcDepositEducation) + + #expect(router[.buy].count == 1, "pushes target the topmost stack") + #expect(router[.balance].isEmpty, "root stack stays clean") + } + + @Test("top-level Destination push while .buy is nested lands on .buy stack") + func push_topLevelDestination_whileBuyNested_landsOnBuyStack() { + let router = AppRouter() + router.present(.balance) + router.presentNested(.buy(Self.mintA)) + + router.push(.usdcDepositEducation) + router.push(.usdcDepositAddress) + + #expect(router[.buy] == AppRouter.navigationPath(.usdcDepositEducation, .usdcDepositAddress)) + #expect(router[.balance].isEmpty, "balance stack stays clean") + } + + // MARK: - Buy sheet wiring + + @Test(".buy(mint) sheet maps to .buy stack") + func buySheet_mapsToBuyStack() { + #expect(AppRouter.SheetPresentation.buy(Self.mintA).stack == .buy) + } +} diff --git a/FlipcashTests/Phantom/PhantomCoordinatorTests.swift b/FlipcashTests/Phantom/PhantomCoordinatorTests.swift new file mode 100644 index 000000000..1c8b9670a --- /dev/null +++ b/FlipcashTests/Phantom/PhantomCoordinatorTests.swift @@ -0,0 +1,172 @@ +// +// PhantomCoordinatorTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +/// Public-API coverage for `PhantomCoordinator`'s state machine. Tests that +/// drive the post-sign chain — simulation, server-notify, chain submit — +/// would require either yielding `DeeplinkEvent`s directly to the stream +/// (which crosses a `private` boundary on `WalletConnection`) or fabricating +/// fully-encrypted `transactionSigned` URLs. Both are deferred to a follow-up +/// that introduces protocol DI on the deeplink source. +@MainActor +@Suite("PhantomCoordinator — state machine") +struct PhantomCoordinatorTests { + + // MARK: - Fixtures + + private static func makeOperation() -> PaymentOperation { + .buy(.init( + mint: .usdc, + currencyName: "USDC", + amount: ExchangedFiat.compute( + onChainAmount: TokenAmount(quarks: 1_000_000, mint: .usdc), + rate: .oneToOne, + supplyQuarks: nil + ), + verifiedState: .stale(bonded: false) + )) + } + + private static func makeLaunchOperation() -> PaymentOperation { + let amount = ExchangedFiat.compute( + onChainAmount: TokenAmount(quarks: 5_000_000, mint: .usdf), + rate: .oneToOne, + supplyQuarks: nil + ) + return .launch(.init( + currencyName: "Jeffy", + total: amount, + launchAmount: amount, + launchFee: ExchangedFiat.compute( + onChainAmount: .zero(mint: .usdf), + rate: .oneToOne, + supplyQuarks: nil + ) + )) + } + + private static func makeCoordinator() -> PhantomCoordinator { + PhantomCoordinator( + walletConnection: .mock, + session: .mock, + client: Container.mock.client + ) + } + + // MARK: - State machine + + @Test("Initial state is .idle with no operation, no pending swap") + func initial_isIdle() { + let coordinator = Self.makeCoordinator() + #expect(coordinator.state == .idle) + #expect(coordinator.operation == nil) + #expect(coordinator.isAwaitingExternalSwap == false) + #expect(coordinator.processingState == .idle) + #expect(coordinator.isProcessingCancelled == false) + } + + @Test("start(_:) transitions to .connecting and captures the operation") + func start_transitionsToConnecting() { + let coordinator = Self.makeCoordinator() + let operation = Self.makeOperation() + + coordinator.start(operation) + + #expect(coordinator.state == .connecting) + #expect(coordinator.operation == operation) + } + + @Test("start(.buy) clears a leftover launchHandler from a prior wizard flow") + func start_buyClearsStaleLaunchHandler() { + let coordinator = Self.makeCoordinator() + coordinator.launchHandler = { _, _ in .buyExisting(swapId: .generate()) } + + coordinator.start(Self.makeOperation()) + + #expect(coordinator.launchHandler == nil) + } + + @Test("start(.launch) preserves a launchHandler set by the caller") + func start_launchPreservesHandler() { + let coordinator = Self.makeCoordinator() + coordinator.launchHandler = { _, _ in .buyExisting(swapId: .generate()) } + + coordinator.start(Self.makeLaunchOperation()) + + // Handler stays — the wizard sets it right before starting the + // launch flow and the coordinator must invoke it after the user signs. + #expect(coordinator.launchHandler != nil) + } + + @Test("confirm() is a no-op outside .awaitingConfirm") + func confirm_noOpFromWrongState() { + let coordinator = Self.makeCoordinator() + // No start() called — state is .idle. + coordinator.confirm() + #expect(coordinator.state == .idle) + + coordinator.start(Self.makeOperation()) + #expect(coordinator.state == .connecting) + // Calling confirm during the connect handshake should not advance. + coordinator.confirm() + #expect(coordinator.state == .connecting) + } + + @Test("cancel() resets pre-signing state to .idle") + func cancel_resetsToIdle() { + let coordinator = Self.makeCoordinator() + coordinator.launchHandler = { _, _ in .buyExisting(swapId: .generate()) } + coordinator.start(Self.makeLaunchOperation()) + + coordinator.cancel() + + #expect(coordinator.state == .idle) + #expect(coordinator.operation == nil) + #expect(coordinator.launchHandler == nil) + #expect(coordinator.isAwaitingExternalSwap == false) + } + + @Test("dismissProcessing() clears processingState and pre-signing state") + func dismissProcessing_resetsAll() { + let coordinator = Self.makeCoordinator() + coordinator.launchHandler = { _, _ in .buyExisting(swapId: .generate()) } + coordinator.start(Self.makeLaunchOperation()) + + coordinator.dismissProcessing() + + #expect(coordinator.state == .idle) + #expect(coordinator.operation == nil) + #expect(coordinator.launchHandler == nil) + #expect(coordinator.processingState == .idle) + } + + @Test("Setting processing = nil while idle is a no-op") + func processing_nilWhileIdle_noOp() { + let coordinator = Self.makeCoordinator() + coordinator.processing = nil // would crash if it tried to mutate + #expect(coordinator.processingState == .idle) + #expect(coordinator.processing == nil) + } + + @Test("Setting launchProcessing = nil while idle is a no-op") + func launchProcessing_nilWhileIdle_noOp() { + let coordinator = Self.makeCoordinator() + coordinator.launchProcessing = nil + #expect(coordinator.processingState == .idle) + #expect(coordinator.launchProcessing == nil) + } + + @Test("isAwaitingExternalSwap is false when no pending swap") + func isAwaitingExternalSwap_falseInitially() { + let coordinator = Self.makeCoordinator() + #expect(coordinator.isAwaitingExternalSwap == false) + coordinator.start(Self.makeOperation()) + #expect(coordinator.isAwaitingExternalSwap == false) // not until confirm + } +} diff --git a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift index 118effce3..f2ac9e00e 100644 --- a/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift +++ b/FlipcashTests/Regressions/Regression_native_amount_mismatch.swift @@ -4,14 +4,15 @@ // // Regression coverage for the "native amount does not match sell amount" // server error. Invariants: -// - `prepareSubmission` computes quarks against the pinned rate (and supply +// - Submission paths compute quarks against the pinned rate (and supply // where applicable), not the live cache. -// - When no fresh pin is cached, `prepareSubmission` returns nil so submit -// bails with a dialog instead of submitting an intent the server rejects. +// - When no fresh pin is cached, the submission path bails with a dialog +// instead of submitting an intent the server rejects. // import Foundation import Testing +import FlipcashUI @testable import Flipcash @testable import FlipcashCore @@ -24,10 +25,23 @@ struct Regression_native_amount_mismatch { // MARK: - Scenario D (buy) - @Test("Scenario D (buy): prepareSubmission computes quarks from the PINNED rate, not the live cache") - func scenarioD_buyPrepareSubmissionUsesPinnedRate() async throws { + @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. - let sessionContainer = SessionContainer.mock + // Zero USDF balance so the picker path is taken and the pinned amount surfaces in + // `pendingMethodSelection` for inspection. + 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) + )] + ) + ) sessionContainer.ratesController.configureTestRates( balanceCurrency: .cad, rates: [Rate(fx: 1.37, currency: .cad)] @@ -36,22 +50,30 @@ struct Regression_native_amount_mismatch { .freshRate(currencyCode: "CAD", rate: 1.35) ]) - let vm = CurrencyBuyViewModel( - currencyPublicKey: .usdf, + let vm = BuyAmountViewModel( + mint: .usdf, currencyName: "USDF", session: sessionContainer.session, ratesController: sessionContainer.ratesController ) vm.enteredAmount = "1" - let submission = try #require(await vm.prepareSubmission()) + let router = AppRouter() + router.present(.balance) + await vm.amountEnteredAction(router: router) + + let operation = try #require(vm.pendingOperation) + guard case .buy(let payload) = operation else { + Issue.record("Expected .buy operation, got \(operation)") + return + } // $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(payload.amount.onChainAmount.quarks == 740_741) + #expect(payload.amount.currencyRate.fx == Decimal(1.35)) + #expect(payload.verifiedState.exchangeRate == 1.35) } // MARK: - Scenario D (sell) @@ -130,8 +152,8 @@ struct Regression_native_amount_mismatch { // MARK: - Scenario E (buy) - @Test("Scenario E (buy): prepareSubmission returns nil when no fresh pin is cached") - func scenarioE_buyPrepareSubmissionReturnsNilWhenNoPin() async { + @Test("Scenario E (buy): amountEnteredAction surfaces .staleRate when no fresh pin is cached") + func scenarioE_buyAmountEnteredActionSurfacesStaleRateWhenNoPin() async { // Live rate is configured, but nothing is seeded in the verified proto // service — the submit path has no pin to use and must bail. let sessionContainer = SessionContainer.mock @@ -140,17 +162,20 @@ struct Regression_native_amount_mismatch { rates: [Rate(fx: 1.35, currency: .cad)] ) - let vm = CurrencyBuyViewModel( - currencyPublicKey: .usdf, + let vm = BuyAmountViewModel( + mint: .usdf, currencyName: "USDF", session: sessionContainer.session, ratesController: sessionContainer.ratesController ) vm.enteredAmount = "1" - let submission = await vm.prepareSubmission() + let router = AppRouter() + router.present(.balance) + await vm.amountEnteredAction(router: router) - #expect(submission == nil) + #expect(vm.pendingOperation == nil) + #expect(vm.dialogItem?.title == DialogItem.staleRate.title) } // MARK: - Scenario E (sell) diff --git a/FlipcashTests/SessionTests.swift b/FlipcashTests/SessionTests.swift index cc70c4c84..df6c5b3b8 100644 --- a/FlipcashTests/SessionTests.swift +++ b/FlipcashTests/SessionTests.swift @@ -554,6 +554,30 @@ struct SessionSellVerifiedStateTests { // MARK: - +@MainActor +@Suite("Session.balances(for:) USDF inclusion") +struct SessionBalancesUSDFInclusionTests { + + @Test("USDF appears in balances(for:) even with zero quarks — pins the BalanceScreen normalization invariant") + func balances_includesUSDF_atZero() throws { + let container = try SessionContainer.makeTest(holdings: [ + .init(mint: .usdf, quarks: 0), + .init( + mint: .makeLaunchpad( + address: .jeffy, + supplyFromBonding: 1_000_000 * 10_000_000_000 + ), + quarks: 10 * 10_000_000_000 + ), + ]) + + let listed = container.session.balances(for: .oneToOne) + let mints = listed.map(\.stored.mint) + #expect(mints.contains(.usdf), "USDF must appear regardless of zero quarks") + #expect(mints.contains(.jeffy), "non-USDF balances continue to appear") + } +} + @MainActor @Suite("Session.withdraw verified state") struct SessionWithdrawVerifiedStateTests { diff --git a/FlipcashTests/TestSupport/Mocks.swift b/FlipcashTests/TestSupport/Mocks.swift index 433b7c918..e716f20cf 100644 --- a/FlipcashTests/TestSupport/Mocks.swift +++ b/FlipcashTests/TestSupport/Mocks.swift @@ -96,6 +96,7 @@ extension SessionContainer { return .init( session: session, database: database, + client: Container.mock.client, walletConnection: .mock, ratesController: ratesController, historyController: historyController, diff --git a/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift b/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift index 862be692c..318831acd 100644 --- a/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift +++ b/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift @@ -23,10 +23,15 @@ extension SessionContainer { /// at construction, so the returned container's `session.balances` /// reflects the seed on the first access. /// + /// Pass `limits` to also seed `session.limits` — useful for view + /// models that gate on `sendLimitFor(currency:)` (the limits + /// Updateable also loads at Session init, so this must run before + /// the Session is constructed). + /// /// Each call produces an independent database file so tests don't /// share balance state. @MainActor - static func makeTest(holdings: [Holding]) throws -> SessionContainer { + static func makeTest(holdings: [Holding], limits: Limits? = nil) throws -> SessionContainer { let database = try Database( url: URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("give-test-\(UUID().uuidString).sqlite") @@ -45,6 +50,10 @@ extension SessionContainer { } } + if let limits { + try database.insertLimits(limits) + } + let ratesController = RatesController(container: .mock, database: database) let session = Session( container: .mock, @@ -63,6 +72,7 @@ extension SessionContainer { return SessionContainer( session: session, database: database, + client: Container.mock.client, walletConnection: .mock, ratesController: ratesController, historyController: .mock, diff --git a/FlipcashTests/WalletConnectionStateTests.swift b/FlipcashTests/WalletConnectionStateTests.swift deleted file mode 100644 index 4c9e64ea5..000000000 --- a/FlipcashTests/WalletConnectionStateTests.swift +++ /dev/null @@ -1,382 +0,0 @@ -// -// WalletConnectionStateTests.swift -// FlipcashTests -// - -import Foundation -import Testing -import FlipcashCore -@testable import Flipcash - -@MainActor -@Suite("WalletConnection state machine") -struct WalletConnectionStateTests { - - nonisolated private static let buyingContext = ExternalSwapProcessing( - swapId: .generate(), - currencyName: "Test Coin", - amount: ExchangedFiat.mockOne - ) - - nonisolated private static let launchingContext = ExternalLaunchProcessing( - swapId: .generate(), - launchedMint: .jeffy, - currencyName: "New Coin", - amount: ExchangedFiat.mockOne - ) - - private func makeConnection(rpc: any SolanaRPC = SolanaJSONRPCClient()) -> WalletConnection { - WalletConnection(owner: .mock, client: .mock, rpc: rpc) - } - - @Test("`.idle` reports no active processing") - func idleReportsNoProcessing() { - let conn = makeConnection() - #expect(conn.state == .idle) - #expect(conn.processing == nil) - #expect(conn.launchProcessing == nil) - #expect(conn.isProcessingCancelled == false) - } - - @Test("`.buying` exposes context via `processing`, not `launchProcessing`") - func buyingExposesProcessing() { - let conn = makeConnection() - conn.state = .buying(Self.buyingContext, isFailed: false) - #expect(conn.processing == Self.buyingContext) - #expect(conn.launchProcessing == nil) - #expect(conn.isProcessingCancelled == false) - } - - @Test("`.launching` exposes context via `launchProcessing`, not `processing`") - func launchingExposesLaunchProcessing() { - let conn = makeConnection() - conn.state = .launching(Self.launchingContext, isFailed: false) - #expect(conn.launchProcessing == Self.launchingContext) - #expect(conn.processing == nil) - #expect(conn.isProcessingCancelled == false) - } - - @Test( - "`isFailed: true` surfaces through `isProcessingCancelled`, context preserved", - arguments: [ - WalletProcessingState.buying(buyingContext, isFailed: true), - WalletProcessingState.launching(launchingContext, isFailed: true), - ] - ) - func failedStateFlipsCancelledFlag(initial: WalletProcessingState) { - let conn = makeConnection() - conn.state = initial - #expect(conn.isProcessingCancelled == true) - #expect(conn.state == initial) - } - - @Test("Setting `processing = nil` transitions `.buying` to `.idle`") - func nillingProcessingWhileBuyingResetsToIdle() { - let conn = makeConnection() - conn.state = .buying(Self.buyingContext, isFailed: false) - conn.processing = nil - #expect(conn.state == .idle) - } - - @Test("Setting `processing = nil` while `.launching` is a no-op") - func nillingProcessingWhileLaunchingIsNoOp() { - let conn = makeConnection() - let initial = WalletProcessingState.launching(Self.launchingContext, isFailed: false) - conn.state = initial - conn.processing = nil - #expect(conn.state == initial) - } - - @Test("Setting `launchProcessing = nil` transitions `.launching` to `.idle`") - func nillingLaunchProcessingWhileLaunchingResetsToIdle() { - let conn = makeConnection() - conn.state = .launching(Self.launchingContext, isFailed: false) - conn.launchProcessing = nil - #expect(conn.state == .idle) - } - - @Test("Setting `launchProcessing = nil` while `.buying` is a no-op") - func nillingLaunchProcessingWhileBuyingIsNoOp() { - let conn = makeConnection() - let initial = WalletProcessingState.buying(Self.buyingContext, isFailed: false) - conn.state = initial - conn.launchProcessing = nil - #expect(conn.state == initial) - } - - @Test( - "`dismissProcessing()` resets to `.idle` from any active state", - arguments: [ - WalletProcessingState.buying(buyingContext, isFailed: false), - WalletProcessingState.buying(buyingContext, isFailed: true), - WalletProcessingState.launching(launchingContext, isFailed: false), - WalletProcessingState.launching(launchingContext, isFailed: true), - ] - ) - func dismissProcessingResetsActiveStates(initial: WalletProcessingState) { - let conn = makeConnection() - conn.state = initial - conn.dismissProcessing() - #expect(conn.state == .idle) - } - - @Test("`dismissProcessing()` clears `dialogItem` even when `.idle`") - func dismissProcessingClearsDialog() { - let conn = makeConnection() - conn.dialogItem = .init( - style: .destructive, - title: "T", - subtitle: "S", - dismissable: true - ) { .okay(kind: .destructive) } - conn.dismissProcessing() - #expect(conn.dialogItem == nil) - #expect(conn.state == .idle) - } - - // MARK: - Simulation outcome - - - @Test("Preflight rejection returns `.blocked` with a user-facing dialog") - func simulationRejectionBlocks() async { - let conn = makeConnection(rpc: StubRPC(simulate: .failure( - SolanaRPCError.transactionSimulationError(logs: [ - "Program 11111111 invoke [1]", - "Transfer: insufficient lamports", - "Program 11111111 failed: custom program error: 0x1", - ]) - ))) - - let outcome = await conn.simulateSignedTransaction("dummyBase64", swapMetadata: [:]) - - switch outcome { - case .proceed: - Issue.record("Expected .blocked for simulation rejection") - case .blocked(let dialog): - #expect(dialog.title == "Transaction Failed") - #expect(dialog.subtitle?.contains("wouldn't accept") == true) - } - } - - @Test("Non-simulation errors pass through as `.proceed`") - func simulationTransportErrorProceeds() async { - let conn = makeConnection(rpc: StubRPC(simulate: .failure(URLError(.timedOut)))) - - let outcome = await conn.simulateSignedTransaction("dummyBase64", swapMetadata: [:]) - - if case .blocked = outcome { - Issue.record("Transport errors must not block — the RPC may just be flaky") - } - } - - @Test("Successful simulation returns `.proceed`") - func simulationSuccessProceeds() async { - let conn = makeConnection(rpc: StubRPC(simulate: .succeeds)) - - let outcome = await conn.simulateSignedTransaction("dummyBase64", swapMetadata: [:]) - - if case .blocked = outcome { - Issue.record("Successful simulation must proceed to chain submission") - } - } - - // MARK: - completeSwap full flow - - - @Test( - "Any RPC-reported preflight rejection halts the flow before server + chain submit", - arguments: [ - SolanaRPCError.transactionSimulationError(logs: ["insufficient funds"]), - SolanaRPCError.responseError(SolanaRPCResponseError( - code: -32002, - message: "insufficient funds", - data: SolanaRPCResponseError.Payload(logs: ["Transfer: insufficient lamports"]) - )), - ] - ) - func completeSwap_preflightRejectionHaltsFlow(_ rpcError: SolanaRPCError) async { - let rpc = StubRPC( - simulate: .failure(rpcError), - send: .failure(TestError.shouldNotBeCalled) - ) - let conn = makeConnection(rpc: rpc) - let pending = Self.makePendingSwap(onCompleted: { _, _ in - Issue.record("Server notification must not run after preflight rejection") - throw TestError.shouldNotBeCalled - }) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: pending).value - - #expect(conn.state == .idle) - #expect(conn.dialogItem?.title == "Transaction Failed") - } - - @Test("Chain submit failure flips `.buying` to isFailed: true") - func completeSwap_chainSubmitFailureMarksFailed() async { - let swapId = SwapId.generate() - let rpc = StubRPC(simulate: .succeeds, send: .failure(URLError(.networkConnectionLost))) - let conn = makeConnection(rpc: rpc) - let pending = Self.makePendingSwap( - fundingSwapId: swapId, - onCompleted: { _, _ in .buyExisting(swapId: swapId) } - ) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: pending).value - - guard case .buying(_, let isFailed) = conn.state else { - Issue.record("Expected state to remain `.buying` so the processing screen can surface the failure") - return - } - #expect(isFailed == true) - } - - @Test("Chain submit success for `.buyExisting` leaves `.buying` clean") - func completeSwap_buyExistingSuccessStaysBuying() async { - let swapId = SwapId.generate() - let rpc = StubRPC(simulate: .succeeds, send: .succeeds) - let conn = makeConnection(rpc: rpc) - let pending = Self.makePendingSwap( - fundingSwapId: swapId, - onCompleted: { _, _ in .buyExisting(swapId: swapId) } - ) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: pending).value - - guard case .buying(let ctx, let isFailed) = conn.state else { - Issue.record("Expected `.buying` state after successful buy-existing flow") - return - } - #expect(isFailed == false) - #expect(ctx.swapId == swapId) - } - - @Test("Chain submit success for `.launch` transitions state to `.launching`") - func completeSwap_launchSuccessTransitionsToLaunching() async { - let fundingId = SwapId.generate() - let buyId = SwapId.generate() - let mint = PublicKey.mock - let rpc = StubRPC(simulate: .succeeds, send: .succeeds) - let conn = makeConnection(rpc: rpc) - let pending = Self.makePendingSwap( - fundingSwapId: fundingId, - onCompleted: { _, _ in .launch(swapId: buyId, mint: mint) } - ) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: pending).value - - guard case .launching(let ctx, let isFailed) = conn.state else { - Issue.record("Expected `.launching` state after successful launch flow") - return - } - #expect(isFailed == false) - #expect(ctx.swapId == buyId) - #expect(ctx.launchedMint == mint) - } - - @Test("Server notification failure resets to `.idle` without submitting") - func completeSwap_serverNotificationFailureResetsToIdle() async { - let rpc = StubRPC(simulate: .succeeds, send: .failure(TestError.shouldNotBeCalled)) - let conn = makeConnection(rpc: rpc) - let pending = Self.makePendingSwap(onCompleted: { _, _ in - throw TestError.serverRejected - }) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: pending).value - - #expect(conn.state == .idle) - } - - @Test("Missing pending is a no-op") - func completeSwap_nilPendingIsNoOp() async { - let rpc = StubRPC( - simulate: .failure(TestError.shouldNotBeCalled), - send: .failure(TestError.shouldNotBeCalled) - ) - let conn = makeConnection(rpc: rpc) - - await conn.completeSwap(signedTx: Self.validSignedTxBase58(), pending: nil).value - - #expect(conn.state == .idle) - #expect(conn.dialogItem == nil) - } - - // MARK: - Helpers - - - private static func makePendingSwap( - fundingSwapId: SwapId = .generate(), - displayName: String = "Test Coin", - amount: ExchangedFiat = .mockOne, - onCompleted: @escaping @MainActor @Sendable (FlipcashCore.Signature, ExchangedFiat) async throws -> SignedSwapResult - ) -> WalletConnection.PendingSwap { - WalletConnection.PendingSwap( - fundingSwapId: fundingSwapId, - amount: amount, - displayName: displayName, - onCompleted: onCompleted - ) - } - - /// Produces base58 bytes that round-trip cleanly through - /// `SolanaTransaction(data:)` — the decode gate inside `didSignTransaction`. - /// The signatures are zero-filled, which is fine because the production - /// flow only reads `tx.identifier` (which maps to `signatures[0]`) and - /// forwards it to the server-notify closure. - private static func validSignedTxBase58() -> String { - let tx = SolanaTransaction( - payer: PublicKey.mock, - recentBlockhash: nil, - instructions: [] as [Instruction] - ) - return Base58.fromBytes([UInt8](tx.encode())) - } -} - -// MARK: - StubRPC - - -/// Minimal `SolanaRPC` for tests. Both `simulateTransaction` and -/// `sendTransaction` are configurable; `getLatestBlockhash` traps because no -/// current test exercises paths that fetch a blockhash. -private struct StubRPC: SolanaRPC { - enum SimulateBehavior { - case succeeds - case failure(Error) - } - - enum SendBehavior { - case succeeds - case failure(Error) - } - - let simulate: SimulateBehavior - let send: SendBehavior - - init(simulate: SimulateBehavior, send: SendBehavior = .failure(TestError.shouldNotBeCalled)) { - self.simulate = simulate - self.send = send - } - - func getLatestBlockhash(commitment: SolanaCommitment) async throws -> Hash { - fatalError("getLatestBlockhash not stubbed") - } - - func sendTransaction(_ base64Transaction: String, configuration: SolanaSendTransactionConfig) async throws -> Signature { - switch send { - case .succeeds: - return .mock - case .failure(let error): - throw error - } - } - - func simulateTransaction(_ base64Transaction: String, configuration: SolanaSimulateTransactionConfig) async throws -> SolanaSimulationResult { - switch simulate { - case .succeeds: - return SolanaSimulationResult(err: nil, logs: []) - case .failure(let error): - throw error - } - } -} - -private enum TestError: Error { - case shouldNotBeCalled - case serverRejected -} diff --git a/FlipcashTests/WithdrawViewModelTests.swift b/FlipcashTests/WithdrawViewModelTests.swift index 8390e215d..34301db23 100644 --- a/FlipcashTests/WithdrawViewModelTests.swift +++ b/FlipcashTests/WithdrawViewModelTests.swift @@ -412,6 +412,18 @@ struct WithdrawViewModelTests { #expect(pushed == [.enterAmount]) } + @Test("selectCurrency before pushSubstep is wired sets kind without pushing — PreselectedWithdrawRoot contract") + func selectCurrency_withoutPushSubstepWired_setsKindWithoutPushing() { + let viewModel = WithdrawViewModelTestHelpers.createViewModel() + // Intentionally do NOT wire pushSubstep — mirrors PreselectedWithdrawRoot.init + // calling selectCurrency before .onAppear wires the closure. + let balance = WithdrawViewModelTestHelpers.createExchangedBalance(mint: .usdf) + + viewModel.selectCurrency(balance) + + #expect(viewModel.kind == .usdfToUsdc(balance)) + } + // MARK: - prepareSubmission pin-at-compute @Test("prepareSubmission (USDF→USDC) computes quarks from the PINNED rate, not the live cache") diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Checkmark.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Checkmark.svg new file mode 100644 index 000000000..daf4249e7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Checkmark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Contents.json new file mode 100644 index 000000000..57dc0f049 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Checkmark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Checkmark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Contents.json new file mode 100644 index 000000000..92376f1cb --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Flipcash.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Flipcash.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Flipcash.svg new file mode 100644 index 000000000..d5d6431ce --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Flipcash.imageset/Flipcash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Contents.json new file mode 100644 index 000000000..be1753a03 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Phantom.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Phantom.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Phantom.svg new file mode 100644 index 000000000..3da180aba --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Phantom.imageset/Phantom.svg @@ -0,0 +1,4 @@ + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Contents.json new file mode 100644 index 000000000..c0aebd1bb --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Solana.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Solana.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Solana.svg new file mode 100644 index 000000000..a9ede1bad --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/Solana.imageset/Solana.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/Contents.json new file mode 100644 index 000000000..b36da8854 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "USDC.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/USDC.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/USDC.svg new file mode 100644 index 000000000..dd457affd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/buy/USDC.imageset/USDC.svg @@ -0,0 +1,4 @@ + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index cbfa2312f..b6f6e0024 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -136,6 +136,15 @@ public enum Asset: String, Sendable { case phantom case solanaUSDC case downloadCircle + + // Buy / Withdraw / Deposit hero icons. Namespaced under `buy/` in + // `UI.xcassets` so SVG variants stay grouped; the raw value carries the + // path so call sites can use `Image.asset(.buyPhantom)` etc. + case buyPhantom = "buy/Phantom" + case buyFlipcash = "buy/Flipcash" + case buyUSDC = "buy/USDC" + case buySolana = "buy/Solana" + case buyCheckmark = "buy/Checkmark" // Flipchat diff --git a/FlipcashUI/Sources/FlipcashUI/Views/BadgedIcon.swift b/FlipcashUI/Sources/FlipcashUI/Views/BadgedIcon.swift new file mode 100644 index 000000000..8a17684f9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/BadgedIcon.swift @@ -0,0 +1,51 @@ +// +// BadgedIcon.swift +// FlipcashUI +// + +import SwiftUI + +/// Round/rounded-square icon with an optional small badge anchored to the +/// bottom-trailing corner. Used by buy/withdraw hero graphics where a base +/// asset (USDC, Phantom) gets paired with a network/state marker (Solana hex, +/// checkmark) without baking the badge into the parent SVG. +public struct BadgedIcon: View { + private let icon: Image + private let badge: Image? + private let size: CGFloat = 100 + private let badgeSize: CGFloat = 38 + private let badgeOffset: CGPoint = CGPoint(x: 8, y: 8) + + public init( + icon: Image, + badge: Image? = nil, + ) { + self.icon = icon + self.badge = badge + } + + public var body: some View { + icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + .overlay(alignment: .bottomTrailing) { + if let badge { + badge + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: badgeSize, height: badgeSize) + .offset(x: badgeOffset.x, y: badgeOffset.y) + } + } + } +} + +#Preview { + BadgedIcon(icon: Image.asset(.buyPhantom)) + + BadgedIcon( + icon: Image.asset(.buyUSDC), + badge: Image.asset(.buySolana) + ) +} diff --git a/FlipcashUITests/Regression/BuyDepositRegressionTests.swift b/FlipcashUITests/Regression/BuyDepositRegressionTests.swift new file mode 100644 index 000000000..8e19a10a3 --- /dev/null +++ b/FlipcashUITests/Regression/BuyDepositRegressionTests.swift @@ -0,0 +1,61 @@ +// +// BuyDepositRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Regression test for the "Other Wallet" (direct USDC deposit) funding +/// path. Exercises the in-app flow end-to-end: +/// +/// - The buy nested sheet opens on top of CurrencyInfoScreen. +/// - An amount above the USDF balance routes to `PurchaseMethodSheet`. +/// - Selecting "Other Wallet" pushes `USDCDepositEducationScreen`. +/// - Tapping Next pushes `USDCDepositAddressScreen` with the Copy Address +/// button hittable. The address itself is derived from the session's +/// owner key — its exact value isn't asserted, just that the CTA renders. +/// +/// **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 testDepositFlow_otherWallet_showsAddressScreen() { + let wallet = WalletScreen(app: app) + let currencyInfo = CurrencyInfoUIScreen(app: app) + let amountEntry = AmountEntryScreen(app: app) + let purchaseMethods = PurchaseMethodSheet(app: app) + let depositEducation = 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 a high amount so the USDF gate fails and the picker shows. + amountEntry.enterPickerTriggeringAmount() + waitUntilHittableAndTap(amountEntry.buyActionButton) + + // Picker → Other Wallet → USDCDepositEducationScreen → Next. + purchaseMethods.assertReached() + purchaseMethods.selectOtherWallet(from: self) + + depositEducation.assertReached() + depositEducation.tapNext(from: self) + + // Address screen renders with the Copy Address CTA. The address + // value depends on the per-user PDA derivation and is not asserted. + depositAddress.assertReached() + XCTAssertTrue( + depositAddress.copyAddressButton.isHittable, + "Expected the Copy Address CTA to be hittable" + ) + } +} diff --git a/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift b/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift new file mode 100644 index 000000000..017905067 --- /dev/null +++ b/FlipcashUITests/Regression/BuyPhantomRegressionTests.swift @@ -0,0 +1,57 @@ +// +// BuyPhantomRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Regression test for the Phantom funding path. 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 `PurchaseMethodSheet`. +/// - Selecting Phantom pushes `PhantomEducationScreen` (the picker no longer +/// triggers a connect deeplink — that happens when the user taps the +/// Connect CTA on the education screen). +/// - The Connect CTA is hittable. +/// +/// The test stops short of the Connect tap. Driving the actual Phantom +/// callback is out of scope for the local simulator without a real Phantom +/// install. +/// +/// **Prerequisites:** +/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` +final class BuyPhantomRegressionTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + func testPhantomFlow_newAccount_showsEducationScreen() { + let wallet = WalletScreen(app: app) + let currencyInfo = CurrencyInfoUIScreen(app: app) + let amountEntry = AmountEntryScreen(app: app) + let purchaseMethods = PurchaseMethodSheet(app: app) + let phantomEducation = PhantomEducationScreen(app: app) + + assertMainScreenReached() + + // Navigate: Main → Wallet → first currency → CurrencyInfoScreen → Buy + wallet.open(from: self) + wallet.selectFirstCurrency() + currencyInfo.assertReached() + waitAndTap(currencyInfo.buyButton) + + // Enter a high amount so the USDF gate fails and the picker shows. + amountEntry.enterPickerTriggeringAmount() + waitUntilHittableAndTap(amountEntry.buyActionButton) + + // Picker → Phantom → PhantomEducationScreen (no saved session). + purchaseMethods.assertReached() + purchaseMethods.selectPhantom(from: self) + + phantomEducation.assertReached() + XCTAssertTrue( + phantomEducation.connectButton.isHittable, + "Expected the Connect CTA to be hittable on the education screen" + ) + } +} diff --git a/FlipcashUITests/Regression/BuyReservesRegressionTests.swift b/FlipcashUITests/Regression/BuyReservesRegressionTests.swift new file mode 100644 index 000000000..f97dd6c02 --- /dev/null +++ b/FlipcashUITests/Regression/BuyReservesRegressionTests.swift @@ -0,0 +1,67 @@ +// +// BuyReservesRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Regression test for the full buy flow using the user's USDF reserve as +/// the funding source. 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 (no PurchaseMethodSheet appears). +/// - After OK on the processing screen, the user lands back on +/// CurrencyInfoScreen — not the Wallet root, not the Scanner. +/// +/// **Prerequisites:** +/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` +/// - The test account must have non-zero USDF reserves +/// - 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 BuyReservesRegressionTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + func testBuyCurrency_fullFlowWithReserves() { + let wallet = WalletScreen(app: app) + let currencyInfo = CurrencyInfoUIScreen(app: app) + let amountEntry = AmountEntryScreen(app: app) + let processing = SwapProcessingUIScreen(app: app) + + assertMainScreenReached() + + // Navigate: Main → Wallet → first currency → CurrencyInfoScreen + wallet.open(from: self) + 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. + waitAndTap(currencyInfo.buyButton) + amountEntry.enterMinimumAmount() + waitUntilHittableAndTap(amountEntry.buyActionButton) + + processing.assertReached() + + // Swipe-down on the processing screen must NOT dismiss the .buy sheet. + // Two known regressions break this: + // 1) The recursive `.appRouterNestedSheet(...)` call inside the + // depth-1 sheet content swallows `interactiveDismissDisabled` + // preferences from descendants. + // 2) A source-level `.interactiveDismissDisabled(false)` on + // BuyAmountScreen overrides the destination's `true`. + // After the swipe, the processing title must still be visible. + app.swipeDown() + processing.assertReached(timeout: 5) + + // Wait for the swap to settle and dismiss via OK. + processing.waitForCompletionAndDismiss() + + // OK on the processing screen pops the entire .buy nested sheet, + // revealing CurrencyInfoScreen underneath. A regression here means + // OK dismissed the wallet too (the cascading-dismiss bug fixed by + // guarding the nested binding's setter against post-dismiss nil). + currencyInfo.assertReached() + } +} diff --git a/FlipcashUITests/Regression/CurrencyBuyRegressionTests.swift b/FlipcashUITests/Regression/CurrencyBuyRegressionTests.swift deleted file mode 100644 index 6497e6df1..000000000 --- a/FlipcashUITests/Regression/CurrencyBuyRegressionTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// CurrencyBuyRegressionTests.swift -// FlipcashUITests -// - -import XCTest - -/// Regression test for the full currency buy flow using USDF reserves. -/// -/// **Prerequisites:** -/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` -/// - The test account must have USDF reserves -/// - The test account must have at least one non-USDF currency visible in Wallet -final class CurrencyBuyRegressionTests: BaseUITestCase { - - override var requiresAuthentication: Bool { true } - - func testBuyCurrency_fullFlowWithReserves() { - let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - let funding = FundingSelectionScreen(app: app) - let amountEntry = AmountEntryScreen(app: app) - let processing = SwapProcessingUIScreen(app: app) - - assertMainScreenReached() - - // Navigate: Main → Wallet → first currency → CurrencyInfoScreen - wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - - // Buy → select USDF reserves → enter $0.01 → submit - waitAndTap(currencyInfo.buyButton) - funding.selectUSDF(from: self) - amountEntry.enterMinimumAmount() - waitUntilHittableAndTap(amountEntry.buyActionButton) - - // Wait for swap to complete and dismiss - processing.waitForCompletionAndDismiss() - - // Verify we returned to CurrencyInfoScreen - currencyInfo.assertReached() - } -} diff --git a/FlipcashUITests/Regression/WalletCallbackRegressionTests.swift b/FlipcashUITests/Regression/WalletCallbackRegressionTests.swift deleted file mode 100644 index dc22e6e62..000000000 --- a/FlipcashUITests/Regression/WalletCallbackRegressionTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// WalletCallbackRegressionTests.swift -// FlipcashUITests -// - -import XCTest - -final class WalletCallbackRegressionTests: BaseUITestCase { - - override var requiresAuthentication: Bool { true } - - /// Simulates Phantom returning a "walletConnected" callback while - /// the user is on the CurrencyInfoScreen. The screen must remain - /// visible — not reset to the root. - func testWalletConnectedCallback_doesNotResetInterface() { - let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - let funding = FundingSelectionScreen(app: app) - - assertMainScreenReached() - - // Navigate to CurrencyInfoScreen → Buy → select Phantom - wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - waitAndTap(currencyInfo.buyButton) - funding.selectPhantom(from: self) - - // Phantom would open here — simulate its redirect back - // with a walletConnected callback. The encrypted payload - // won't decrypt (no real Phantom session), but the - // interface must NOT reset — that's the regression. - let walletConnectedURL = URL(string: "https://app.flipcash.com/wallet/walletConnected?nonce=test&data=test")! - XCUIDevice.shared.system.open(walletConnectedURL) - - // Verify we're still on CurrencyInfoScreen, not reset to root - currencyInfo.assertReached(timeout: 5) - } - - /// Simulates Phantom returning an error (user cancelled) after - /// tapping Buy → Phantom. The screen must remain visible. - func testWalletErrorCallback_doesNotResetInterface() { - let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - let funding = FundingSelectionScreen(app: app) - - assertMainScreenReached() - - // Navigate to CurrencyInfoScreen → Buy → select Phantom - wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - waitAndTap(currencyInfo.buyButton) - funding.selectPhantom(from: self) - - // Simulate Phantom returning with errorCode=4001 (user cancelled) - let walletErrorURL = URL(string: "https://app.flipcash.com/wallet/walletConnected?errorCode=4001")! - XCUIDevice.shared.system.open(walletErrorURL) - - // Verify we're still on CurrencyInfoScreen - currencyInfo.assertReached(timeout: 5) - } -} diff --git a/FlipcashUITests/Support/Screens/AmountEntryScreen.swift b/FlipcashUITests/Support/Screens/AmountEntryScreen.swift index f5f1a8973..c325d4cbf 100644 --- a/FlipcashUITests/Support/Screens/AmountEntryScreen.swift +++ b/FlipcashUITests/Support/Screens/AmountEntryScreen.swift @@ -22,7 +22,7 @@ struct AmountEntryScreen { var nextButton: XCUIElement { app.buttons["Next"] } /// The "Buy" `CodeButton` on the amount entry screen. - /// When the CurrencyBuyAmountScreen sheet is presented, there are two "Buy" buttons in + /// 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 { @@ -42,4 +42,16 @@ 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/FundingSelectionScreen.swift b/FlipcashUITests/Support/Screens/FundingSelectionScreen.swift deleted file mode 100644 index 429d7e740..000000000 --- a/FlipcashUITests/Support/Screens/FundingSelectionScreen.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// FundingSelectionScreen.swift -// FlipcashUITests -// - -import XCTest - -/// Page object for the FundingSelectionSheet that appears when buying a currency. -@MainActor -struct FundingSelectionScreen { - - private let app: XCUIApplication - - init(app: XCUIApplication) { - self.app = app - } - - // MARK: - Elements - - /// The USDF reserves button. Title is dynamic ("USDF ($X.XX)"), matched by prefix. - var usdfReservesButton: XCUIElement { - app.buttons.matching( - NSPredicate(format: "label BEGINSWITH 'USDF'") - ).firstMatch - } - - /// The Phantom wallet button. Label includes "Solana USDC With, Phantom". - var phantomButton: XCUIElement { - app.buttons.matching( - NSPredicate(format: "label CONTAINS 'Phantom'") - ).firstMatch - } - - // MARK: - Actions - - func selectUSDF(from testCase: BaseUITestCase) { - testCase.waitAndTap( - usdfReservesButton, - timeout: 10, - "Expected USDF reserves option in funding sheet" - ) - } - - func selectPhantom(from testCase: BaseUITestCase) { - testCase.waitAndTap( - phantomButton, - timeout: 10, - "Expected Phantom option in funding sheet" - ) - } -} diff --git a/FlipcashUITests/Support/Screens/PhantomEducationScreen.swift b/FlipcashUITests/Support/Screens/PhantomEducationScreen.swift new file mode 100644 index 000000000..ffbd0b012 --- /dev/null +++ b/FlipcashUITests/Support/Screens/PhantomEducationScreen.swift @@ -0,0 +1,40 @@ +// +// PhantomEducationScreen.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the PhantomEducationScreen — the pre-flight shown when +/// the user picks Phantom from `PurchaseMethodSheet`. Tapping `connectButton` +/// triggers `PhantomCoordinator.start(_:)` which always deeplinks out to +/// Phantom for connect (tests can't reasonably continue past that point on +/// the local simulator without a real Phantom install). +@MainActor +struct PhantomEducationScreen { + + private let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var connectButton: XCUIElement { + app.buttons["Connect Your Phantom Wallet"] + } + + var title: XCUIElement { + app.staticTexts["Buy With Phantom"] + } + + // MARK: - Assertions + + func assertReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + connectButton.waitForExistence(timeout: timeout), + "Expected PhantomEducationScreen with 'Connect Your Phantom Wallet' CTA" + ) + } +} diff --git a/FlipcashUITests/Support/Screens/PurchaseMethodSheet.swift b/FlipcashUITests/Support/Screens/PurchaseMethodSheet.swift new file mode 100644 index 000000000..9f85009c8 --- /dev/null +++ b/FlipcashUITests/Support/Screens/PurchaseMethodSheet.swift @@ -0,0 +1,65 @@ +// +// PurchaseMethodSheet.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the PurchaseMethodSheet shown when the user's USDF +/// reserve doesn't cover the entered buy amount (or any time the launch flow +/// opens the picker). Lists Apple Pay (conditional on the Coinbase onramp +/// gate), Phantom, and Other Wallet (omitted for the launch flow), plus a +/// Dismiss row. +@MainActor +struct PurchaseMethodSheet { + + private let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + /// Apple Pay row. Matched by accessibility identifier — the visible label + /// is just the U+F8FF Apple logo glyph + "Pay" (intentional per HIG), + /// which is brittle to match by label predicate. + var applePayButton: XCUIElement { + app.buttons["apple-pay-method-button"] + } + + /// Phantom row. The button's label is just "Phantom" since the inline + /// icon is template-rendered and contributes no accessibility text. + var phantomButton: XCUIElement { + app.buttons.matching(NSPredicate(format: "label == 'Phantom'")).firstMatch + } + + /// Other Wallet row. + var otherWalletButton: XCUIElement { + app.buttons["Other Wallet"] + } + + /// Dismiss row at the bottom of the sheet. + var dismissButton: XCUIElement { + app.buttons["Dismiss"] + } + + // MARK: - Assertions + + func assertReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + phantomButton.waitForExistence(timeout: timeout), + "Expected PurchaseMethodSheet with the Phantom row" + ) + } + + // MARK: - Actions + + func selectPhantom(from testCase: BaseUITestCase) { + testCase.waitUntilHittableAndTap(phantomButton) + } + + func selectOtherWallet(from testCase: BaseUITestCase) { + testCase.waitUntilHittableAndTap(otherWalletButton) + } +} diff --git a/FlipcashUITests/Support/Screens/SwapProcessingScreen.swift b/FlipcashUITests/Support/Screens/SwapProcessingScreen.swift index 03974503d..4cf21ece5 100644 --- a/FlipcashUITests/Support/Screens/SwapProcessingScreen.swift +++ b/FlipcashUITests/Support/Screens/SwapProcessingScreen.swift @@ -19,8 +19,21 @@ struct SwapProcessingUIScreen { var okButton: XCUIElement { app.buttons["OK"] } + /// Static title rendered during the processing state ("This Will Take a Minute"). + /// Useful as a stable anchor while the swap is in-flight — the success/failed + /// states swap it for "Transaction Complete" / "Something Went Wrong". + var processingTitle: XCUIElement { app.staticTexts["This Will Take a Minute"] } + // MARK: - Actions + /// Waits for the processing screen to become visible. + func assertReached(timeout: TimeInterval = 30) { + XCTAssertTrue( + processingTitle.waitForExistence(timeout: timeout), + "Expected processing screen to be visible within \(timeout)s" + ) + } + /// Waits for the swap to complete (up to 2 minutes) and taps OK. func waitForCompletionAndDismiss() { XCTAssertTrue( diff --git a/FlipcashUITests/Support/Screens/USDCDepositAddressScreen.swift b/FlipcashUITests/Support/Screens/USDCDepositAddressScreen.swift new file mode 100644 index 000000000..201f876a1 --- /dev/null +++ b/FlipcashUITests/Support/Screens/USDCDepositAddressScreen.swift @@ -0,0 +1,44 @@ +// +// USDCDepositAddressScreen.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the USDCDepositAddressScreen — the leaf of the +/// "Other Wallet" buy path. Shows the per-user USDC deposit address and +/// a Copy Address button. The address is derived from the session's owner +/// key, so its presence (rather than its exact value) is what the regression +/// test asserts. +@MainActor +struct USDCDepositAddressScreen { + + private let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + /// "Copy Address" CTA at the bottom of the screen. Flips its label to + /// "Copied" after a successful tap. + var copyAddressButton: XCUIElement { + app.buttons["Copy Address"] + } + + /// "Copied" success-state label that replaces "Copy Address" briefly + /// after a successful copy. + var copiedButton: XCUIElement { + app.buttons["Copied"] + } + + // MARK: - Assertions + + func assertReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + copyAddressButton.waitForExistence(timeout: timeout), + "Expected USDCDepositAddressScreen with 'Copy Address' CTA" + ) + } +} diff --git a/FlipcashUITests/Support/Screens/USDCDepositEducationScreen.swift b/FlipcashUITests/Support/Screens/USDCDepositEducationScreen.swift new file mode 100644 index 000000000..07106488e --- /dev/null +++ b/FlipcashUITests/Support/Screens/USDCDepositEducationScreen.swift @@ -0,0 +1,45 @@ +// +// USDCDepositEducationScreen.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the USDCDepositEducationScreen — the pre-flight shown +/// when the user picks "Other Wallet" from `PurchaseMethodSheet`. Explains +/// the USDC→USDF auto-conversion and hands off to the address screen via +/// the Next button. +@MainActor +struct USDCDepositEducationScreen { + + private let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var title: XCUIElement { + app.staticTexts["Deposit USDC"] + } + + var nextButton: XCUIElement { + app.buttons["Next"] + } + + // MARK: - Assertions + + func assertReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + title.waitForExistence(timeout: timeout), + "Expected USDCDepositEducationScreen with 'Deposit USDC' title" + ) + } + + // MARK: - Actions + + func tapNext(from testCase: BaseUITestCase) { + testCase.waitUntilHittableAndTap(nextButton) + } +} diff --git a/FlipcashUITests/Support/Screens/WalletScreen.swift b/FlipcashUITests/Support/Screens/WalletScreen.swift index f360caf9a..0334d8682 100644 --- a/FlipcashUITests/Support/Screens/WalletScreen.swift +++ b/FlipcashUITests/Support/Screens/WalletScreen.swift @@ -18,13 +18,20 @@ struct WalletScreen { // MARK: - Elements - /// The first currency row. Identified by the row's accessibility identifier - /// rather than `app.cells`, since the wallet uses a `ScrollView` + `LazyVStack` - /// rather than a `List`. + /// The first non-USDF currency row. Identified by the row's accessibility + /// identifier rather than `app.cells`, since the wallet uses a `ScrollView` + /// + `LazyVStack` rather than a `List`. The USDF row carries the + /// distinct identifier "currency-row-usdf" so this selector reliably + /// targets an investable token regardless of where USDF sorts. var firstCurrencyRow: XCUIElement { app.buttons.matching(identifier: "currency-row").firstMatch } + /// The USDF row in the wallet. Always present once balances have synced. + var usdfRow: XCUIElement { + app.buttons["currency-row-usdf"] + } + /// The balance header button that shows the flag + total amount + chevron. var balanceHeader: XCUIElement { app.buttons["balance-header"] }