Skip to content

Commit 2acfecf

Browse files
authored
fix(cash-link): report a rejected claim intent as a failure (#1570)
ReceiveGiftCardTransactor.start() mapped the account-creation Result into receiveRemotely(), which returns its own Result. That produced a Result<Result<IntentType>>, 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 6d502a9.
1 parent d7b0cde commit 2acfecf

2 files changed

Lines changed: 82 additions & 13 deletions

File tree

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

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,19 @@ internal class ReceiveGiftCardTransactor(
135135
Result.success(Unit)
136136
}
137137

138-
return@timedTraceSuspend userAccountResult.map {
139-
transactionController.receiveRemotely(
140-
giftCard = giftCard,
141-
amount = amount,
142-
owner = requestingTokenOwner,
143-
mint = token.address
144-
)
145-
}.fold(
138+
// fold rather than map: receiveRemotely returns its own Result, and
139+
// mapping would nest it where its failure is never read.
140+
return@timedTraceSuspend userAccountResult.fold(
141+
onSuccess = {
142+
transactionController.receiveRemotely(
143+
giftCard = giftCard,
144+
amount = amount,
145+
owner = requestingTokenOwner,
146+
mint = token.address
147+
)
148+
},
149+
onFailure = { Result.failure(it) },
150+
).fold(
146151
onSuccess = {
147152
onStep("intent")
148153
Result.success(token to amount)

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

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,23 @@ import com.getcode.opencode.controllers.TransactionController
55
import com.getcode.opencode.managers.GiftCardManager
66
import com.getcode.opencode.managers.MnemonicManager
77
import com.getcode.opencode.model.accounts.AccountCluster
8+
import com.getcode.opencode.model.accounts.AccountInfo
9+
import com.getcode.opencode.model.accounts.AccountResponse
10+
import com.getcode.opencode.model.core.errors.SubmitIntentError
11+
import com.getcode.opencode.model.financial.DataSource
812
import com.getcode.opencode.model.financial.Token
13+
import com.getcode.opencode.model.financial.TokenResult
14+
import com.getcode.opencode.model.transactions.ExchangeData
915
import com.getcode.opencode.providers.TokenMetadataProvider
1016
import com.getcode.solana.keys.Key32
17+
import com.getcode.solana.keys.Mint
18+
import io.mockk.coEvery
1119
import io.mockk.every
1220
import io.mockk.mockk
1321
import kotlinx.coroutines.ExperimentalCoroutinesApi
1422
import kotlinx.coroutines.test.runTest
1523
import org.junit.Test
24+
import kotlin.test.assertSame
1625
import kotlin.test.assertTrue
1726

1827
@OptIn(ExperimentalCoroutinesApi::class)
@@ -56,11 +65,41 @@ class ReceiveGiftCardTransactorTest {
5665
assertTrue(result.isFailure || result.getOrNull()?.isFailure == true)
5766
}
5867

59-
// Note: Tests that require `accountController.getAccounts()` to return specific
60-
// Result.success(...) values are blocked by a MockK bug with Result<T> inline class
61-
// returns on suspend functions (https://github.com/mockk/mockk/issues/937).
62-
// Those tests should use fake repositories + real controllers when the project
63-
// establishes that pattern for transactor-level tests.
68+
// endregion
69+
70+
// region intent submission
71+
72+
@Test
73+
fun `start fails with the stale state when the gift card was already claimed`() = runTest {
74+
setupWithOwner()
75+
setupClaimableGiftCard()
76+
val alreadyClaimed = SubmitIntentError.StaleState(
77+
listOf("gift card balance has already been claimed")
78+
)
79+
coEvery {
80+
transactionController.receiveRemotely(any(), any(), any(), any())
81+
} returns Result.failure(alreadyClaimed)
82+
83+
val result = transactor.start(claimIfOwned = false)
84+
85+
assertTrue(result.isFailure)
86+
assertSame(alreadyClaimed, result.exceptionOrNull())
87+
}
88+
89+
@Test
90+
fun `start fails when the receive intent is rejected`() = runTest {
91+
setupWithOwner()
92+
setupClaimableGiftCard()
93+
val denied = SubmitIntentError.Denied(listOf("denied"))
94+
coEvery {
95+
transactionController.receiveRemotely(any(), any(), any(), any())
96+
} returns Result.failure(denied)
97+
98+
val result = transactor.start(claimIfOwned = false)
99+
100+
assertTrue(result.isFailure)
101+
assertSame(denied, result.exceptionOrNull())
102+
}
64103

65104
// endregion
66105

@@ -93,5 +132,30 @@ class ReceiveGiftCardTransactorTest {
93132
transactor.with(owner, "test-entropy")
94133
}
95134

135+
private fun setupClaimableGiftCard() {
136+
val mint = Mint("giftMint11111111111111111111111111111111111")
137+
val info = mockk<AccountInfo>(relaxed = true) {
138+
every { claimState } returns AccountInfo.ClaimState.NotClaimed
139+
every { isGiftCardIssuer } returns false
140+
every { this@mockk.mint } returns mint
141+
every { originalExchangeData } returns ExchangeData.WithRate(
142+
currencyCode = "usd",
143+
exchangeRate = 1.0,
144+
nativeAmount = 5.0,
145+
quarks = 5_000_000,
146+
mint = mint,
147+
)
148+
}
149+
val token = mockk<Token>(relaxed = true) {
150+
every { address } returns mint
151+
}
152+
coEvery {
153+
accountController.getAccounts(any(), any(), any())
154+
} returns Result.success(AccountResponse(mapOf(Key32.mock to info)))
155+
coEvery { tokenProvider.getTokenMetadata(mint) } returns
156+
Result.success(TokenResult(token, DataSource.Network))
157+
every { accountController.hasAccountFor(mint) } returns true
158+
}
159+
96160
// endregion
97161
}

0 commit comments

Comments
 (0)