From c154714cdfb257b1d32855a6ee71851a7c2f7d1a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 20 May 2026 12:36:18 -0400 Subject: [PATCH] fix(cash): refresh stale exchange rate in GiveBillTransactor before failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../transactors/GiveBillTransactor.kt | 20 +++++++-- .../transactors/GiveBillTransactorTest.kt | 45 ++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactor.kt index 575943330..4456b720b 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactor.kt @@ -126,15 +126,29 @@ internal class GiveBillTransactor( val sendingAmount = amount ?: return logAndFail(GiveTransactorError.Other(message = "No amount. Did you call with() first?")) - val verifiedState = providedVerifiedState + val initialState = providedVerifiedState ?: verifiedFiatCalculator.resolveVerifiedState(sendingAmount.rate.currency, desiredToken.address) ?: return logAndFail(GiveTransactorError.Other("Failed to get verified state")) - val exchangeData = verifiedState.exchangeDataFor( + val (verifiedState, exchangeData) = initialState.exchangeDataFor( amount = sendingAmount, mint = desiredToken.address, billExchangeDataTimeout = exchangeDataTimeout - ) ?: return logAndFail(GiveTransactorError.ExchangeRateExpiredException()) + )?.let { initialState to it } + ?: run { + // Rate expired — attempt to resolve a fresh verified state + val freshState = verifiedFiatCalculator.resolveVerifiedState( + sendingAmount.rate.currency, desiredToken.address + ) ?: return logAndFail(GiveTransactorError.ExchangeRateExpiredException()) + + val freshExchange = freshState.exchangeDataFor( + amount = sendingAmount, + mint = desiredToken.address, + billExchangeDataTimeout = exchangeDataTimeout + ) ?: return logAndFail(GiveTransactorError.ExchangeRateExpiredException()) + + freshState to freshExchange + } // 1. Send request to "give" the bill to the recipient. // This provides the recipient with the desired token mint of the cash. diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactorTest.kt index 15585f137..b7ad77fd4 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactorTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactorTest.kt @@ -76,13 +76,20 @@ class GiveBillTransactorTest { @Test fun `start fails when exchange data expired`() = runTest { val transactor = createTransactor(this) - // Provide verified state directly to skip resolveVerifiedState fallback chain + // Provide verified state directly — its rate is stale val verifiedState = mockk(relaxed = true) setupWith(transactor, verifiedState = verifiedState) mockkStatic("com.getcode.opencode.internal.extensions.VerifiedStateKt") every { verifiedState.exchangeDataFor(any(), any(), any()) } returns null + // Fresh resolve also returns a stale state so the retry still fails + val freshState = mockk(relaxed = true) + every { freshState.exchangeDataFor(any(), any(), any()) } returns null + coEvery { + verifiedFiatCalculator.resolveVerifiedState(any(), any()) + } returns freshState + val result = transactor.start() assertTrue(result.isFailure) @@ -91,6 +98,42 @@ class GiveBillTransactorTest { unmockkStatic("com.getcode.opencode.internal.extensions.VerifiedStateKt") } + @Test + fun `start refreshes state when exchange data expired`() = runTest { + val transactor = createTransactor(this) + // Provide verified state whose rate is stale + val staleState = mockk(relaxed = true) + setupWith(transactor, verifiedState = staleState) + + mockkStatic("com.getcode.opencode.internal.extensions.VerifiedStateKt") + every { staleState.exchangeDataFor(any(), any(), any()) } returns null + + // Fresh resolve returns a valid state with fresh exchange data + val freshState = mockk(relaxed = true) + every { freshState.exchangeDataFor(any(), any(), any()) } returns mockk(relaxed = true) + coEvery { + verifiedFiatCalculator.resolveVerifiedState(any(), any()) + } returns freshState + + coEvery { + messagingController.sendRequestToGiveBill(any(), any(), any()) + } returns Result.success(mockk(relaxed = true)) + + coEvery { + messagingController.awaitRequestToGrabBill(any(), any()) + } returns null + + // start() should proceed past exchange data resolution and fail later + // (at awaitRequestToGrabBill) — confirming the fresh resolve succeeded + val result = transactor.start() + + assertTrue(result.isFailure) + // Should NOT be ExchangeRateExpiredException — it recovered via fresh state + assertIs(result.exceptionOrNull()) + + unmockkStatic("com.getcode.opencode.internal.extensions.VerifiedStateKt") + } + @Test fun `start fails when send give bill fails`() = runTest { val transactor = createTransactor(this)