Skip to content

Commit 8708564

Browse files
authored
Merge pull request #660 from code-payments/fix/exchange-rate-expired-missing-flags
fix(auth): prevent exchange rate expired due to missing user flags
2 parents d011e95 + e366322 commit 8708564

6 files changed

Lines changed: 180 additions & 38 deletions

File tree

apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.getcode.opencode.controllers.TokenController
1919
import com.getcode.opencode.model.core.ID
2020
import com.getcode.utils.TraceManager
2121
import com.getcode.utils.TraceType
22+
import com.getcode.utils.network.retryable
2223
import com.getcode.utils.trace
2324
import kotlinx.coroutines.CoroutineScope
2425
import kotlinx.coroutines.Dispatchers
@@ -117,17 +118,23 @@ class AuthManager @Inject constructor(
117118
suspend fun presentCredentialStorage(): Result<Unit> {
118119
return credentialManager.presentSaveOption()
119120
.onSuccess {
120-
accountController.getUserFlags().onSuccess { userManager.set(it) }
121+
accountController.getUserFlags().onSuccess { flags ->
122+
userManager.set(flags)
123+
if (flags.isRegistered) {
124+
userManager.set(AuthState.LoggedInWithUser)
125+
}
126+
}
121127
}.map { Unit }
122128
}
123129

124130
suspend fun onAccountPurchased(): Result<Unit> {
125131
return credentialManager.onAccountPurchased()
126132
.fold(
127133
onSuccess = {
128-
userManager.set(AuthState.LoggedInWithUser)
129-
accountController.getUserFlags()
134+
val flagsResult = accountController.getUserFlags()
130135
.onSuccess { userManager.set(it) }
136+
userManager.set(AuthState.LoggedInWithUser)
137+
flagsResult
131138
},
132139
onFailure = { Result.failure(it) }
133140
).onSuccess { savePrefs() }.map { Unit }
@@ -159,14 +166,17 @@ class AuthManager @Inject constructor(
159166

160167
coroutineScope {
161168
launch {
162-
accountController.getUserFlags()
163-
.onSuccess { flags ->
164-
userManager.set(flags)
165-
userManager.set(if (flags.isRegistered) AuthState.LoggedInWithUser else AuthState.Registered())
166-
}.onFailure {
167-
taggedTrace("Failed to get user flags", type = TraceType.Error, cause = it)
168-
userManager.set(authState = AuthState.Registered())
169-
}
169+
val flags = retryable(maxRetries = 3) {
170+
accountController.getUserFlags().getOrNull()
171+
}
172+
173+
if (flags != null) {
174+
userManager.set(flags)
175+
userManager.set(if (flags.isRegistered) AuthState.LoggedInWithUser else AuthState.Registered())
176+
} else {
177+
taggedTrace("Failed to get user flags after retries", type = TraceType.Error)
178+
userManager.set(authState = AuthState.Registered())
179+
}
170180
}
171181
launch { savePrefs() }
172182
}

apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import com.flipcash.app.userflags.UserFlagsCoordinator
1212
import com.flipcash.services.controllers.AccountController
1313
import com.flipcash.services.controllers.PushController
1414
import com.flipcash.services.models.UserFlags
15+
import com.flipcash.services.user.AuthState
1516
import com.flipcash.services.user.UserManager
1617
import io.mockk.coEvery
1718
import io.mockk.coVerify
@@ -215,4 +216,42 @@ class AuthManagerTest {
215216
val secondRead = authManager.consumePendingSwitchEntropy()
216217
assertNull(secondRead)
217218
}
219+
220+
@Test
221+
fun `login retries getUserFlags on failure then succeeds`() = runTest {
222+
val entropy = "dGVzdGVudHJvcHkxMjM0NQ=="
223+
val accountMetadata: AccountMetadata = mockk(relaxed = true)
224+
val testId = listOf<Byte>(1, 2, 3)
225+
every { accountMetadata.id } returns testId
226+
227+
coEvery { credentialManager.login(entropy, any()) } returns Result.success(accountMetadata)
228+
229+
val flags = UserFlags.Default.copy(isRegistered = true)
230+
coEvery { accountController.getUserFlags() } returnsMany listOf(
231+
Result.failure(RuntimeException("transient failure")),
232+
Result.success(flags)
233+
)
234+
235+
val result = authManager.login(entropyB64 = entropy)
236+
237+
assertTrue(result.isSuccess)
238+
verify { userManager.set(flags) }
239+
verify { userManager.set(authState = AuthState.LoggedInWithUser) }
240+
}
241+
242+
@Test
243+
fun `login falls back to Registered after all retries exhausted`() = runTest {
244+
val entropy = "dGVzdGVudHJvcHkxMjM0NQ=="
245+
val accountMetadata: AccountMetadata = mockk(relaxed = true)
246+
val testId = listOf<Byte>(1, 2, 3)
247+
every { accountMetadata.id } returns testId
248+
249+
coEvery { credentialManager.login(entropy, any()) } returns Result.success(accountMetadata)
250+
coEvery { accountController.getUserFlags() } returns Result.failure(RuntimeException("persistent failure"))
251+
252+
val result = authManager.login(entropyB64 = entropy)
253+
254+
assertTrue(result.isSuccess)
255+
verify { userManager.set(authState = AuthState.Registered()) }
256+
}
218257
}

apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ class RealSessionController @Inject constructor(
149149
stopPolling()
150150
_state.update { SessionState() }
151151
}
152-
authState.canAccessAuthenticatedApis -> {
152+
authState.isAtLeastRegistered -> {
153153
onAppInForeground()
154154
}
155155
}
@@ -187,6 +187,17 @@ class RealSessionController @Inject constructor(
187187
.onEach { tokens ->
188188
_state.update { it.copy(tokens = tokens) }
189189
}.launchIn(scope)
190+
191+
// Retry updateUserFlags when network is restored
192+
networkObserver.state
193+
.map { it.connected }
194+
.distinctUntilChanged()
195+
.filter { connected -> connected }
196+
.onEach {
197+
if (userManager.authState.isAtLeastRegistered) {
198+
updateUserFlags()
199+
}
200+
}.launchIn(scope)
190201
}
191202

192203
/**
@@ -256,10 +267,15 @@ class RealSessionController @Inject constructor(
256267
}
257268

258269
private fun updateUserFlags() {
259-
if (userManager.authState.canAccessAuthenticatedApis) {
270+
if (userManager.authState.isAtLeastRegistered) {
260271
scope.launch {
261272
accountController.getUserFlags()
262-
.onSuccess { userManager.set(it) }
273+
.onSuccess { flags ->
274+
userManager.set(flags)
275+
if (flags.isRegistered && !userManager.authState.canAccessAuthenticatedApis) {
276+
userManager.set(authState = AuthState.LoggedInWithUser)
277+
}
278+
}
263279
}
264280
}
265281
}

services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,17 @@ class UserManager @Inject constructor(
130130
}
131131

132132
fun set(authState: AuthState) {
133+
val previous = _state.value.authState
133134
_state.update { it.copy(authState = authState) }
134135

135136
when (authState) {
136137
is AuthState.LoggedIn -> {
137138
accountCluster?.let { owner ->
138139
eventBus.send(Events.UpdateLimits(owner = owner, force = true))
140+
// Fire OnLoggedIn only on transition INTO LoggedInWithUser
141+
if (authState is AuthState.LoggedInWithUser && previous !is AuthState.LoggedInWithUser) {
142+
eventBus.send(Events.OnLoggedIn(owner))
143+
}
139144
}
140145
}
141146

@@ -149,10 +154,6 @@ class UserManager @Inject constructor(
149154
flags = userFlags,
150155
)
151156
}
152-
153-
if (userFlags?.isRegistered == true) {
154-
accountCluster?.let { eventBus.send(Events.OnLoggedIn(accountCluster!!)) }
155-
}
156157
}
157158

158159
fun set(pushToken: String?) {

services/opencode/src/main/kotlin/com/getcode/opencode/internal/extensions/VerifiedState.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,29 @@ import com.getcode.opencode.internal.manager.VerifiedState
44
import com.getcode.opencode.model.financial.LocalFiat
55
import com.getcode.opencode.model.transactions.ExchangeData
66
import com.getcode.solana.keys.Mint
7+
import kotlin.time.Clock
78
import kotlin.time.Duration
9+
import kotlin.time.Duration.Companion.minutes
10+
import kotlin.time.Instant
11+
12+
private val DefaultBillExchangeDataTimeout = 15.minutes
813

914
fun VerifiedState.exchangeDataFor(
1015
amount: LocalFiat,
1116
mint: Mint,
1217
billExchangeDataTimeout: Duration?
1318
): ExchangeData.Verified? {
14-
if (billExchangeDataTimeout == null) {
19+
val timeout = billExchangeDataTimeout ?: DefaultBillExchangeDataTimeout
20+
if (timeout <= Duration.ZERO) return null
21+
22+
val ts = Instant.fromEpochSeconds(
23+
rateProto.exchangeRate.timestamp.seconds,
24+
rateProto.exchangeRate.timestamp.nanos
25+
)
26+
if (Clock.System.now() - ts > timeout) {
1527
return null
1628
}
29+
1730
return ExchangeData.Verified(
1831
mint = mint,
1932
nativeAmount = amount.nativeAmount.decimalValue,
Lines changed: 82 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,48 @@
11
package com.getcode.opencode.internal.extensions
22

3+
import com.codeinc.opencode.gen.currency.v1.coreMintFiatExchangeRate
4+
import com.codeinc.opencode.gen.currency.v1.verifiedCoreMintFiatExchangeRate
35
import com.getcode.opencode.internal.manager.VerifiedState
46
import com.getcode.opencode.model.financial.CurrencyCode
57
import com.getcode.opencode.model.financial.Fiat
68
import com.getcode.opencode.model.financial.LocalFiat
79
import com.getcode.opencode.model.financial.Rate
810
import com.getcode.opencode.model.transactions.ExchangeData
911
import com.getcode.solana.keys.Mint
10-
import io.mockk.mockk
12+
import com.google.protobuf.Timestamp
1113
import org.junit.Test
1214
import kotlin.test.assertEquals
1315
import kotlin.test.assertIs
16+
import kotlin.test.assertNotNull
1417
import kotlin.test.assertNull
18+
import kotlin.time.Clock
19+
import kotlin.time.Duration
20+
import kotlin.time.Duration.Companion.minutes
1521
import kotlin.time.Duration.Companion.seconds
1622

1723
class VerifiedStateExtTest {
1824

19-
private val verifiedState = VerifiedState(
20-
rateProto = mockk(relaxed = true),
21-
reserveProto = null,
22-
)
25+
private fun verifiedStateAt(epochSeconds: Long): VerifiedState {
26+
return VerifiedState(
27+
rateProto = verifiedCoreMintFiatExchangeRate {
28+
exchangeRate = coreMintFiatExchangeRate {
29+
currencyCode = "usd"
30+
exchangeRate = 1.0
31+
timestamp = Timestamp.newBuilder().setSeconds(epochSeconds).build()
32+
}
33+
},
34+
reserveProto = null,
35+
)
36+
}
37+
38+
private fun freshVerifiedState(): VerifiedState {
39+
return verifiedStateAt(Clock.System.now().epochSeconds)
40+
}
41+
42+
private fun staleVerifiedState(age: Duration): VerifiedState {
43+
val staleEpoch = Clock.System.now().epochSeconds - age.inWholeSeconds
44+
return verifiedStateAt(staleEpoch)
45+
}
2346

2447
private val amount = LocalFiat(
2548
underlyingTokenAmount = Fiat(quarks = 500_000L, currencyCode = CurrencyCode.USD),
@@ -29,40 +52,80 @@ class VerifiedStateExtTest {
2952
)
3053

3154
@Test
32-
fun `returns null when timeout is null`() {
33-
val result = verifiedState.exchangeDataFor(
55+
fun `returns Verified with correct fields when timeout is provided and rate is fresh`() {
56+
val state = freshVerifiedState()
57+
val result = state.exchangeDataFor(
3458
amount = amount,
3559
mint = Mint.usdf,
36-
billExchangeDataTimeout = null,
60+
billExchangeDataTimeout = 30.seconds,
3761
)
3862

39-
assertNull(result)
63+
assertIs<ExchangeData.Verified>(result)
64+
assertEquals(Mint.usdf, result.mint)
65+
assertEquals(amount.nativeAmount.decimalValue, result.nativeAmount)
66+
assertEquals(amount.underlyingTokenAmount.quarks, result.quarks)
67+
assertEquals(state, result.verifiedState)
4068
}
4169

4270
@Test
43-
fun `returns Verified with correct fields when timeout is provided`() {
44-
val result = verifiedState.exchangeDataFor(
71+
fun `passes through the verifiedState reference`() {
72+
val state = freshVerifiedState()
73+
val result = state.exchangeDataFor(
4574
amount = amount,
4675
mint = Mint.usdf,
4776
billExchangeDataTimeout = 30.seconds,
4877
)
4978

5079
assertIs<ExchangeData.Verified>(result)
51-
assertEquals(Mint.usdf, result.mint)
52-
assertEquals(amount.nativeAmount.decimalValue, result.nativeAmount)
53-
assertEquals(amount.underlyingTokenAmount.quarks, result.quarks)
54-
assertEquals(verifiedState, result.verifiedState)
80+
assert(result.verifiedState === state)
5581
}
5682

5783
@Test
58-
fun `passes through the verifiedState reference`() {
59-
val result = verifiedState.exchangeDataFor(
84+
fun `returns null when timeout is zero`() {
85+
val state = freshVerifiedState()
86+
val result = state.exchangeDataFor(
87+
amount = amount,
88+
mint = Mint.usdf,
89+
billExchangeDataTimeout = Duration.ZERO,
90+
)
91+
92+
assertNull(result)
93+
}
94+
95+
@Test
96+
fun `returns null when rate exceeds timeout`() {
97+
val state = staleVerifiedState(age = 60.seconds)
98+
val result = state.exchangeDataFor(
6099
amount = amount,
61100
mint = Mint.usdf,
62-
billExchangeDataTimeout = 1.seconds,
101+
billExchangeDataTimeout = 30.seconds,
63102
)
64103

104+
assertNull(result)
105+
}
106+
107+
@Test
108+
fun `uses default timeout when null and rate is fresh`() {
109+
val state = freshVerifiedState()
110+
val result = state.exchangeDataFor(
111+
amount = amount,
112+
mint = Mint.usdf,
113+
billExchangeDataTimeout = null,
114+
)
115+
116+
assertNotNull(result)
65117
assertIs<ExchangeData.Verified>(result)
66-
assert(result.verifiedState === verifiedState)
118+
}
119+
120+
@Test
121+
fun `returns null when null timeout and rate exceeds default 15 minutes`() {
122+
val state = staleVerifiedState(age = 16.minutes)
123+
val result = state.exchangeDataFor(
124+
amount = amount,
125+
mint = Mint.usdf,
126+
billExchangeDataTimeout = null,
127+
)
128+
129+
assertNull(result)
67130
}
68131
}

0 commit comments

Comments
 (0)