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 @@ -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
}

Expand Down Expand Up @@ -99,34 +107,33 @@ class AccountController @Inject constructor(
* [SubmitIntentError.Denied] — means the caller must NOT proceed.
*/
suspend fun ensureCoreAccount(owner: AccountCluster): Result<Unit> {
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)
Expand All @@ -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<Unit> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())) },
)
Expand All @@ -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<PublicKey, AccountInfo> =
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<PublicKey>()
return mockk<AccountInfo> {
every { address } returns addr
every { mint } returns accountMint
every { accountType } returns type
}
}
}
Loading