From 61feba7fafd5d6ddd732a0202c29a5369a91a4d0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 1 Nov 2026 21:44:45 -0500 Subject: [PATCH 1/2] feat(analytics): add phase durations to Add Money for Coinbase Seven optional duration properties split a Coinbase add-money purchase into Order, Payment, Coinbase, Deposit, Swap and Credit phases plus a total, so a dashboard can show which phase slowed down. The event description names each property's start and end moment in platform-neutral words so iOS can time the same spans. Token Purchase With {method}, Onramp: {step} and Create Account Payment drop the "part 2 decides whether iOS sends it" wording: iOS sends all three. --- libs/analytics-events/EVENTS.md | 15 +++++--- libs/analytics-events/events.toml | 16 ++++++--- .../flipcash/analytics/AddMoneyEventsTest.kt | 34 +++++++++++++++++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md index 2eab241348..8dc934fb50 100644 --- a/libs/analytics-events/EVENTS.md +++ b/libs/analytics-events/EVENTS.md @@ -400,7 +400,7 @@ The creator closed a payment request before its deadline, on success or failure. ### Add Money -The outcome of an add-money flow, sent as the event named `Add Money`. +The outcome of an add-money flow, sent as the event named `Add Money`. The seven Time properties split a Coinbase purchase into phases and are sent only when Method is Coinbase. Order Time: from the user confirming the amount to Coinbase returning the placed order. Payment Time: from the order placed to Coinbase's `onramp_api.payment_authorized` event, so it includes the user's own time on the payment sheet. Coinbase Time: from `onramp_api.payment_authorized` to Coinbase's `onramp_api.polling_success` event. Deposit Time: from `onramp_api.polling_success` to the app first seeing a nonzero USDC balance in the user's account, which includes waiting for Coinbase to send it on-chain. Swap Time: from the USDC being seen to the USDC to USDF swap returning success. Credit Time: from the swap succeeding to the app seeing the USDF credited and showing success. Total Time: from the user confirming the amount to this event being sent, on success or failure. On a failure, only the phases that finished are sent, plus Total Time. When the user is sent to verify a phone number or email first, both clocks restart when they come back and the amount is confirmed again. | Property | Type | Optional | Values | |---|---|---|---| @@ -408,6 +408,13 @@ The outcome of an add-money flow, sent as the event named `Add Money`. | State | list | no | [State](#state) | | amount | group | yes | [amount](#amount) | | Error | text | yes | | +| Order Time | duration | yes | | +| Payment Time | duration | yes | | +| Coinbase Time | duration | yes | | +| Deposit Time | duration | yes | | +| Swap Time | duration | yes | | +| Credit Time | duration | yes | | +| Total Time | duration | yes | | > **Drift:** iOS sends Exchange Rate and no USDC (see Amount). @@ -415,8 +422,6 @@ The outcome of an add-money flow, sent as the event named `Add Money`. ### Onramp: {step} -Show Verification Info is Android only; part 2 decides whether iOS sends it. - `{step}` in the name is one of [OnrampStep](#onrampstep). No properties. @@ -500,7 +505,7 @@ Buying and selling a token. ### Token Purchase With {method} -Phantom and Coinbase are Android only; part 2 decides whether iOS sends them. Currency is a buy paid with a held currency other than USDF reserves. +Currency is a buy paid with a held currency other than USDF reserves. `{method}` in the name is one of [PurchaseMethod](#purchasemethod). @@ -649,7 +654,7 @@ The user replaced an existing display name. ### Create Account Payment -Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at registration and is a different moment. +The account-creation payment. It is a different moment from Create Account, which fires at registration. | Property | Type | Optional | Values | |---|---|---|---| diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 356cb6a737..84e2c4add2 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -676,8 +676,15 @@ builder = "result" properties = [ { name = "Method", type = "AddMoneyMethod" }, { group = "outcome" }, -] -description = "The outcome of an add-money flow, sent as the event named `Add Money`." + { name = "Order Time", type = "duration", optional = true }, + { name = "Payment Time", type = "duration", optional = true }, + { name = "Coinbase Time", type = "duration", optional = true }, + { name = "Deposit Time", type = "duration", optional = true }, + { name = "Swap Time", type = "duration", optional = true }, + { name = "Credit Time", type = "duration", optional = true }, + { name = "Total Time", type = "duration", optional = true }, +] +description = "The outcome of an add-money flow, sent as the event named `Add Money`. The seven Time properties split a Coinbase purchase into phases and are sent only when Method is Coinbase. Order Time: from the user confirming the amount to Coinbase returning the placed order. Payment Time: from the order placed to Coinbase's `onramp_api.payment_authorized` event, so it includes the user's own time on the payment sheet. Coinbase Time: from `onramp_api.payment_authorized` to Coinbase's `onramp_api.polling_success` event. Deposit Time: from `onramp_api.polling_success` to the app first seeing a nonzero USDC balance in the user's account, which includes waiting for Coinbase to send it on-chain. Swap Time: from the USDC being seen to the USDC to USDF swap returning success. Credit Time: from the swap succeeding to the app seeing the USDF credited and showing success. Total Time: from the user confirming the amount to this event being sent, on success or failure. On a failure, only the phases that finished are sent, plus Total Time. When the user is sent to verify a phone number or email first, both clocks restart when they come back and the amount is confirmed again." drift = "iOS sends Exchange Rate and no USDC (see Amount)." # Onramp @@ -687,7 +694,6 @@ domain = "Onramp" name = "Onramp: {step}" builder = "step" placeholders = { step = "OnrampStep" } -description = "Show Verification Info is Android only; part 2 decides whether iOS sends it." # Wallet @@ -763,7 +769,7 @@ properties = [ { group = "amount" }, { name = "Error", type = "text", optional = true }, ] -description = "Phantom and Coinbase are Android only; part 2 decides whether iOS sends them. Currency is a buy paid with a held currency other than USDF reserves." +description = "Currency is a buy paid with a held currency other than USDF reserves." drift = "(Reserves and Currency) iOS also sends State, Payment Mint and Payment Token Symbol, and sends only Fiat and Currency of the amount, with no USDC or Quarks." [[event]] @@ -903,7 +909,7 @@ properties = [ { name = "Currency", type = "text" }, { name = "Owner Public Key", type = "text", param = "owner" }, ] -description = "Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at registration and is a different moment." +description = "The account-creation payment. It is a different moment from Create Account, which fires at registration." [[event]] domain = "Account" diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AddMoneyEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AddMoneyEventsTest.kt index 6a54b8080a..f0bbf5c847 100644 --- a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AddMoneyEventsTest.kt +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AddMoneyEventsTest.kt @@ -81,4 +81,38 @@ class AddMoneyEventsTest { AddMoneyEvents.result(AddMoneyMethod.OTHER_WALLET, State.FAILURE, null, "declined"), ) } + + @Test + fun resultCarriesTheCoinbasePhaseTimesInMilliseconds() { + assertEquals( + AnalyticsEvent( + "Add Money", + mapOf( + "Method" to PropertyValue.Text("Coinbase"), + "State" to PropertyValue.Text("Success"), + ) + amountProperties + mapOf( + "Order Time" to PropertyValue.Number(1_000.0), + "Payment Time" to PropertyValue.Number(2_000.0), + "Coinbase Time" to PropertyValue.Number(3_000.0), + "Deposit Time" to PropertyValue.Number(4_000.0), + "Swap Time" to PropertyValue.Number(5_000.0), + "Credit Time" to PropertyValue.Number(6_000.0), + "Total Time" to PropertyValue.Number(21_000.0), + ), + ), + AddMoneyEvents.result( + AddMoneyMethod.COINBASE, + State.SUCCESS, + amount, + null, + orderTimeMillis = 1_000, + paymentTimeMillis = 2_000, + coinbaseTimeMillis = 3_000, + depositTimeMillis = 4_000, + swapTimeMillis = 5_000, + creditTimeMillis = 6_000, + totalTimeMillis = 21_000, + ), + ) + } } From db47676a4b5f33fd393f42b3497d5647ca796918 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 1 Nov 2026 21:51:06 -0500 Subject: [PATCH 2/2] feat(tokens): send Coinbase add-money phase durations AddMoneyTimer holds a monotonic mark per moment and turns them into the Add Money phase durations; the clock is injectable so tests drive it with TestTimeSource. SwapViewModel starts it when the amount is confirmed and reads it on every Coinbase Add Money send, success or failure. Two moments had no outlet. The webview's payment_authorized event now reaches CoinbaseOnRampController, which emits it and polling_success on a non-conflated milestones flow; the state flow can skip Completed when the handler resets right after. UsdcDepositSweep emits UsdcSeen and Swapped on its own milestones flow. It is a singleton the session also starts, so the view model listens to whichever sweep runs; the timer ignores sweep moments before polling_success so a leftover-balance sweep can't count. --- .../app/onramp/CoinbaseOnRampController.kt | 10 ++ .../app/onramp/CoinbaseOnRampHandler.kt | 3 + .../app/onramp/CoinbaseOnRampState.kt | 12 ++ .../app/onramp/CoinbaseOnRampWebview.kt | 4 + .../internal/CoinbaseOnRampEventHandler.kt | 2 + .../onramp/CoinbaseOnRampControllerTest.kt | 19 +++ .../CoinbaseOnRampEventHandlerTest.kt | 9 ++ .../flipcash/app/tokens/AddMoneyTimerTest.kt | 118 +++++++++++++++ .../app/tokens/UsdcDepositSweepTest.kt | 54 +++++++ .../ui/SwapViewModelBalancePrefetchTest.kt | 1 + .../app/tokens/ui/SwapViewModelErrorTest.kt | 138 ++++++++++++++++++ .../flipcash/app/tokens/ui/SwapViewModel.kt | 115 +++++++++------ .../com/flipcash/app/tokens/AddMoneyTimer.kt | 93 ++++++++++++ .../flipcash/app/tokens/UsdcDepositSweep.kt | 21 +++ 14 files changed, 555 insertions(+), 44 deletions(-) create mode 100644 apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/AddMoneyTimerTest.kt create mode 100644 apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/AddMoneyTimer.kt diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt index 8be965f739..fcd568ff30 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt @@ -91,6 +91,9 @@ class CoinbaseOnRampController @Inject constructor( private val _pendingCompletion = MutableSharedFlow(extraBufferCapacity = 1) val pendingCompletion: SharedFlow = _pendingCompletion.asSharedFlow() + private val _milestones = MutableSharedFlow(extraBufferCapacity = 4) + val milestones: SharedFlow = _milestones.asSharedFlow() + fun emitCompletion(completion: CoinbaseOnRampCompletion) { _pendingCompletion.tryEmit(completion) } @@ -99,9 +102,16 @@ class CoinbaseOnRampController @Inject constructor( _state.value = CoinbaseOnRampState.Paying(order, token, amount, swapId) } + fun onPaymentAuthorized() { + if (_state.value is CoinbaseOnRampState.Paying) { + _milestones.tryEmit(CoinbaseOnRampMilestone.PaymentAuthorized) + } + } + fun onPaymentSuccess(orderId: String) { val current = _state.value if (current is CoinbaseOnRampState.Paying) { + _milestones.tryEmit(CoinbaseOnRampMilestone.PaymentCompleted) _state.update { CoinbaseOnRampState.Completed( current.swapId, diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt index 3e911591aa..321a014497 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt @@ -39,6 +39,9 @@ fun CoinbaseOnRampHandler( onCancel = { controller.onPaymentCancel() }, + onPaymentAuthorized = { + controller.onPaymentAuthorized() + }, ) } diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt index 665f9c0f79..5a6a46c144 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt @@ -21,6 +21,18 @@ sealed interface CoinbaseOnRampState { data class Failed(val error: CoinbaseOnRampWebError) : CoinbaseOnRampState } +/** + * Coinbase's progress through a payment, for timing it. Unlike [CoinbaseOnRampState] these are + * not conflated, so a collector sees each one even when the state moves on straight after. + */ +enum class CoinbaseOnRampMilestone { + /** Coinbase's `onramp_api.payment_authorized` webview event. */ + PaymentAuthorized, + + /** Coinbase's `onramp_api.polling_success` webview event. */ + PaymentCompleted, +} + sealed interface CoinbaseOnRampCompletion { data class SwapSubmitted(val swapId: SwapId) : CoinbaseOnRampCompletion data class DepositSubmitted(val orderId: String) : CoinbaseOnRampCompletion diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampWebview.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampWebview.kt index 977c1de862..9b6a64a933 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampWebview.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampWebview.kt @@ -34,6 +34,7 @@ fun CoinbaseOnRampWebview( onPaymentSuccess: (String) -> Unit, onPaymentFailure: (CoinbaseOnRampWebError) -> Unit, onCancel: () -> Unit, + onPaymentAuthorized: () -> Unit = {}, ) { var cleanup: (() -> Unit)? = null ComposeWebView( @@ -47,6 +48,7 @@ fun CoinbaseOnRampWebview( onPaymentSuccess = { onPaymentSuccess(orderId) }, onPaymentFailure = onPaymentFailure, onCancel = onCancel, + onPaymentAuthorized = onPaymentAuthorized, ) }, onRelease = { cleanup?.invoke() }, @@ -59,6 +61,7 @@ private fun WebView.configureForCoinbaseOnRamp( onPaymentSuccess: () -> Unit, onPaymentFailure: (CoinbaseOnRampWebError) -> Unit, onCancel: () -> Unit, + onPaymentAuthorized: () -> Unit, ): () -> Unit { val startMark = TimeSource.Monotonic.markNow() val webViewVersion = WebViewCompat.getCurrentWebViewPackage(context)?.versionName.orEmpty() @@ -177,6 +180,7 @@ private fun WebView.configureForCoinbaseOnRamp( onPaymentSuccess = wrappedOnPaymentSuccess, onPaymentFailure = wrappedOnPaymentFailure, onCancel = wrappedOnCancel, + onPaymentAuthorized = onPaymentAuthorized, onHeartbeat = heartbeat, onPauseWatchdog = pauseWatchdog, onAutoClickGPay = { diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandler.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandler.kt index 1f43e4354e..958febcf53 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandler.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandler.kt @@ -141,6 +141,7 @@ internal class CoinbaseOnRampEventHandler( private val onAutoClickGPay: () -> Unit, private val onHeartbeat: () -> Unit = {}, private val onPauseWatchdog: () -> Unit = {}, + private val onPaymentAuthorized: () -> Unit = {}, ) { private var errorReported = false private var watchdogPaused = false @@ -221,6 +222,7 @@ internal class CoinbaseOnRampEventHandler( "onramp_api.payment_authorized" -> { watchdogPaused = false + onPaymentAuthorized() } "timing.gpay_button_clicked" -> { diff --git a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt index 24589adc9a..fa7d1f3524 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt @@ -26,6 +26,8 @@ import io.mockk.mockkStatic import io.mockk.unmockkStatic import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -291,6 +293,23 @@ class CoinbaseOnRampControllerTest { // endregion + // region milestones + + @Test + fun `no milestones are emitted outside a payment`() = runTest { + val seen = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + controller.milestones.collect { seen += it } + } + + controller.onPaymentAuthorized() + controller.onPaymentSuccess("order") + + assertEquals(emptyList(), seen) + } + + // endregion + // region email resolution (requireCoinbaseEmailVerification flag) @Test diff --git a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandlerTest.kt b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandlerTest.kt index 96c7331c3c..d3e896dfd3 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandlerTest.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandlerTest.kt @@ -47,6 +47,7 @@ class CoinbaseOnRampEventHandlerTest { private var autoClickCount = 0 private var heartbeatCount = 0 private var pauseWatchdogCount = 0 + private var authorizedCount = 0 private var lastError: CoinbaseOnRampWebError? = null private val handler = CoinbaseOnRampEventHandler( @@ -60,6 +61,7 @@ class CoinbaseOnRampEventHandlerTest { onAutoClickGPay = { autoClickCount++ }, onHeartbeat = { heartbeatCount++ }, onPauseWatchdog = { pauseWatchdogCount++ }, + onPaymentAuthorized = { authorizedCount++ }, ) // --- Event routing --- @@ -82,6 +84,13 @@ class CoinbaseOnRampEventHandlerTest { assertEquals(1, successCount) } + @Test + fun paymentAuthorizedTriggersOnPaymentAuthorized() { + handler.handleEvent("""{"eventName":"onramp_api.payment_authorized"}""") + assertEquals(1, authorizedCount) + assertEquals(0, successCount) + } + @Test fun cancelTriggersOnCancel() { handler.handleEvent("""{"eventName":"onramp_api.cancel"}""") diff --git a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/AddMoneyTimerTest.kt b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/AddMoneyTimerTest.kt new file mode 100644 index 0000000000..aed7d0e1c1 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/AddMoneyTimerTest.kt @@ -0,0 +1,118 @@ +package com.flipcash.app.tokens + +import com.flipcash.app.tokens.AddMoneyTimer.Moment +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TestTimeSource + +class AddMoneyTimerTest { + + private val timeSource = TestTimeSource() + private val timer = AddMoneyTimer(timeSource) + + @Test + fun `each phase runs from the previous moment to its own`() { + timer.start() + Moment.entries.forEachIndexed { i, moment -> + timeSource += ((i + 1) * 100).milliseconds + timer.mark(moment) + } + timeSource += 50.milliseconds + + assertEquals( + AddMoneyPhases( + orderTimeMillis = 100, + paymentTimeMillis = 200, + coinbaseTimeMillis = 300, + depositTimeMillis = 400, + swapTimeMillis = 500, + creditTimeMillis = 600, + totalTimeMillis = 2_150, + ), + timer.finish(), + ) + } + + @Test + fun `a phase whose start never happened is absent`() { + timer.start() + timeSource += 100.milliseconds + timer.mark(Moment.ORDER_PLACED) + timeSource += 100.milliseconds + // payment_authorized never arrived. + timer.mark(Moment.PAYMENT_COMPLETED) + timeSource += 100.milliseconds + timer.mark(Moment.USDC_SEEN) + + assertEquals( + AddMoneyPhases( + orderTimeMillis = 100, + paymentTimeMillis = null, + coinbaseTimeMillis = null, + depositTimeMillis = 100, + swapTimeMillis = null, + creditTimeMillis = null, + totalTimeMillis = 300, + ), + timer.finish(), + ) + } + + @Test + fun `a moment keeps its first mark`() { + timer.start() + timeSource += 100.milliseconds + timer.mark(Moment.ORDER_PLACED) + timeSource += 100.milliseconds + timer.mark(Moment.ORDER_PLACED) + + assertEquals(100, timer.finish()?.orderTimeMillis) + } + + @Test + fun `sweep moments before Coinbase completes the payment are ignored`() { + timer.start() + timer.mark(Moment.ORDER_PLACED) + // A sweep of leftover USDC, not this purchase's deposit. + timer.mark(Moment.USDC_SEEN) + timer.mark(Moment.SWAPPED) + timeSource += 100.milliseconds + timer.mark(Moment.PAYMENT_AUTHORIZED) + timer.mark(Moment.PAYMENT_COMPLETED) + timeSource += 100.milliseconds + timer.mark(Moment.USDC_SEEN) + + val phases = timer.finish() + assertEquals(100, phases?.depositTimeMillis) + assertNull(phases?.swapTimeMillis) + } + + @Test + fun `finish closes the purchase`() { + timer.start() + timer.finish() + + timer.mark(Moment.ORDER_PLACED) + assertNull(timer.finish()) + } + + @Test + fun `marks before start are ignored`() { + timer.mark(Moment.ORDER_PLACED) + timer.start() + + assertNull(timer.finish()?.orderTimeMillis) + } + + @Test + fun `start drops an earlier purchase's marks`() { + timer.start() + timer.mark(Moment.ORDER_PLACED) + timeSource += 100.milliseconds + timer.start() + + assertNull(timer.finish()?.orderTimeMillis) + } +} diff --git a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt index 40e88c525f..84d95a5e25 100644 --- a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt +++ b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt @@ -14,7 +14,10 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.After @@ -153,6 +156,57 @@ class UsdcDepositSweepTest { } } + @Test + fun `reports USDC seen once a balance appears, then the swap`() = runTest(testDispatchers.dispatcher) { + var callCount = 0 + coEvery { + accountController.getAccount(any(), any(), any()) + } coAnswers { + callCount++ + val balance = if (callCount >= 2) 1_000_000L else 0L + val accountInfo = mockk { + every { accountType } returns AccountType.AssociatedToken + every { this@mockk.balance } returns balance + every { address } returns mockk(relaxed = true) + } + Result.success(accountInfo) + } + coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit) + val seen = collectMilestones() + + sweep.execute(owner) + // A second caller finds this sweep in flight and still sees its milestones. + sweep.execute(owner) + advanceUntilIdle() + + assertEquals(listOf(UsdcSweepMilestone.UsdcSeen, UsdcSweepMilestone.Swapped), seen) + } + + @Test + fun `reports neither when no USDC appears, and no swap when it fails`() = runTest(testDispatchers.dispatcher) { + val seen = collectMilestones() + stubUsdcAccount(balance = 0L) + + sweep.execute(owner) + advanceUntilIdle() + assertEquals(emptyList(), seen) + + stubUsdcAccount(balance = 1_000_000L) + coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.failure(RuntimeException("boom")) + + sweep.execute(owner) + advanceUntilIdle() + assertEquals(listOf(UsdcSweepMilestone.UsdcSeen), seen) + } + + private fun TestScope.collectMilestones(): List { + val seen = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + sweep.milestones.collect { seen += it } + } + return seen + } + @Test fun `polls for USDF balance on successful swap`() = runTest(testDispatchers.dispatcher) { stubUsdcAccount(balance = 1_000_000L) diff --git a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/SwapViewModelBalancePrefetchTest.kt b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/SwapViewModelBalancePrefetchTest.kt index c003f67e68..9cbdc90e1c 100644 --- a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/SwapViewModelBalancePrefetchTest.kt +++ b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/SwapViewModelBalancePrefetchTest.kt @@ -118,6 +118,7 @@ class SwapViewModelBalancePrefetchTest { userFlags = mockk(relaxed = true), usdcDepositSweep = mockk(relaxed = true), balancePoller = mockk(relaxed = true), + addMoneyTimer = com.flipcash.app.tokens.AddMoneyTimer(), ) private fun SwapViewModel.primeForSell() { diff --git a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt index 7516c00407..56ecf5bf88 100644 --- a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt +++ b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt @@ -6,6 +6,7 @@ import com.getcode.opencode.model.core.errors.SwapError import androidx.lifecycle.viewModelScope import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.launchIn @@ -31,6 +32,8 @@ import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.InlineCompletion import com.flipcash.app.tokens.BalancePollError +import com.flipcash.app.tokens.AddMoneyTimer +import com.flipcash.app.tokens.UsdcSweepMilestone import com.flipcash.app.tokens.BalancePoller import com.flipcash.app.tokens.UsdcDepositSweep import com.flipcash.services.user.UserManager @@ -70,7 +73,10 @@ import io.mockk.mockkStatic import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.time.TestTimeSource +import com.flipcash.app.onramp.CoinbaseOnRampMilestone import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -111,6 +117,10 @@ class SwapViewModelErrorTest { private val userFlagsCoordinator = mockk(relaxed = true) private val usdcDepositSweep = mockk(relaxed = true) private val balancePoller = mockk(relaxed = true) + private val timeSource = TestTimeSource() + private val addMoneyTimer = AddMoneyTimer(timeSource) + private val coinbaseMilestones = MutableSharedFlow(extraBufferCapacity = 4) + private val sweepMilestones = MutableSharedFlow(extraBufferCapacity = 2) private val accountCluster = mockk(relaxed = true) @@ -126,6 +136,8 @@ class SwapViewModelErrorTest { every { PublicKey.generate() } returns mockk(relaxed = true) every { userManager.accountCluster } returns accountCluster + every { coinbaseOnRampController.milestones } returns coinbaseMilestones + every { usdcDepositSweep.milestones } returns sweepMilestones // A Get asks whether the mint already has a token account to decide first-buy vs top-up. // Nothing here exercises that branch, so answer "no" rather than leaving it on a relaxed @@ -167,6 +179,7 @@ class SwapViewModelErrorTest { userFlags = userFlagsCoordinator, usdcDepositSweep = usdcDepositSweep, balancePoller = balancePoller, + addMoneyTimer = addMoneyTimer, ) } @@ -698,6 +711,131 @@ class SwapViewModelErrorTest { ) } + // --- Coinbase phase times ------------------------------------------------------------------ + // + // Placing the order runs through Coinbase's JWT and order APIs, which this suite doesn't stand + // up, so these tests open the timer and mark the order themselves, as OnAmountConfirmed and + // placeOrderAndStartPayment do. + + private val amountKeys = setOf("Fiat", "Currency", "USDC", "Quarks", "Exchange Rate", "Mint") + + private fun SwapViewModel.buyUsdfWith(fundingSource: FundingSource) = dispatchEvent( + SwapViewModel.Event.OnPurposeChanged(SwapPurpose.Buy(Mint.usdf, fundingSource = fundingSource)) + ) + + /** Confirms the amount, then walks Coinbase's checkout through polling_success. */ + private suspend fun TestScope.payThroughCoinbase() { + addMoneyTimer.start() + timeSource += 100.milliseconds + addMoneyTimer.mark(AddMoneyTimer.Moment.ORDER_PLACED) + timeSource += 200.milliseconds + coinbaseMilestones.emit(CoinbaseOnRampMilestone.PaymentAuthorized) + runCurrent() + timeSource += 300.milliseconds + coinbaseMilestones.emit(CoinbaseOnRampMilestone.PaymentCompleted) + runCurrent() + timeSource += 400.milliseconds + } + + @Test + fun `a coinbase purchase sends Add Money with every phase time`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val usdf = MutableStateFlow(Fiat.Zero) + every { tokenCoordinator.balanceForToken(Mint.usdf) } returns usdf + coEvery { coinbaseOnRampController.awaitOrderDelivered("order-5") } returns + OrderDeliveryResult.Delivered(txHash = "sig") + + val vm = createViewModel() + vm.buyUsdfWith(FundingSource.Coinbase) + runCurrent() + payThroughCoinbase() + + vm.dispatchEvent(SwapViewModel.Event.DepositSubmitted(orderId = "order-5")) + runCurrent() + sweepMilestones.emit(UsdcSweepMilestone.UsdcSeen) + runCurrent() + timeSource += 500.milliseconds + sweepMilestones.emit(UsdcSweepMilestone.Swapped) + runCurrent() + timeSource += 600.milliseconds + usdf.value = Fiat(12.0) + advanceUntilIdle() + + val result = analytics.events.single { it.name == "Add Money" } + assertEquals( + AddMoneyEvents.result( + method = AddMoneyMethod.COINBASE, + state = AnalyticsState.SUCCESS, + amount = null, + error = null, + orderTimeMillis = 100, + paymentTimeMillis = 200, + coinbaseTimeMillis = 300, + depositTimeMillis = 400, + swapTimeMillis = 500, + creditTimeMillis = 600, + totalTimeMillis = 2_100, + ), + result.copy(properties = result.properties - amountKeys), + ) + } + + @Test + fun `a failed coinbase delivery sends the phases that completed and the total`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { tokenCoordinator.balanceForToken(Mint.usdf) } returns MutableStateFlow(Fiat.Zero) + coEvery { coinbaseOnRampController.awaitOrderDelivered("order-6") } returns + OrderDeliveryResult.Failed + + val vm = createViewModel() + vm.buyUsdfWith(FundingSource.Coinbase) + runCurrent() + payThroughCoinbase() + + vm.dispatchEvent(SwapViewModel.Event.DepositSubmitted(orderId = "order-6")) + advanceUntilIdle() + + val result = analytics.events.single { it.name == "Add Money" } + assertEquals( + AddMoneyEvents.result( + method = AddMoneyMethod.COINBASE, + state = AnalyticsState.FAILURE, + amount = null, + error = "Order delivery failed", + orderTimeMillis = 100, + paymentTimeMillis = 200, + coinbaseTimeMillis = 300, + totalTimeMillis = 1_000, + ), + result.copy(properties = result.properties - amountKeys), + ) + } + + @Test + fun `a phantom deposit sends Add Money without phase times`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val usdf = MutableStateFlow(Fiat.Zero) + every { tokenCoordinator.balanceForToken(Mint.usdf) } returns usdf + + val vm = createViewModel() + vm.buyUsdfWith(FundingSource.Phantom) + runCurrent() + // A Coinbase attempt left open earlier in the session must not leak into this send. + payThroughCoinbase() + + vm.dispatchEvent(SwapViewModel.Event.DepositSubmitted(orderId = null)) + runCurrent() + usdf.value = Fiat(12.0) + advanceUntilIdle() + + val result = analytics.events.single { it.name == "Add Money" } + assertEquals( + AddMoneyEvents.result(AddMoneyMethod.PHANTOM, AnalyticsState.SUCCESS, amount = null, error = null), + result.copy(properties = result.properties - amountKeys), + ) + } + @Test fun `a deposit outside a USDF buy tracks no Add Money result`() = runTest(mainCoroutineRule.dispatcher) { dispatchers = TestDispatchers(testScheduler) diff --git a/apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 3603908aae..42e84e30e0 100644 --- a/apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -3,6 +3,7 @@ package com.flipcash.app.tokens.ui import androidx.lifecycle.viewModelScope import com.flipcash.analytics.AddMoneyMethod import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.Amount as AnalyticsAmount import com.flipcash.analytics.PurchaseMethod as AnalyticsPurchaseMethod import com.flipcash.analytics.State as AnalyticsState import com.flipcash.analytics.events.AddMoneyEvents @@ -18,6 +19,8 @@ import com.getcode.opencode.internal.extensions.fractionDigits import com.getcode.opencode.model.accounts.AccountCluster import com.flipcash.app.core.extensions.to import com.flipcash.app.core.onramp.ui.buildPhantomButtonLabel +import com.flipcash.app.tokens.AddMoneyTimer +import com.flipcash.app.tokens.UsdcSweepMilestone import com.flipcash.app.tokens.BalancePollError import com.flipcash.app.tokens.BalancePoller import com.flipcash.app.tokens.InlineCompletion @@ -25,6 +28,7 @@ import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.ui.forViewer import com.flipcash.app.onramp.CoinbaseOnRampController +import com.flipcash.app.onramp.CoinbaseOnRampMilestone import com.flipcash.app.onramp.CoinbaseOnRampState import com.flipcash.app.onramp.DeeplinkError import com.flipcash.app.onramp.DeeplinkOnRampError @@ -139,6 +143,7 @@ class SwapViewModel @Inject constructor( private val userFlags: UserFlagsCoordinator, private val usdcDepositSweep: UsdcDepositSweep, private val balancePoller: BalancePoller, + private val addMoneyTimer: AddMoneyTimer, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -1016,6 +1021,7 @@ class SwapViewModel @Inject constructor( .onEach { (delegateState, purpose) -> val isAddingMoney = stateFlow.value.isAddingMoney addMoneyMethod?.let { method -> + if (method == AddMoneyMethod.COINBASE) addMoneyTimer.start() analytics.track( AddMoneyEvents.amountConfirmed( method = method, @@ -1226,32 +1232,22 @@ class SwapViewModel @Inject constructor( is OrderDeliveryResult.Delivered -> true OrderDeliveryResult.Failed -> { dispatchEvent(Event.UpdateProcessingState(loading = false, error = true)) - addMoneyMethod?.let { method -> - analytics.track( - AddMoneyEvents.result( - method = method, - state = AnalyticsState.FAILURE, - amount = netTransferAmount.analytics, - error = IllegalStateException("Order delivery failed").analytics, - ) - ) - } + trackAddMoneyResult( + state = AnalyticsState.FAILURE, + amount = netTransferAmount.analytics, + error = IllegalStateException("Order delivery failed").analytics, + ) false } // The deposit may still land later; the session's foreground sweep // will reconcile it. Don't claim success we can't confirm. OrderDeliveryResult.TimedOut -> { dispatchEvent(Event.UpdateProcessingState(loading = false, error = true)) - addMoneyMethod?.let { method -> - analytics.track( - AddMoneyEvents.result( - method = method, - state = AnalyticsState.FAILURE, - amount = netTransferAmount.analytics, - error = IllegalStateException("Order delivery timed out").analytics, - ) - ) - } + trackAddMoneyResult( + state = AnalyticsState.FAILURE, + amount = netTransferAmount.analytics, + error = IllegalStateException("Order delivery timed out").analytics, + ) false } } @@ -1654,11 +1650,7 @@ class SwapViewModel @Inject constructor( .onEach { s -> when (s) { is CoinbaseOnRampState.Failed -> { - addMoneyMethod?.let { method -> - analytics.track( - AddMoneyEvents.result(method, AnalyticsState.FAILURE, null, s.error.analytics) - ) - } + trackAddMoneyResult(AnalyticsState.FAILURE, null, s.error.analytics) dispatchEvent(Event.UpdateBuyState()) } @@ -1669,6 +1661,28 @@ class SwapViewModel @Inject constructor( } }.launchIn(viewModelScope) + // The session's foreground sweep may already be running when the deposit lands, so + // listen to whichever sweep runs rather than only the one started here. + usdcDepositSweep.milestones + .onEach { milestone -> + addMoneyTimer.mark( + when (milestone) { + UsdcSweepMilestone.UsdcSeen -> AddMoneyTimer.Moment.USDC_SEEN + UsdcSweepMilestone.Swapped -> AddMoneyTimer.Moment.SWAPPED + } + ) + }.launchIn(viewModelScope) + + coinbaseOnRampController.milestones + .onEach { milestone -> + addMoneyTimer.mark( + when (milestone) { + CoinbaseOnRampMilestone.PaymentAuthorized -> AddMoneyTimer.Moment.PAYMENT_AUTHORIZED + CoinbaseOnRampMilestone.PaymentCompleted -> AddMoneyTimer.Moment.PAYMENT_COMPLETED + } + ) + }.launchIn(viewModelScope) + purchaseMethodController.selections .onEach { (method, metadata) -> // The add-money deposit sheet (presentDepositOptions) emits an amount-less @@ -1845,6 +1859,7 @@ class SwapViewModel @Inject constructor( token = token, verifiedFiat = amountFiat, ).onSuccess { + addMoneyTimer.mark(AddMoneyTimer.Moment.ORDER_PLACED) trackTransaction(token) addMoneyMethod?.let { method -> analytics.track( @@ -1862,16 +1877,11 @@ class SwapViewModel @Inject constructor( else -> { trackTransaction(token, error = error) - addMoneyMethod?.let { method -> - analytics.track( - AddMoneyEvents.result( - method = method, - state = AnalyticsState.FAILURE, - amount = amountFiat.localFiat.nativeAmount.analytics, - error = error.analytics, - ) - ) - } + trackAddMoneyResult( + state = AnalyticsState.FAILURE, + amount = amountFiat.localFiat.nativeAmount.analytics, + error = error.analytics, + ) BottomBarManager.showError( title = resources.getString(R.string.error_title_buySellFailed), message = resources.getString(R.string.error_description_buySellFailed), @@ -2122,11 +2132,32 @@ class SwapViewModel @Inject constructor( if (markProcessingSuccess) { dispatchEvent(Event.UpdateProcessingState(loading = false, success = true)) } - addMoneyMethod?.let { method -> - analytics.track( - AddMoneyEvents.result(method, AnalyticsState.SUCCESS, netTransferAmount.analytics, null) + addMoneyTimer.mark(AddMoneyTimer.Moment.CREDITED) + trackAddMoneyResult(AnalyticsState.SUCCESS, netTransferAmount.analytics, null) + } + + /** + * Sends `Add Money` for the current add-money flow, if there is one. A Coinbase purchase also + * carries the phase times from [addMoneyTimer], which this closes. + */ + private fun trackAddMoneyResult(state: AnalyticsState, amount: AnalyticsAmount?, error: String?) { + val method = addMoneyMethod ?: return + val phases = if (method == AddMoneyMethod.COINBASE) addMoneyTimer.finish() else null + analytics.track( + AddMoneyEvents.result( + method = method, + state = state, + amount = amount, + error = error, + orderTimeMillis = phases?.orderTimeMillis, + paymentTimeMillis = phases?.paymentTimeMillis, + coinbaseTimeMillis = phases?.coinbaseTimeMillis, + depositTimeMillis = phases?.depositTimeMillis, + swapTimeMillis = phases?.swapTimeMillis, + creditTimeMillis = phases?.creditTimeMillis, + totalTimeMillis = phases?.totalTimeMillis, ) - } + ) } private fun handlePhantomError(error: Throwable) { @@ -2139,11 +2170,7 @@ class SwapViewModel @Inject constructor( if (deeplinkError is DeeplinkOnRampError.FailedToSendTransaction) { analytics.track(WalletEvents.transactionsFailed(OnRampProvider.Phantom.analytics)) } - addMoneyMethod?.let { method -> - analytics.track( - AddMoneyEvents.result(method, AnalyticsState.FAILURE, null, deeplinkError.analytics) - ) - } + trackAddMoneyResult(AnalyticsState.FAILURE, null, deeplinkError.analytics) } trace( diff --git a/apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/AddMoneyTimer.kt b/apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/AddMoneyTimer.kt new file mode 100644 index 0000000000..7991fd7355 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/AddMoneyTimer.kt @@ -0,0 +1,93 @@ +package com.flipcash.app.tokens + +import javax.inject.Inject +import kotlin.jvm.Synchronized +import kotlin.time.ComparableTimeMark +import kotlin.time.TimeSource + +/** + * Times the phases of a Coinbase add-money purchase for the `Add Money` event. [start] opens a + * purchase when the amount is confirmed; each later [Moment] is marked once, as it happens; [finish] + * reads the phases and closes the purchase, so a later send can't reuse its marks. + * + * Marks arrive from the deposit sweep's IO scope as well as the view model, so every access is + * synchronized. The clock is monotonic; tests pass a `TestTimeSource`. + */ +class AddMoneyTimer(private val timeSource: TimeSource.WithComparableMarks) { + + @Inject constructor() : this(TimeSource.Monotonic) + + /** The moments a purchase passes through, in order. */ + enum class Moment { + /** Coinbase returned the placed order. */ + ORDER_PLACED, + + /** Coinbase's `onramp_api.payment_authorized` webview event. */ + PAYMENT_AUTHORIZED, + + /** Coinbase's `onramp_api.polling_success` webview event. */ + PAYMENT_COMPLETED, + + /** The deposit sweep first saw a nonzero USDC balance. */ + USDC_SEEN, + + /** The USDC to USDF swap returned success. */ + SWAPPED, + + /** The USDF credit landed and the success state was shown. */ + CREDITED, + } + + private var started: ComparableTimeMark? = null + private val marks = mutableMapOf() + + @Synchronized + fun start() { + marks.clear() + started = timeSource.markNow() + } + + /** + * Records [moment] the first time it happens in an open purchase. The USDC sweep also runs + * for leftover balances, so its moments only count once Coinbase has completed the payment. + */ + @Synchronized + fun mark(moment: Moment) { + if (started == null) return + if (moment >= Moment.USDC_SEEN && Moment.PAYMENT_COMPLETED !in marks) return + if (moment !in marks) marks[moment] = timeSource.markNow() + } + + /** The phases so far and the total up to now, or null when no purchase is open. */ + @Synchronized + fun finish(): AddMoneyPhases? { + val start = started ?: return null + val now = timeSource.markNow() + fun between(from: ComparableTimeMark?, to: ComparableTimeMark?): Long? = + if (from != null && to != null) (to - from).inWholeMilliseconds else null + + val phases = AddMoneyPhases( + orderTimeMillis = between(start, marks[Moment.ORDER_PLACED]), + paymentTimeMillis = between(marks[Moment.ORDER_PLACED], marks[Moment.PAYMENT_AUTHORIZED]), + coinbaseTimeMillis = between(marks[Moment.PAYMENT_AUTHORIZED], marks[Moment.PAYMENT_COMPLETED]), + depositTimeMillis = between(marks[Moment.PAYMENT_COMPLETED], marks[Moment.USDC_SEEN]), + swapTimeMillis = between(marks[Moment.USDC_SEEN], marks[Moment.SWAPPED]), + creditTimeMillis = between(marks[Moment.SWAPPED], marks[Moment.CREDITED]), + totalTimeMillis = (now - start).inWholeMilliseconds, + ) + started = null + marks.clear() + return phases + } +} + +/** A phase is null when either of its moments never happened. */ +data class AddMoneyPhases( + val orderTimeMillis: Long?, + val paymentTimeMillis: Long?, + val coinbaseTimeMillis: Long?, + val depositTimeMillis: Long?, + val swapTimeMillis: Long?, + val creditTimeMillis: Long?, + val totalTimeMillis: Long, +) diff --git a/apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt b/apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt index 4ad4deabce..34021bd30e 100644 --- a/apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt +++ b/apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt @@ -15,6 +15,9 @@ import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -53,6 +56,14 @@ class UsdcDepositSweep( private val scope = CoroutineScope(dispatchers.IO + SupervisorJob()) private var activeJob: Job? = null + private val _milestones = MutableSharedFlow(extraBufferCapacity = 2) + + /** + * Each sweep's progress, whoever started it. A caller that finds a sweep already in flight + * still sees that sweep's milestones here. + */ + val milestones: SharedFlow = _milestones.asSharedFlow() + fun execute(owner: AccountCluster) { if (activeJob?.isActive == true) return activeJob = scope.launch { @@ -77,6 +88,7 @@ class UsdcDepositSweep( check(balance > 0L) { "USDC balance <= 0. nothing to sweep" } balance } ?: return@launch + _milestones.tryEmit(UsdcSweepMilestone.UsdcSeen) trace(tag = TAG, message = "Swapping $amount USDC to USDF", type = TraceType.Process) @@ -85,6 +97,7 @@ class UsdcDepositSweep( amount = amount, ).onSuccess { trace(tag = TAG, message = "USDC→USDF sweep completed") + _milestones.tryEmit(UsdcSweepMilestone.Swapped) balancePoller.awaitBalanceChange( mint = Mint.usdf, baseline = Fiat.Zero, @@ -120,3 +133,11 @@ class UsdcDepositSweep( internal const val POLL_MAX_ATTEMPTS = 60 } } + +enum class UsdcSweepMilestone { + /** The sweep found a nonzero USDC balance. */ + UsdcSeen, + + /** The USDC to USDF swap returned success. */ + Swapped, +}