From 0b31b0d9a2ac9c810846486da34f6dffdff7a358 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 11:01:29 -0400 Subject: [PATCH] fix(cash-link): report a rejected claim intent as a failure ReceiveGiftCardTransactor.start() mapped the account-creation Result into receiveRemotely(), which returns its own Result. That produced a Result>, and the outer fold only saw the account step. A rejected intent landed in onSuccess and start() returned success. When two people claim the same cash link, the server rejects the second with SubmitIntentError.StaleState (already claimed). Android still called onReceived for that claim: CashLinkDelegate added a phantom local balance, showed a received bill, and emitted SettledClaim(collected = true), which posted a duplicate "thanks for the cash" reply. Analytics logged receiveCashLink as a success. Any other intent failure after the account step took the same path. Fold the account result into receiveRemotely() so the intent's Result is the one folded. The already-claimed stale state is still returned without logAndFail; other failures still go through it. This regressed in 6d502a93a7. --- .../transactors/ReceiveGiftCardTransactor.kt | 21 ++++-- .../ReceiveGiftCardTransactorTest.kt | 74 +++++++++++++++++-- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt index 533d075dc7..f2ece09f20 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt @@ -135,14 +135,19 @@ internal class ReceiveGiftCardTransactor( Result.success(Unit) } - return@timedTraceSuspend userAccountResult.map { - transactionController.receiveRemotely( - giftCard = giftCard, - amount = amount, - owner = requestingTokenOwner, - mint = token.address - ) - }.fold( + // fold rather than map: receiveRemotely returns its own Result, and + // mapping would nest it where its failure is never read. + return@timedTraceSuspend userAccountResult.fold( + onSuccess = { + transactionController.receiveRemotely( + giftCard = giftCard, + amount = amount, + owner = requestingTokenOwner, + mint = token.address + ) + }, + onFailure = { Result.failure(it) }, + ).fold( onSuccess = { onStep("intent") Result.success(token to amount) diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactorTest.kt index 2bf6908c09..416dc32d9e 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactorTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactorTest.kt @@ -5,14 +5,23 @@ import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.managers.GiftCardManager import com.getcode.opencode.managers.MnemonicManager import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.accounts.AccountInfo +import com.getcode.opencode.model.accounts.AccountResponse +import com.getcode.opencode.model.core.errors.SubmitIntentError +import com.getcode.opencode.model.financial.DataSource import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenResult +import com.getcode.opencode.model.transactions.ExchangeData import com.getcode.opencode.providers.TokenMetadataProvider import com.getcode.solana.keys.Key32 +import com.getcode.solana.keys.Mint +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test +import kotlin.test.assertSame import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -56,11 +65,41 @@ class ReceiveGiftCardTransactorTest { assertTrue(result.isFailure || result.getOrNull()?.isFailure == true) } - // Note: Tests that require `accountController.getAccounts()` to return specific - // Result.success(...) values are blocked by a MockK bug with Result inline class - // returns on suspend functions (https://github.com/mockk/mockk/issues/937). - // Those tests should use fake repositories + real controllers when the project - // establishes that pattern for transactor-level tests. + // endregion + + // region intent submission + + @Test + fun `start fails with the stale state when the gift card was already claimed`() = runTest { + setupWithOwner() + setupClaimableGiftCard() + val alreadyClaimed = SubmitIntentError.StaleState( + listOf("gift card balance has already been claimed") + ) + coEvery { + transactionController.receiveRemotely(any(), any(), any(), any()) + } returns Result.failure(alreadyClaimed) + + val result = transactor.start(claimIfOwned = false) + + assertTrue(result.isFailure) + assertSame(alreadyClaimed, result.exceptionOrNull()) + } + + @Test + fun `start fails when the receive intent is rejected`() = runTest { + setupWithOwner() + setupClaimableGiftCard() + val denied = SubmitIntentError.Denied(listOf("denied")) + coEvery { + transactionController.receiveRemotely(any(), any(), any(), any()) + } returns Result.failure(denied) + + val result = transactor.start(claimIfOwned = false) + + assertTrue(result.isFailure) + assertSame(denied, result.exceptionOrNull()) + } // endregion @@ -93,5 +132,30 @@ class ReceiveGiftCardTransactorTest { transactor.with(owner, "test-entropy") } + private fun setupClaimableGiftCard() { + val mint = Mint("giftMint11111111111111111111111111111111111") + val info = mockk(relaxed = true) { + every { claimState } returns AccountInfo.ClaimState.NotClaimed + every { isGiftCardIssuer } returns false + every { this@mockk.mint } returns mint + every { originalExchangeData } returns ExchangeData.WithRate( + currencyCode = "usd", + exchangeRate = 1.0, + nativeAmount = 5.0, + quarks = 5_000_000, + mint = mint, + ) + } + val token = mockk(relaxed = true) { + every { address } returns mint + } + coEvery { + accountController.getAccounts(any(), any(), any()) + } returns Result.success(AccountResponse(mapOf(Key32.mock to info))) + coEvery { tokenProvider.getTokenMetadata(mint) } returns + Result.success(TokenResult(token, DataSource.Network)) + every { accountController.hasAccountFor(mint) } returns true + } + // endregion }