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
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ class CoinbaseOnRampController @Inject constructor(
private val _pendingCompletion = MutableSharedFlow<CoinbaseOnRampCompletion>(extraBufferCapacity = 1)
val pendingCompletion: SharedFlow<CoinbaseOnRampCompletion> = _pendingCompletion.asSharedFlow()

private val _milestones = MutableSharedFlow<CoinbaseOnRampMilestone>(extraBufferCapacity = 4)
val milestones: SharedFlow<CoinbaseOnRampMilestone> = _milestones.asSharedFlow()

fun emitCompletion(completion: CoinbaseOnRampCompletion) {
_pendingCompletion.tryEmit(completion)
}
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ fun CoinbaseOnRampHandler(
onCancel = {
controller.onPaymentCancel()
},
onPaymentAuthorized = {
controller.onPaymentAuthorized()
},
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ fun CoinbaseOnRampWebview(
onPaymentSuccess: (String) -> Unit,
onPaymentFailure: (CoinbaseOnRampWebError) -> Unit,
onCancel: () -> Unit,
onPaymentAuthorized: () -> Unit = {},
) {
var cleanup: (() -> Unit)? = null
ComposeWebView(
Expand All @@ -47,6 +48,7 @@ fun CoinbaseOnRampWebview(
onPaymentSuccess = { onPaymentSuccess(orderId) },
onPaymentFailure = onPaymentFailure,
onCancel = onCancel,
onPaymentAuthorized = onPaymentAuthorized,
)
},
onRelease = { cleanup?.invoke() },
Expand All @@ -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()
Expand Down Expand Up @@ -177,6 +180,7 @@ private fun WebView.configureForCoinbaseOnRamp(
onPaymentSuccess = wrappedOnPaymentSuccess,
onPaymentFailure = wrappedOnPaymentFailure,
onCancel = wrappedOnCancel,
onPaymentAuthorized = onPaymentAuthorized,
onHeartbeat = heartbeat,
onPauseWatchdog = pauseWatchdog,
onAutoClickGPay = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -221,6 +222,7 @@ internal class CoinbaseOnRampEventHandler(

"onramp_api.payment_authorized" -> {
watchdogPaused = false
onPaymentAuthorized()
}

"timing.gpay_button_clicked" -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -291,6 +293,23 @@ class CoinbaseOnRampControllerTest {

// endregion

// region milestones

@Test
fun `no milestones are emitted outside a payment`() = runTest {
val seen = mutableListOf<CoinbaseOnRampMilestone>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
controller.milestones.collect { seen += it }
}

controller.onPaymentAuthorized()
controller.onPaymentSuccess("order")

assertEquals(emptyList(), seen)
}

// endregion

// region email resolution (requireCoinbaseEmailVerification flag)

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -60,6 +61,7 @@ class CoinbaseOnRampEventHandlerTest {
onAutoClickGPay = { autoClickCount++ },
onHeartbeat = { heartbeatCount++ },
onPauseWatchdog = { pauseWatchdogCount++ },
onPaymentAuthorized = { authorizedCount++ },
)

// --- Event routing ---
Expand All @@ -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"}""")
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<AccountInfo> {
every { accountType } returns AccountType.AssociatedToken
every { this@mockk.balance } returns balance
every { address } returns mockk<PublicKey>(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<UsdcSweepMilestone> {
val seen = mutableListOf<UsdcSweepMilestone>()
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ class SwapViewModelBalancePrefetchTest {
userFlags = mockk<UserFlagsCoordinator>(relaxed = true),
usdcDepositSweep = mockk<UsdcDepositSweep>(relaxed = true),
balancePoller = mockk<com.flipcash.app.tokens.BalancePoller>(relaxed = true),
addMoneyTimer = com.flipcash.app.tokens.AddMoneyTimer(),
)

private fun SwapViewModel.primeForSell() {
Expand Down
Loading
Loading