Skip to content

Commit cfdcd56

Browse files
authored
fix(accounts): provision USDF core account for fresh onboards even with a stale cache (#1158)
ensureCoreAccount gated onboarding on the in-memory `accounts` cache, which can still hold a prior account's USDF core-mint primary after a logout -> re-onboard (or account switch) in the same process. The gate then reported "already present" and released a freshly-onboarded account to the scanner with no core account server-side, so it could not receive a direct-send tip until a process restart cleared the cache — both Android and iOS senders saw "payments to external destinations must be withdrawals". - ensureCoreAccount is now server-authoritative: it always confirms the current owner via getAccounts and provisions the USDF primary when absent (checking account TYPE, not just mint), tolerating a race with the reactive bootstrap. - onUserLoggedIn clears the cached account list when the account changes, so no prior account's accounts bleed into a new one. Adds AccountControllerTest coverage for the stale-cache and account-switch cases.
1 parent 06de01b commit cfdcd56

2 files changed

Lines changed: 189 additions & 23 deletions

File tree

‎services/opencode/src/main/kotlin/com/getcode/opencode/controllers/AccountController.kt‎

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ class AccountController @Inject constructor(
6262
message = "onUserLoggedIn",
6363
type = TraceType.User
6464
)
65+
if (this.cluster.value != cluster) {
66+
// A different account than this singleton last served (e.g. logout -> new/again
67+
// login without a process restart). Drop the previous account's cached account
68+
// list so it can't bleed into the new account — otherwise consumers (balances,
69+
// hasAccountFor, and the onboarding core-account gate) can observe the prior
70+
// account's accounts, most damagingly its USDF primary.
71+
accounts.value = emptyList()
72+
}
6573
this.cluster.value = cluster
6674
}
6775

@@ -99,34 +107,33 @@ class AccountController @Inject constructor(
99107
* [SubmitIntentError.Denied] — means the caller must NOT proceed.
100108
*/
101109
suspend fun ensureCoreAccount(owner: AccountCluster): Result<Unit> {
102-
if (hasAccountFor(Mint.usdf)) {
103-
trace(tag = "Onboarding", message = "USDF core account already present", type = TraceType.Process)
104-
return Result.success(Unit)
105-
}
110+
// Source of truth is the server, not the in-memory [accounts] cache. That cache can
111+
// still hold a PRIOR account's USDF primary when a new account onboards in the same
112+
// process (logout -> re-onboard, or an account switch). Short-circuiting on it made
113+
// this gate report "already present" and release a fresh account to the scanner with
114+
// no core account server-side — so it could never receive a direct-send tip until a
115+
// process restart cleared the cache. Always confirm the current owner against
116+
// getAccounts here.
106117
return getAccounts(owner, owner).fold(
107118
onSuccess = { response ->
108119
accounts.value = response.accounts.values.toList()
109-
trace(tag = "Onboarding", message = "USDF core account already present", type = TraceType.Process)
110-
Result.success(Unit)
120+
if (hasCoreMintPrimary()) {
121+
trace(tag = "Onboarding", message = "USDF core account already present", type = TraceType.Process)
122+
Result.success(Unit)
123+
} else {
124+
// The server responded, but the owner has no USDF core-mint PRIMARY yet
125+
// (e.g. a freshly-onboarded owner with only non-primary/other-mint
126+
// accounts, or a create still racing the reactive bootstrap). The server
127+
// recognizes an OCP user — and can auto-open currency destinations for
128+
// direct-send tips — only once a USDF primary exists, so provision it
129+
// before the onboarding gate releases the user to the scanner. A
130+
// successful lookup that lacks the primary is NOT "already provisioned".
131+
provisionCoreAccount(owner)
132+
}
111133
},
112134
onFailure = { error ->
113135
if (error is GetAccountsError.NotFound) {
114-
trace(tag = "Onboarding", message = "Provisioning USDF core account (onboarding gate)", type = TraceType.Process)
115-
createUserAccount(owner, mint = Mint.usdf).fold(
116-
onSuccess = {
117-
trace(tag = "Onboarding", message = "USDF core account provisioned", type = TraceType.Process)
118-
// Best-effort refresh so hasAccountFor(USDF) is true for
119-
// downstream grabs; the account already exists server-side.
120-
getAccounts(owner, owner).onSuccess {
121-
accounts.value = it.accounts.values.toList()
122-
}
123-
Result.success(Unit)
124-
},
125-
onFailure = {
126-
trace(tag = "Onboarding", message = "USDF core account provisioning failed", error = it, type = TraceType.Error)
127-
Result.failure(it)
128-
}
129-
)
136+
provisionCoreAccount(owner)
130137
} else {
131138
trace(tag = "Onboarding", message = "USDF core account lookup failed", error = error, type = TraceType.Error)
132139
Result.failure(error)
@@ -135,6 +142,47 @@ class AccountController @Inject constructor(
135142
)
136143
}
137144

145+
/**
146+
* Whether the local account state holds a USDF core-mint PRIMARY. This is the exact
147+
* account the OCP server keys owner-recognition off of, so onboarding must confirm it
148+
* specifically — a USDF balance under a non-primary account type does not count.
149+
*/
150+
private fun hasCoreMintPrimary(): Boolean =
151+
accounts.value.any { it.mint == Mint.usdf && it.accountType == AccountType.Primary }
152+
153+
/**
154+
* Submits the OpenAccounts intent for the USDF core-mint primary and refreshes local
155+
* state. Tolerates losing a race to a concurrent provision (e.g. the reactive account
156+
* bootstrap): if the create is rejected but a re-fetch shows the primary now exists,
157+
* the gate still succeeds rather than blocking onboarding on a redundant open.
158+
*/
159+
private suspend fun provisionCoreAccount(owner: AccountCluster): Result<Unit> {
160+
trace(tag = "Onboarding", message = "Provisioning USDF core account (onboarding gate)", type = TraceType.Process)
161+
return createUserAccount(owner, mint = Mint.usdf).fold(
162+
onSuccess = {
163+
trace(tag = "Onboarding", message = "USDF core account provisioned", type = TraceType.Process)
164+
getAccounts(owner, owner).onSuccess {
165+
accounts.value = it.accounts.values.toList()
166+
}
167+
Result.success(Unit)
168+
},
169+
onFailure = { error ->
170+
// A concurrent provision may have already opened the core account, making
171+
// this create redundant. Re-check before surfacing the failure.
172+
getAccounts(owner, owner).onSuccess {
173+
accounts.value = it.accounts.values.toList()
174+
}
175+
if (hasCoreMintPrimary()) {
176+
trace(tag = "Onboarding", message = "USDF core account provisioned by concurrent open", type = TraceType.Process)
177+
Result.success(Unit)
178+
} else {
179+
trace(tag = "Onboarding", message = "USDF core account provisioning failed", error = error, type = TraceType.Error)
180+
Result.failure(error)
181+
}
182+
}
183+
)
184+
}
185+
138186
suspend fun getAccounts(
139187
accountOwner: AccountCluster,
140188
requestingOwner: AccountCluster,

‎services/opencode/src/test/kotlin/com/getcode/opencode/controllers/AccountControllerTest.kt‎

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@ import com.getcode.opencode.model.accounts.AccountCluster
55
import com.getcode.opencode.model.accounts.AccountFilter
66
import com.getcode.opencode.model.accounts.AccountInfo
77
import com.getcode.opencode.model.accounts.AccountResponse
8+
import com.getcode.opencode.model.accounts.AccountType
89
import com.getcode.opencode.model.core.ID
910
import com.getcode.opencode.model.core.errors.GetAccountsError
1011
import com.getcode.opencode.model.core.errors.SubmitIntentError
1112
import com.getcode.opencode.repositories.AccountRepository
1213
import com.getcode.solana.keys.Mint
14+
import com.getcode.solana.keys.PublicKey
1315
import com.getcode.utils.network.NetworkConnectivityListener
16+
import io.mockk.every
1417
import io.mockk.mockk
1518
import kotlinx.coroutines.CoroutineScope
1619
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -70,7 +73,41 @@ class AccountControllerTest {
7073
}
7174

7275
@Test
73-
fun `ensureCoreAccount is a no-op when getAccounts already returns accounts`() = runTest {
76+
fun `ensureCoreAccount is a no-op when getAccounts already returns a USDF primary`() = runTest {
77+
val repo = FakeAccountRepository(
78+
onGetAccounts = { Result.success(AccountResponse(accounts = accountsOf(usdfPrimary()))) },
79+
)
80+
val controller = AccountController(repo, networkObserver)
81+
82+
val result = controller.ensureCoreAccount(owner)
83+
84+
assertTrue(result.isSuccess)
85+
assertEquals(0, repo.createCount)
86+
}
87+
88+
@Test
89+
fun `ensureCoreAccount creates USDF when getAccounts succeeds without a USDF primary`() = runTest {
90+
// Regression: a freshly-onboarded owner whose getAccounts responds OK but does
91+
// not yet contain a USDF core-mint PRIMARY (e.g. only a non-primary or other-mint
92+
// account, or a create still racing the reactive bootstrap). The server recognizes
93+
// an OCP user — and can auto-open currency destinations for direct-send tips — only
94+
// once a USDF primary exists, so onboarding must provision it rather than pass the
95+
// gate on any successful response.
96+
val repo = FakeAccountRepository(
97+
onGetAccounts = {
98+
Result.success(AccountResponse(accounts = accountsOf(usdfPool(), otherMintPrimary())))
99+
},
100+
)
101+
val controller = AccountController(repo, networkObserver)
102+
103+
val result = controller.ensureCoreAccount(owner)
104+
105+
assertTrue(result.isSuccess)
106+
assertEquals(1, repo.createCount)
107+
}
108+
109+
@Test
110+
fun `ensureCoreAccount creates USDF when getAccounts succeeds with no accounts`() = runTest {
74111
val repo = FakeAccountRepository(
75112
onGetAccounts = { Result.success(AccountResponse(accounts = emptyMap())) },
76113
)
@@ -79,6 +116,87 @@ class AccountControllerTest {
79116
val result = controller.ensureCoreAccount(owner)
80117

81118
assertTrue(result.isSuccess)
119+
assertEquals(1, repo.createCount)
120+
}
121+
122+
@Test
123+
fun `ensureCoreAccount tolerates a concurrent provision that already opened the core account`() = runTest {
124+
// The create loses a race to the reactive bootstrap (or a duplicate open) and is
125+
// rejected, but a re-fetch shows the USDF primary now exists — onboarding should
126+
// NOT be blocked in that case.
127+
var call = 0
128+
val repo = FakeAccountRepository(
129+
onCreate = { Result.failure(SubmitIntentError.Denied(listOf("account already exists"))) },
130+
onGetAccounts = {
131+
call++
132+
if (call == 1) {
133+
Result.success(AccountResponse(accounts = emptyMap()))
134+
} else {
135+
Result.success(AccountResponse(accounts = accountsOf(usdfPrimary())))
136+
}
137+
},
138+
)
139+
val controller = AccountController(repo, networkObserver)
140+
141+
val result = controller.ensureCoreAccount(owner)
142+
143+
assertTrue(result.isSuccess)
144+
assertEquals(1, repo.createCount)
145+
}
146+
147+
@Test
148+
fun `ensureCoreAccount ignores a stale cached USDF primary from a prior account`() = runTest {
149+
// Cross-account bleed regression: the controller cached a prior account's USDF
150+
// primary, then a new account onboards in the same process. The server (source of
151+
// truth) has no accounts for the new owner, so the gate must provision rather than
152+
// short-circuit on the stale cache — otherwise the fresh account is released to the
153+
// scanner with no core account and can't receive a direct-send tip until restart.
154+
val repo = FakeAccountRepository(
155+
onGetAccounts = { Result.success(AccountResponse(accounts = accountsOf(usdfPrimary()))) },
156+
)
157+
val controller = AccountController(repo, networkObserver)
158+
159+
// Seed the cache as if a prior account's accounts had been fetched.
160+
controller.ensureCoreAccount(owner)
82161
assertEquals(0, repo.createCount)
162+
163+
// New account: server reports NotFound for this owner.
164+
repo.onGetAccounts = { Result.failure(GetAccountsError.NotFound()) }
165+
val result = controller.ensureCoreAccount(owner)
166+
167+
assertTrue(result.isSuccess)
168+
assertEquals(1, repo.createCount)
169+
}
170+
171+
@Test
172+
fun `onUserLoggedIn clears cached accounts when the account changes`() = runTest {
173+
val repo = FakeAccountRepository(
174+
onGetAccounts = { Result.success(AccountResponse(accounts = accountsOf(usdfPrimary()))) },
175+
)
176+
val controller = AccountController(repo, networkObserver)
177+
controller.ensureCoreAccount(owner)
178+
assertTrue(controller.hasAccountFor(Mint.usdf))
179+
180+
// A different account signs in; server has nothing for it, so nothing repopulates.
181+
repo.onGetAccounts = { Result.failure(GetAccountsError.NotFound()) }
182+
controller.onUserLoggedIn(mockk(relaxed = true))
183+
184+
assertTrue(!controller.hasAccountFor(Mint.usdf))
185+
}
186+
187+
private fun accountsOf(vararg infos: AccountInfo): Map<PublicKey, AccountInfo> =
188+
infos.associateBy { it.address }
189+
190+
private fun usdfPrimary() = accountInfo(Mint.usdf, AccountType.Primary)
191+
private fun usdfPool() = accountInfo(Mint.usdf, AccountType.Pool)
192+
private fun otherMintPrimary() = accountInfo(Mint.usdc, AccountType.Primary)
193+
194+
private fun accountInfo(accountMint: Mint, type: AccountType): AccountInfo {
195+
val addr = mockk<PublicKey>()
196+
return mockk<AccountInfo> {
197+
every { address } returns addr
198+
every { mint } returns accountMint
199+
every { accountType } returns type
200+
}
83201
}
84202
}

0 commit comments

Comments
 (0)