From 3e6a0e885a0362656ca5291258e82595cf6a34c2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 4 May 2026 09:26:34 -0400 Subject: [PATCH] fix(services/opencode): recover from unexpected owner account during grab bill When createUserAccount fails with "unexpected owner account" during a multi-mint grab, recover by calling refreshAccountState() to bootstrap the core account via the normal path, then retry the non-core mint account creation. Signed-off-by: Brandon McAnsh --- .../transactors/GrabBillTransactor.kt | 18 +++- .../opencode/model/core/errors/Errors.kt | 5 +- .../transactors/GrabBillTransactorTest.kt | 92 +++++++++++++++++++ .../core/errors/SubmitIntentErrorTest.kt | 18 ++++ 4 files changed, 131 insertions(+), 2 deletions(-) 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 3ff28db35..39eb788ea 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 @@ -10,6 +10,7 @@ import com.getcode.opencode.model.core.OpenCodePayload import com.getcode.opencode.model.core.PayloadKind import com.getcode.opencode.model.transactions.TransactionMetadata import com.getcode.opencode.providers.TokenMetadataProvider +import com.getcode.opencode.model.core.errors.SubmitIntentError import com.getcode.utils.CodeServerError import com.getcode.utils.NotifiableError import com.getcode.utils.timedTraceSuspend @@ -132,7 +133,22 @@ internal class GrabBillTransactor( accountController.createUserAccount( ownerForMint = tokenizedCluster, mint = token.address - ).onFailure { + ).recoverCatching { error -> + if (error is SubmitIntentError.Denied && error.isUnexpectedOwnerAccount) { + // Safety net: PR #660 mitigates the upstream cause (getUserFlags + // failure preventing account bootstrap), but if the core account + // still isn't set up we recover here by triggering the normal + // bootstrap path before retrying. + accountController.refreshAccountState() + // Retry the original non-core mint account + accountController.createUserAccount( + ownerForMint = tokenizedCluster, + mint = token.address + ).getOrThrow() + } else { + throw error + } + }.onFailure { onStep("createUserAccount (needed=true)") return@timedTraceSuspend handleGrabError(it) } 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 9e57aefbd..84b5a5034 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 @@ -128,7 +128,10 @@ sealed class SubmitIntentError( } data class Denied(private val reasons: List) : - SubmitIntentError(message = reasons.joinToString()) + SubmitIntentError(message = reasons.joinToString()) { + val isUnexpectedOwnerAccount: Boolean + get() = reasons.any { it.contains("unexpected owner account") } + } class Unrecognized : SubmitIntentError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : SubmitIntentError(message = cause?.message, cause = cause), NotifiableError 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 42f09c5e4..442336ca7 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 @@ -6,9 +6,14 @@ import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.model.accounts.AccountCluster 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.GiveRequest 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.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -83,6 +88,79 @@ class GrabBillTransactorTest { assertTrue(result.isFailure || result.getOrNull()?.isFailure == true) } + @Test + fun `MultiMintCash recovers from unexpected owner account via refreshAccountState then retry`() = runTest { + val transactor = createTransactor(this) + setupWithMultiMint(transactor) + + val nonCoreMint = Mint("nonCoreMint11111111111111111111111111111111") + val token = mockk(relaxed = true) { + every { address } returns nonCoreMint + } + val giveRequest = GiveRequest( + messageId = Key32.mock, + mint = nonCoreMint, + exchangeData = mockk(relaxed = true), + tokenMetadata = token + ) + + coEvery { messagingController.pollForGiveRequest(any()) } returns Result.success(giveRequest) + coEvery { accountController.hasAccountFor(nonCoreMint) } returns false + + // First createUserAccount call fails with unexpected owner account + coEvery { + accountController.createUserAccount(any(), eq(nonCoreMint)) + } returns Result.failure( + SubmitIntentError.Denied(listOf("unexpected owner account")) + ) andThen Result.success(emptyList()) + + // The grab request will fail (not the focus of this test) but we verify recovery happened + runCatching { transactor.start() } + + coVerify(exactly = 1) { + accountController.refreshAccountState() + } + coVerify(exactly = 2) { + accountController.createUserAccount(any(), eq(nonCoreMint)) + } + } + + @Test + fun `MultiMintCash does not recover from non-unexpected-owner denied error`() = runTest { + val transactor = createTransactor(this) + setupWithMultiMint(transactor) + + val nonCoreMint = Mint("nonCoreMint11111111111111111111111111111111") + val token = mockk(relaxed = true) { + every { address } returns nonCoreMint + } + val giveRequest = GiveRequest( + messageId = Key32.mock, + mint = nonCoreMint, + exchangeData = mockk(relaxed = true), + tokenMetadata = token + ) + + coEvery { messagingController.pollForGiveRequest(any()) } returns Result.success(giveRequest) + coEvery { accountController.hasAccountFor(nonCoreMint) } returns false + + val deniedError = SubmitIntentError.Denied(listOf("some other reason")) + coEvery { + accountController.createUserAccount(any(), eq(nonCoreMint)) + } returns Result.failure(deniedError) + + runCatching { transactor.start() } + + // Should NOT trigger account bootstrap + coVerify(exactly = 0) { + accountController.refreshAccountState() + } + // Original call only happens once (no retry) + coVerify(exactly = 1) { + accountController.createUserAccount(any(), eq(nonCoreMint)) + } + } + // endregion // region dispose @@ -106,6 +184,20 @@ class GrabBillTransactorTest { // region helpers + private fun setupWithMultiMint(transactor: GrabBillTransactor): Pair { + val owner = mockk(relaxed = true) { + every { withTimelockForToken(any()) } returns this + every { vaultPublicKey } returns Key32.mock + every { authority } returns mockk(relaxed = true) { every { keyPair } returns mockk(relaxed = true) } + } + val payload = mockk(relaxed = true) { + every { kind } returns PayloadKind.MultiMintCash + every { rendezvous } returns mockk(relaxed = true) + } + transactor.with(owner, payload) + return owner to payload + } + private fun setupWith(transactor: GrabBillTransactor, kind: PayloadKind) { val owner = mockk(relaxed = true) { every { withTimelockForToken(any()) } returns this diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt index a24246de0..06458a47f 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt @@ -171,6 +171,24 @@ class SubmitIntentErrorTest { assertFalse(error.isGiftCardAlreadyClaimed) } + @Test + fun deniedWithUnexpectedOwnerAccountReasonIsUnexpectedOwnerAccount() { + val error = SubmitIntentError.Denied(listOf("unexpected owner account")) + assertTrue(error.isUnexpectedOwnerAccount) + } + + @Test + fun deniedWithOtherReasonIsNotUnexpectedOwnerAccount() { + val error = SubmitIntentError.Denied(listOf("some other reason")) + assertFalse(error.isUnexpectedOwnerAccount) + } + + @Test + fun deniedWithNoReasonsIsNotUnexpectedOwnerAccount() { + val error = SubmitIntentError.Denied(emptyList()) + assertFalse(error.isUnexpectedOwnerAccount) + } + @Test fun otherWrausesCause() { val cause = RuntimeException("root cause")