Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ sealed class SubmitIntentError(
}

data class Denied(private val reasons: List<String>) :
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Token>(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<Token>(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
Expand All @@ -106,6 +184,20 @@ class GrabBillTransactorTest {

// region helpers

private fun setupWithMultiMint(transactor: GrabBillTransactor): Pair<AccountCluster, OpenCodePayload> {
val owner = mockk<AccountCluster>(relaxed = true) {
every { withTimelockForToken(any<Token>()) } returns this
every { vaultPublicKey } returns Key32.mock
every { authority } returns mockk(relaxed = true) { every { keyPair } returns mockk(relaxed = true) }
}
val payload = mockk<OpenCodePayload>(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<AccountCluster>(relaxed = true) {
every { withTimelockForToken(any<Token>()) } returns this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 staleStateWithRaceDetectedIsRaceCondition() {
val error = SubmitIntentError.typed(
Expand Down
Loading