Flipcash talks to two gRPC backends — the Flipcash service (accounts, profiles, chat, activity) and the Open Code Protocol / OCP service (transactions, intents, exchange rates) — plus a little REST for the Coinbase on-ramp. The gRPC code is organized as a strict four-layer stack so that protobuf details never leak up into features.
graph TD
Feature["Feature / shared coordinator"]
Controller["Controller — public, stateful API"]
Repository["Repository — orchestration, caching"]
Service["Service — Result<T>, error mapping"]
Api["Api — gRPC stub, request build, validation, signing"]
Channel["ManagedChannel (unary / streaming)"]
Backend["gRPC backend"]
Feature --> Controller --> Repository --> Service --> Api --> Channel --> Backend
For both :services:flipcash and :services:opencode:
| Layer | Location | Responsibility |
|---|---|---|
| Api | …/internal/network/api/ |
Holds the generated gRPC stub, builds requests, validates them (protovalidate), signs/authenticates, returns raw protobuf. E.g. AccountApi, TransactionApi. |
| Service | …/internal/network/services/ |
Wraps an Api, converts responses to Result<T>, maps RPC/status errors to typed domain errors (e.g. RegisterError.InvalidSignature). E.g. AccountService. |
| Repository | …/internal/repositories/ (or …/repositories/) |
Coordinates one or more services, applies caching, exposes suspend functions / flows. E.g. InternalAccountRepository, TransactionRepository. |
| Controller | …/controllers/ |
The public, often stateful API features consume. Holds StateFlows, pulls key material from UserManager. E.g. AccountController, TransactionController. |
The *-compose modules (:services:flipcash-compose, :services:opencode-compose)
add Compose-facing bindings — for example LocalExchange, the composition local
that surfaces exchange rates to the UI.
Each service module provides two gRPC channels via Hilt, distinguished by
qualifier — see
FlipcashModule.kt:
@Provides @FlipcashManagedChannel
fun provideManagedChannel(/* context, config */): ManagedChannel =
AndroidChannelBuilder
.usingBuilder(OkHttpChannelBuilder.forAddress(config.baseUrl, config.port))
.keepAliveWithoutCalls(false)
.intercept(LoggingClientInterceptor())
.build()
@Provides @FlipcashManagedStreamingChannel
fun provideManagedStreamingChannel(/* ... */): ManagedChannel =
AndroidChannelBuilder
.usingBuilder(OkHttpChannelBuilder.forAddress(config.baseUrl, config.port))
.keepAliveTime(config.keepAlive.inWholeMilliseconds, TimeUnit.MILLISECONDS)
.keepAliveTimeout(config.keepAliveTimeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)
.keepAliveWithoutCalls(true) // held open for bidirectional streams
.intercept(LoggingClientInterceptor())
.build()- The unary channel is for request/response RPCs; the streaming channel keeps a keep-alive open for long-lived bidirectional streams.
@OpenCodeManagedChannel/@OpenCodeManagedStreamingChannelmirror this in the OCP module against its own base URL.LoggingClientInterceptortraces every call (see 08 — Cross-cutting concerns).
Requests are signed with the owner's Ed25519 key. Builder extensions in
services/flipcash/.../internal/network/extensions/ add the signature/auth fields
before send:
// SignMessage.kt — sign the serialized message
fun <M, B> GeneratedMessageLite.Builder<M, B>.sign(owner: Ed25519.KeyPair): Common.Signature {
val bytes = buildPartial().toByteArray()
return Ed25519.sign(bytes, owner).asSignature()
}
// AuthenticateMessage.kt — attach pubkey + signature as Common.Auth
fun <M, B> GeneratedMessageLite.Builder<M, B>.authenticate(owner: Ed25519.KeyPair): Common.Auth { /* ... */ }Companion LocalToProtobuf / ProtobufToLocal extensions translate between domain
models (keys, token amounts, timestamps) and their protobuf representations at the
Api boundary.
Payments use a bidirectional stream. TransactionApi.submitIntent takes a
Flow<SubmitIntentRequest> and returns a Flow<SubmitIntentResponse>; the
client/server handshake is:
- Client sends
SubmitActions(the intent + actions). - Server validates and returns
ServerParameters. - Client builds the Solana transactions locally and signs them.
- Client sends
SubmitSignatures. - Server verifies and returns
Success.
The full handshake and the intent types are covered in 06 — Payments & operations.
The Coinbase on-ramp uses Retrofit. CoinbaseApi
(libs/network/coinbase/onramp/...) declares @POST/@GET endpoints that take an
@Header("Authorization") JWT and dynamic @Urls. JWTs are minted per endpoint by
JwtProvider (libs/network/jwt/...):
interface JwtProvider {
suspend fun provideJwtForEndpoint(apiKey: String, endpoint: JwtSecuredEndpoint): Result<Jwt>
}Tokens are short-lived and passed per request.
- Exchange (
libs/network/exchange/.../Exchange.kt, implemented byOpenCodeExchange) exposes current and observable rates as Flows (observeLocalRate(),observeRates(),fetchRatesIfNeeded()) and is surfaced to Compose asLocalExchange. All transfers verify a cryptographically-signed exchange rate. - Connectivity (
libs/network/connectivity/...,Api24NetworkObserver) watchesConnectivityManagervia acallbackFlow,debounce(2000)s WiFi↔mobile transitions, andshareIns a single connectivity flow. It's published app-wide asLocalNetworkObserver.
Each service module carries its own README.md with the full controller catalog and
internals:
services/opencode— OCP: intents, theSubmitIntenthandshake, transactors, Solana programs, swaps, exchange.services/flipcash— accounts, chat, contacts, profiles; signing path and event streaming.services/opencode-compose·services/flipcash-compose— Compose bindings.
The four-layer split means a proto or transport change is absorbed at the Api/
Service boundary and never ripples into a feature. Features depend only on
controllers, which speak in domain types and Result<T>, not in gRPC stubs.