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 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..87988deadd --- /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.financial.Fiat +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 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 { + 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..118234719d --- /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.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 +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..ce8cd82af1 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/BalanceService.kt @@ -0,0 +1,39 @@ +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.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 + +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( + Fiat(quarks = response.coreMintValue, currencyCode = CurrencyCode.USD) + ) + 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/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..1be3d04a28 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/BalanceRepository.kt @@ -0,0 +1,14 @@ +package com.getcode.opencode.repositories + +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.PublicKey + +interface BalanceRepository { + /** + * 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 +}