diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt index cb8f2f5a6a..011d45831f 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt @@ -58,6 +58,8 @@ import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import javax.inject.Singleton +import kotlin.reflect.KClass +import kotlin.reflect.safeCast import kotlin.time.Clock import kotlin.time.Duration @@ -565,6 +567,7 @@ class TransactionController @Inject constructor( ): Result = repository.getIntentMetadata(intentId, owner) suspend fun pollIntentMetadata( + type: KClass, intentId: PublicKey, owner: KeyPair, maxAttempts: Int = 10, @@ -612,14 +615,18 @@ class TransactionController @Inject constructor( type = TraceType.Process ) } - metadata + type.safeCast(metadata) + ?: throw GetIntentMetadataError.UnexpectedType( + expected = type, + actual = metadata::class, + ) }, onFailure = { error -> if (error is GetIntentMetadataError.Denied) throw error null } ) - }.mapNotNull { it as? T } + } .map { Result.success(it) } .catch { emit(Result.failure(it)) } .firstOrNull() 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 8852fbeac2..2a0d282ab2 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 @@ -219,6 +219,7 @@ internal class GiveBillTransactor( ).fold( onSuccess = { transactionController.pollIntentMetadata( + type = TransactionMetadata.SendPublicPayment::class, owner = sendingVault.authority.keyPair, intentId = it.id ) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactor.kt index 30e1a047e9..1cdf7ab81c 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactor.kt @@ -103,7 +103,7 @@ internal class GrabBillTransactor( private suspend fun handleMultiMintScan( ownerKey: AccountCluster, data: OpenCodePayload - ): Result = timedTraceSuspend( + ): Result = timedTraceSuspend( message = "handleMultiMintScan", tag = tag, ) { onStep -> @@ -157,8 +157,15 @@ internal class GrabBillTransactor( onStep("createUserAccount (needed=$needsAccount)") // 4. Send grab request and wait for confirmation - val result = requestGrab(tokenizedCluster, data) - .map { it.copy(verifiedExchangeData = exchangeData) } + // The server may report the grab as either side of the payment (iOS + // accepts both too), so apply the give request's exchange data to whichever arrives. + val result = requestGrab(tokenizedCluster, data) + .map { metadata -> + when (metadata) { + is TransactionMetadata.SendPublicPayment -> metadata.copy(verifiedExchangeData = exchangeData) + is TransactionMetadata.ReceivePublicPayment -> metadata.copy(verifiedExchangeData = exchangeData) + } + } .onSuccess { // 5. Ack the receipt of the give request to clear it from the stream messagingController.ackMessages(data.rendezvous, listOf(messageId)) @@ -172,10 +179,10 @@ internal class GrabBillTransactor( return logAndFail(error) } - private suspend fun requestGrab( + private suspend fun requestGrab( owner: AccountCluster, data: OpenCodePayload - ): Result { + ): Result { return messagingController.sendRequestToGrabBill( destination = owner.vaultPublicKey, payload = data @@ -183,6 +190,7 @@ internal class GrabBillTransactor( onSuccess = { // 5. Wait for confirmation transactionController.pollIntentMetadata( + type = TransactionMetadata.PublicPayment::class, owner = owner.authority.keyPair, intentId = data.rendezvous.toPublicKey(), debugLogs = true diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt index 8e327645a9..bd3440bbd6 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt @@ -9,9 +9,11 @@ import com.getcode.opencode.model.core.errors.SubmitIntentError.Signature import com.getcode.opencode.model.core.errors.SubmitIntentError.StaleState import com.getcode.opencode.model.core.errors.SubmitIntentError.Unrecognized import com.getcode.opencode.model.transactions.SwapState +import com.getcode.opencode.model.transactions.TransactionMetadata import com.getcode.utils.CodeServerError import com.getcode.utils.ConditionallyNotifiable import com.getcode.utils.NotifiableError +import kotlin.reflect.KClass sealed class CodeAccountCheckError( override val message: String? = null, @@ -225,10 +227,25 @@ sealed class GetIntentMetadataError( class NotFound : GetIntentMetadataError("Not found") class Denied : GetIntentMetadataError("Denied") class Timeout : GetIntentMetadataError("Never received the desired metadata") + class UnexpectedType( + expected: KClass, + actual: KClass, + ) : GetIntentMetadataError("Expected ${expected.label()} metadata but received ${actual.label()}"), NotifiableError class Unrecognized : GetIntentMetadataError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : GetIntentMetadataError(message = cause?.message, cause = cause), NotifiableError } +// R8 renames these classes in release builds, so the error message spells them out. +private fun KClass.label(): String = when (this) { + TransactionMetadata.OpenAccount::class -> "OpenAccount" + TransactionMetadata.PublicPayment::class -> "PublicPayment" + TransactionMetadata.SendPublicPayment::class -> "SendPublicPayment" + TransactionMetadata.ReceivePublicPayment::class -> "ReceivePublicPayment" + TransactionMetadata.PublicDistribution::class -> "PublicDistribution" + TransactionMetadata.Unknown::class -> "Unknown" + else -> "TransactionMetadata" +} + sealed class GetLimitsError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/controllers/TransactionControllerPollIntentMetadataTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/controllers/TransactionControllerPollIntentMetadataTest.kt new file mode 100644 index 0000000000..28fe81dda7 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/controllers/TransactionControllerPollIntentMetadataTest.kt @@ -0,0 +1,59 @@ +package com.getcode.opencode.controllers + +import com.getcode.opencode.model.core.errors.GetIntentMetadataError +import com.getcode.opencode.model.transactions.TransactionMetadata +import com.getcode.opencode.repositories.TransactionRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame + +class TransactionControllerPollIntentMetadataTest { + + private val repository = mockk(relaxed = true) + private val controller = TransactionController( + repository = repository, + swapRepository = mockk(relaxed = true), + accountController = mockk(relaxed = true), + ) + + private val received = TransactionMetadata.ReceivePublicPayment( + source = mockk(relaxed = true), + quarks = 1, + isIndirect = false, + exchangeData = mockk(relaxed = true), + mint = mockk(relaxed = true), + ) + + @Test + fun `returns metadata that matches the requested type`() = runTest { + coEvery { repository.getIntentMetadata(any(), any()) } returns Result.success(received) + + val result = controller.pollIntentMetadata( + type = TransactionMetadata.PublicPayment::class, + intentId = mockk(relaxed = true), + owner = mockk(relaxed = true), + ) + + assertSame(received, result.getOrThrow()) + } + + @Test + fun `fails fast with UnexpectedType when metadata is a different type`() = runTest { + coEvery { repository.getIntentMetadata(any(), any()) } returns Result.success(received) + + val result = controller.pollIntentMetadata( + type = TransactionMetadata.SendPublicPayment::class, + intentId = mockk(relaxed = true), + owner = mockk(relaxed = true), + ) + + val error = assertIs(result.exceptionOrNull()) + assertEquals("Expected SendPublicPayment metadata but received ReceivePublicPayment", error.message) + coVerify(exactly = 1) { repository.getIntentMetadata(any(), any()) } + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactorTest.kt index 442336ca79..cfd91d326e 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactorTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactorTest.kt @@ -8,7 +8,9 @@ import com.getcode.opencode.model.core.OpenCodePayload import com.getcode.opencode.model.core.PayloadKind import com.getcode.opencode.model.core.errors.SubmitIntentError import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.transactions.ExchangeData import com.getcode.opencode.model.transactions.GiveRequest +import com.getcode.opencode.model.transactions.TransactionMetadata import com.getcode.opencode.providers.TokenMetadataProvider import com.getcode.solana.keys.Key32 import com.getcode.solana.keys.Mint @@ -21,6 +23,8 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test +import kotlin.test.assertIs +import kotlin.test.assertSame import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -161,6 +165,50 @@ class GrabBillTransactorTest { } } + @Test + fun `MultiMintCash succeeds when the server reports the grab as a receive payment`() = runTest { + val transactor = createTransactor(this) + setupWithMultiMint(transactor) + + val mint = Mint("nonCoreMint11111111111111111111111111111111") + val token = mockk(relaxed = true) { + every { address } returns mint + } + val giveExchangeData = mockk(relaxed = true) + val giveRequest = GiveRequest( + messageId = Key32.mock, + mint = mint, + exchangeData = giveExchangeData, + tokenMetadata = token + ) + val received = TransactionMetadata.ReceivePublicPayment( + source = Key32.mock, + quarks = 639_538_459, + isIndirect = true, + exchangeData = mockk(relaxed = true), + mint = mint, + ) + + coEvery { messagingController.pollForGiveRequest(any()) } returns Result.success(giveRequest) + coEvery { accountController.hasAccountFor(mint) } returns true + coEvery { messagingController.sendRequestToGrabBill(any(), any()) } returns Result.success(Key32.mock) + coEvery { + transactionController.pollIntentMetadata( + type = TransactionMetadata.PublicPayment::class, + intentId = any(), + owner = any(), + maxAttempts = any(), + debugLogs = any(), + ) + } returns Result.success(received) + + val result = transactor.start() + + val metadata = result.getOrThrow() + assertIs(metadata) + assertSame(giveExchangeData, metadata.verifiedExchangeData) + } + // endregion // region dispose