Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ struct DeepLinkAction {
if sheet == .give {
let rate = container.ratesController.rateForBalanceCurrency()
let gate = giveCashGate(session: container.session, rate: rate)
if let dialog = gate.blockingDialog(router: container.appRouter) {
if let dialog = gate.blockingDialog(router: container.appRouter, addMoneySource: .giveShortfall) {
container.session.dialogItem = dialog
return
}
Expand Down
6 changes: 4 additions & 2 deletions Flipcash/Core/Navigation/AppRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,10 @@ final class AppRouter {
}

/// Presents the Add Money sheet — as the root sheet when nothing is
/// presented, stacked on top of the current sheet otherwise.
func presentAddMoney(_ context: AddMoneyContext) {
/// presented, stacked on top of the current sheet otherwise. `source`
/// names the entry point on the funnel's opened event.
func presentAddMoney(_ context: AddMoneyContext, source: Analytics.AddMoneySource) {
Analytics.addMoneyOpened(source: source)
if presentedSheets.isEmpty {
present(.addMoney(context))
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ struct ConversationScreen: View {
private func sendCash() {
guard let sendTarget else { return }
let rate = ratesController.rateForBalanceCurrency()
if let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router) {
if let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router, addMoneySource: .chat) {
session.dialogItem = dialog
return
}
Expand Down
18 changes: 18 additions & 0 deletions Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ final class AddMoneyAmountViewModel {
verificationCoordinator: VerificationCoordinator,
onProceed: @escaping (AddMoneyProcessingInput) -> Void
) {
Analytics.addMoneyAmountConfirmed(method: .coinbase, exchangedFiat: amount)
verificationCoordinator.runGated(
for: session,
bind: { [weak self] vm in self?.verificationViewModel = vm }
Expand All @@ -134,6 +135,12 @@ final class AddMoneyAmountViewModel {
)
}
} catch let DepositError.externalRejected(title, subtitle) {
Analytics.addMoney(
method: .coinbase,
exchangedFiat: amount,
successful: false,
error: DepositError.externalRejected(title: title, subtitle: subtitle)
)
self?.dialogItem = .error(title: title, subtitle: subtitle)
} catch {
logger.error("Coinbase deposit failed", metadata: [
Expand All @@ -142,6 +149,7 @@ final class AddMoneyAmountViewModel {
"orderId": "\(operation.orderId ?? "nil")",
])
ErrorReporting.captureError(error)
Analytics.addMoney(method: .coinbase, exchangedFiat: amount, successful: false, error: error)
self?.dialogItem = .error(title: "Something Went Wrong", subtitle: "Please try again later")
}
}
Expand All @@ -153,6 +161,7 @@ final class AddMoneyAmountViewModel {
walletConnection: any TransactionSigning,
onProceed: @escaping (AddMoneyProcessingInput) -> Void
) {
Analytics.addMoneyAmountConfirmed(method: .phantom, exchangedFiat: amount)
let operation = PhantomDepositOperation(walletConnection: walletConnection)
depositTask = Task { [weak self, operation] in
self?.actionButtonState = .loading
Expand All @@ -163,14 +172,23 @@ final class AddMoneyAmountViewModel {
onProceed(.init(amount: amount, method: .phantom, depositRef: operation.submittedSignature))
} catch is CancellationError {
// User dismissed the Phantom flow — silent.
} catch DepositError.userCancelled {
// User rejected the transaction in Phantom — silent.
} catch let DepositError.externalRejected(title, subtitle) {
Analytics.addMoney(
method: .phantom,
exchangedFiat: amount,
successful: false,
error: DepositError.externalRejected(title: title, subtitle: subtitle)
)
self?.dialogItem = .error(title: title, subtitle: subtitle)
} catch {
logger.error("Phantom deposit failed", metadata: [
"error": "\(error)",
"amount": "\(amount.nativeAmount.formatted())",
])
ErrorReporting.captureError(error)
Analytics.addMoney(method: .phantom, exchangedFiat: amount, successful: false, error: error)
self?.dialogItem = .error(title: "Something Went Wrong", subtitle: "Please try again later")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ final class AddMoneyProcessingViewModel {
"depositRef": "\(input.depositRef ?? "nil")",
])
displayState = .success
Analytics.addMoney(method: input.method, exchangedFiat: input.amount, successful: true, error: nil)
return
}

Expand All @@ -138,5 +139,16 @@ final class AddMoneyProcessingViewModel {
"depositRef": "\(input.depositRef ?? "nil")",
])
displayState = .failed
Analytics.addMoney(
method: input.method,
exchangedFiat: input.amount,
successful: false,
error: SettlementError.deliveryTimedOut
)
}

/// The USDF credit never arrived within the poll deadline.
private enum SettlementError: Error {
case deliveryTimedOut
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ struct AddMoneyStartScreen: View {
/// Over the buy amount sheet the deposit flow pushes onto that sheet's
/// stack; everywhere else it opens as its own sheet on top.
private func select(_ method: DepositMethod) {
Analytics.addMoneyMethodSelected(method: method)
if router.isAddMoneyOverBuy {
router.dismissSheet()
router.pushAny(AddMoneyFlowStep.method(method))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ final class CoinbaseDepositOperation {
state = .working
let order = try await createOrder(for: amount)
orderId = order.id
Analytics.addMoneyPaymentInvoked(method: .coinbase, exchangedFiat: amount)

coinbaseService.setOrder(order)
defer { coinbaseService.clearOrder() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ final class PhantomDepositOperation {
swapId: swapId,
displayName: "USDF"
)
Analytics.addMoneyPaymentInvoked(method: .phantom, exchangedFiat: amount)

let signedTx = try await awaitSignedTransaction(swapId: swapId)

Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Main/BalanceScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ private struct BalanceScreenContent: View {
.frame(maxWidth: .infinity, alignment: .center)

BubbleButton(text: "Add Money") {
router.presentAddMoney(.general)
router.presentAddMoney(.general, source: .balance)
}
.padding(.top, 8)
}
Expand Down Expand Up @@ -195,7 +195,7 @@ private struct BalanceScreenContent: View {
} footer: {
if hasBalances {
CodeButton(style: .filledSecondary, title: "Add Money") {
router.presentAddMoney(.general)
router.presentAddMoney(.general, source: .balance)
}
.padding(.horizontal, 20)
.padding(.top, 20)
Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ final class BuyAmountViewModel: Identifiable {
break
case .insufficient:
session.dialogItem = .noBalance(subtitle: AddMoneyContext.buyCurrency.noBalanceSubtitle) {
router.presentAddMoney(.buyCurrency)
router.presentAddMoney(.buyCurrency, source: .buyShortfall)
}
return
}
Expand Down Expand Up @@ -120,7 +120,7 @@ final class BuyAmountViewModel: Identifiable {
// Race: the balance gate said OK but the reserves buy disagreed.
actionButtonState = .normal
session.dialogItem = .noBalance(subtitle: AddMoneyContext.buyCurrency.noBalanceSubtitle) {
router.presentAddMoney(.buyCurrency)
router.presentAddMoney(.buyCurrency, source: .buyShortfall)
}
} catch Session.Error.verifiedStateStale {
actionButtonState = .normal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ struct CurrencyCreationSummaryScreen: View {
Button("Get Started") {
if shouldAddMoneyBeforeLaunch(session: session, launchCost: totalLaunchCost.onChainAmount) {
session.dialogItem = .noBalance(subtitle: AddMoneyContext.createCurrency.noBalanceSubtitle) {
router.presentAddMoney(.createCurrency)
router.presentAddMoney(.createCurrency, source: .buyShortfall)
}
} else {
router.push(.currencyCreationWizard)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@ struct CurrencyCreationWizardScreen: View {
launchAndBuyWithReserves()
} else {
session.dialogItem = .noBalance(subtitle: AddMoneyContext.createCurrency.noBalanceSubtitle) {
router.presentAddMoney(.createCurrency)
router.presentAddMoney(.createCurrency, source: .buyShortfall)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ private struct CurrencyInfoScreenContent: View {
private func presentBuyOrAddMoney() {
if shouldAddMoneyBeforeBuy(session: session) {
session.dialogItem = .noBalance(subtitle: AddMoneyContext.buyCurrency.noBalanceSubtitle) {
router.presentAddMoney(.buyCurrency)
router.presentAddMoney(.buyCurrency, source: .buyShortfall)
}
} else {
router.presentNested(.buy(mint))
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Main/ScanScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ private struct ScanScreenContent: View {

private func presentGive() {
let rate = sessionContainer.ratesController.rateForBalanceCurrency()
if let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router) {
if let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router, addMoneySource: .scanner) {
session.dialogItem = dialog
return
}
Expand Down
10 changes: 7 additions & 3 deletions Flipcash/Core/Screens/Settings/Deposit/DepositScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ struct DepositScreen: View {

private let address: String
private let name: String?
private let mint: PublicKey

init(address: String, name: String?) {
init(address: String, name: String?, mint: PublicKey) {
self.address = address
self.name = name
self.mint = mint
}

var body: some View {
Expand Down Expand Up @@ -52,6 +54,7 @@ struct DepositScreen: View {

private func copyAddress() {
UIPasteboard.general.string = address
Analytics.addMoneyAddressCopied(mint: mint)
buttonState = .successText("Copied")
Task {
try? await Task.sleep(for: .seconds(1.5))
Expand All @@ -69,7 +72,7 @@ extension DepositScreen {
/// exist on-chain yet, so wallets derive another ATA and funds land one
/// level deeper than the server queries.
static func usdcDeposit(session: Session) -> DepositScreen {
DepositScreen(address: session.owner.authorityPublicKey.base58, name: "USDC")
DepositScreen(address: session.owner.authorityPublicKey.base58, name: "USDC", mint: .usdc)
}

/// The deposit screen for `mint`'s derived deposit ATA, or `nil` when
Expand All @@ -79,7 +82,8 @@ extension DepositScreen {
let vmAuthority = balance.vmAuthority else { return nil }
return DepositScreen(
address: session.owner.use(mint: mint, timeAuthority: vmAuthority).depositPublicKey.base58,
name: mint == .usdf ? balance.symbol : balance.name
name: mint == .usdf ? balance.symbol : balance.name,
mint: mint
)
}
}
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Settings/SettingsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ struct SettingsScreen: View {
VStack(spacing: 6) {
HStack(spacing: 12) {
Button("Add Money") {
router.presentAddMoney(.general)
router.presentAddMoney(.general, source: .menu)
}
.buttonStyle(.card(icon: .deposit))

Expand Down
7 changes: 4 additions & 3 deletions Flipcash/UI/DialogItem+CashFlows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,18 @@ extension DialogItem {

extension GiveCashGate {
/// The blocking dialog for this gate outcome, or `nil` to proceed into
/// the flow.
/// the flow. `addMoneySource` names the hosting entry point on the Add
/// Money funnel's opened event.
@MainActor
func blockingDialog(router: AppRouter) -> DialogItem? {
func blockingDialog(router: AppRouter, addMoneySource: Analytics.AddMoneySource) -> DialogItem? {
switch self {
case .proceed:
nil
case .discoverCurrencies:
.noCommunityCurrencies { router.present(.discover) }
case .addMoney:
.noBalance(subtitle: AddMoneyContext.giveCash.noBalanceSubtitle) {
router.presentAddMoney(.giveCash)
router.presentAddMoney(.giveCash, source: addMoneySource)
}
}
}
Expand Down
81 changes: 81 additions & 0 deletions Flipcash/Utilities/Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,29 @@ extension Analytics {
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 opened = "Add Money: Opened"
case methodSelected = "Add Money: Method Selected"
case amountConfirmed = "Add Money: Amount Confirmed"
case paymentInvoked = "Add Money: Payment Invoked"
case addressCopied = "Add Money: Address Copied"
case terminal = "Add Money"
}

/// The `Source` property of `AddMoneyEvent.opened` — where the user
/// entered the flow. Values are shared verbatim with Android.
enum AddMoneySource: String {
case menu = "Menu"
case giveShortfall = "Give Shortfall"
case buyShortfall = "Buy Shortfall"
case chat = "Chat"
case scanner = "Scanner"
case balance = "Balance"
}
}

// MARK: - General -
Expand Down Expand Up @@ -192,6 +215,62 @@ extension Analytics {
}
}

// MARK: - Add Money -

extension Analytics {
static func addMoneyOpened(source: AddMoneySource) {
track(event: AddMoneyEvent.opened, properties: [.source: source.rawValue])
}

static func addMoneyMethodSelected(method: DepositMethod) {
track(event: AddMoneyEvent.methodSelected, properties: [.method: method.analyticsValue])
}

static func addMoneyAmountConfirmed(method: DepositMethod, exchangedFiat: ExchangedFiat) {
var properties = amountProperties(exchangedFiat)
properties[.method] = method.analyticsValue
track(event: AddMoneyEvent.amountConfirmed, properties: properties)
}

static func addMoneyPaymentInvoked(method: DepositMethod, exchangedFiat: ExchangedFiat) {
var properties = amountProperties(exchangedFiat)
properties[.method] = method.analyticsValue
track(event: AddMoneyEvent.paymentInvoked, properties: properties)
}

static func addMoneyAddressCopied(mint: PublicKey) {
track(event: AddMoneyEvent.addressCopied, properties: [.mint: mint.base58])
}

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)
}

private 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 DepositMethod {
/// The `Method` property value, shared verbatim with Android.
var analyticsValue: String {
switch self {
case .coinbase: "Coinbase"
case .phantom: "Phantom"
case .otherWallet: "Other Wallet"
}
}
}

// MARK: - Onramp -

extension Analytics {
Expand Down Expand Up @@ -326,6 +405,8 @@ extension Analytics {
case time = "Time"

case state = "State"
case source = "Source"
case method = "Method"
case quarks = "Quarks"
case mint = "Mint"
case fiat = "Fiat"
Expand Down
Loading