Skip to content

Commit a759a79

Browse files
authored
fix(cash): refresh stale exchange rate in GiveBillTransactor before failing (#745)
When a user backgrounds the app and the exchange rate expires, the transactor now attempts to resolve a fresh verified state before failing with ExchangeRateExpiredException. The existing fast path is preserved — no extra network call when the rate is still fresh. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 1090367 commit a759a79

2 files changed

Lines changed: 61 additions & 4 deletions

File tree

services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactor.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,15 +126,29 @@ internal class GiveBillTransactor(
126126
val sendingAmount = amount
127127
?: return logAndFail(GiveTransactorError.Other(message = "No amount. Did you call with() first?"))
128128

129-
val verifiedState = providedVerifiedState
129+
val initialState = providedVerifiedState
130130
?: verifiedFiatCalculator.resolveVerifiedState(sendingAmount.rate.currency, desiredToken.address)
131131
?: return logAndFail(GiveTransactorError.Other("Failed to get verified state"))
132132

133-
val exchangeData = verifiedState.exchangeDataFor(
133+
val (verifiedState, exchangeData) = initialState.exchangeDataFor(
134134
amount = sendingAmount,
135135
mint = desiredToken.address,
136136
billExchangeDataTimeout = exchangeDataTimeout
137-
) ?: return logAndFail(GiveTransactorError.ExchangeRateExpiredException())
137+
)?.let { initialState to it }
138+
?: run {
139+
// Rate expired — attempt to resolve a fresh verified state
140+
val freshState = verifiedFiatCalculator.resolveVerifiedState(
141+
sendingAmount.rate.currency, desiredToken.address
142+
) ?: return logAndFail(GiveTransactorError.ExchangeRateExpiredException())
143+
144+
val freshExchange = freshState.exchangeDataFor(
145+
amount = sendingAmount,
146+
mint = desiredToken.address,
147+
billExchangeDataTimeout = exchangeDataTimeout
148+
) ?: return logAndFail(GiveTransactorError.ExchangeRateExpiredException())
149+
150+
freshState to freshExchange
151+
}
138152

139153
// 1. Send request to "give" the bill to the recipient.
140154
// This provides the recipient with the desired token mint of the cash.

services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactorTest.kt

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,20 @@ class GiveBillTransactorTest {
7676
@Test
7777
fun `start fails when exchange data expired`() = runTest {
7878
val transactor = createTransactor(this)
79-
// Provide verified state directly to skip resolveVerifiedState fallback chain
79+
// Provide verified state directly — its rate is stale
8080
val verifiedState = mockk<VerifiedState>(relaxed = true)
8181
setupWith(transactor, verifiedState = verifiedState)
8282

8383
mockkStatic("com.getcode.opencode.internal.extensions.VerifiedStateKt")
8484
every { verifiedState.exchangeDataFor(any<LocalFiat>(), any<Mint>(), any()) } returns null
8585

86+
// Fresh resolve also returns a stale state so the retry still fails
87+
val freshState = mockk<VerifiedState>(relaxed = true)
88+
every { freshState.exchangeDataFor(any<LocalFiat>(), any<Mint>(), any()) } returns null
89+
coEvery {
90+
verifiedFiatCalculator.resolveVerifiedState(any<CurrencyCode>(), any<Mint>())
91+
} returns freshState
92+
8693
val result = transactor.start()
8794

8895
assertTrue(result.isFailure)
@@ -91,6 +98,42 @@ class GiveBillTransactorTest {
9198
unmockkStatic("com.getcode.opencode.internal.extensions.VerifiedStateKt")
9299
}
93100

101+
@Test
102+
fun `start refreshes state when exchange data expired`() = runTest {
103+
val transactor = createTransactor(this)
104+
// Provide verified state whose rate is stale
105+
val staleState = mockk<VerifiedState>(relaxed = true)
106+
setupWith(transactor, verifiedState = staleState)
107+
108+
mockkStatic("com.getcode.opencode.internal.extensions.VerifiedStateKt")
109+
every { staleState.exchangeDataFor(any<LocalFiat>(), any<Mint>(), any()) } returns null
110+
111+
// Fresh resolve returns a valid state with fresh exchange data
112+
val freshState = mockk<VerifiedState>(relaxed = true)
113+
every { freshState.exchangeDataFor(any<LocalFiat>(), any<Mint>(), any()) } returns mockk<ExchangeData.Verified>(relaxed = true)
114+
coEvery {
115+
verifiedFiatCalculator.resolveVerifiedState(any<CurrencyCode>(), any<Mint>())
116+
} returns freshState
117+
118+
coEvery {
119+
messagingController.sendRequestToGiveBill(any(), any(), any())
120+
} returns Result.success(mockk(relaxed = true))
121+
122+
coEvery {
123+
messagingController.awaitRequestToGrabBill(any(), any())
124+
} returns null
125+
126+
// start() should proceed past exchange data resolution and fail later
127+
// (at awaitRequestToGrabBill) — confirming the fresh resolve succeeded
128+
val result = transactor.start()
129+
130+
assertTrue(result.isFailure)
131+
// Should NOT be ExchangeRateExpiredException — it recovered via fresh state
132+
assertIs<GiveBillTransactor.GiveTransactorError.NoGrabReceived>(result.exceptionOrNull())
133+
134+
unmockkStatic("com.getcode.opencode.internal.extensions.VerifiedStateKt")
135+
}
136+
94137
@Test
95138
fun `start fails when send give bill fails`() = runTest {
96139
val transactor = createTransactor(this)

0 commit comments

Comments
 (0)