From e20311f2a863300d4bb63bd7c3981768ea49b551 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 17:50:26 -0400 Subject: [PATCH] feat(wallet): show a grabbed deposit landing in the wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Put in Wallet" dismissed the bill and left the user on the Scan tab, with nothing to show that the money had arrived. The grab refreshes `Session.balances` while the bill is still on screen, so by then the balance had already risen out of sight. The button now routes to the wallet, and `WalletDeposit` carries the pre-deposit total and card set across. On arrival the wallet rewinds to those figures in one un-animated frame, still under the outgoing bill, waits for the bill and its sheet to clear, then sets the real ones: the balance header rolls its digits, and a token the wallet had no card for rises into the deck. The baseline is held on the session rather than in the view because the wallet may not exist yet — the pre-iOS 26 tab path builds it when the tab is selected, which is the moment "Put in Wallet" asks for. While a deposit is pending, `refresh()` is held off the figures the animation is moving; it runs once more after, to pick up anything that shifted. --- Flipcash/Core/Navigation/AppRouter.swift | 23 +++ .../Screens/Main/Bill/BillOverlayView.swift | 14 +- .../Screens/Main/Home/TokenCardStack.swift | 21 ++- .../Screens/Main/Home/WalletDeposit.swift | 69 +++++++++ .../Core/Screens/Main/Home/WalletScreen.swift | 79 ++++++++++- Flipcash/Core/Session/Session.swift | 31 +++- .../Navigation/WalletTabRoutingTests.swift | 65 +++++++++ FlipcashTests/WalletDepositTests.swift | 134 ++++++++++++++++++ 8 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Home/WalletDeposit.swift create mode 100644 FlipcashTests/Navigation/WalletTabRoutingTests.swift create mode 100644 FlipcashTests/WalletDepositTests.swift diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index c0194a25f..fc28c4bac 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -510,6 +510,29 @@ final class AppRouter { setPath([destination], on: targetStack) } + /// Surfaces the wallet at its root — where a grabbed deposit lands after + /// "Put in Wallet". + /// + /// Closes an expanded token card too: the wallet's own overlay is not a + /// pushed screen, so clearing the stack alone would leave it covering the + /// balance the deposit is about to move. + func showWallet() { + dismissExpandedCard() + + // `requestedTabStack` covers the gap before `HomeTabView` selects the + // tab and publishes `activeTabStack` — until then the request is in + // flight, not yet arrived. + let alreadyThere = presentedSheets.isEmpty + && activeTabStack == .balance + && self[.balance].isEmpty + guard !alreadyThere, requestedTabStack != .balance else { return } + + while !presentedSheets.isEmpty { dismissSheet() } + setPath([], on: .balance) + requestedTabStack = .balance + logger.info("Routed to wallet", metadata: ["stack": "\(Stack.balance)"]) + } + /// Surfaces the user's own tip card: the You tab at its root. /// /// Not expressible as `navigate(to:)` — the You tab's stack is entered by diff --git a/Flipcash/Core/Screens/Main/Bill/BillOverlayView.swift b/Flipcash/Core/Screens/Main/Bill/BillOverlayView.swift index 245a2d36b..9a4d8641a 100644 --- a/Flipcash/Core/Screens/Main/Bill/BillOverlayView.swift +++ b/Flipcash/Core/Screens/Main/Bill/BillOverlayView.swift @@ -27,6 +27,8 @@ struct BillOverlayView: View { private struct BillOverlayContent: View { + @Environment(AppRouter.self) private var router + @Bindable private var session: Session private let sessionContainer: SessionContainer @@ -68,7 +70,7 @@ private struct BillOverlayContent: View { currencyName: valuation.mintMetadata?.name ?? "currency", currencyImageURL: valuation.mintMetadata?.imageURL, actionTitle: "Put in Wallet", - dismissAction: dismissBill + dismissAction: putInWallet ) } .interactiveDismissDisabled() @@ -288,6 +290,16 @@ private struct BillOverlayContent: View { } } + /// Takes a grabbed deposit to the wallet: the bill comes down and the wallet + /// comes forward, where the balance is seen to rise. + private func putInWallet() { + // Released before the dismissal, which drops any deposit the user never + // asked to see. + session.walletDeposit.release() + session.dismissCashBill(style: .slide) + router.showWallet() + } + private func dismissBill() { switch session.billState.bill { case .tipcard: diff --git a/Flipcash/Core/Screens/Main/Home/TokenCardStack.swift b/Flipcash/Core/Screens/Main/Home/TokenCardStack.swift index f920c7fc2..065ec0f6c 100644 --- a/Flipcash/Core/Screens/Main/Home/TokenCardStack.swift +++ b/Flipcash/Core/Screens/Main/Home/TokenCardStack.swift @@ -26,6 +26,9 @@ struct TokenCardStack: View { /// exactly and only the front card shows — the back cards hide completely /// behind it (no slivers), Apple Wallet style. static let defaultCollapsedReveal: CGFloat = 0 + /// How far below its slot an arriving card starts. `nonisolated` because the + /// `visualEffect` closure that reads it is. + nonisolated static let arrivalRise: CGFloat = 40 /// How long (in scroll px) the fully-collapsed deck holds before releasing. /// /// Zero: the deck collapses and then scrolls away like any other content. @@ -65,6 +68,13 @@ struct TokenCardStack: View { /// Extra displacement for the opened card, used to pick it up exactly where a /// pull-to-close let go of it rather than at its open slot. var openExtraOffset: CGFloat = 0 + /// A card arriving with a deposit: it rises into its slot rather than simply + /// being there. `nil` when nothing is arriving. + var arrivingMint: PublicKey? = nil + /// How far that arrival has run: 0 is off-stage, 1 is settled. A scalar for + /// the same reason `expansionProgress` is one — a change of branch does not + /// interpolate. + var arrivalProgress: CGFloat = 1 /// Reports the tapped card along with its current on-screen top edge, so the /// caller can work out the lift. var onCardTap: (TokenCardData, CGFloat) -> Void = { _, _ in } @@ -109,10 +119,19 @@ struct TokenCardStack: View { // Resting fan, and the reorganisation when a card is opened, are // both expressed here so they interpolate as one animation. .opacity(item.mint == hiddenMint ? 0 : 1) - .visualEffect { [index, expandingIndex, containerHeight, expansionProgress, openTopInset, openExtraOffset] content, proxy in + .visualEffect { [index, expandingIndex, containerHeight, expansionProgress, openTopInset, openExtraOffset, arrivingMint, arrivalProgress, mint = item.mint] content, proxy in let rect = proxy.frame(in: .scrollView) let fan = offset(for: index, stackTop: rect.minY) + // An arriving card rises into its slot from under the deck + // and fades up. Ahead of the expansion branches: a card + // still arriving cannot also be the one being opened. + if mint == arrivingMint, arrivalProgress < 1 { + return content + .offset(y: fan + Self.arrivalRise * (1 - arrivalProgress)) + .opacity(Double(arrivalProgress)) + } + guard let expandingIndex, expansionProgress > 0 else { return content.offset(y: fan).opacity(1) } diff --git a/Flipcash/Core/Screens/Main/Home/WalletDeposit.swift b/Flipcash/Core/Screens/Main/Home/WalletDeposit.swift new file mode 100644 index 000000000..1e470b119 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/WalletDeposit.swift @@ -0,0 +1,69 @@ +// +// WalletDeposit.swift +// Flipcash +// + +import Observation +import FlipcashCore + +/// Carries a grabbed deposit from the bill overlay across to the wallet, so the +/// wallet can be seen to receive it. +/// +/// A grab refreshes `Session.balances` while the bill is still on screen, so by +/// the time "Put in Wallet" brings the wallet forward the rise has already +/// happened out of sight. This records what the wallet was showing beforehand; +/// the wallet rewinds to those figures and plays the rise on arrival. +@Observable +final class WalletDeposit { + + /// A deposit that has reached the balances but has not yet been shown + /// arriving on the wallet. + struct Landing: Equatable { + + /// The token the deposit arrived in. + let mint: PublicKey + + /// The wallet's total before it arrived — where the rise starts. + let previousTotal: ExchangedFiat + + /// The tokens the wallet was already showing a card for. + let previousMints: Set + + /// Whether the deposit brings a token the wallet has no card for yet. + var isNewToken: Bool { !previousMints.contains(mint) } + } + + /// The deposit waiting to be played, or `nil` when none is in flight. + private(set) var landing: Landing? + + /// Whether the user has asked for the wallet, releasing it to play. + private(set) var isReleased = false + + /// The deposit the wallet has been released to play, or `nil` when nothing + /// is waiting on it. + var releasedLanding: Landing? { isReleased ? landing : nil } + + /// Records what the wallet is showing, ahead of a deposit landing in the balances. + func arm(mint: PublicKey, previousTotal: ExchangedFiat, previousMints: Set) { + landing = Landing(mint: mint, previousTotal: previousTotal, previousMints: previousMints) + isReleased = false + } + + /// Releases the wallet to play the armed deposit — the user asked to put it in the wallet. + func release() { + guard landing != nil else { return } + isReleased = true + } + + /// Drops a deposit that was never released, leaving the wallet to track balances as usual. + func discard() { + guard !isReleased else { return } + landing = nil + } + + /// Clears the deposit once the wallet has played it. + func consume() { + landing = nil + isReleased = false + } +} diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift index b7a49e02b..6c1e94b34 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -43,6 +43,12 @@ private struct WalletScreenContent: View { /// The unified recent-activity preview (newest first). @State private var recentActivities: [Activity] + /// The card the wallet is showing arrive with a deposit — it enters rather + /// than simply being there. `nil` when nothing is arriving. + @State private var arrivingMint: PublicKey? + /// How far that arrival has run: 0 is off-stage, 1 is settled. + @State private var arrivalProgress: CGFloat = 1 + /// The card being opened. The deck reorganises around it and the detail /// panel rises beneath it. @State private var expandingMint: PublicKey? @@ -141,7 +147,14 @@ private struct WalletScreenContent: View { // empty-state flash. let seed = Self.snapshot(session: sessionContainer.session, rate: rate) _cards = State(initialValue: seed.cards) - _total = State(initialValue: seed.total) + // A released deposit can land before this view exists — the legacy tab + // path builds the wallet when the tab is selected, which is the moment + // "Put in Wallet" asks for. Seeded at the pre-deposit figures so the + // rise still has somewhere to start. + let landing = sessionContainer.session.walletDeposit.releasedLanding + _total = State(initialValue: landing?.previousTotal ?? seed.total) + _arrivingMint = State(initialValue: landing?.isNewToken == true ? landing?.mint : nil) + _arrivalProgress = State(initialValue: landing?.isNewToken == true ? 0 : 1) _appreciation = State(initialValue: seed.appreciation) _hasAddedMoney = State(initialValue: sessionContainer.session.hasEverAddedMoney()) _hasTipped = State(initialValue: sessionContainer.session.hasEverTipped()) @@ -204,6 +217,10 @@ private struct WalletScreenContent: View { .onChange(of: router.requestedCardDismiss) { _, _ in closeCard() } + // Keyed on the deposit itself, so an arrival plays whether the + // wallet was already alive behind the bill (iOS 26 keeps every tab + // mounted) or is built on arrival. + .task(id: session.walletDeposit.releasedLanding?.mint) { await playDeposit() } .onGeometryChange(for: CGSize.self) { $0.size } action: { containerSize = $0 } .onGeometryChange(for: EdgeInsets.self) { $0.safeAreaInsets } action: { safeArea = $0 } // No top bar on the wallet root (per Figma) — the balance header @@ -504,6 +521,8 @@ private struct WalletScreenContent: View { hiddenMint: hiddenMint, openTopInset: WalletCardGeometry.openCardTopInset, openExtraOffset: releasedPullOffset, + arrivingMint: arrivingMint, + arrivalProgress: arrivalProgress, onCardTap: openCard ) } @@ -651,9 +670,65 @@ private struct WalletScreenContent: View { } } + // MARK: - Deposit + + /// Whether a released deposit is still on its way to being shown. + private var isLandingDeposit: Bool { session.walletDeposit.releasedLanding != nil } + + /// Shows a grabbed deposit arriving: the wallet rewinds to the figures it + /// was last seen with, then runs the balance up and brings in a card for a + /// token it had none for. + private func playDeposit() async { + guard let landing = session.walletDeposit.releasedLanding else { return } + let isNewToken = landing.isNewToken + + // The balances already carry the deposit, so this is a step backwards — + // taken in one un-animated frame, under the bill that is still covering + // the wallet. + var rewind = Transaction() + rewind.disablesAnimations = true + withTransaction(rewind) { + total = landing.previousTotal + arrivingMint = isNewToken ? landing.mint : nil + arrivalProgress = isNewToken ? 0 : 1 + } + + // The bill is sliding down and its sheet coming off as this starts; + // running the rise underneath them spends it where it cannot be seen. + try? await Task.delay(milliseconds: Self.depositRevealDelay) + guard !Task.isCancelled else { return } + + let snapshot = Self.snapshot(session: session, rate: rate) + withAnimation(.smooth(duration: Self.depositArrivalDuration), completionCriteria: .removed) { + cards = snapshot.cards + total = snapshot.total + appreciation = snapshot.appreciation + arrivalProgress = 1 + } completion: { + arrivingMint = nil + // Held until here rather than cleared up front: while a landing is + // pending it is also what holds `refresh()` off the figures the + // animation is moving. + session.walletDeposit.consume() + // Picks up anything that moved while the figures were held. + refresh() + } + } + + /// How long the wallet waits for the bill and its sheet to clear before + /// playing the arrival. + private static let depositRevealDelay = 450 + /// How long the arriving card takes to rise into its slot. The balance rolls + /// on `BalanceHeaderButton`'s own animation, not this one. + private static let depositArrivalDuration: TimeInterval = 0.9 + // MARK: - Data private func refresh() { + // A landing owns the figures until it has played them; refreshing here + // would put the wallet back to the post-deposit total it is rewound + // from, cutting to the answer the rise is on its way to. + guard !isLandingDeposit else { return } let snapshot = Self.snapshot(session: session, rate: rate) withAnimation(.default) { cards = snapshot.cards @@ -692,7 +767,7 @@ private struct WalletScreenContent: View { rate: Rate ) -> (cards: [TokenCardData], total: ExchangedFiat, appreciation: (amount: FiatAmount, isPositive: Bool)) { let all = session.balances(for: rate) - let visible = all.filter { $0.stored.mint != .usdf || $0.exchangedFiat.hasDisplayableValue() } + let visible = Session.walletCardBalances(from: all) let cards = visible.map { balance -> TokenCardData in let (value, isPositive) = balance.stored.computeAppreciation(with: rate) diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 9aa06859f..802f05758 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -45,6 +45,10 @@ class Session { /// Post-transaction amount display, shown as a sheet. var valuation: BillValuation? = nil + /// A grabbed deposit on its way to the wallet, held from the moment the + /// received bill is shown until the wallet has played its arrival. + let walletDeposit = WalletDeposit() + /// The currently visible balance-change toast, or `nil` when none is shown. /// Set by ``consumeToast()`` and cleared after a 3-second display window. var toast: Toast? = nil @@ -146,6 +150,12 @@ class Session { } } + /// The balances the wallet shows a card for: every token it holds, and + /// Dollars only once they are worth showing. + static func walletCardBalances(from balances: [ExchangedBalance]) -> [ExchangedBalance] { + balances.filter { $0.stored.mint != .usdf || $0.exchangedFiat.hasDisplayableValue() } + } + func balance(for mint: PublicKey) -> StoredBalance? { // Avoid the display-ordered sort in `balances` — this is called per // SwiftUI body re-eval from amount-entry computed props. @@ -1181,6 +1191,10 @@ class Session { func showCashBill(_ billDescription: BillDescription) { // Only inbound bills enqueue a "+$" deposit toast; sent bills don't. if billDescription.received { + // Armed here rather than after the grab's balance refresh: that + // refresh is what the wallet needs the figures from *before*. + armWalletDeposit(mint: billDescription.exchangedFiat.mint) + enqueue(toast: .init( amount: billDescription.exchangedFiat.nativeAmount, isDeposit: true @@ -1424,12 +1438,27 @@ class Session { presentationState = .hidden(style) billState = .default() valuation = nil + // A bill that went away without the user asking for the wallet has + // nothing left to play there. + walletDeposit.discard() // Consume toast after bill state is cleared // so isShowingBill returns false consumeToast() } - + + /// Records the wallet's current figures against an incoming deposit in `mint`. + private func armWalletDeposit(mint: PublicKey) { + let rate = ratesController.rateForBalanceCurrency() + let balances = balances(for: rate) + + walletDeposit.arm( + mint: mint, + previousTotal: balances.map(\.exchangedFiat).total(rate: rate), + previousMints: Set(Self.walletCardBalances(from: balances).map(\.stored.mint)) + ) + } + // MARK: - Cash Links - private func createCashLink(payload: CashCode.Payload, exchangedFiat: ExchangedFiat, verifiedState: VerifiedState) async throws -> GiftCardCluster { diff --git a/FlipcashTests/Navigation/WalletTabRoutingTests.swift b/FlipcashTests/Navigation/WalletTabRoutingTests.swift new file mode 100644 index 000000000..dbc850a37 --- /dev/null +++ b/FlipcashTests/Navigation/WalletTabRoutingTests.swift @@ -0,0 +1,65 @@ +// +// WalletTabRoutingTests.swift +// FlipcashTests +// + +import SwiftUI +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Wallet tab routing") +struct WalletTabRoutingTests { + + @Test("putting a grabbed bill in the wallet brings the Wallet tab forward at its root") + func showWallet_selectsBalanceTabAtRoot() { + let router = AppRouter() + // Scanning happens on another tab, and a cash link can arrive over a sheet. + router.activeTabStack = .tips + router.setPath([.discoverCurrencies], on: .balance) + router.present(.give) + + router.showWallet() + + #expect(router.requestedTabStack == .balance) + #expect(router.presentedSheet == nil) + #expect(router[.balance].isEmpty) + } + + @Test("an expanded token card is closed, so nothing covers the balance") + func showWallet_closesExpandedCard() { + let router = AppRouter() + let before = router.requestedCardDismiss + + router.showWallet() + + #expect(router.requestedCardDismiss == before &+ 1) + } + + @Test("a second deposit while the tab request is in flight does not re-request it") + func showWallet_whileRequestPending_doesNotRefire() { + let router = AppRouter() + router.showWallet() + #expect(router.requestedTabStack == .balance) + + // What HomeTabView does on selecting the tab. + router.requestedTabStack = nil + router.activeTabStack = .balance + + router.showWallet() + #expect(router.requestedTabStack == nil, "arriving must not re-request the tab") + } + + @Test("a deposit grabbed from a pushed wallet screen returns to the wallet root") + func showWallet_fromPushedBalanceScreen_popsToRoot() { + let router = AppRouter() + router.activeTabStack = .balance + router.push(.currencyInfo(.usdc)) + + router.showWallet() + + #expect(router[.balance].isEmpty) + #expect(router.requestedTabStack == .balance) + } +} diff --git a/FlipcashTests/WalletDepositTests.swift b/FlipcashTests/WalletDepositTests.swift new file mode 100644 index 000000000..2d66cee2e --- /dev/null +++ b/FlipcashTests/WalletDepositTests.swift @@ -0,0 +1,134 @@ +// +// WalletDepositTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Wallet deposit landing") +struct WalletDepositTests { + + private static func fiat(_ value: Decimal) -> ExchangedFiat { + ExchangedFiat(nativeAmount: FiatAmount(value: value, currency: .usd), rate: .oneToOne) + } + + // MARK: - Arming + + @Test("arming records the wallet's figures and waits for the user") + func arm_recordsLandingUnreleased() { + let deposit = WalletDeposit() + deposit.arm(mint: .usdf, previousTotal: Self.fiat(5), previousMints: [.usdf]) + + #expect(deposit.landing?.previousTotal == Self.fiat(5)) + #expect(deposit.isReleased == false) + #expect(deposit.releasedLanding == nil, "nothing plays until the user asks for the wallet") + } + + @Test("a token the wallet has no card for is a new one") + func isNewToken_forUnknownMint() { + let deposit = WalletDeposit() + deposit.arm(mint: .usdc, previousTotal: Self.fiat(5), previousMints: [.usdf]) + #expect(deposit.landing?.isNewToken == true) + } + + @Test("a token already on the wallet is not a new one") + func isNewToken_forHeldMint() { + let deposit = WalletDeposit() + deposit.arm(mint: .usdf, previousTotal: Self.fiat(5), previousMints: [.usdf, .usdc]) + #expect(deposit.landing?.isNewToken == false) + } + + @Test("re-arming replaces an unplayed deposit and takes back its release") + func arm_replacesPending() { + let deposit = WalletDeposit() + deposit.arm(mint: .usdf, previousTotal: Self.fiat(5), previousMints: []) + deposit.release() + + deposit.arm(mint: .usdc, previousTotal: Self.fiat(9), previousMints: [.usdf]) + + #expect(deposit.landing?.mint == .usdc) + #expect(deposit.isReleased == false) + } + + // MARK: - Release + + @Test("releasing hands the deposit to the wallet") + func release_publishesLanding() { + let deposit = WalletDeposit() + deposit.arm(mint: .usdc, previousTotal: Self.fiat(5), previousMints: [.usdf]) + deposit.release() + + #expect(deposit.releasedLanding?.mint == .usdc) + } + + @Test("releasing with nothing armed leaves the wallet alone") + func release_withoutLanding_isNoop() { + let deposit = WalletDeposit() + deposit.release() + + #expect(deposit.isReleased == false) + #expect(deposit.releasedLanding == nil) + } + + // MARK: - Discard / consume + + @Test("a bill dismissed without asking for the wallet drops its deposit") + func discard_dropsUnreleasedLanding() { + let deposit = WalletDeposit() + deposit.arm(mint: .usdc, previousTotal: Self.fiat(5), previousMints: []) + deposit.discard() + + #expect(deposit.landing == nil) + } + + @Test("the bill's own dismissal cannot drop a deposit the user just released") + func discard_afterRelease_keepsLanding() { + let deposit = WalletDeposit() + deposit.arm(mint: .usdc, previousTotal: Self.fiat(5), previousMints: []) + deposit.release() + + // What `dismissCashBill` runs as "Put in Wallet" takes the bill down. + deposit.discard() + + #expect(deposit.releasedLanding?.mint == .usdc) + } + + @Test("consuming clears the deposit once the wallet has played it") + func consume_clearsLanding() { + let deposit = WalletDeposit() + deposit.arm(mint: .usdc, previousTotal: Self.fiat(5), previousMints: []) + deposit.release() + deposit.consume() + + #expect(deposit.landing == nil) + #expect(deposit.isReleased == false) + #expect(deposit.releasedLanding == nil) + } +} + +@MainActor +@Suite("Wallet card balances") +struct WalletCardBalancesTests { + + @Test("a token the user holds gets a card even when it is worth nothing yet") + func keepsValuelessToken() { + let balances = [WithdrawViewModelTestHelpers.createExchangedBalance(mint: .usdc, quarks: 0)] + #expect(Session.walletCardBalances(from: balances).count == 1) + } + + @Test("Dollars stay off the wallet until they are worth showing") + func dropsEmptyDollars() { + let balances = [WithdrawViewModelTestHelpers.createExchangedBalance(mint: .usdf, quarks: 0)] + #expect(Session.walletCardBalances(from: balances).isEmpty) + } + + @Test("Dollars with a value get a card") + func keepsFundedDollars() { + let balances = [WithdrawViewModelTestHelpers.createExchangedBalance(mint: .usdf, quarks: 10_000_000)] + #expect(Session.walletCardBalances(from: balances).count == 1) + } +}