From 8c6f5757dec599a66e7ab66aff25471dd74b0d45 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 13 Jun 2026 16:36:18 -0400 Subject: [PATCH] fix(cash): use relaxed timeout on exchange rate retry in GiveBillTransactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry path in start() resolved a fresh VerifiedState but still applied the strict billExchangeDataTimeout (30s) to it. When the server's rate timestamp was already >30s old (common after backgrounding), the freshly-fetched rate also failed the check. Now the retry passes null for billExchangeDataTimeout, falling back to the default 15-minute window. The initial check still uses the strict timeout — the relaxation only applies after we've just fetched fresh data via resolveVerifiedState. Closes: Bugsnag 6a1bd94017b0f7c09452ffd7 Signed-off-by: Brandon McAnsh --- .../transactors/GiveBillTransactor.kt | 2 +- .../transactors/GiveBillTransactorTest.kt | 45 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 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 4456b720b..8852fbeac 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 @@ -144,7 +144,7 @@ internal class GiveBillTransactor( val freshExchange = freshState.exchangeDataFor( amount = sendingAmount, mint = desiredToken.address, - billExchangeDataTimeout = exchangeDataTimeout + billExchangeDataTimeout = null // relaxed — we just fetched this rate ) ?: return logAndFail(GiveTransactorError.ExchangeRateExpiredException()) freshState to freshExchange 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 b7ad77fd4..2f5953f4f 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 @@ -19,6 +19,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -29,6 +30,8 @@ import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotEquals import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) class GiveBillTransactorTest { @@ -134,6 +137,45 @@ class GiveBillTransactorTest { unmockkStatic("com.getcode.opencode.internal.extensions.VerifiedStateKt") } + @Test + fun `retry uses relaxed timeout for freshly resolved exchange data`() = runTest { + val transactor = createTransactor(this) + val staleState = mockk(relaxed = true) + // Set a strict 30s timeout — mirrors the server-configured value + setupWith(transactor, verifiedState = staleState, billExchangeDataTimeout = 30.seconds) + + mockkStatic("com.getcode.opencode.internal.extensions.VerifiedStateKt") + // Initial: rate expired with strict 30s timeout + every { staleState.exchangeDataFor(any(), any(), any()) } returns null + + // Fresh state: valid exchange data available + 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 + + val result = transactor.start() + + // Should proceed past exchange resolution and fail at grab — NOT ExchangeRateExpiredException + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + + // Verify the retry path called exchangeDataFor with null (relaxed) timeout, + // NOT the strict 30s billExchangeDataTimeout + verify { freshState.exchangeDataFor(any(), any(), isNull()) } + + unmockkStatic("com.getcode.opencode.internal.extensions.VerifiedStateKt") + } + @Test fun `start fails when send give bill fails`() = runTest { val transactor = createTransactor(this) @@ -268,6 +310,7 @@ class GiveBillTransactorTest { transactor: GiveBillTransactor, verifiedState: VerifiedState? = null, nonce: List? = null, + billExchangeDataTimeout: Duration? = null, ) { val token = mockk(relaxed = true) { every { address } returns Mint.usdf @@ -288,7 +331,7 @@ class GiveBillTransactorTest { token = token, amount = amount, owner = owner, - billExchangeDataTimeout = null, + billExchangeDataTimeout = billExchangeDataTimeout, verifiedState = verifiedState, providedNonce = nonce, )