From cba5d1315b65de118fa57ec8e33ce3c11e3b6bde Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 10:47:12 -0400 Subject: [PATCH 1/3] feat(opencode): scaffold Balance service for GetBalance Add the app-side layering (Api, Service, Repository, Controller) for the new Balance.GetBalance RPC now published in com.flipcash:ocp-client-protocol. GetBalanceRequest carries no auth/signature field, unlike every other OCP endpoint, so BalanceApi does not sign the request and BalanceController takes a bare PublicKey rather than an AccountCluster. No feature or ViewModel consumes this yet. --- .../opencode/controllers/BalanceController.kt | 24 +++++++++ .../getcode/opencode/inject/OpenCodeModule.kt | 10 ++++ .../repositories/InternalBalanceRepository.kt | 21 ++++++++ .../internal/network/api/BalanceApi.kt | 49 +++++++++++++++++++ .../network/services/BalanceService.kt | 38 ++++++++++++++ .../getcode/opencode/model/balance/Balance.kt | 16 ++++++ .../opencode/model/core/errors/Errors.kt | 10 ++++ .../repositories/BalanceRepository.kt | 12 +++++ 8 files changed, 180 insertions(+) create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/controllers/BalanceController.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalBalanceRepository.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/BalanceApi.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/BalanceService.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/model/balance/Balance.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/repositories/BalanceRepository.kt diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/BalanceController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/BalanceController.kt new file mode 100644 index 0000000000..5a1b5ba8b4 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/BalanceController.kt @@ -0,0 +1,24 @@ +package com.getcode.opencode.controllers + +import com.getcode.opencode.model.balance.Balance +import com.getcode.opencode.repositories.BalanceRepository +import com.getcode.solana.keys.PublicKey +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BalanceController @Inject constructor( + private val balanceRepository: BalanceRepository, +) { + /** + * Returns balance data for any owner account. + * + * Unlike the rest of this package's controllers, this does not take an + * `AccountCluster` — the underlying RPC is unauthenticated and unsigned, so a + * bare [PublicKey] is all that's needed, and this can resolve balance for any + * owner account, not just the current user's. + */ + suspend fun getBalance(owner: PublicKey): Result { + return balanceRepository.getBalance(owner) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/inject/OpenCodeModule.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/inject/OpenCodeModule.kt index b793e56414..b6a6b33081 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/inject/OpenCodeModule.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/inject/OpenCodeModule.kt @@ -13,6 +13,7 @@ import com.getcode.opencode.internal.annotations.OpenCodeManagedChannel import com.getcode.opencode.internal.annotations.OpenCodeManagedStreamingChannel import com.getcode.opencode.internal.annotations.OpenCodeProtocol import com.getcode.opencode.internal.domain.repositories.InternalAccountRepository +import com.getcode.opencode.internal.domain.repositories.InternalBalanceRepository import com.getcode.opencode.internal.domain.repositories.InternalCurrencyRepository import com.getcode.opencode.internal.domain.repositories.InternalEventRepository import com.getcode.opencode.internal.domain.repositories.InternalMessagingRepository @@ -23,12 +24,14 @@ import com.getcode.opencode.internal.exchange.RealVerifiedFiatCalculator import com.getcode.opencode.internal.manager.VerifiedProtoManager import com.getcode.opencode.internal.network.pollers.SwapPoller import com.getcode.opencode.internal.network.services.AccountService +import com.getcode.opencode.internal.network.services.BalanceService import com.getcode.opencode.internal.network.services.CurrencyService import com.getcode.opencode.internal.network.services.MessagingService import com.getcode.opencode.internal.network.services.SwapService import com.getcode.opencode.internal.network.services.TransactionService import com.getcode.opencode.providers.SessionListener import com.getcode.opencode.repositories.AccountRepository +import com.getcode.opencode.repositories.BalanceRepository import com.getcode.opencode.repositories.CurrencyRepository import com.getcode.opencode.repositories.EventRepository import com.getcode.opencode.repositories.MessagingRepository @@ -179,6 +182,13 @@ object OpenCodeModule { internal fun providesCurrencyRepository( service: CurrencyService ): CurrencyRepository = InternalCurrencyRepository(service) + + @Provides + @Singleton + internal fun providesBalanceRepository( + service: BalanceService + ): BalanceRepository = InternalBalanceRepository(service) + @Provides @Singleton internal fun providesEventBus(): ChannelEventBus = ChannelEventBus() diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalBalanceRepository.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalBalanceRepository.kt new file mode 100644 index 0000000000..10f726e26e --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalBalanceRepository.kt @@ -0,0 +1,21 @@ +package com.getcode.opencode.internal.domain.repositories + +import com.getcode.opencode.internal.network.services.BalanceService +import com.getcode.opencode.model.balance.Balance +import com.getcode.opencode.model.core.errors.GetBalanceError +import com.getcode.opencode.repositories.BalanceRepository +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.ErrorUtils +import javax.inject.Inject + +internal class InternalBalanceRepository @Inject constructor( + private val service: BalanceService, +) : BalanceRepository { + override suspend fun getBalance(owner: PublicKey): Result = + service.getBalance(owner) + .onFailure { error -> + if (error !is GetBalanceError.NotFound && error !is GetBalanceError.Denied) { + ErrorUtils.handleError(error) + } + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/BalanceApi.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/BalanceApi.kt new file mode 100644 index 0000000000..f73e0d279a --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/BalanceApi.kt @@ -0,0 +1,49 @@ +package com.getcode.opencode.internal.network.api + +import com.codeinc.opencode.gen.balance.v1.BalanceGrpcKt +import com.codeinc.opencode.gen.balance.v1.OcpBalanceService +import com.codeinc.opencode.gen.balance.v1.validate +import com.getcode.opencode.internal.annotations.OpenCodeManagedChannel +import com.getcode.opencode.internal.network.core.GrpcApi +import com.getcode.opencode.internal.network.extensions.asSolanaAccountId +import com.getcode.solana.keys.PublicKey +import dev.bmcreations.protovalidate.orThrow +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class BalanceApi @Inject constructor( + @OpenCodeManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = BalanceGrpcKt.BalanceCoroutineStub(managedChannel) + .withWaitForReady() + + /** + * Returns balance data for any owner account. + * + * Unlike every other OpenCode endpoint, this RPC carries no auth/signature field — + * it is intentionally unauthenticated so it can resolve the balance for any owner + * account address, not just the caller's own. Do not sign this request. + * + * @param owner The owner account to fetch balance data for. + * @return The [OcpBalanceService.GetBalanceResponse] + */ + suspend fun getBalance( + owner: PublicKey, + ): OcpBalanceService.GetBalanceResponse { + val request = OcpBalanceService.GetBalanceRequest.newBuilder() + .setOwner(owner.asSolanaAccountId()) + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getBalance(request) + } + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/BalanceService.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/BalanceService.kt new file mode 100644 index 0000000000..8f965a6c03 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/BalanceService.kt @@ -0,0 +1,38 @@ +package com.getcode.opencode.internal.network.services + +import com.codeinc.opencode.gen.balance.v1.OcpBalanceService +import com.getcode.opencode.internal.network.api.BalanceApi +import com.getcode.opencode.internal.network.extensions.foldWithSuppression +import com.getcode.opencode.model.balance.Balance +import com.getcode.opencode.model.core.errors.GetBalanceError +import com.getcode.opencode.utils.toValidationOrElse +import com.getcode.solana.keys.PublicKey +import javax.inject.Inject + +internal class BalanceService @Inject constructor( + private val api: BalanceApi, +) { + suspend fun getBalance(owner: PublicKey): Result { + return runCatching { + api.getBalance(owner) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + OcpBalanceService.GetBalanceResponse.Result.OK -> Result.success( + Balance(coreMintValue = response.coreMintValue) + ) + OcpBalanceService.GetBalanceResponse.Result.DENIED -> Result.failure( + GetBalanceError.Denied()) + OcpBalanceService.GetBalanceResponse.Result.NOT_FOUND -> Result.failure( + GetBalanceError.NotFound()) + OcpBalanceService.GetBalanceResponse.Result.UNRECOGNIZED -> Result.failure( + GetBalanceError.Unrecognized()) + else -> Result.failure(GetBalanceError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetBalanceError.Other(cause = it) }) + } + ) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/balance/Balance.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/balance/Balance.kt new file mode 100644 index 0000000000..418edbafd0 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/balance/Balance.kt @@ -0,0 +1,16 @@ +package com.getcode.opencode.model.balance + +/** + * Balance data for an owner account, as returned by the OpenCode `Balance.GetBalance` + * RPC. Unlike most OCP responses, this is not scoped to a single token account — + * it reports the owner's core-mint (USDF) balance directly. + */ +data class Balance( + /** + * The owner's core-mint (USDF) balance, in quarks. Mirrors how token-account + * balances are represented elsewhere in this module (e.g. [com.getcode.opencode.model.accounts.AccountInfo.balance]) — + * a raw quark [Long] rather than a currency-aware [com.getcode.opencode.model.financial.Fiat], + * since the response carries no currency code. + */ + val coreMintValue: Long, +) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt index 6efc41bc85..2b3e84eaef 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt @@ -61,6 +61,16 @@ sealed class GetRatesError( data class Other(override val cause: Throwable? = null) : GetRatesError(message = cause?.message, cause = cause), NotifiableError } +sealed class GetBalanceError( + override val message: String? = null, + override val cause: Throwable? = null +) : CodeServerError(message, cause) { + class Denied : GetBalanceError("Denied") + class NotFound : GetBalanceError("Not found") + class Unrecognized : GetBalanceError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetBalanceError(message = cause?.message, cause = cause), NotifiableError +} + sealed class GetMintsError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/BalanceRepository.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/BalanceRepository.kt new file mode 100644 index 0000000000..b6e902a29f --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/BalanceRepository.kt @@ -0,0 +1,12 @@ +package com.getcode.opencode.repositories + +import com.getcode.opencode.model.balance.Balance +import com.getcode.solana.keys.PublicKey + +interface BalanceRepository { + /** + * Returns balance data for any owner account. Unauthenticated — no signing + * key is required, only the account's address. + */ + suspend fun getBalance(owner: PublicKey): Result +} From 518f1b5b14c50ec31c59eaa96dfe029bb5ac23ff Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 10:50:49 -0400 Subject: [PATCH 2/3] chore(deps): bump ocp-client-protocol to 0.2.0 Picks up the Balance service added upstream in ea6418c5. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e0bdc8a840..fa357381fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,7 +66,7 @@ protovalidate-kt = "0.1.1" # Generated client SDKs for the two backend contracts. Separate versions on purpose: the # protos are independent (flipcash2 does not import ocp), so the packages move on their own # cadence and there is nothing to keep aligned. -ocp-client-protocol = "0.1.0" +ocp-client-protocol = "0.2.0" flipcash2-client-protocol = "0.1.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's From a650c292b2df3bb614cd8dd8d3e9e890170e4424 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 11:28:29 -0400 Subject: [PATCH 3/3] refactor(opencode): return Fiat from GetBalance instead of a wrapper type The `Balance` data class held one field, a quark count, and the doc comment on it argued the value could not be a `Fiat` because the response carries no currency code. That reasoning does not hold: `core_mint_value` is always USDF, and `Fiat.tokenBalance` already collapses to `Fiat(quarks, CurrencyCode.USD)` on the USDF branch. USDF is 6 decimals and `Fiat.MULTIPLIER` is 1_000_000, so the quark count transfers without conversion. Drop the wrapper and return `Fiat` through the service, repository, and controller. Callers get formatting and the arithmetic operators for free instead of unwrapping a Long and constructing the same `Fiat` themselves. --- .../opencode/controllers/BalanceController.kt | 6 +++--- .../repositories/InternalBalanceRepository.kt | 4 ++-- .../internal/network/services/BalanceService.kt | 7 ++++--- .../getcode/opencode/model/balance/Balance.kt | 16 ---------------- .../opencode/repositories/BalanceRepository.kt | 10 ++++++---- 5 files changed, 15 insertions(+), 28 deletions(-) delete mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/model/balance/Balance.kt diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/BalanceController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/BalanceController.kt index 5a1b5ba8b4..87988deadd 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/BalanceController.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/BalanceController.kt @@ -1,6 +1,6 @@ package com.getcode.opencode.controllers -import com.getcode.opencode.model.balance.Balance +import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.repositories.BalanceRepository import com.getcode.solana.keys.PublicKey import javax.inject.Inject @@ -11,14 +11,14 @@ class BalanceController @Inject constructor( private val balanceRepository: BalanceRepository, ) { /** - * Returns balance data for any owner account. + * Returns the owner's core-mint (USDF) balance. * * Unlike the rest of this package's controllers, this does not take an * `AccountCluster` — the underlying RPC is unauthenticated and unsigned, so a * bare [PublicKey] is all that's needed, and this can resolve balance for any * owner account, not just the current user's. */ - suspend fun getBalance(owner: PublicKey): Result { + suspend fun getBalance(owner: PublicKey): Result { return balanceRepository.getBalance(owner) } } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalBalanceRepository.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalBalanceRepository.kt index 10f726e26e..118234719d 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalBalanceRepository.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalBalanceRepository.kt @@ -1,8 +1,8 @@ package com.getcode.opencode.internal.domain.repositories import com.getcode.opencode.internal.network.services.BalanceService -import com.getcode.opencode.model.balance.Balance import com.getcode.opencode.model.core.errors.GetBalanceError +import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.repositories.BalanceRepository import com.getcode.solana.keys.PublicKey import com.getcode.utils.ErrorUtils @@ -11,7 +11,7 @@ import javax.inject.Inject internal class InternalBalanceRepository @Inject constructor( private val service: BalanceService, ) : BalanceRepository { - override suspend fun getBalance(owner: PublicKey): Result = + override suspend fun getBalance(owner: PublicKey): Result = service.getBalance(owner) .onFailure { error -> if (error !is GetBalanceError.NotFound && error !is GetBalanceError.Denied) { diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/BalanceService.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/BalanceService.kt index 8f965a6c03..ce8cd82af1 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/BalanceService.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/BalanceService.kt @@ -3,8 +3,9 @@ package com.getcode.opencode.internal.network.services import com.codeinc.opencode.gen.balance.v1.OcpBalanceService import com.getcode.opencode.internal.network.api.BalanceApi import com.getcode.opencode.internal.network.extensions.foldWithSuppression -import com.getcode.opencode.model.balance.Balance import com.getcode.opencode.model.core.errors.GetBalanceError +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.utils.toValidationOrElse import com.getcode.solana.keys.PublicKey import javax.inject.Inject @@ -12,14 +13,14 @@ import javax.inject.Inject internal class BalanceService @Inject constructor( private val api: BalanceApi, ) { - suspend fun getBalance(owner: PublicKey): Result { + suspend fun getBalance(owner: PublicKey): Result { return runCatching { api.getBalance(owner) }.foldWithSuppression( onSuccess = { response -> when (response.result) { OcpBalanceService.GetBalanceResponse.Result.OK -> Result.success( - Balance(coreMintValue = response.coreMintValue) + Fiat(quarks = response.coreMintValue, currencyCode = CurrencyCode.USD) ) OcpBalanceService.GetBalanceResponse.Result.DENIED -> Result.failure( GetBalanceError.Denied()) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/balance/Balance.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/balance/Balance.kt deleted file mode 100644 index 418edbafd0..0000000000 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/balance/Balance.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.getcode.opencode.model.balance - -/** - * Balance data for an owner account, as returned by the OpenCode `Balance.GetBalance` - * RPC. Unlike most OCP responses, this is not scoped to a single token account — - * it reports the owner's core-mint (USDF) balance directly. - */ -data class Balance( - /** - * The owner's core-mint (USDF) balance, in quarks. Mirrors how token-account - * balances are represented elsewhere in this module (e.g. [com.getcode.opencode.model.accounts.AccountInfo.balance]) — - * a raw quark [Long] rather than a currency-aware [com.getcode.opencode.model.financial.Fiat], - * since the response carries no currency code. - */ - val coreMintValue: Long, -) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/BalanceRepository.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/BalanceRepository.kt index b6e902a29f..1be3d04a28 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/BalanceRepository.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/BalanceRepository.kt @@ -1,12 +1,14 @@ package com.getcode.opencode.repositories -import com.getcode.opencode.model.balance.Balance +import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.PublicKey interface BalanceRepository { /** - * Returns balance data for any owner account. Unauthenticated — no signing - * key is required, only the account's address. + * Returns the owner's core-mint (USDF) balance. The response carries a raw quark count + * and no currency code; USDF is 6 decimals, which is the unit [Fiat] already counts in. + * + * Unauthenticated — no signing key is required, only the account's address. */ - suspend fun getBalance(owner: PublicKey): Result + suspend fun getBalance(owner: PublicKey): Result }