diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/AccountController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/AccountController.kt index ca0831fa2..c81a1f59e 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/AccountController.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/AccountController.kt @@ -62,6 +62,14 @@ class AccountController @Inject constructor( message = "onUserLoggedIn", type = TraceType.User ) + if (this.cluster.value != cluster) { + // A different account than this singleton last served (e.g. logout -> new/again + // login without a process restart). Drop the previous account's cached account + // list so it can't bleed into the new account — otherwise consumers (balances, + // hasAccountFor, and the onboarding core-account gate) can observe the prior + // account's accounts, most damagingly its USDF primary. + accounts.value = emptyList() + } this.cluster.value = cluster } @@ -99,34 +107,33 @@ class AccountController @Inject constructor( * [SubmitIntentError.Denied] — means the caller must NOT proceed. */ suspend fun ensureCoreAccount(owner: AccountCluster): Result { - if (hasAccountFor(Mint.usdf)) { - trace(tag = "Onboarding", message = "USDF core account already present", type = TraceType.Process) - return Result.success(Unit) - } + // Source of truth is the server, not the in-memory [accounts] cache. That cache can + // still hold a PRIOR account's USDF primary when a new account onboards in the same + // process (logout -> re-onboard, or an account switch). Short-circuiting on it made + // this gate report "already present" and release a fresh account to the scanner with + // no core account server-side — so it could never receive a direct-send tip until a + // process restart cleared the cache. Always confirm the current owner against + // getAccounts here. return getAccounts(owner, owner).fold( onSuccess = { response -> accounts.value = response.accounts.values.toList() - trace(tag = "Onboarding", message = "USDF core account already present", type = TraceType.Process) - Result.success(Unit) + if (hasCoreMintPrimary()) { + trace(tag = "Onboarding", message = "USDF core account already present", type = TraceType.Process) + Result.success(Unit) + } else { + // The server responded, but the owner has no USDF core-mint PRIMARY yet + // (e.g. a freshly-onboarded owner with only non-primary/other-mint + // accounts, or a create still racing the reactive bootstrap). The server + // recognizes an OCP user — and can auto-open currency destinations for + // direct-send tips — only once a USDF primary exists, so provision it + // before the onboarding gate releases the user to the scanner. A + // successful lookup that lacks the primary is NOT "already provisioned". + provisionCoreAccount(owner) + } }, onFailure = { error -> if (error is GetAccountsError.NotFound) { - trace(tag = "Onboarding", message = "Provisioning USDF core account (onboarding gate)", type = TraceType.Process) - createUserAccount(owner, mint = Mint.usdf).fold( - onSuccess = { - trace(tag = "Onboarding", message = "USDF core account provisioned", type = TraceType.Process) - // Best-effort refresh so hasAccountFor(USDF) is true for - // downstream grabs; the account already exists server-side. - getAccounts(owner, owner).onSuccess { - accounts.value = it.accounts.values.toList() - } - Result.success(Unit) - }, - onFailure = { - trace(tag = "Onboarding", message = "USDF core account provisioning failed", error = it, type = TraceType.Error) - Result.failure(it) - } - ) + provisionCoreAccount(owner) } else { trace(tag = "Onboarding", message = "USDF core account lookup failed", error = error, type = TraceType.Error) Result.failure(error) @@ -135,6 +142,47 @@ class AccountController @Inject constructor( ) } + /** + * Whether the local account state holds a USDF core-mint PRIMARY. This is the exact + * account the OCP server keys owner-recognition off of, so onboarding must confirm it + * specifically — a USDF balance under a non-primary account type does not count. + */ + private fun hasCoreMintPrimary(): Boolean = + accounts.value.any { it.mint == Mint.usdf && it.accountType == AccountType.Primary } + + /** + * Submits the OpenAccounts intent for the USDF core-mint primary and refreshes local + * state. Tolerates losing a race to a concurrent provision (e.g. the reactive account + * bootstrap): if the create is rejected but a re-fetch shows the primary now exists, + * the gate still succeeds rather than blocking onboarding on a redundant open. + */ + private suspend fun provisionCoreAccount(owner: AccountCluster): Result { + trace(tag = "Onboarding", message = "Provisioning USDF core account (onboarding gate)", type = TraceType.Process) + return createUserAccount(owner, mint = Mint.usdf).fold( + onSuccess = { + trace(tag = "Onboarding", message = "USDF core account provisioned", type = TraceType.Process) + getAccounts(owner, owner).onSuccess { + accounts.value = it.accounts.values.toList() + } + Result.success(Unit) + }, + onFailure = { error -> + // A concurrent provision may have already opened the core account, making + // this create redundant. Re-check before surfacing the failure. + getAccounts(owner, owner).onSuccess { + accounts.value = it.accounts.values.toList() + } + if (hasCoreMintPrimary()) { + trace(tag = "Onboarding", message = "USDF core account provisioned by concurrent open", type = TraceType.Process) + Result.success(Unit) + } else { + trace(tag = "Onboarding", message = "USDF core account provisioning failed", error = error, type = TraceType.Error) + Result.failure(error) + } + } + ) + } + suspend fun getAccounts( accountOwner: AccountCluster, requestingOwner: AccountCluster, diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/controllers/AccountControllerTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/controllers/AccountControllerTest.kt index c462befeb..4836c226f 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/controllers/AccountControllerTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/controllers/AccountControllerTest.kt @@ -5,12 +5,15 @@ import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.accounts.AccountFilter import com.getcode.opencode.model.accounts.AccountInfo import com.getcode.opencode.model.accounts.AccountResponse +import com.getcode.opencode.model.accounts.AccountType import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.core.errors.GetAccountsError import com.getcode.opencode.model.core.errors.SubmitIntentError import com.getcode.opencode.repositories.AccountRepository import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey import com.getcode.utils.network.NetworkConnectivityListener +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -70,7 +73,41 @@ class AccountControllerTest { } @Test - fun `ensureCoreAccount is a no-op when getAccounts already returns accounts`() = runTest { + fun `ensureCoreAccount is a no-op when getAccounts already returns a USDF primary`() = runTest { + val repo = FakeAccountRepository( + onGetAccounts = { Result.success(AccountResponse(accounts = accountsOf(usdfPrimary()))) }, + ) + val controller = AccountController(repo, networkObserver) + + val result = controller.ensureCoreAccount(owner) + + assertTrue(result.isSuccess) + assertEquals(0, repo.createCount) + } + + @Test + fun `ensureCoreAccount creates USDF when getAccounts succeeds without a USDF primary`() = runTest { + // Regression: a freshly-onboarded owner whose getAccounts responds OK but does + // not yet contain a USDF core-mint PRIMARY (e.g. only a non-primary or other-mint + // account, or a create still racing the reactive bootstrap). The server recognizes + // an OCP user — and can auto-open currency destinations for direct-send tips — only + // once a USDF primary exists, so onboarding must provision it rather than pass the + // gate on any successful response. + val repo = FakeAccountRepository( + onGetAccounts = { + Result.success(AccountResponse(accounts = accountsOf(usdfPool(), otherMintPrimary()))) + }, + ) + val controller = AccountController(repo, networkObserver) + + val result = controller.ensureCoreAccount(owner) + + assertTrue(result.isSuccess) + assertEquals(1, repo.createCount) + } + + @Test + fun `ensureCoreAccount creates USDF when getAccounts succeeds with no accounts`() = runTest { val repo = FakeAccountRepository( onGetAccounts = { Result.success(AccountResponse(accounts = emptyMap())) }, ) @@ -79,6 +116,87 @@ class AccountControllerTest { val result = controller.ensureCoreAccount(owner) assertTrue(result.isSuccess) + assertEquals(1, repo.createCount) + } + + @Test + fun `ensureCoreAccount tolerates a concurrent provision that already opened the core account`() = runTest { + // The create loses a race to the reactive bootstrap (or a duplicate open) and is + // rejected, but a re-fetch shows the USDF primary now exists — onboarding should + // NOT be blocked in that case. + var call = 0 + val repo = FakeAccountRepository( + onCreate = { Result.failure(SubmitIntentError.Denied(listOf("account already exists"))) }, + onGetAccounts = { + call++ + if (call == 1) { + Result.success(AccountResponse(accounts = emptyMap())) + } else { + Result.success(AccountResponse(accounts = accountsOf(usdfPrimary()))) + } + }, + ) + val controller = AccountController(repo, networkObserver) + + val result = controller.ensureCoreAccount(owner) + + assertTrue(result.isSuccess) + assertEquals(1, repo.createCount) + } + + @Test + fun `ensureCoreAccount ignores a stale cached USDF primary from a prior account`() = runTest { + // Cross-account bleed regression: the controller cached a prior account's USDF + // primary, then a new account onboards in the same process. The server (source of + // truth) has no accounts for the new owner, so the gate must provision rather than + // short-circuit on the stale cache — otherwise the fresh account is released to the + // scanner with no core account and can't receive a direct-send tip until restart. + val repo = FakeAccountRepository( + onGetAccounts = { Result.success(AccountResponse(accounts = accountsOf(usdfPrimary()))) }, + ) + val controller = AccountController(repo, networkObserver) + + // Seed the cache as if a prior account's accounts had been fetched. + controller.ensureCoreAccount(owner) assertEquals(0, repo.createCount) + + // New account: server reports NotFound for this owner. + repo.onGetAccounts = { Result.failure(GetAccountsError.NotFound()) } + val result = controller.ensureCoreAccount(owner) + + assertTrue(result.isSuccess) + assertEquals(1, repo.createCount) + } + + @Test + fun `onUserLoggedIn clears cached accounts when the account changes`() = runTest { + val repo = FakeAccountRepository( + onGetAccounts = { Result.success(AccountResponse(accounts = accountsOf(usdfPrimary()))) }, + ) + val controller = AccountController(repo, networkObserver) + controller.ensureCoreAccount(owner) + assertTrue(controller.hasAccountFor(Mint.usdf)) + + // A different account signs in; server has nothing for it, so nothing repopulates. + repo.onGetAccounts = { Result.failure(GetAccountsError.NotFound()) } + controller.onUserLoggedIn(mockk(relaxed = true)) + + assertTrue(!controller.hasAccountFor(Mint.usdf)) + } + + private fun accountsOf(vararg infos: AccountInfo): Map = + infos.associateBy { it.address } + + private fun usdfPrimary() = accountInfo(Mint.usdf, AccountType.Primary) + private fun usdfPool() = accountInfo(Mint.usdf, AccountType.Pool) + private fun otherMintPrimary() = accountInfo(Mint.usdc, AccountType.Primary) + + private fun accountInfo(accountMint: Mint, type: AccountType): AccountInfo { + val addr = mockk() + return mockk { + every { address } returns addr + every { mint } returns accountMint + every { accountType } returns type + } } }