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