From af068d8bda0300a547e827ec8dffe614ecb5b710 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 1 Nov 2026 22:00:32 -0500 Subject: [PATCH 1/3] feat(analytics): send the catalogue events iOS never sent Adds Token Purchase With Coinbase at the order's outcome, Onramp: Show Verification Info on the intro screen, the Buy With Coinbase and Buy With Phantom buttons at amount confirm, and Deeplink: Parse's failure form for a scanned code that is no Flipcash link. Each fires where Android's does. --- .../AddMoney/AddMoneyAmountViewModel.swift | 2 + .../AddMoney/CoinbaseDepositOperation.swift | 20 +++- .../Core/Screens/Main/ScanViewModel.swift | 38 +++++-- .../OnrampVerificationIntroScreen.swift | 3 + Flipcash/Utilities/Events.swift | 22 ++++ .../CatalogueParityEventsTests.swift | 100 ++++++++++++++++++ 6 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 FlipcashTests/Analytics/CatalogueParityEventsTests.swift diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift index 9cefaf016..95558e979 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift @@ -122,6 +122,7 @@ final class AddMoneyAmountViewModel { verificationCoordinator: VerificationCoordinator, onProceed: @escaping (AddMoneyProcessingInput) -> Void ) { + Analytics.buttonTapped(name: .buyWithCoinbase) Analytics.addMoneyAmountConfirmed(method: .coinbase, exchangedFiat: amount) verificationCoordinator.runGated( for: session, @@ -172,6 +173,7 @@ final class AddMoneyAmountViewModel { dismiss: @escaping () -> Void, onProceed: @escaping (AddMoneyProcessingInput) -> Void ) { + Analytics.buttonTapped(name: .buyWithPhantom) Analytics.addMoneyAmountConfirmed(method: .phantom, exchangedFiat: amount) let operation = PhantomDepositOperation(walletConnection: walletConnection) depositTask = Task { [weak self, operation] in diff --git a/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift b/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift index 12f1d7fe9..4cf0b2be0 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift @@ -77,8 +77,15 @@ final class CoinbaseDepositOperation { try checkMinimum(amount) state = .working - let order = try await createOrder(for: amount) + let order: OnrampOrderResponse + do { + order = try await createOrder(for: amount) + } catch { + Self.trackPurchase(amount, error: error) + throw error + } orderId = order.id + Self.trackPurchase(amount, error: nil) Analytics.addMoneyPaymentInvoked(method: .coinbase, exchangedFiat: amount) coinbaseService.setOrder(order) @@ -88,6 +95,17 @@ final class CoinbaseDepositOperation { try await awaitApplePayCompletion(orderId: order.id, rate: amount.currencyRate) } + /// `Token Purchase With Coinbase` at the order's outcome, as Android sends it once the order is + /// placed. A missing contact or a cancel sends nothing: Android's flow routes to verification + /// there instead of failing the purchase. + static func trackPurchase(_ amount: ExchangedFiat, error: Error?) { + if let error { + if error is CancellationError { return } + if case DepositError.requirementUnsatisfied = error { return } + } + Analytics.tokenPurchase(method: .coinbase, mint: amount.mint, amount: amount.nativeAmount, error: error) + } + private func checkRequirements() throws { guard CoinbaseOrderEmail.resolveContact(profile: session.profile) != nil else { throw DepositError.requirementUnsatisfied(.verifiedContact) diff --git a/Flipcash/Core/Screens/Main/ScanViewModel.swift b/Flipcash/Core/Screens/Main/ScanViewModel.swift index 213b3248c..898c4d54a 100644 --- a/Flipcash/Core/Screens/Main/ScanViewModel.swift +++ b/Flipcash/Core/Screens/Main/ScanViewModel.swift @@ -165,6 +165,18 @@ class ScanViewModel { } } + /// Whether `url` is no Flipcash link at all, which Android's scanner reports as + /// `Deeplink: Parse` with an Error. A recognised route that `canScanQR` refuses is not one. + nonisolated static func isUnparseableQR(url: URL) -> Bool { + guard Route.isFlipcashLink(url), let route = Route(url: url) else { + return true + } + if case .unknown = route.path { + return true + } + return false + } + private func didScanQR(_ string: String) { guard !session.isShowingBill else { return @@ -183,6 +195,12 @@ class ScanViewModel { } guard Self.canScanQR(url: url) else { + if Self.isUnparseableQR(url: url) { + // Held in the dedup set like a scanned link, so a code in frame reports once + // per cooldown rather than once per frame. + rememberScannedQR(string) + Analytics.deeplinkParseFailed(url: url) + } return } @@ -190,12 +208,7 @@ class ScanViewModel { Haptics.tap() } - scannedQRCodes.insert(string) - - // Remove from dedup set after cooldown to allow re-scanning - DispatchQueue.main.asyncAfter(deadline: .now() + Self.qrCooldownInterval) { [weak self] in - self?.scannedQRCodes.remove(string) - } + rememberScannedQR(string) logger.debug("QR code scanned", metadata: ["url": "\(url.sanitizedForAnalytics)"]) @@ -205,6 +218,16 @@ class ScanViewModel { userInfo: ["url": url] ) } + + private func rememberScannedQR(_ string: String) { + scannedQRCodes.insert(string) + + // Remove from dedup set after cooldown to allow re-scanning + DispatchQueue.main.asyncAfter(deadline: .now() + Self.qrCooldownInterval) { [weak self] in + self?.scannedQRCodes.remove(string) + } + } + // MARK: - Still Images - /// What a still-image scan ended up doing, for the UI to report. @@ -243,6 +266,9 @@ class ScanViewModel { case .url(let url): guard Self.canScanQR(url: url) else { + if Self.isUnparseableQR(url: url) { + Analytics.deeplinkParseFailed(url: url) + } Analytics.galleryScanFoundNothing(reason: .routeRefused, elapsed: elapsed) // Deliberately indistinguishable from "nothing found". Someone who has been // sent a login QR learns nothing about why it was refused. diff --git a/Flipcash/Core/Screens/Onramp/OnrampVerificationIntroScreen.swift b/Flipcash/Core/Screens/Onramp/OnrampVerificationIntroScreen.swift index b75f51701..86ec2e5bf 100644 --- a/Flipcash/Core/Screens/Onramp/OnrampVerificationIntroScreen.swift +++ b/Flipcash/Core/Screens/Onramp/OnrampVerificationIntroScreen.swift @@ -42,5 +42,8 @@ struct OnrampVerificationIntroScreen: View { .padding(.horizontal, 40) .padding(.bottom, 20) } + .task { + Analytics.onrampStep(.showInfo) + } } } diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index 01395409c..78e5559c6 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -21,6 +21,8 @@ extension Analytics { case skipPush case buyWithReserves case buyWithCurrency + case buyWithCoinbase + case buyWithPhantom case give case shareTokenInfo } @@ -36,6 +38,7 @@ extension Analytics { /// An onramp verification screen, sent as `Onramp: Show `. enum OnrampStep { + case showInfo case enterPhone case confirmPhone case enterEmail @@ -44,6 +47,7 @@ extension Analytics { /// The shared contract's step for this screen. var shared: SharedCoreKit.OnrampStep { switch self { + case .showInfo: .showInfo case .enterPhone: .enterPhone case .confirmPhone: .confirmPhone case .enterEmail: .enterEmail @@ -158,6 +162,8 @@ extension Analytics { case .buyWithReserves: track(ButtonEvents.shared.tapped(button: .buyWithReserves)) case .shareTokenInfo: track(ButtonEvents.shared.tapped(button: .shareTokenInfo)) case .buyWithCurrency: track(ButtonEvents.shared.tapped(button: .buyWithCurrency)) + case .buyWithCoinbase: track(ButtonEvents.shared.tapped(button: .buyWithCoinbase)) + case .buyWithPhantom: track(ButtonEvents.shared.tapped(button: .buyWithPhantom)) case .give: track(ButtonEvents.shared.tapped(button: .give)) } } @@ -968,6 +974,16 @@ extension Analytics { track(event: method, properties: properties, error: error) } + /// A buy submitted, sent as `Token Purchase With {method}`. `mint` is the token bought and + /// `amount` the net purchase in the user's currency, as Android sends it. `error` is nil + /// when the submit succeeded. + static func tokenPurchase(method: SharedCoreKit.PurchaseMethod, mint: PublicKey, amount: FiatAmount, error: Error?) { + track( + SwapEvents.shared.purchase(method: method, mint: mint.base58, amount: amount.analyticsAmount, error: nil), + error: error + ) + } + static func tokenSell(exchangedFiat: ExchangedFiat, successful: Bool, error: Error? = nil) { let properties: [Property: AnalyticsValue] = [ .state: successful ? String.success : String.failure, @@ -1020,6 +1036,12 @@ extension Analytics { track(event: DeeplinkEvent.parse, properties: properties) } + /// A scanned QR code that isn't a Flipcash link, as Android's scanner reports it. Query and + /// fragment are stripped, so no token or key in them reaches analytics. + static func deeplinkParseFailed(url: URL) { + track(DeeplinkEvents.shared.parseFailed(url: url.sanitizedForAnalytics)) + } + static func deeplinkRouted(kind: DeepLinkAction.Kind, error: Error? = nil) { track( event: DeeplinkEvent.routed, diff --git a/FlipcashTests/Analytics/CatalogueParityEventsTests.swift b/FlipcashTests/Analytics/CatalogueParityEventsTests.swift new file mode 100644 index 000000000..2bda99538 --- /dev/null +++ b/FlipcashTests/Analytics/CatalogueParityEventsTests.swift @@ -0,0 +1,100 @@ +// +// CatalogueParityEventsTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +/// Catalogue events iOS sends to match Android, written as the catalogue spells them. +@MainActor +@Suite("Catalogue parity events", .serialized) +struct CatalogueParityEventsTests { + + init() { + Analytics.tokenSymbolResolver = nil + } + + private static let fiat = ExchangedFiat.compute( + onChainAmount: TokenAmount(quarks: 5_000_000, mint: .usdf), + rate: Rate(fx: 1, currency: .usd), + supplyQuarks: nil + ) + + // MARK: - Buttons and onramp - + + @Test("Buy With Coinbase and Buy With Phantom buttons", arguments: [ + (Analytics.ButtonEvent.buyWithCoinbase, "Button: Buy With Coinbase"), + (.buyWithPhantom, "Button: Buy With Phantom"), + ]) + func buyWithButtons(_ button: Analytics.ButtonEvent, _ expected: String) throws { + let event = try Analytics.recordingSends { Analytics.buttonTapped(name: button) }.single + #expect(event.name == expected) + } + + @Test("The verification intro is Onramp: Show Verification Info") + func onrampShowInfo() throws { + let event = try Analytics.recordingSends { Analytics.onrampStep(.showInfo) }.single + #expect(event.name == "Onramp: Show Verification Info") + } + + // MARK: - Token Purchase With Coinbase - + + @Test("A placed Coinbase order is Token Purchase With Coinbase, with the mint and amount") + func coinbasePurchase() throws { + let event = try Analytics.recordingSends { + CoinbaseDepositOperation.trackPurchase(Self.fiat, error: nil) + }.single + #expect(event.name == "Token Purchase With Coinbase") + #expect(event.properties["Mint"] as? String == PublicKey.usdf.base58) + #expect(event.properties["Fiat"] as? Double == 5) + #expect(event.properties["USDC"] as? Double == 5) + #expect(event.properties["Quarks"] as? Double == 5_000_000) + #expect(event.properties["Currency"] != nil) + #expect(event.properties["Error"] == nil) + } + + @Test("A failed Coinbase order carries the error") + func coinbasePurchaseFailed() throws { + let event = try Analytics.recordingSends { + CoinbaseDepositOperation.trackPurchase(Self.fiat, error: NSError(domain: "D", code: 7)) + }.single + #expect(event.name == "Token Purchase With Coinbase") + #expect(event.properties["Error"] != nil) + } + + @Test("A cancel or a missing contact sends no Token Purchase") + func coinbasePurchaseSkipped() { + let cancelled = Analytics.recordingSends { + CoinbaseDepositOperation.trackPurchase(Self.fiat, error: CancellationError()) + } + #expect(cancelled.isEmpty) + + let unverified = Analytics.recordingSends { + CoinbaseDepositOperation.trackPurchase(Self.fiat, error: DepositError.requirementUnsatisfied(.verifiedContact)) + } + #expect(unverified.isEmpty) + } + + // MARK: - Deeplink: Parse failure - + + @Test("A scanned non-Flipcash code is Deeplink: Parse with the stripped URL as the Error") + func parseFailed() throws { + let url = URL(string: "https://google.com/search?q=secret#frag")! + let event = try Analytics.recordingSends { Analytics.deeplinkParseFailed(url: url) }.single + #expect(event.name == "Deeplink: Parse") + #expect(event.properties["Error"] as? String == "Failed to parse deeplink => https://google.com/search") + } + + @Test("Only a code that is no Flipcash route counts as unparseable", arguments: [ + ("https://google.com", true), + ("https://evil.com/c/#/e=HQPkfAZjgpGGANQfUNPKvW", true), + ("https://app.flipcash.com/token/54ggcQ23uen5b9QXMAns99MQNTKn7iyzq4wvCW6e8r25", false), + ("https://app.flipcash.com/login#e=HQPkfAZjgpGGANQfUNPKvW", false), + ]) + func unparseable(_ string: String, _ expected: Bool) { + #expect(ScanViewModel.isUnparseableQR(url: URL(string: string)!) == expected) + } +} From a09608b208399ae2a44b02ed9202f078b3aff8f2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 1 Nov 2026 22:07:30 -0500 Subject: [PATCH 2/3] feat(analytics): send the catalogue amount block on money events Withdrawal, the transfer events, Tip Received, the Add Money steps, Token Purchase and Token Sell now go through the shared builders, so each sends Fiat, Currency, USDC and Quarks, plus Exchange Rate and Mint when known. Grab Time is sent in ms. Token Purchase and Token Sell now fire on the submit result, as Android's do, instead of when the swap finishes; a convert reports as a sell of its source. State, Payment Mint and the completion-time sends are gone. A Phantom Add Money failure sends no amount, as on Android. --- .../AddMoney/AddMoneyAmountViewModel.swift | 5 +- .../Main/Buy/BuyConfirmationViewModel.swift | 38 ++-- .../ConvertConfirmationViewModel.swift | 30 +-- .../Currency Swap/InlineSwapCompletion.swift | 21 +-- .../SwapProcessingViewModel.swift | 7 +- Flipcash/Utilities/Events.swift | 173 +++++++----------- .../Analytics/AddMoneyEventsTests.swift | 31 +--- .../CatalogueParityEventsTests.swift | 137 ++++++++++++++ .../Analytics/ReceivedEventsTests.swift | 5 - FlipcashTests/InlineSwapCompletionTests.swift | 2 +- 10 files changed, 262 insertions(+), 187 deletions(-) diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift index 95558e979..c70fe2a23 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift @@ -196,9 +196,10 @@ final class AddMoneyAmountViewModel { } catch DepositError.userCancelled { // User rejected the transaction in Phantom — silent. } catch let DepositError.externalRejected(title, subtitle) { + // Android sends a Phantom failure with no amount. Analytics.addMoney( method: .phantom, - exchangedFiat: amount, + exchangedFiat: nil, successful: false, error: DepositError.externalRejected(title: title, subtitle: subtitle) ) @@ -209,7 +210,7 @@ final class AddMoneyAmountViewModel { "amount": "\(amount.nativeAmount.formatted())", ]) ErrorReporting.captureError(error) - Analytics.addMoney(method: .phantom, exchangedFiat: amount, successful: false, error: error) + Analytics.addMoney(method: .phantom, exchangedFiat: nil, successful: false, error: error) self?.dialogItem = .error(title: "Something Went Wrong", subtitle: "Please try again later") } } diff --git a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift index d0ffa15ce..6df1b755b 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift @@ -130,23 +130,27 @@ final class BuyConfirmationViewModel { actionButtonState = .loading do { let swapId: SwapId - let swapType: SwapType - if isUSDF { - Analytics.buttonTapped(name: .buyWithReserves) - // `paymentAmount` is the net purchase; the fee is added on top - // and split off on-chain, so the debit is `paymentAmount + fee`. - swapId = try await session.buy( - amount: paymentAmount, - feeAmount: fee, - verifiedState: pinnedState, - of: targetMint - ) - swapType = .buyWithReserves - } else { - Analytics.buttonTapped(name: .buyWithCurrency) - swapId = try await session.buy(amount: paymentAmount, with: payment.mint, verifiedState: pinnedState, of: targetMint) - swapType = .buyWithCurrency + let swapType: SwapType = isUSDF ? .buyWithReserves : .buyWithCurrency + do { + if isUSDF { + Analytics.buttonTapped(name: .buyWithReserves) + // `paymentAmount` is the net purchase; the fee is added on top + // and split off on-chain, so the debit is `paymentAmount + fee`. + swapId = try await session.buy( + amount: paymentAmount, + feeAmount: fee, + verifiedState: pinnedState, + of: targetMint + ) + } else { + Analytics.buttonTapped(name: .buyWithCurrency) + swapId = try await session.buy(amount: paymentAmount, with: payment.mint, verifiedState: pinnedState, of: targetMint) + } + } catch { + SwapCompletionReporter.trackSubmit(swapType: swapType, mint: targetMint, amount: amountToBuy, fee: fee, error: error) + throw error } + SwapCompletionReporter.trackSubmit(swapType: swapType, mint: targetMint, amount: amountToBuy, fee: fee, error: nil) await completeInline(swapId: swapId, swapType: swapType, session: session, router: router, dismiss: dismiss) } catch Session.Error.insufficientBalance { // Race: the balance gate said OK but the reserves buy disagreed. @@ -191,7 +195,7 @@ final class BuyConfirmationViewModel { try await session.pollSwapState(swapId: swapId, maxAttempts: attempts, interval: interval) } let completion = InlineSwapCompletion( - reporter: SwapCompletionReporter(swapId: swapId, swapType: swapType, targetMint: targetMint, amount: amountToBuy), + reporter: SwapCompletionReporter(swapId: swapId, swapType: swapType, amount: amountToBuy), poll: poller ) switch await completion.awaitOutcome() { diff --git a/Flipcash/Core/Screens/Main/Currency Convert/ConvertConfirmationViewModel.swift b/Flipcash/Core/Screens/Main/Currency Convert/ConvertConfirmationViewModel.swift index bf457f407..a0e7f998f 100644 --- a/Flipcash/Core/Screens/Main/Currency Convert/ConvertConfirmationViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Convert/ConvertConfirmationViewModel.swift @@ -97,18 +97,24 @@ final class ConvertConfirmationViewModel { Task { do { let swapId: SwapId - if isToDollars { - // Token → Dollars is the sell path. - swapId = try await session.sell(amount: amount, verifiedState: pinnedState, in: sourceMint) - } else if isFromDollars { - // Dollars → a token is a buy paid from reserves. `amount` is - // the net purchase; any Dollars fee is added on top and split off - // on-chain, so the debit is `amount + fee`. - swapId = try await session.buy(amount: amount, feeAmount: fee, verifiedState: pinnedState, of: destinationMint) - } else { - // Token → token is buy-with-currency. - swapId = try await session.buy(amount: amount, with: sourceMint, verifiedState: pinnedState, of: destinationMint) + do { + if isToDollars { + // Token → Dollars is the sell path. + swapId = try await session.sell(amount: amount, verifiedState: pinnedState, in: sourceMint) + } else if isFromDollars { + // Dollars → a token is a buy paid from reserves. `amount` is + // the net purchase; any Dollars fee is added on top and split off + // on-chain, so the debit is `amount + fee`. + swapId = try await session.buy(amount: amount, feeAmount: fee, verifiedState: pinnedState, of: destinationMint) + } else { + // Token → token is buy-with-currency. + swapId = try await session.buy(amount: amount, with: sourceMint, verifiedState: pinnedState, of: destinationMint) + } + } catch { + SwapCompletionReporter.trackSubmit(swapType: .convert, mint: sourceMint, amount: amountAfterFee, fee: fee, error: error) + throw error } + SwapCompletionReporter.trackSubmit(swapType: .convert, mint: sourceMint, amount: amountAfterFee, fee: fee, error: nil) await completeInline(swapId: swapId, session: session, router: router, dismiss: dismiss) } catch Session.Error.verifiedStateStale { // Session.assertFresh already logged this. The pin can't refresh @@ -156,7 +162,7 @@ final class ConvertConfirmationViewModel { try await session.pollSwapState(swapId: swapId, maxAttempts: attempts, interval: interval) } let completion = InlineSwapCompletion( - reporter: SwapCompletionReporter(swapId: swapId, swapType: .convert, targetMint: destinationMint, amount: amountAfterFee), + reporter: SwapCompletionReporter(swapId: swapId, swapType: .convert, amount: amountAfterFee), poll: poller ) switch await completion.awaitOutcome() { diff --git a/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift b/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift index 4903fafa1..68a33c660 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift @@ -45,20 +45,21 @@ typealias SwapPoller = (_ maxAttempts: Int, _ interval: Duration) async throws - struct SwapCompletionReporter { let swapId: SwapId let swapType: SwapType - /// The token being bought, or a convert's destination. - let targetMint: PublicKey? let amount: ExchangedFiat - func trackTransaction(successful: Bool) { + /// `Token Purchase` or `Token Sell` for a submitted swap, sent once per submit as Android + /// sends it, not when the swap finishes. A convert is a sell of its source in every + /// direction. `mint` is the token bought, or a convert's source; `amount` and `fee` are the + /// net amount and the fee. A cancelled submit sends nothing. + static func trackSubmit(swapType: SwapType, mint: PublicKey, amount: ExchangedFiat, fee: ExchangedFiat, error: Error?) { + if error is CancellationError { return } switch swapType { case .buyWithReserves: - Analytics.tokenPurchase(method: .purchaseWithReserves, targetMint: targetMint, exchangedFiat: amount, successful: successful) + Analytics.tokenPurchase(method: .reserves, mint: mint, amount: amount.nativeAmount, error: error) case .buyWithCurrency: - Analytics.tokenPurchase(method: .purchaseWithCurrency, targetMint: targetMint, exchangedFiat: amount, successful: successful) + Analytics.tokenPurchase(method: .currency, mint: mint, amount: amount.nativeAmount, error: error) case .convert: - // A convert always disposes of the source token; record it as a - // sell of the amount that left the wallet. - Analytics.tokenSell(exchangedFiat: amount, successful: successful) + Analytics.tokenSell(mint: mint, amount: amount.nativeAmount, fee: fee.nativeAmount, error: error) } } @@ -125,11 +126,9 @@ struct InlineSwapCompletion { let metadata = try await poll(InlineCompletion.maxAttempts, InlineCompletion.pollInterval) switch metadata.state { case .finalized: - reporter.trackTransaction(successful: true) return .succeeded case .failed, .cancelled: reporter.reportSwapFailure(state: metadata.state, reason: "Swap completed with failure state") - reporter.trackTransaction(successful: false) return .failed case .unknown, .created, .funding, .funded, .submitting, .cancelling: return .pending @@ -143,11 +142,9 @@ struct InlineSwapCompletion { return .pending } catch let error as ClientError where error.isSwapPollRefusal { reporter.reportPollRefusal(error) - reporter.trackTransaction(successful: false) return .failed } catch { logger.error("Swap polling failed", metadata: ["error": "\(error)"]) - reporter.trackTransaction(successful: false) return .failed } } diff --git a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift index 8c8854224..b2d2cbb1f 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift @@ -110,7 +110,7 @@ class SwapProcessingViewModel { init(swapId: SwapId, swapType: SwapType, targetMint: PublicKey? = nil, currencyName: String, amount: ExchangedFiat, attemptsUsed: Int = 0) { self.attemptsUsed = attemptsUsed - self.reporter = SwapCompletionReporter(swapId: swapId, swapType: swapType, targetMint: targetMint, amount: amount) + self.reporter = SwapCompletionReporter(swapId: swapId, swapType: swapType, amount: amount) self.swapId = swapId self.swapType = swapType self.targetMint = targetMint @@ -144,15 +144,12 @@ class SwapProcessingViewModel { switch metadata.state { case .finalized: setSwapDetails() - reporter.trackTransaction(successful: true) displayState = .success case .failed, .cancelled: reporter.reportSwapFailure(state: metadata.state, reason: "Swap completed with failure state") - reporter.trackTransaction(successful: false) displayState = .failed case .unknown, .created, .funding, .funded, .submitting, .cancelling: reporter.reportSwapFailure(state: metadata.state, reason: "Swap timed out in intermediate state") - reporter.trackTransaction(successful: false) displayState = .failed } } catch is CancellationError { @@ -161,12 +158,10 @@ class SwapProcessingViewModel { // will restart the task if still visible. } catch let error as ClientError where error.isSwapPollRefusal { reporter.reportPollRefusal(error) - reporter.trackTransaction(successful: false) displayState = .failed } catch { // No swap state was ever fetched within the poll budget logger.error("Swap polling failed", metadata: ["error": "\(error)"]) - reporter.trackTransaction(successful: false) displayState = .failed } diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index 78e5559c6..03743afb5 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -56,10 +56,6 @@ extension Analytics { } } - enum ConversationEvent: String, AnalyticsEvent { - case tipReceived = "Tip Received" - } - /// The display name a user is known by. `Set` is a first name, `Updated` a /// replacement — decided by whether a name already existed, not by the screen. enum DisplayNameEvent { @@ -103,27 +99,11 @@ extension Analytics { case openedFromSend } - enum TokenTransactionEvent: String, AnalyticsEvent { - case purchaseWithReserves = "Token Purchase With Reserves" - case purchaseWithCurrency = "Token Purchase With Currency" - case sell = "Token Sell" - } - enum DeeplinkEvent: String, AnalyticsEvent { case parse = "Deeplink: Parse" case routed = "Deeplink: Routed" } - /// The Add Money funnel, modeled after the transfer pattern — a single - /// terminal event with State/Error properties. Names are shared verbatim - /// with Android. - enum AddMoneyEvent: String, AnalyticsEvent { - case methodSelected = "Add Money: Method Selected" - case amountConfirmed = "Add Money: Amount Confirmed" - case paymentInvoked = "Add Money: Payment Invoked" - case terminal = "Add Money" - } - /// Where the user entered the Add Money flow, sent as the `Source` of Add Money: Opened. enum AddMoneySource { case menu @@ -271,22 +251,6 @@ extension Analytics { } } -// MARK: - Shared property builders - - -private extension Analytics { - /// The standard money-amount property block — mint, quarks, native fiat, - /// exchange rate, and currency. Shared by the transfer and Add Money events. - static func amountProperties(_ exchangedFiat: ExchangedFiat) -> [Property: AnalyticsValue] { - [ - .mint: exchangedFiat.mint.base58, - .quarks: exchangedFiat.onChainAmount.quarks.analyticsValue, - .fiat: exchangedFiat.nativeAmount.doubleValue, - .fx: exchangedFiat.currencyRate.fx.analyticsValue, - .currency: exchangedFiat.currencyRate.currency.rawValue, - ] - } -} - extension ExchangedFiat { /// The shared contract's amount block. USDC is the USDF value, which Android always sends. var analyticsAmount: AnalyticsAmount { @@ -359,46 +323,50 @@ extension Analytics { } static func withdrawal(exchangedFiat: ExchangedFiat?, successful: Bool, error: Error?) { - var properties: [Property: AnalyticsValue] = exchangedFiat.map(amountProperties) ?? [:] - properties[.state] = successful ? String.success : String.failure - track( - event: TransferEvent.withdrawal, - properties: properties, + TransferEvents.shared.withdrawal( + state: successful ? .success : .failure, + amount: exchangedFiat?.analyticsAmount, + error: nil + ), error: error ) } - static func transfer(event: TransferEvent, exchangedFiat: ExchangedFiat?, grabTime: Double?, successful: Bool, error: Error?) { - var properties: [Property: AnalyticsValue] = exchangedFiat.map(amountProperties) ?? [:] - properties[.state] = successful ? String.success : String.failure - - if let grabTime { - properties[.grabTime] = grabTime - } - - track( - event: event, - properties: properties, - error: error - ) + /// A transfer's outcome. `grabTime` is in seconds; Grab Bill sends it as `Grab Time` in ms, + /// as Android does, and the other events ignore it. + static func transfer(event: TransferEvent, exchangedFiat: ExchangedFiat?, grabTime: TimeInterval?, successful: Bool, error: Error?) { + transfer(event: event, amount: exchangedFiat?.analyticsAmount, grabTime: grabTime, successful: successful, error: error) } + /// A transfer's outcome when only the fiat amount is known, as for a grab that failed before + /// its metadata arrived. static func transfer(event: TransferEvent, fiat: FiatAmount?, successful: Bool, error: Error?) { - var properties: [Property: AnalyticsValue] = [ - .state: successful ? String.success : String.failure, - ] - - if let fiat { - properties[.fiat] = fiat.doubleValue - properties[.currency] = fiat.currency.rawValue + transfer(event: event, amount: fiat?.analyticsAmount, grabTime: nil, successful: successful, error: error) + } + + private static func transfer(event: TransferEvent, amount: AnalyticsAmount?, grabTime: TimeInterval?, successful: Bool, error: Error?) { + let state: EventState = successful ? .success : .failure + let built: TrackedEvent = switch event { + case .withdrawal: + TransferEvents.shared.withdrawal(state: state, amount: amount, error: nil) + case .sentTip: + TransferEvents.shared.sentTip(state: state, amount: amount, error: nil) + case .sendCashLink: + TransferEvents.shared.sendCashLink(state: state, amount: amount, choice: nil, app: nil, error: nil) + case .receiveCashLink: + TransferEvents.shared.receiveCashLink(state: state, amount: amount, error: nil) + case .grabBill: + TransferEvents.shared.grabBill( + state: state, + amount: amount, + grabTimeMillis: grabTime.map { KotlinLong(longLong: Int64(($0 * 1000).rounded())) }, + error: nil + ) + case .giveBill: + TransferEvents.shared.giveBill(state: state, amount: amount, error: nil) } - - track( - event: event, - properties: properties, - error: error - ) + track(built, error: error) } } @@ -495,9 +463,7 @@ extension Analytics { /// An inbound tipped Cash message the user has now read. Mutually exclusive with /// `messageReceived` — a tip reports only as a tip. static func tipReceived(chatType: ConversationType?, exchangedFiat: ExchangedFiat) { - var properties = amountProperties(exchangedFiat) - properties[.chatType] = chatType.analyticsValue - track(event: ConversationEvent.tipReceived, properties: properties) + track(ChatEvents.shared.tipReceived(chatType: chatType.sharedChatType, amount: exchangedFiat.analyticsAmount)) } /// An inbound non-tip message the user has now read. @@ -804,40 +770,43 @@ extension Analytics { } static func addMoneyMethodSelected(method: DepositMethod) { - track(event: AddMoneyEvent.methodSelected, properties: [.method: method.analyticsValue]) + track(AddMoneyEvents.shared.methodSelected(method: method.shared)) } static func addMoneyAmountConfirmed(method: DepositMethod, exchangedFiat: ExchangedFiat) { - var properties = amountProperties(exchangedFiat) - properties[.method] = method.analyticsValue - track(event: AddMoneyEvent.amountConfirmed, properties: properties) + track(AddMoneyEvents.shared.amountConfirmed(method: method.shared, amount: exchangedFiat.analyticsAmount)) } static func addMoneyPaymentInvoked(method: DepositMethod, exchangedFiat: ExchangedFiat) { - var properties = amountProperties(exchangedFiat) - properties[.method] = method.analyticsValue - track(event: AddMoneyEvent.paymentInvoked, properties: properties) + track(AddMoneyEvents.shared.paymentInvoked(method: method.shared, amount: exchangedFiat.analyticsAmount)) } static func addMoneyAddressCopied(mint: PublicKey) { track(AddMoneyEvents.shared.addressCopied(mint: mint.base58)) } + /// The Add Money outcome. A nil `exchangedFiat` sends no amount, as Android does for a + /// Phantom failure. static func addMoney(method: DepositMethod, exchangedFiat: ExchangedFiat?, successful: Bool, error: Error?) { - var properties: [Property: AnalyticsValue] = exchangedFiat.map(amountProperties) ?? [:] - properties[.method] = method.analyticsValue - properties[.state] = successful ? String.success : String.failure - track(event: AddMoneyEvent.terminal, properties: properties, error: error) + track( + AddMoneyEvents.shared.result( + method: method.shared, + state: successful ? .success : .failure, + amount: exchangedFiat?.analyticsAmount, + error: nil + ), + error: error + ) } } extension DepositMethod { - /// The `Method` property value, shared verbatim with Android. - var analyticsValue: String { + /// The shared contract's method. + var shared: SharedCoreKit.AddMoneyMethod { switch self { - case .coinbase: "Coinbase" - case .phantom: "Phantom" - case .otherWallet: "Other Wallet" + case .coinbase: .coinbase + case .phantom: .phantom + case .otherWallet: .otherWallet } } } @@ -957,23 +926,6 @@ extension Analytics { // MARK: - Token Transactions - extension Analytics { - /// `Mint` is the token being purchased; `Payment Mint` is the token the - /// buyer spent (USDF for reserves buys), taken from `exchangedFiat`. - /// A nil `targetMint` omits `Mint` — `exchangedFiat` is payment-denominated, - /// so no truthful substitute exists. - static func tokenPurchase(method: TokenTransactionEvent, targetMint: PublicKey?, exchangedFiat: ExchangedFiat, successful: Bool, error: Error? = nil) { - var properties: [Property: AnalyticsValue] = [ - .state: successful ? String.success : String.failure, - .paymentMint: exchangedFiat.mint.base58, - .fiat: exchangedFiat.nativeAmount.doubleValue, - .currency: exchangedFiat.currencyRate.currency.rawValue, - ] - if let targetMint { - properties[.mint] = targetMint.base58 - } - track(event: method, properties: properties, error: error) - } - /// A buy submitted, sent as `Token Purchase With {method}`. `mint` is the token bought and /// `amount` the net purchase in the user's currency, as Android sends it. `error` is nil /// when the submit succeeded. @@ -984,14 +936,13 @@ extension Analytics { ) } - static func tokenSell(exchangedFiat: ExchangedFiat, successful: Bool, error: Error? = nil) { - let properties: [Property: AnalyticsValue] = [ - .state: successful ? String.success : String.failure, - .mint: exchangedFiat.mint.base58, - .fiat: exchangedFiat.nativeAmount.doubleValue, - .currency: exchangedFiat.currencyRate.currency.rawValue, - ] - track(event: TokenTransactionEvent.sell, properties: properties, error: error) + /// A sell or convert submitted, sent as `Token Sell`. `mint` is the token sold, `amount` the + /// net amount and `fee` the fee, both in the user's currency, as Android sends them. + static func tokenSell(mint: PublicKey, amount: FiatAmount, fee: FiatAmount, error: Error?) { + track( + SwapEvents.shared.sell(mint: mint.base58, amount: amount.analyticsAmount, fee: fee.doubleValue, error: nil), + error: error + ) } } diff --git a/FlipcashTests/Analytics/AddMoneyEventsTests.swift b/FlipcashTests/Analytics/AddMoneyEventsTests.swift index 2d4cc468d..0247ca7bd 100644 --- a/FlipcashTests/Analytics/AddMoneyEventsTests.swift +++ b/FlipcashTests/Analytics/AddMoneyEventsTests.swift @@ -7,28 +7,17 @@ import Testing @testable import Flipcash @MainActor -@Suite("Add Money funnel contract — names shared verbatim with Android") +@Suite("Add Money funnel contract — names shared verbatim with Android", .serialized) struct AddMoneyEventsTests { - @Test("Event names match the Android funnel") - func eventNames_matchAndroid() { - #expect(Analytics.AddMoneyEvent.methodSelected.eventName == "Add Money: Method Selected") - #expect(Analytics.AddMoneyEvent.amountConfirmed.eventName == "Add Money: Amount Confirmed") - #expect(Analytics.AddMoneyEvent.paymentInvoked.eventName == "Add Money: Payment Invoked") - #expect(Analytics.AddMoneyEvent.terminal.eventName == "Add Money") - } - - @Test("Property keys have the expected raw values") - func propertyKeys_rawValues_areExpected() { - #expect(Analytics.Property.method.rawValue == "Method") - #expect(Analytics.Property.state.rawValue == "State") - #expect(Analytics.Property.mint.rawValue == "Mint") - } - - @Test("Method values match the Android funnel") - func methodValues_matchAndroid() { - #expect(DepositMethod.coinbase.analyticsValue == "Coinbase") - #expect(DepositMethod.phantom.analyticsValue == "Phantom") - #expect(DepositMethod.otherWallet.analyticsValue == "Other Wallet") + @Test("Method values match the Android funnel", arguments: [ + (DepositMethod.coinbase, "Coinbase"), + (.phantom, "Phantom"), + (.otherWallet, "Other Wallet"), + ]) + func methodValues_matchAndroid(_ method: DepositMethod, _ expected: String) throws { + let event = try Analytics.recordingSends { Analytics.addMoneyMethodSelected(method: method) }.single + #expect(event.name == "Add Money: Method Selected") + #expect(event.properties["Method"] as? String == expected) } } diff --git a/FlipcashTests/Analytics/CatalogueParityEventsTests.swift b/FlipcashTests/Analytics/CatalogueParityEventsTests.swift index 2bda99538..088af749a 100644 --- a/FlipcashTests/Analytics/CatalogueParityEventsTests.swift +++ b/FlipcashTests/Analytics/CatalogueParityEventsTests.swift @@ -97,4 +97,141 @@ struct CatalogueParityEventsTests { func unparseable(_ string: String, _ expected: Bool) { #expect(ScanViewModel.isUnparseableQR(url: URL(string: string)!) == expected) } + + // MARK: - Amount block - + + /// The catalogue's `amount` group for `fiat`: Fiat, Currency, USDC, Quarks, Exchange Rate, Mint. + private func expectAmount(_ event: SentEvent, sourceLocation: SourceLocation = #_sourceLocation) { + #expect(event.properties["Fiat"] as? Double == 5, sourceLocation: sourceLocation) + #expect(event.properties["Currency"] as? String == "usd", sourceLocation: sourceLocation) + #expect(event.properties["USDC"] as? Double == 5, sourceLocation: sourceLocation) + #expect(event.properties["Quarks"] as? Double == 5_000_000, sourceLocation: sourceLocation) + #expect(event.properties["Exchange Rate"] as? Double == 1, sourceLocation: sourceLocation) + #expect(event.properties["Mint"] as? String == PublicKey.usdf.base58, sourceLocation: sourceLocation) + } + + @Test("Transfer outcomes carry State and the amount group", arguments: [ + (Analytics.TransferEvent.withdrawal, "Withdrawal"), + (.sentTip, "Sent Tip"), + (.sendCashLink, "Send Cash Link"), + (.receiveCashLink, "Receive Cash Link"), + (.grabBill, "Grab Bill"), + (.giveBill, "Give Bill"), + ]) + func transferAmount(_ event: Analytics.TransferEvent, _ expected: String) throws { + let sent = try Analytics.recordingSends { + Analytics.transfer(event: event, exchangedFiat: Self.fiat, grabTime: nil, successful: true, error: nil) + }.single + #expect(sent.name == expected) + #expect(sent.properties["State"] as? String == "Success") + expectAmount(sent) + } + + @Test("Withdrawal carries the amount group") + func withdrawal() throws { + let sent = try Analytics.recordingSends { + Analytics.withdrawal(exchangedFiat: Self.fiat, successful: true, error: nil) + }.single + #expect(sent.name == "Withdrawal") + expectAmount(sent) + } + + @Test("Grab Bill sends Grab Time in ms") + func grabTime() throws { + let sent = try Analytics.recordingSends { + Analytics.transfer(event: .grabBill, exchangedFiat: Self.fiat, grabTime: 1.25, successful: true, error: nil) + }.single + #expect(sent.properties["Grab Time"] as? Double == 1250) + } + + @Test("A failed grab with only the fiat sends Fiat, Currency, USDC and Quarks") + func grabFailedFiatOnly() throws { + let sent = try Analytics.recordingSends { + Analytics.transfer(event: .grabBill, fiat: Self.fiat.nativeAmount, successful: false, error: NSError(domain: "D", code: 1)) + }.single + #expect(sent.properties["State"] as? String == "Failure") + #expect(sent.properties["Fiat"] as? Double == 5) + #expect(sent.properties["USDC"] as? Double == 5) + #expect(sent.properties["Quarks"] as? Double == 5_000_000) + #expect(sent.properties["Exchange Rate"] == nil) + #expect(sent.properties["Error"] != nil) + } + + @Test("A failed Receive Cash Link with no amount sends none") + func receiveCashLinkFailed() throws { + let sent = try Analytics.recordingSends { + Analytics.transfer(event: .receiveCashLink, exchangedFiat: nil, grabTime: nil, successful: false, error: NSError(domain: "D", code: 1)) + }.single + #expect(sent.properties["State"] as? String == "Failure") + #expect(sent.properties["Fiat"] == nil) + #expect(sent.properties["USDC"] == nil) + } + + @Test("Tip Received carries the chat type and the amount group") + func tipReceived() throws { + let sent = try Analytics.recordingSends { Analytics.tipReceived(chatType: .tipDm, exchangedFiat: Self.fiat) }.single + #expect(sent.name == "Tip Received") + #expect(sent.properties["Chat Type"] as? String == "Tip") + expectAmount(sent) + } + + @Test("Add Money steps carry the method and the amount group") + func addMoneyAmount() throws { + let confirmed = try Analytics.recordingSends { Analytics.addMoneyAmountConfirmed(method: .coinbase, exchangedFiat: Self.fiat) }.single + #expect(confirmed.name == "Add Money: Amount Confirmed") + #expect(confirmed.properties["Method"] as? String == "Coinbase") + expectAmount(confirmed) + + let invoked = try Analytics.recordingSends { Analytics.addMoneyPaymentInvoked(method: .phantom, exchangedFiat: Self.fiat) }.single + #expect(invoked.name == "Add Money: Payment Invoked") + expectAmount(invoked) + + let result = try Analytics.recordingSends { Analytics.addMoney(method: .coinbase, exchangedFiat: Self.fiat, successful: true, error: nil) }.single + #expect(result.name == "Add Money") + #expect(result.properties["State"] as? String == "Success") + expectAmount(result) + + let phantomFailed = try Analytics.recordingSends { + Analytics.addMoney(method: .phantom, exchangedFiat: nil, successful: false, error: NSError(domain: "D", code: 1)) + }.single + #expect(phantomFailed.properties["State"] as? String == "Failure") + #expect(phantomFailed.properties["Fiat"] == nil) + } + + // MARK: - Swap submit - + + @Test("A submitted buy is Token Purchase With its method, with the bought mint", arguments: [ + (SwapType.buyWithReserves, "Token Purchase With Reserves"), + (.buyWithCurrency, "Token Purchase With Currency"), + ]) + func buySubmit(_ swapType: SwapType, _ expected: String) throws { + let sent = try Analytics.recordingSends { + SwapCompletionReporter.trackSubmit(swapType: swapType, mint: .jeffy, amount: Self.fiat, fee: Self.fiat, error: nil) + }.single + #expect(sent.name == expected) + #expect(sent.properties["Mint"] as? String == PublicKey.jeffy.base58) + #expect(sent.properties["Fiat"] as? Double == 5) + #expect(sent.properties["USDC"] as? Double == 5) + #expect(sent.properties["State"] == nil) + #expect(sent.properties["Payment Mint"] == nil) + } + + @Test("A submitted convert is Token Sell of the source, with the fee") + func convertSubmit() throws { + let sent = try Analytics.recordingSends { + SwapCompletionReporter.trackSubmit(swapType: .convert, mint: .jeffy, amount: Self.fiat, fee: Self.fiat, error: NSError(domain: "D", code: 1)) + }.single + #expect(sent.name == "Token Sell") + #expect(sent.properties["Mint"] as? String == PublicKey.jeffy.base58) + #expect(sent.properties["Fee"] as? Double == 5) + #expect(sent.properties["Error"] != nil) + } + + @Test("A cancelled submit sends nothing") + func cancelledSubmit() { + let sent = Analytics.recordingSends { + SwapCompletionReporter.trackSubmit(swapType: .buyWithReserves, mint: .jeffy, amount: Self.fiat, fee: Self.fiat, error: CancellationError()) + } + #expect(sent.isEmpty) + } } diff --git a/FlipcashTests/Analytics/ReceivedEventsTests.swift b/FlipcashTests/Analytics/ReceivedEventsTests.swift index 2d7f80b0c..569119262 100644 --- a/FlipcashTests/Analytics/ReceivedEventsTests.swift +++ b/FlipcashTests/Analytics/ReceivedEventsTests.swift @@ -17,9 +17,4 @@ struct ReceivedEventsTests { func setVersusUpdated(_ hadPreviousName: Bool, _ expected: Analytics.DisplayNameEvent) { #expect(Analytics.displayNameEvent(hadPreviousName: hadPreviousName) == expected) } - - @Test("Received event names are the shared contract") - func receivedEventNames() { - #expect(Analytics.ConversationEvent.tipReceived.eventName == "Tip Received") - } } diff --git a/FlipcashTests/InlineSwapCompletionTests.swift b/FlipcashTests/InlineSwapCompletionTests.swift index 2f5c1ac13..903448292 100644 --- a/FlipcashTests/InlineSwapCompletionTests.swift +++ b/FlipcashTests/InlineSwapCompletionTests.swift @@ -25,7 +25,7 @@ struct InlineSwapCompletionTests { result: @escaping () throws -> SwapMetadata ) -> InlineSwapCompletion { InlineSwapCompletion( - reporter: SwapCompletionReporter(swapId: .generate(), swapType: .buyWithReserves, targetMint: .jeffy, amount: .mockOne), + reporter: SwapCompletionReporter(swapId: .generate(), swapType: .buyWithReserves, amount: .mockOne), poll: { attempts, interval in recorder.record(attempts, interval) return try result() From 49a1b66ec6078e65d70af197b6ef44e555d7c7ee Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 1 Nov 2026 22:22:11 -0500 Subject: [PATCH 3/3] fix(analytics): send Provider on Wallet events and fire them where Android does Every Wallet event now goes through the shared builders with Provider Phantom. Wallet: Connect fires once Phantom returns a session instead of on the request, and Wallet: Cancel only on a user reject (4001). Wallet: Transactions Failed is new: it fires when the chain refuses a transaction Phantom signed, at simulation or send, except for an expired blockhash, matching Android's send failure. Wallet: Request Amount sent Fiat 0: ExchangedFiat.compute values USDC as a bonded mint with no supply and returns zero. It now sends the USDC amount in USD, valued with USDC's 6 decimals. --- .../Deep Links/Wallet/WalletConnection.swift | 24 +++++---- .../AddMoney/PhantomDepositOperation.swift | 18 +++++++ Flipcash/Utilities/Events.swift | 28 +++++++---- .../CatalogueParityEventsTests.swift | 50 +++++++++++++++++++ 4 files changed, 98 insertions(+), 22 deletions(-) diff --git a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift index 5c08c6968..394b1a9b2 100644 --- a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift +++ b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift @@ -116,10 +116,10 @@ public final class WalletConnection { } if let code = url.queryItemValue(for: "errorCode") { - Analytics.track(event: Analytics.WalletEvent.cancel) - let isUserCancel = code == "4001" - if !isUserCancel { + if isUserCancel { + Analytics.walletCancelled() + } else { logger.error("Wallet returned error", metadata: [ "code": "\(code)", "url": "\(url.sanitizedForAnalytics)", @@ -238,6 +238,7 @@ public final class WalletConnection { Keychain.connectedWalletSession = session self.session = session + Analytics.walletConnected() if let continuation = pendingConnect { pendingConnect = nil @@ -322,10 +323,15 @@ public final class WalletConnection { URLQueryItem(name: "nonce", value: nonce) ] - Analytics.track(event: Analytics.WalletEvent.connect) openExternalWallet(c.url!) } + /// The USD value of a USDC amount, which is 1:1. Neither `ExchangedFiat.compute` (no bonding + /// supply) nor `TokenAmount.decimalValue` (10 decimals for any non-USDF mint) values USDC. + nonisolated static func usdValue(of usdc: FlipcashCore.TokenAmount) -> FiatAmount { + .usd(usdc.quarks.scaleDown(MintMetadata.usdc.decimals)) + } + /// Builds + sends a USDC→USDF swap to Phantom for signing, crediting the /// swapped USDF to the owner's USDF VM deposit ATA. The signed transaction /// comes back via `didReceiveURL` → `deeplinkEvents`. @@ -338,11 +344,7 @@ public final class WalletConnection { throw Error.noSession } - let amount = ExchangedFiat.compute( - onChainAmount: usdc, - rate: .oneToOne, - supplyQuarks: nil - ) + let amount = Self.usdValue(of: usdc) let externalWallet = try FlipcashCore.PublicKey(base58: connectedSession.walletPublicKey.base58) let flipcashOwner = owner.authorityPublicKey @@ -399,10 +401,10 @@ public final class WalletConnection { case .coinbaseStableSwapper: "coinbaseStableSwapper" } - Analytics.walletRequestAmount(amount: amount.nativeAmount) + Analytics.walletRequestAmount(amount: amount) openExternalWallet(url) logger.info("Requested USDC→USDF deposit swap", metadata: [ - "amount": "\(amount.nativeAmount.formatted())", + "amount": "\(amount.formatted())", "swapId": "\(swapId.publicKey.base58)", "pool": "\(poolLabel)", ]) diff --git a/Flipcash/Core/Screens/Main/AddMoney/PhantomDepositOperation.swift b/Flipcash/Core/Screens/Main/AddMoney/PhantomDepositOperation.swift index a82215e61..c71d7453a 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/PhantomDepositOperation.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/PhantomDepositOperation.swift @@ -167,6 +167,9 @@ final class PhantomDepositOperation { // The node refused the transaction at its preflight — commonly an // expired blockhash after a slow Phantom approval — so nothing was // submitted and the user can simply retry. + if !Self.isBlockhashNotFound(message: response.message) { + Analytics.walletTransactionsFailed() + } logger.error("Chain submission rejected at preflight", metadata: [ "code": "\(response.code ?? -1)", "message": "\(response.message ?? "nil")", @@ -177,6 +180,7 @@ final class PhantomDepositOperation { subtitle: "The transaction simulation failed. Check your Phantom wallet and try again." ) } catch { + Analytics.walletTransactionsFailed() logger.error("Chain submission failed", metadata: [ "error": "\(error)", "swapId": "\(swapId.publicKey.base58)", @@ -185,6 +189,14 @@ final class PhantomDepositOperation { } } + /// Whether a send refusal is an expired blockhash, matched as Android's + /// `RpcException.isBlockhashNotFound` matches it. + nonisolated static func isBlockhashNotFound(message: String?) -> Bool { + guard let message else { return false } + return message.range(of: "Blockhash not found", options: .caseInsensitive) != nil + || message.range(of: "BlockhashNotFound", options: .caseInsensitive) != nil + } + /// Pre-flights the signed transaction, throwing only on an explicit RPC /// rejection. Transport-level failures proceed to submit — a flaky RPC /// must not block a valid deposit. @@ -199,6 +211,9 @@ final class PhantomDepositOperation { ) ) } catch SolanaRPCError.transactionSimulationError(let logs) { + // Android sends without a separate simulation, so its send preflight refuses this + // transaction instead; count it the same way. + Analytics.walletTransactionsFailed() logger.error("Phantom signed transaction failed simulation", metadata: [ "logs": "\(logs.suffix(5).joined(separator: " | "))", "swapId": "\(swapId.publicKey.base58)", @@ -208,6 +223,9 @@ final class PhantomDepositOperation { subtitle: "The network wouldn't accept this transaction" ) } catch SolanaRPCError.responseError(let response) { + if !Self.isBlockhashNotFound(message: response.message) { + Analytics.walletTransactionsFailed() + } logger.error("Phantom signed transaction rejected at preflight", metadata: [ "code": "\(response.code ?? -1)", "message": "\(response.message ?? "nil")", diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index 03743afb5..c72c37a3b 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -82,12 +82,6 @@ extension Analytics { var stoppedEarly: Bool { self == .cancelled } } - enum WalletEvent: String, AnalyticsEvent { - case connect = "Wallet: Connect" - case requestAmount = "Wallet: Request Amount" - case cancel = "Wallet: Cancel" - } - /// Where Token Info was opened from, built by the shared contract. enum TokenInfoEvent { case openedFromDeeplink @@ -814,13 +808,25 @@ extension DepositMethod { // MARK: - Wallet - extension Analytics { - static func walletRequestAmount(amount: FiatAmount) { - var properties: [Property: AnalyticsValue] = [:] + /// Phantom returned a connected session. Phantom is the only wallet iOS connects. + static func walletConnected() { + track(WalletEvents.shared.connect(provider: .phantom)) + } - properties[.fiat] = amount.doubleValue - properties[.currency] = amount.currency.rawValue + /// The user rejected a Phantom request (code 4001). Other wallet errors are not a cancel. + static func walletCancelled() { + track(WalletEvents.shared.cancel(provider: .phantom)) + } + + /// A transaction Phantom signed was refused when sent to the chain. An expired blockhash + /// is not counted, as on Android. + static func walletTransactionsFailed() { + track(WalletEvents.shared.transactionsFailed(provider: .phantom)) + } - track(event: WalletEvent.requestAmount, properties: properties) + /// A deposit sent to Phantom for signing. `amount` is the USDC being swapped, in USD. + static func walletRequestAmount(amount: FiatAmount) { + track(WalletEvents.shared.requestAmount(provider: .phantom, amount: amount.analyticsAmount)) } } diff --git a/FlipcashTests/Analytics/CatalogueParityEventsTests.swift b/FlipcashTests/Analytics/CatalogueParityEventsTests.swift index 088af749a..82147f3f2 100644 --- a/FlipcashTests/Analytics/CatalogueParityEventsTests.swift +++ b/FlipcashTests/Analytics/CatalogueParityEventsTests.swift @@ -234,4 +234,54 @@ struct CatalogueParityEventsTests { } #expect(sent.isEmpty) } + + // MARK: - Wallet - + + @Test("Wallet events carry Provider Phantom") + func walletProvider() throws { + let sent = Analytics.recordingSends { + Analytics.walletConnected() + Analytics.walletCancelled() + Analytics.walletTransactionsFailed() + } + #expect(sent.map(\.name) == ["Wallet: Connect", "Wallet: Cancel", "Wallet: Transactions Failed"]) + #expect(sent.allSatisfy { $0.properties["Provider"] as? String == "Phantom" }) + } + + @Test("Request Amount sends the USDC amount's group in USD") + func walletRequestAmount() throws { + let sent = try Analytics.recordingSends { + Analytics.walletRequestAmount(amount: WalletConnection.usdValue(of: TokenAmount(quarks: 5_000_000, mint: .usdc))) + }.single + #expect(sent.name == "Wallet: Request Amount") + #expect(sent.properties["Provider"] as? String == "Phantom") + #expect(sent.properties["Fiat"] as? Double == 5) + #expect(sent.properties["Currency"] as? String == "usd") + #expect(sent.properties["USDC"] as? Double == 5) + #expect(sent.properties["Quarks"] as? Double == 5_000_000) + } + + @Test("Only Phantom's user reject (4001) is Wallet: Cancel", arguments: [ + ("4001", true), + ("-32603", false), + ("4100", false), + ]) + func walletCancel(_ code: String, _ isCancel: Bool) { + defer { Keychain.connectedWalletSession = nil } + let connection = WalletConnection(owner: .mock, rpc: MockSolanaRPC(), preferredLiquidityPool: { .unknown }) + let sent = Analytics.recordingSends { + connection.didReceiveURL(url: URL(string: "https://app.flipcash.com/wallet/transactionSigned?errorCode=\(code)")!) + } + #expect(sent.map(\.name) == (isCancel ? ["Wallet: Cancel"] : [])) + } + + @Test("An expired blockhash is not a failed wallet transaction", arguments: [ + ("Transaction simulation failed: Blockhash not found", true), + ("BlockhashNotFound", true), + ("Transaction simulation failed: Error processing Instruction 2", false), + (nil, false), + ] as [(String?, Bool)]) + func blockhashNotFound(_ message: String?, _ expected: Bool) { + #expect(PhantomDepositOperation.isBlockhashNotFound(message: message) == expected) + } }