Skip to content
Merged
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
24 changes: 13 additions & 11 deletions Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -238,6 +238,7 @@ public final class WalletConnection {

Keychain.connectedWalletSession = session
self.session = session
Analytics.walletConnected()

if let continuation = pendingConnect {
pendingConnect = nil
Expand Down Expand Up @@ -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`.
Expand All @@ -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
Expand Down Expand Up @@ -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)",
])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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)
)
Expand All @@ -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")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions Flipcash/Core/Screens/Main/AddMoney/PhantomDepositOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")",
Expand All @@ -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)",
Expand All @@ -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.
Expand All @@ -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)",
Expand All @@ -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")",
Expand Down
38 changes: 21 additions & 17 deletions Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
Loading
Loading