Skip to content

Commit e9fca87

Browse files
committed
fix(onramp/coinbase): poll lookupOrder for txHash + pass amount through TxProcessing
lookupOrder() is called once after payment but txHash may not yet be populated by Coinbase. Poll with 3s intervals (100 attempts / ~5min timeout) using the new pollUntil utility until txHash appears. Also threads the purchase amount from CoinbaseOnRampState.Completed through to TxProcessing and SwapViewModel so the UI can display it. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 91faf34 commit e9fca87

13 files changed

Lines changed: 132 additions & 63 deletions

File tree

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ fun appEntryProvider(
109109
SwapFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
110110
}
111111
annotatedEntry<AppRoute.Token.TxProcessing> { key ->
112-
TokenTxProcessingScreen(key.swapId, key.swapPurpose, key.awaitExternalWallet, key.isFundingShortfall)
112+
TokenTxProcessingScreen(key.swapId, key.swapPurpose, key.amount, key.awaitExternalWallet, key.isFundingShortfall)
113113
}
114114
annotatedEntry<AppRoute.Token.OnRamp> { key -> OnRampCustomAmountScreen(key.mint) }
115115
annotatedEntry<AppRoute.Token.Discovery> { TokenDiscoveryScreen() }

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.flipcash.app.core.withdrawal.WithdrawalStep
1616
import com.getcode.navigation.NonDismissableRoute
1717
import com.getcode.navigation.NonDraggableRoute
1818
import com.getcode.navigation.flow.FlowRouteWithResult
19+
import com.getcode.opencode.exchange.VerifiedFiat
1920
import com.getcode.opencode.internal.solana.model.SwapId
2021
import com.getcode.opencode.model.financial.Fiat
2122
import com.getcode.solana.keys.Mint
@@ -139,6 +140,7 @@ sealed interface AppRoute : NavKey, Parcelable {
139140
data class TxProcessing(
140141
val swapId: SwapId,
141142
val swapPurpose: SwapPurpose? = null,
143+
val amount: VerifiedFiat? = null,
142144
val awaitExternalWallet: Boolean = false,
143145
val isFundingShortfall: Boolean = false,
144146
) : Token, NonDismissableRoute, NonDraggableRoute

apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column
44
import androidx.compose.foundation.layout.fillMaxSize
55
import androidx.compose.runtime.Composable
66
import androidx.compose.runtime.LaunchedEffect
7+
import androidx.compose.runtime.getValue
78
import androidx.compose.ui.Alignment
89
import androidx.compose.ui.Modifier
910
import androidx.compose.ui.res.stringResource
@@ -14,6 +15,7 @@ import com.flipcash.app.onramp.internal.OnRampViewModel
1415
import com.flipcash.app.onramp.internal.screens.OnRampAmountScreen
1516
import com.flipcash.features.onramp.R
1617
import androidx.hilt.navigation.compose.hiltViewModel
18+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1719
import com.getcode.navigation.core.LocalCodeNavigator
1820
import com.getcode.ui.components.AppBarWithTitle
1921
import kotlinx.coroutines.flow.filterIsInstance
@@ -26,13 +28,14 @@ fun OnRampCustomAmountScreen(mint: Mint) {
2628
val navigator = LocalCodeNavigator.current
2729
val viewModel = hiltViewModel<OnRampViewModel>()
2830

31+
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
2932
Column(
3033
modifier = Modifier.fillMaxSize(),
3134
) {
3235
AppBarWithTitle(
3336
title = stringResource(R.string.title_amountToBuy),
3437
isInModal = true,
35-
backButton = true,
38+
backButton = state.orderLookup.isIdle,
3639
onBackIconClicked = { navigator.pop() },
3740
titleAlignment = Alignment.CenterHorizontally,
3841
)

apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ internal class OnRampViewModel @Inject constructor(
9898
val hasVerifiedEmail: Boolean = false,
9999
val selectedProvider: OnRampProvider.ThirdParty? = null,
100100
val amountEntryState: AmountEntryState = AmountEntryState(),
101+
val orderLookup: LoadingSuccessState = LoadingSuccessState(),
101102
) {
102103
val minimumPurchaseAmount = 5.toFiat()
103104
}
@@ -132,6 +133,11 @@ internal class OnRampViewModel @Inject constructor(
132133
val success: Boolean = false
133134
) : Event
134135

136+
data class UpdateOrderLookupState(
137+
val loading: Boolean = false,
138+
val success: Boolean = false
139+
): Event
140+
135141
data class OnAmountAccepted(val amount: VerifiedFiat) : Event
136142

137143
data class CreateAndSendTransactionToWallet(val amount: VerifiedFiat) : Event
@@ -158,10 +164,13 @@ internal class OnRampViewModel @Inject constructor(
158164
numberInputHelper.reset()
159165

160166
onRampController.state
161-
.filter { it !is CoinbaseOnRampState.Paying }
162-
.onEach {
163-
if (stateFlow.value.amountEntryState.confirmingAmount.loading) {
164-
dispatchEvent(Event.UpdateConfirmingAmountState())
167+
.onEach { s ->
168+
when (s) {
169+
is CoinbaseOnRampState.Completed -> dispatchEvent(Event.UpdateOrderLookupState(success = true))
170+
is CoinbaseOnRampState.Failed -> dispatchEvent(Event.UpdateOrderLookupState())
171+
CoinbaseOnRampState.Idle -> dispatchEvent(Event.UpdateOrderLookupState())
172+
is CoinbaseOnRampState.Paying -> dispatchEvent(Event.UpdateOrderLookupState(loading = true))
173+
is CoinbaseOnRampState.Processing -> dispatchEvent(Event.UpdateOrderLookupState(loading = true))
165174
}
166175
}
167176
.launchIn(viewModelScope)
@@ -452,6 +461,16 @@ internal class OnRampViewModel @Inject constructor(
452461
)
453462
}
454463

464+
is Event.UpdateOrderLookupState -> { state ->
465+
val lookupState = state.orderLookup
466+
state.copy(
467+
orderLookup = lookupState.copy(
468+
loading = event.loading,
469+
success = event.success,
470+
)
471+
)
472+
}
473+
455474
is Event.OnVerificationNeeded,
456475
is Event.CreateAndSendTransactionToWallet,
457476
Event.OnAmountConfirmed,

apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import com.flipcash.app.tokens.ui.SwapViewModel.Event
2121
import com.getcode.navigation.core.LocalCodeNavigator
2222
import com.getcode.navigation.flow.flowSharedViewModel
2323
import com.getcode.navigation.flow.rememberFlowNavigator
24+
import com.getcode.opencode.exchange.VerifiedFiat
2425
import com.getcode.opencode.internal.solana.model.SwapId
26+
import com.getcode.opencode.model.financial.LocalFiat
2527
import com.getcode.view.LoadingSuccessState
2628
import kotlinx.coroutines.flow.filterIsInstance
2729
import kotlinx.coroutines.flow.firstOrNull
@@ -98,6 +100,7 @@ internal fun SwapProcessingContent(
98100
fun TokenTxProcessingScreen(
99101
swapId: SwapId,
100102
swapPurpose: SwapPurpose?,
103+
swapAmount: VerifiedFiat?,
101104
awaitExternalWallet: Boolean = false,
102105
isFundingShortfall: Boolean = false,
103106
) {
@@ -136,6 +139,9 @@ fun TokenTxProcessingScreen(
136139
if (swapPurpose != null) {
137140
viewModel.dispatchEvent(Event.OnPurposeChanged(swapPurpose))
138141
}
142+
if (swapAmount != null) {
143+
viewModel.dispatchEvent(Event.OnAmountAccepted(swapAmount, swapAmount.localFiat.nativeAmount))
144+
}
139145
}
140146
}
141147

apps/flipcash/shared/onramp/coinbase/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ dependencies {
2424
implementation(project(":apps:flipcash:shared:web"))
2525
api(project(":libs:network:coinbase:onramp"))
2626
implementation(project(":libs:network:jwt"))
27+
implementation(project(":libs:network:connectivity:public"))
2728
}

apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import com.getcode.opencode.model.financial.Token
2626
import com.getcode.opencode.model.financial.usdf
2727
import com.getcode.opencode.model.transactions.SwapFundingSource
2828
import com.getcode.solana.keys.base58
29-
import com.getcode.utils.base64
29+
import com.getcode.utils.network.pollUntil
3030
import com.getcode.vendor.Base58
3131
import com.flipcash.app.core.AppRoute
3232
import com.flipcash.app.onramp.internal.CoinbaseOnRampWebError
@@ -43,6 +43,7 @@ import kotlinx.serialization.json.Json
4343
import retrofit2.HttpException
4444
import java.security.SecureRandom
4545
import javax.inject.Inject
46+
import kotlin.time.Duration.Companion.seconds
4647

4748
typealias OrderWithPaymentLink = Pair<String, OnRampPurchaseResponse.PaymentLink>
4849

@@ -121,11 +122,15 @@ class CoinbaseOnRampController @Inject constructor(
121122
return Result.failure(IllegalStateException("Not in Processing state"))
122123
}
123124

124-
return lookupOrder(current.orderId)
125-
.mapCatching { order ->
125+
return pollUntil(
126+
call = { lookupOrder(current.orderId).getOrThrow() },
127+
required = { order -> order.txHash != null },
128+
maxAttempts = 100,
129+
interval = 3.seconds,
130+
tag = "CoinbaseOrderPoller",
131+
).mapCatching { order ->
126132
order.txHash ?: throw IllegalStateException("No hash provided from provider")
127-
}
128-
.mapCatching { txHash ->
133+
}.mapCatching { txHash ->
129134
val owner = userManager.accountCluster
130135
?: throw IllegalStateException("No account cluster")
131136

@@ -142,7 +147,7 @@ class CoinbaseOnRampController @Inject constructor(
142147
).getOrThrow()
143148
}
144149
.onSuccess { swapId ->
145-
_state.update { CoinbaseOnRampState.Completed(swapId, current.token) }
150+
_state.update { CoinbaseOnRampState.Completed(swapId, current.token, current.amount) }
146151
}
147152
.onFailure {
148153
reset()

apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ fun CoinbaseOnRampHandler(
5353
is CoinbaseOnRampState.Completed -> {
5454
LaunchedEffect(current) {
5555
controller.emitPendingNavigation(
56-
AppRoute.Token.TxProcessing(current.swapId, SwapPurpose.Buy(current.token.address))
56+
AppRoute.Token.TxProcessing(current.swapId, SwapPurpose.Buy(current.token.address), current.amount)
5757
)
5858
controller.reset()
5959
}

apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ sealed interface CoinbaseOnRampState {
1818
data object Idle : CoinbaseOnRampState
1919
data class Paying(val order: OnrampOrder, val token: Token, val amount: VerifiedFiat) : CoinbaseOnRampState
2020
data class Processing(val orderId: String, val token: Token, val amount: VerifiedFiat) : CoinbaseOnRampState
21-
data class Completed(val swapId: SwapId, val token: Token) : CoinbaseOnRampState
21+
data class Completed(val swapId: SwapId, val token: Token, val amount: VerifiedFiat) : CoinbaseOnRampState
2222
data class Failed(val error: CoinbaseOnRampWebError) : CoinbaseOnRampState
2323
}

apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import com.getcode.opencode.model.financial.CurrencyCode
1212
import com.getcode.opencode.model.financial.Fiat
1313
import com.getcode.opencode.model.financial.Token
1414
import com.getcode.opencode.model.financial.usdf
15-
import com.getcode.solana.keys.PublicKey
1615
import io.mockk.every
1716
import io.mockk.mockk
1817
import io.mockk.mockkStatic
@@ -25,6 +24,7 @@ import org.junit.Test
2524
import org.junit.runner.RunWith
2625
import org.robolectric.RobolectricTestRunner
2726
import org.robolectric.annotation.Config
27+
import kotlin.test.assertEquals
2828
import kotlin.test.assertIs
2929
import kotlin.test.assertTrue
3030

@@ -84,14 +84,6 @@ class CoinbaseOnRampControllerTest {
8484
}
8585
}
8686

87-
private fun stubAccountId(present: Boolean = true) {
88-
if (present) {
89-
every { userManager.accountId } returns listOf(1, 2, 3, 4).map { it.toByte() }
90-
} else {
91-
every { userManager.accountId } returns null
92-
}
93-
}
94-
9587
private fun stubProfile(email: String? = "test@test.com", phone: String? = "+11234567890") {
9688
val profile = UserProfile(
9789
displayName = "Test",
@@ -104,7 +96,6 @@ class CoinbaseOnRampControllerTest {
10496

10597
private fun stubValidUser() {
10698
stubAccountCluster()
107-
stubAccountId()
10899
stubProfile()
109100
}
110101

@@ -113,29 +104,16 @@ class CoinbaseOnRampControllerTest {
113104
@Test
114105
fun `placeOrderInclusiveOfFees fails when owner is null`() = runTest {
115106
stubAccountCluster(present = false)
116-
stubAccountId()
117107
stubProfile()
118108

119109
val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD))
120110
assertTrue(result.isFailure)
121-
assertTrue(result.exceptionOrNull()?.message?.contains("Owner") == true)
122-
}
123-
124-
@Test
125-
fun `placeOrderInclusiveOfFees fails when accountId is null`() = runTest {
126-
stubAccountCluster()
127-
stubAccountId(present = false)
128-
stubProfile()
129-
130-
val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD))
131-
assertTrue(result.isFailure)
132-
assertTrue(result.exceptionOrNull()?.message?.contains("User ID") == true)
111+
assertEquals(result.exceptionOrNull()?.message?.contains("Owner"), true)
133112
}
134113

135114
@Test
136115
fun `placeOrderInclusiveOfFees fails when email is null`() = runTest {
137116
stubAccountCluster()
138-
stubAccountId()
139117
stubProfile(email = null, phone = "+11234567890")
140118

141119
val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD))
@@ -147,7 +125,6 @@ class CoinbaseOnRampControllerTest {
147125
@Test
148126
fun `placeOrderInclusiveOfFees fails when phone is null`() = runTest {
149127
stubAccountCluster()
150-
stubAccountId()
151128
stubProfile(email = "test@test.com", phone = null)
152129

153130
val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD))
@@ -159,7 +136,6 @@ class CoinbaseOnRampControllerTest {
159136
@Test
160137
fun `placeOrderInclusiveOfFees returns VerificationRequired with correct flags`() = runTest {
161138
stubAccountCluster()
162-
stubAccountId()
163139
stubProfile(email = null, phone = null)
164140

165141
val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD))
@@ -174,17 +150,6 @@ class CoinbaseOnRampControllerTest {
174150

175151
// region placeOrderExclusiveOfFees validation
176152

177-
@Test
178-
fun `placeOrderExclusiveOfFees fails when accountId is null`() = runTest {
179-
stubAccountCluster()
180-
stubAccountId(present = false)
181-
stubProfile()
182-
183-
val result = controller.placeOrderExclusiveOfFees(Fiat(10, CurrencyCode.USD))
184-
assertTrue(result.isFailure)
185-
assertTrue(result.exceptionOrNull()?.message?.contains("User ID") == true)
186-
}
187-
188153
@Test
189154
fun `placeOrderExclusiveOfFees fails when exchange rate missing for non-USD`() = runTest {
190155
stubValidUser()
@@ -198,7 +163,6 @@ class CoinbaseOnRampControllerTest {
198163
@Test
199164
fun `placeOrderExclusiveOfFees fails when email and phone both null`() = runTest {
200165
stubAccountCluster()
201-
stubAccountId()
202166
stubProfile(email = null, phone = null)
203167

204168
val result = controller.placeOrderExclusiveOfFees(Fiat(10, CurrencyCode.USD))

0 commit comments

Comments
 (0)