Skip to content

Commit 3e6a0e8

Browse files
committed
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 <git@bmcreations.dev>
1 parent 0c7526f commit 3e6a0e8

4 files changed

Lines changed: 131 additions & 2 deletions

File tree

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.getcode.opencode.model.core.OpenCodePayload
1010
import com.getcode.opencode.model.core.PayloadKind
1111
import com.getcode.opencode.model.transactions.TransactionMetadata
1212
import com.getcode.opencode.providers.TokenMetadataProvider
13+
import com.getcode.opencode.model.core.errors.SubmitIntentError
1314
import com.getcode.utils.CodeServerError
1415
import com.getcode.utils.NotifiableError
1516
import com.getcode.utils.timedTraceSuspend
@@ -132,7 +133,22 @@ internal class GrabBillTransactor(
132133
accountController.createUserAccount(
133134
ownerForMint = tokenizedCluster,
134135
mint = token.address
135-
).onFailure {
136+
).recoverCatching { error ->
137+
if (error is SubmitIntentError.Denied && error.isUnexpectedOwnerAccount) {
138+
// Safety net: PR #660 mitigates the upstream cause (getUserFlags
139+
// failure preventing account bootstrap), but if the core account
140+
// still isn't set up we recover here by triggering the normal
141+
// bootstrap path before retrying.
142+
accountController.refreshAccountState()
143+
// Retry the original non-core mint account
144+
accountController.createUserAccount(
145+
ownerForMint = tokenizedCluster,
146+
mint = token.address
147+
).getOrThrow()
148+
} else {
149+
throw error
150+
}
151+
}.onFailure {
136152
onStep("createUserAccount (needed=true)")
137153
return@timedTraceSuspend handleGrabError(it)
138154
}

services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,10 @@ sealed class SubmitIntentError(
128128
}
129129

130130
data class Denied(private val reasons: List<String>) :
131-
SubmitIntentError(message = reasons.joinToString())
131+
SubmitIntentError(message = reasons.joinToString()) {
132+
val isUnexpectedOwnerAccount: Boolean
133+
get() = reasons.any { it.contains("unexpected owner account") }
134+
}
132135

133136
class Unrecognized : SubmitIntentError("Unrecognized"), NotifiableError
134137
data class Other(override val cause: Throwable? = null) : SubmitIntentError(message = cause?.message, cause = cause), NotifiableError

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ import com.getcode.opencode.controllers.TransactionController
66
import com.getcode.opencode.model.accounts.AccountCluster
77
import com.getcode.opencode.model.core.OpenCodePayload
88
import com.getcode.opencode.model.core.PayloadKind
9+
import com.getcode.opencode.model.core.errors.SubmitIntentError
910
import com.getcode.opencode.model.financial.Token
11+
import com.getcode.opencode.model.transactions.GiveRequest
1012
import com.getcode.opencode.providers.TokenMetadataProvider
1113
import com.getcode.solana.keys.Key32
14+
import com.getcode.solana.keys.Mint
15+
import io.mockk.coEvery
16+
import io.mockk.coVerify
1217
import io.mockk.every
1318
import io.mockk.mockk
1419
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -83,6 +88,79 @@ class GrabBillTransactorTest {
8388
assertTrue(result.isFailure || result.getOrNull()?.isFailure == true)
8489
}
8590

91+
@Test
92+
fun `MultiMintCash recovers from unexpected owner account via refreshAccountState then retry`() = runTest {
93+
val transactor = createTransactor(this)
94+
setupWithMultiMint(transactor)
95+
96+
val nonCoreMint = Mint("nonCoreMint11111111111111111111111111111111")
97+
val token = mockk<Token>(relaxed = true) {
98+
every { address } returns nonCoreMint
99+
}
100+
val giveRequest = GiveRequest(
101+
messageId = Key32.mock,
102+
mint = nonCoreMint,
103+
exchangeData = mockk(relaxed = true),
104+
tokenMetadata = token
105+
)
106+
107+
coEvery { messagingController.pollForGiveRequest(any()) } returns Result.success(giveRequest)
108+
coEvery { accountController.hasAccountFor(nonCoreMint) } returns false
109+
110+
// First createUserAccount call fails with unexpected owner account
111+
coEvery {
112+
accountController.createUserAccount(any(), eq(nonCoreMint))
113+
} returns Result.failure(
114+
SubmitIntentError.Denied(listOf("unexpected owner account"))
115+
) andThen Result.success(emptyList())
116+
117+
// The grab request will fail (not the focus of this test) but we verify recovery happened
118+
runCatching { transactor.start() }
119+
120+
coVerify(exactly = 1) {
121+
accountController.refreshAccountState()
122+
}
123+
coVerify(exactly = 2) {
124+
accountController.createUserAccount(any(), eq(nonCoreMint))
125+
}
126+
}
127+
128+
@Test
129+
fun `MultiMintCash does not recover from non-unexpected-owner denied error`() = runTest {
130+
val transactor = createTransactor(this)
131+
setupWithMultiMint(transactor)
132+
133+
val nonCoreMint = Mint("nonCoreMint11111111111111111111111111111111")
134+
val token = mockk<Token>(relaxed = true) {
135+
every { address } returns nonCoreMint
136+
}
137+
val giveRequest = GiveRequest(
138+
messageId = Key32.mock,
139+
mint = nonCoreMint,
140+
exchangeData = mockk(relaxed = true),
141+
tokenMetadata = token
142+
)
143+
144+
coEvery { messagingController.pollForGiveRequest(any()) } returns Result.success(giveRequest)
145+
coEvery { accountController.hasAccountFor(nonCoreMint) } returns false
146+
147+
val deniedError = SubmitIntentError.Denied(listOf("some other reason"))
148+
coEvery {
149+
accountController.createUserAccount(any(), eq(nonCoreMint))
150+
} returns Result.failure(deniedError)
151+
152+
runCatching { transactor.start() }
153+
154+
// Should NOT trigger account bootstrap
155+
coVerify(exactly = 0) {
156+
accountController.refreshAccountState()
157+
}
158+
// Original call only happens once (no retry)
159+
coVerify(exactly = 1) {
160+
accountController.createUserAccount(any(), eq(nonCoreMint))
161+
}
162+
}
163+
86164
// endregion
87165

88166
// region dispose
@@ -106,6 +184,20 @@ class GrabBillTransactorTest {
106184

107185
// region helpers
108186

187+
private fun setupWithMultiMint(transactor: GrabBillTransactor): Pair<AccountCluster, OpenCodePayload> {
188+
val owner = mockk<AccountCluster>(relaxed = true) {
189+
every { withTimelockForToken(any<Token>()) } returns this
190+
every { vaultPublicKey } returns Key32.mock
191+
every { authority } returns mockk(relaxed = true) { every { keyPair } returns mockk(relaxed = true) }
192+
}
193+
val payload = mockk<OpenCodePayload>(relaxed = true) {
194+
every { kind } returns PayloadKind.MultiMintCash
195+
every { rendezvous } returns mockk(relaxed = true)
196+
}
197+
transactor.with(owner, payload)
198+
return owner to payload
199+
}
200+
109201
private fun setupWith(transactor: GrabBillTransactor, kind: PayloadKind) {
110202
val owner = mockk<AccountCluster>(relaxed = true) {
111203
every { withTimelockForToken(any<Token>()) } returns this

services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ class SubmitIntentErrorTest {
171171
assertFalse(error.isGiftCardAlreadyClaimed)
172172
}
173173

174+
@Test
175+
fun deniedWithUnexpectedOwnerAccountReasonIsUnexpectedOwnerAccount() {
176+
val error = SubmitIntentError.Denied(listOf("unexpected owner account"))
177+
assertTrue(error.isUnexpectedOwnerAccount)
178+
}
179+
180+
@Test
181+
fun deniedWithOtherReasonIsNotUnexpectedOwnerAccount() {
182+
val error = SubmitIntentError.Denied(listOf("some other reason"))
183+
assertFalse(error.isUnexpectedOwnerAccount)
184+
}
185+
186+
@Test
187+
fun deniedWithNoReasonsIsNotUnexpectedOwnerAccount() {
188+
val error = SubmitIntentError.Denied(emptyList())
189+
assertFalse(error.isUnexpectedOwnerAccount)
190+
}
191+
174192
@Test
175193
fun otherWrausesCause() {
176194
val cause = RuntimeException("root cause")

0 commit comments

Comments
 (0)