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/AddMoneyAmountViewModel.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift index 9cefaf016..c70fe2a23 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 @@ -194,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) ) @@ -207,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/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/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/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/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..c72c37a3b 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 @@ -52,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 { @@ -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 @@ -99,27 +93,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 @@ -158,6 +136,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)) } } @@ -265,22 +245,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 { @@ -353,46 +317,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) } } @@ -489,9 +457,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. @@ -798,40 +764,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 } } } @@ -839,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)) + } - track(event: WalletEvent.requestAmount, properties: properties) + /// 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)) + } + + /// 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)) } } @@ -951,31 +932,23 @@ 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. + 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, - .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 + ) } } @@ -1020,6 +993,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/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 new file mode 100644 index 000000000..82147f3f2 --- /dev/null +++ b/FlipcashTests/Analytics/CatalogueParityEventsTests.swift @@ -0,0 +1,287 @@ +// +// 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) + } + + // 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) + } + + // 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) + } +} 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()