From 9fb9f7828551ea27309af9e058bf3e5bbd6cffac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 26 May 2026 10:09:37 -0400 Subject: [PATCH] feat(contacts): add contact list and resolver services with ContactMethod.Phone Add proto definitions, gRPC API stubs, service/repository/controller layers for contact list sync and phone resolver, using ContactMethod.Phone for type-safe phone number handling throughout. --- .../contact/v1/contact_list_service.proto | 135 ++++++++++++++++++ .../src/main/proto/contact/v1/model.proto | 14 ++ .../src/main/proto/resolver/v1/model.proto | 31 ++++ .../proto/resolver/v1/resolver_service.proto | 36 +++++ .../kotlin/com/getcode/solana/keys/Types.kt | 1 + .../controllers/ContactListController.kt | 45 ++++++ .../controllers/ResolverController.kt | 18 +++ .../services/inject/FlipcashModule.kt | 16 +++ .../services/internal/extensions/ByteArray.kt | 5 + .../internal/network/api/ContactListApi.kt | 103 +++++++++++++ .../internal/network/api/ResolverApi.kt | 47 ++++++ .../network/extensions/LocalToProtobuf.kt | 5 + .../network/extensions/ProtobufToLocal.kt | 3 + .../network/services/ContactListService.kt | 130 +++++++++++++++++ .../network/services/ResolverService.kt | 42 ++++++ .../InternalContactListRepository.kt | 40 ++++++ .../InternalResolverRepository.kt | 18 +++ .../com/flipcash/services/models/Errors.kt | 55 +++++++ .../repository/ContactListRepository.kt | 32 +++++ .../services/repository/ResolverRepository.kt | 9 ++ 20 files changed, 785 insertions(+) create mode 100644 definitions/flipcash/protos/src/main/proto/contact/v1/contact_list_service.proto create mode 100644 definitions/flipcash/protos/src/main/proto/contact/v1/model.proto create mode 100644 definitions/flipcash/protos/src/main/proto/resolver/v1/model.proto create mode 100644 definitions/flipcash/protos/src/main/proto/resolver/v1/resolver_service.proto create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactListController.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ResolverController.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ContactListApi.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalResolverRepository.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactListRepository.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/repository/ResolverRepository.kt diff --git a/definitions/flipcash/protos/src/main/proto/contact/v1/contact_list_service.proto b/definitions/flipcash/protos/src/main/proto/contact/v1/contact_list_service.proto new file mode 100644 index 000000000..c78e4e273 --- /dev/null +++ b/definitions/flipcash/protos/src/main/proto/contact/v1/contact_list_service.proto @@ -0,0 +1,135 @@ +syntax = "proto3"; + +package flipcash.contact.v1; + +import "contact/v1/model.proto"; +import "common/v1/common.proto"; +import "phone/v1/model.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/contact/v1;contactpb"; +option java_package = "com.codeinc.flipcash.gen.contact.v1"; +option objc_class_prefix = "FPBContactV1"; + +// ContactList manages a user's contact list and surfaces which contacts are +// Flipcash users. +// +// Sync model: +// - The client maintains a 32-byte XOR-of-SHA256 checksum over its current +// contact set, and an OS-specific cursor for incremental change discovery. +// The cursor is local-only and never leaves the device. +// - Steady state: client computes a delta from the OS cursor, sends it via +// DeltaUpload with old/new checksums for compare-and-swap. +// - Recovery: on first install, after OS history truncation, or after +// CHECKSUM_DRIFT, the client uses FullUpload to replace the server's state +// wholesale. +service ContactList { + // CheckSync compares the client's checksum to the server's. Cheap, used + // on app foreground to decide whether any upload is needed. + rpc CheckSync(CheckSyncRequest) returns (CheckSyncResponse); + + // DeltaUpload applies a delta under compare-and-swap on the checksum. + // Safe to retry indefinitely with the same payload. + rpc DeltaUpload(DeltaUploadRequest) returns (DeltaUploadResponse); + + // FullUpload replaces the user's contact set entirely. Used when + // a delta cannot be constructed or CHECKSUM_DRIFT was returned. + rpc FullUpload(stream FullUploadRequest) returns (FullUploadResponse); + + // GetFlipcashContacts gets the set of contacts that are on Flipcash + rpc GetFlipcashContacts(GetFlipcashContactsRequest) returns (stream GetFlipcashContactsResponse); +} + + +message CheckSyncRequest { + common.v1.Auth auth = 1 [(validate.rules).message.required = true]; + + // XOR-of-SHA256 over the client's current set of normalized E.164 phones. + common.v1.Hash client_checksum = 2 [(validate.rules).message.required = true]; +} + +message CheckSyncResponse { + enum Result { + OK = 0; + DENIED = 1; + OUT_OF_SYNC = 2; + } + Result result = 1; + + // Authoritative server-side checksum. Clients persist this and use it + // as the basis for the next DeltaUpload.old_checksum. + common.v1.Hash server_checksum = 2; +} + +message DeltaUploadRequest { + common.v1.Auth auth = 1 [(validate.rules).message.required = true]; + + repeated phone.v1.PhoneNumber adds = 2 [(validate.rules).repeated.max_items = 1000]; + + repeated phone.v1.PhoneNumber removes = 3 [(validate.rules).repeated.max_items = 1000]; + + // The checksum the client expected the server to have *before* applying + // this delta. Server applies only if stored == old_checksum. + common.v1.Hash old_checksum = 4 [(validate.rules).message.required = true]; + + // The checksum the client computes for the state *after* applying this + // delta. Server persists this on success. Used to detect retries: if + // stored == new_checksum, the server treats the request as a no-op. + common.v1.Hash new_checksum = 5 [(validate.rules).message.required = true]; +} + +message DeltaUploadResponse { + enum Result { + OK = 0; + DENIED = 1; + // Server's recomputed checksum did not match expected_checksum. + CHECKSUM_MISMATCH = 2; + // Stored checksum matched neither old_checksum nor new_checksum. + // Client should call FullUpload to reconcile. + CHECKSUM_DRIFT = 3; + TOO_MANY_CONTACTS = 4; + } + Result result = 1; +} + +message FullUploadRequest { + common.v1.Auth auth = 1 [(validate.rules).message.required = true]; + + // The complete current contact set. Server replaces stored state with + // this list in one transaction. + repeated phone.v1.PhoneNumber phones = 2 [(validate.rules).repeated.max_items = 1000]; + + // XOR-of-SHA256 over the client's current set of normalized E.164 phones. + // Sent on the last streamed request to indicate the end of the upload. + common.v1.Hash expected_checksum = 3 [(validate.rules).message.required = true]; +} + +message FullUploadResponse { + enum Result { + OK = 0; + DENIED = 1; + // Server's recomputed checksum did not match expected_checksum. + CHECKSUM_MISMATCH = 2; + TOO_MANY_CONTACTS = 3; + } + Result result = 1; +} + +message GetFlipcashContactsRequest { + common.v1.Auth auth = 1 [(validate.rules).message.required = true]; + + common.v1.Hash checksum = 2 [(validate.rules).message.required = true]; +} + +message GetFlipcashContactsResponse { + enum Result { + OK = 0; + DENIED = 1; + NOT_FOUND = 2; + // Server checksum doesn't match client checksum. + CHECKSUM_DRIFT = 3; + } + Result result = 1; + + repeated FlipcashContact contacts = 2 [(validate.rules).repeated.max_items = 1000]; +} diff --git a/definitions/flipcash/protos/src/main/proto/contact/v1/model.proto b/definitions/flipcash/protos/src/main/proto/contact/v1/model.proto new file mode 100644 index 000000000..c6a62e8c0 --- /dev/null +++ b/definitions/flipcash/protos/src/main/proto/contact/v1/model.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package flipcash.contact.v1; + +import "phone/v1/model.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/contact/v1;contactpb"; +option java_package = "com.codeinc.flipcash.gen.contact.v1"; +option objc_class_prefix = "FPBContactV1"; + +message FlipcashContact { + phone.v1.PhoneNumber phone = 1 [(validate.rules).message.required = true]; +} diff --git a/definitions/flipcash/protos/src/main/proto/resolver/v1/model.proto b/definitions/flipcash/protos/src/main/proto/resolver/v1/model.proto new file mode 100644 index 000000000..0d0d53b18 --- /dev/null +++ b/definitions/flipcash/protos/src/main/proto/resolver/v1/model.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +package flipcash.resolver.v1; + +option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/resolver/v1;resolverpb"; +option java_package = "com.codeinc.flipcash.gen.resolver.v1"; +option objc_class_prefix = "FPBResolverV1"; + +import "common/v1/common.proto"; +import "phone/v1/model.proto"; +import "validate/validate.proto"; + +// Identifier wraps a real-world identifier that can be resolved to a +// payment destination address. +message Identifier { + oneof kind { + option (validate.required) = true; + + phone.v1.PhoneNumber phone = 1; + } +} + +// Resolution contains a payment destiation address mapping for an +// Identifier +message Resolution { + oneof kind { + option (validate.required) = true; + + common.v1.PublicKey address = 1; + } +} diff --git a/definitions/flipcash/protos/src/main/proto/resolver/v1/resolver_service.proto b/definitions/flipcash/protos/src/main/proto/resolver/v1/resolver_service.proto new file mode 100644 index 000000000..ac7034778 --- /dev/null +++ b/definitions/flipcash/protos/src/main/proto/resolver/v1/resolver_service.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package flipcash.resolver.v1; + +option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/resolver/v1;resolverpb"; +option java_package = "com.codeinc.flipcash.gen.resolver.v1"; +option objc_class_prefix = "FPBResolverV1"; + +import "common/v1/common.proto"; +import "resolver/v1/model.proto"; +import "validate/validate.proto"; + +// Resolver maps a real-world identifier (phone number, etc.) to a payment +// destination address. +service Resolver { + // Resolve looks up the payment destination address for the given identifier. + rpc Resolve(ResolveRequest) returns (ResolveResponse); +} + +message ResolveRequest { + common.v1.Auth auth = 1 [(validate.rules).message.required = true]; + + Identifier identifier = 2 [(validate.rules).message.required = true]; +} + +message ResolveResponse { + Result result = 1; + enum Result { + OK = 0; + NOT_FOUND = 1; + DENIED = 2; + } + + // The resolved payment destination address. Set when result == OK. + Resolution resolution = 2; +} diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt index 2476abd2a..1fb523810 100644 --- a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt @@ -6,6 +6,7 @@ typealias Seed16 = Key16 typealias Seed32 = Key32 typealias Hash = Key32 +typealias Checksum = Key32 typealias PrivateKey = Key64 diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactListController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactListController.kt new file mode 100644 index 000000000..a1ec51bc6 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactListController.kt @@ -0,0 +1,45 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.repository.ContactListRepository +import com.flipcash.services.user.UserManager +import com.getcode.solana.keys.Checksum +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class ContactListController @Inject constructor( + private val repository: ContactListRepository, + private val userManager: UserManager, +) { + suspend fun checkSync(clientChecksum: Checksum): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.checkSync(owner, clientChecksum) + } + + suspend fun deltaUpload( + adds: List, + removes: List, + oldChecksum: Checksum, + newChecksum: Checksum, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.deltaUpload(owner, adds, removes, oldChecksum, newChecksum) + } + + suspend fun fullUpload( + phones: Flow>, + expectedChecksum: Checksum, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.fullUpload(owner, phones, expectedChecksum) + } + + fun getFlipcashContacts(checksum: Checksum): Flow>> { + val owner = userManager.accountCluster?.authority?.keyPair + ?: throw IllegalStateException("No account cluster in UserManager") + return repository.getFlipcashContacts(owner, checksum) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ResolverController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ResolverController.kt new file mode 100644 index 000000000..1288b9f59 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ResolverController.kt @@ -0,0 +1,18 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.repository.ResolverRepository +import com.flipcash.services.user.UserManager +import com.getcode.solana.keys.PublicKey +import javax.inject.Inject + +class ResolverController @Inject constructor( + private val repository: ResolverRepository, + private val userManager: UserManager, +) { + suspend fun resolve(phone: ContactMethod.Phone): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.resolve(owner, phone) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt index 4ec218321..c076fdfa0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt @@ -12,29 +12,35 @@ import com.flipcash.services.internal.domain.UserProfileMapper import com.flipcash.services.internal.network.services.AccountService import com.flipcash.services.internal.network.services.ActivityFeedService import com.flipcash.services.internal.network.services.EmailVerificationService +import com.flipcash.services.internal.network.services.ContactListService import com.flipcash.services.internal.network.services.ModerationService import com.flipcash.services.internal.network.services.PhoneVerificationService import com.flipcash.services.internal.network.services.ProfileService import com.flipcash.services.internal.network.services.PurchaseService import com.flipcash.services.internal.network.services.PushService +import com.flipcash.services.internal.network.services.ResolverService import com.flipcash.services.internal.network.services.SettingsService import com.flipcash.services.internal.network.services.ThirdPartyService import com.flipcash.services.internal.repositories.InternalAccountRepository import com.flipcash.services.internal.repositories.InternalActivityFeedRepository +import com.flipcash.services.internal.repositories.InternalContactListRepository import com.flipcash.services.internal.repositories.InternalContactVerificationRepository import com.flipcash.services.internal.repositories.InternalModerationRepository import com.flipcash.services.internal.repositories.InternalProfileRepository import com.flipcash.services.internal.repositories.InternalPurchaseRepository import com.flipcash.services.internal.repositories.InternalPushRepository +import com.flipcash.services.internal.repositories.InternalResolverRepository import com.flipcash.services.internal.repositories.InternalSettingsRepository import com.flipcash.services.internal.repositories.InternalThirdPartyRepository import com.flipcash.services.repository.AccountRepository import com.flipcash.services.repository.ActivityFeedRepository +import com.flipcash.services.repository.ContactListRepository import com.flipcash.services.repository.ContactVerificationRepository import com.flipcash.services.repository.ModerationRepository import com.flipcash.services.repository.ProfileRepository import com.flipcash.services.repository.PurchaseRepository import com.flipcash.services.repository.PushRepository +import com.flipcash.services.repository.ResolverRepository import com.flipcash.services.repository.SettingsRepository import com.flipcash.services.repository.ThirdPartyRepository import com.getcode.opencode.ProtocolConfig @@ -108,6 +114,16 @@ internal object FlipcashModule { } } + @Provides + internal fun providesContactListRepository( + service: ContactListService, + ): ContactListRepository = InternalContactListRepository(service) + + @Provides + internal fun providesResolverRepository( + service: ResolverService, + ): ResolverRepository = InternalResolverRepository(service) + @Provides internal fun providesAccountRepository( service: AccountService, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/extensions/ByteArray.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/extensions/ByteArray.kt index b2c9f5157..13d6c806d 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/extensions/ByteArray.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/extensions/ByteArray.kt @@ -1,5 +1,6 @@ package com.flipcash.services.internal.extensions +import com.getcode.solana.keys.Checksum import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey @@ -7,6 +8,10 @@ internal fun ByteArray.toHash(): com.getcode.solana.keys.Hash { return com.getcode.solana.keys.Hash(this.toList()) } +internal fun ByteArray.toChecksum(): Checksum { + return Checksum(this.toList()) +} + internal fun ByteArray.toPublicKey(): PublicKey { return PublicKey(this.toList()) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ContactListApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ContactListApi.kt new file mode 100644 index 000000000..d6120e669 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ContactListApi.kt @@ -0,0 +1,103 @@ +package com.flipcash.services.internal.network.api + +import com.codeinc.flipcash.gen.contact.v1.ContactListGrpcKt +import com.codeinc.flipcash.gen.phone.v1.Model +import com.codeinc.flipcash.gen.contact.v1.validate +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.internal.annotations.FlipcashManagedChannel +import com.flipcash.services.internal.network.extensions.asHash +import com.flipcash.services.internal.network.extensions.authenticate +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.core.GrpcApi +import com.getcode.solana.keys.Checksum +import dev.bmcreations.protovalidate.orThrow +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton +import com.codeinc.flipcash.gen.contact.v1.ContactListService as RpcContactListService + +@Singleton +internal class ContactListApi @Inject constructor( + @FlipcashManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = ContactListGrpcKt.ContactListCoroutineStub(managedChannel) + .withWaitForReady() + + suspend fun checkSync( + owner: KeyPair, + clientChecksum: Checksum, + ): RpcContactListService.CheckSyncResponse { + val request = RpcContactListService.CheckSyncRequest.newBuilder() + .setClientChecksum(clientChecksum.asHash()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.checkSync(request) + } + } + + suspend fun deltaUpload( + owner: KeyPair, + adds: List, + removes: List, + oldChecksum: Checksum, + newChecksum: Checksum, + ): RpcContactListService.DeltaUploadResponse { + val request = RpcContactListService.DeltaUploadRequest.newBuilder() + .addAllAdds(adds.map { Model.PhoneNumber.newBuilder().setValue(it.phoneNumber).build() }) + .addAllRemoves(removes.map { Model.PhoneNumber.newBuilder().setValue(it.phoneNumber).build() }) + .setOldChecksum(oldChecksum.asHash()) + .setNewChecksum(newChecksum.asHash()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.deltaUpload(request) + } + } + + suspend fun fullUpload( + owner: KeyPair, + phones: Flow>, + expectedChecksum: Checksum, + ): RpcContactListService.FullUploadResponse { + val requestFlow = kotlinx.coroutines.flow.flow { + phones.collect { batch -> + val request = RpcContactListService.FullUploadRequest.newBuilder() + .addAllPhones(batch.map { Model.PhoneNumber.newBuilder().setValue(it.phoneNumber).build() }) + .setExpectedChecksum(expectedChecksum.asHash()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + emit(request) + } + } + + return withContext(Dispatchers.IO) { + api.fullUpload(requestFlow) + } + } + + fun getFlipcashContacts( + owner: KeyPair, + checksum: Checksum, + ): Flow { + val request = RpcContactListService.GetFlipcashContactsRequest.newBuilder() + .setChecksum(checksum.asHash()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api.getFlipcashContacts(request) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt new file mode 100644 index 000000000..6d2594d7e --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt @@ -0,0 +1,47 @@ +package com.flipcash.services.internal.network.api + +import com.codeinc.flipcash.gen.phone.v1.Model +import com.codeinc.flipcash.gen.resolver.v1.ResolverGrpcKt +import com.codeinc.flipcash.gen.resolver.v1.validate +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.internal.annotations.FlipcashManagedChannel +import com.flipcash.services.internal.network.extensions.authenticate +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.core.GrpcApi +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 +import com.codeinc.flipcash.gen.resolver.v1.ResolverService as RpcResolverService +import com.codeinc.flipcash.gen.resolver.v1.Model as ResolverModel + +@Singleton +internal class ResolverApi @Inject constructor( + @FlipcashManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = ResolverGrpcKt.ResolverCoroutineStub(managedChannel) + .withWaitForReady() + + suspend fun resolve( + owner: KeyPair, + phone: ContactMethod.Phone, + ): RpcResolverService.ResolveResponse { + val request = RpcResolverService.ResolveRequest.newBuilder() + .setIdentifier( + ResolverModel.Identifier.newBuilder() + .setPhone(Model.PhoneNumber.newBuilder().setValue(phone.phoneNumber)) + ) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.resolve(request) + } + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index 3e29b1190..a98f79381 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -10,11 +10,16 @@ import com.flipcash.services.models.SocialAccountLinkRequest import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.network.jwt.ApiProvider import com.getcode.opencode.model.core.ID +import com.getcode.solana.keys.Checksum import com.getcode.solana.keys.PublicKey import com.getcode.utils.toByteString import com.google.protobuf.Timestamp import kotlinx.datetime.Instant +internal fun Checksum.asHash(): Common.Hash { + return Common.Hash.newBuilder().setValue(byteArray.toByteString()).build() +} + internal fun ByteArray.asSignature(): Common.Signature { return Common.Signature.newBuilder().setValue(this.toByteString()) .build() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index f32e7054f..cc3252d04 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -6,12 +6,14 @@ import com.codeinc.flipcash.gen.common.v1.Common.UserId import com.codeinc.flipcash.gen.moderation.v1.ModerationService import com.codeinc.flipcash.gen.push.v1.navigationOrNull import com.codeinc.flipcash.gen.push.v1.Model as PushModels +import com.flipcash.services.internal.extensions.toChecksum import com.flipcash.services.internal.extensions.toMint import com.flipcash.services.internal.extensions.toPublicKey import com.flipcash.services.models.NavigationTrigger import com.flipcash.services.models.NotificationCategory import com.flipcash.services.models.NotificationPayload import com.getcode.opencode.model.core.ID +import com.getcode.solana.keys.Checksum import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.Signature @@ -19,6 +21,7 @@ import com.codeinc.flipcash.gen.activity.v1.Model as ActivityModels internal fun ActivityModels.NotificationId.toId(): ID = value.toByteArray().toList() internal fun Common.UserId.toId(): ID = value.toByteArray().toList() +internal fun Common.Hash.toChecksum(): Checksum = value.toByteArray().toChecksum() internal fun Common.PublicKey.toPublicKey(): PublicKey = value.toByteArray().toPublicKey() internal fun Common.PublicKey.toMint(): Mint = value.toByteArray().toMint() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt new file mode 100644 index 000000000..24a926e54 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt @@ -0,0 +1,130 @@ +package com.flipcash.services.internal.network.services + +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.internal.network.api.ContactListApi +import com.flipcash.services.internal.network.extensions.toChecksum +import com.flipcash.services.models.CheckSyncError +import com.flipcash.services.models.DeltaUploadError +import com.flipcash.services.models.FullUploadError +import com.flipcash.services.models.GetContactsError +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.extensions.foldWithSuppression +import com.getcode.opencode.utils.toValidationOrElse +import com.getcode.solana.keys.Checksum +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import com.codeinc.flipcash.gen.contact.v1.ContactListService as RpcContactListService + +internal class ContactListService @Inject constructor( + private val api: ContactListApi, +) { + suspend fun checkSync( + owner: KeyPair, + clientChecksum: Checksum, + ): Result { + return runCatching { + api.checkSync(owner, clientChecksum) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcContactListService.CheckSyncResponse.Result.OK -> + Result.success(response.serverChecksum.toChecksum()) + RpcContactListService.CheckSyncResponse.Result.DENIED -> + Result.failure(CheckSyncError.Denied()) + RpcContactListService.CheckSyncResponse.Result.OUT_OF_SYNC -> + Result.failure(CheckSyncError.OutOfSync(response.serverChecksum.toChecksum())) + RpcContactListService.CheckSyncResponse.Result.UNRECOGNIZED -> + Result.failure(CheckSyncError.Unrecognized()) + else -> Result.failure(CheckSyncError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { CheckSyncError.Other(cause = it) }) + } + ) + } + + suspend fun deltaUpload( + owner: KeyPair, + adds: List, + removes: List, + oldChecksum: Checksum, + newChecksum: Checksum, + ): Result { + return runCatching { + api.deltaUpload(owner, adds, removes, oldChecksum, newChecksum) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcContactListService.DeltaUploadResponse.Result.OK -> + Result.success(Unit) + RpcContactListService.DeltaUploadResponse.Result.DENIED -> + Result.failure(DeltaUploadError.Denied()) + RpcContactListService.DeltaUploadResponse.Result.CHECKSUM_MISMATCH -> + Result.failure(DeltaUploadError.ChecksumMismatch()) + RpcContactListService.DeltaUploadResponse.Result.CHECKSUM_DRIFT -> + Result.failure(DeltaUploadError.ChecksumDrift()) + RpcContactListService.DeltaUploadResponse.Result.TOO_MANY_CONTACTS -> + Result.failure(DeltaUploadError.TooManyContacts()) + RpcContactListService.DeltaUploadResponse.Result.UNRECOGNIZED -> + Result.failure(DeltaUploadError.Unrecognized()) + else -> Result.failure(DeltaUploadError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { DeltaUploadError.Other(cause = it) }) + } + ) + } + + suspend fun fullUpload( + owner: KeyPair, + phones: Flow>, + expectedChecksum: Checksum, + ): Result { + return runCatching { + api.fullUpload(owner, phones, expectedChecksum) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcContactListService.FullUploadResponse.Result.OK -> + Result.success(Unit) + RpcContactListService.FullUploadResponse.Result.DENIED -> + Result.failure(FullUploadError.Denied()) + RpcContactListService.FullUploadResponse.Result.CHECKSUM_MISMATCH -> + Result.failure(FullUploadError.ChecksumMismatch()) + RpcContactListService.FullUploadResponse.Result.TOO_MANY_CONTACTS -> + Result.failure(FullUploadError.TooManyContacts()) + RpcContactListService.FullUploadResponse.Result.UNRECOGNIZED -> + Result.failure(FullUploadError.Unrecognized()) + else -> Result.failure(FullUploadError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { FullUploadError.Other(cause = it) }) + } + ) + } + + fun getContacts( + owner: KeyPair, + checksum: Checksum, + ): Flow>> { + return api.getFlipcashContacts(owner, checksum).map { response -> + when (response.result) { + RpcContactListService.GetFlipcashContactsResponse.Result.OK -> + Result.success(response.contactsList.map { ContactMethod.Phone(it.phone.value) }) + RpcContactListService.GetFlipcashContactsResponse.Result.DENIED -> + Result.failure(GetContactsError.Denied()) + RpcContactListService.GetFlipcashContactsResponse.Result.NOT_FOUND -> + Result.failure(GetContactsError.NotFound()) + RpcContactListService.GetFlipcashContactsResponse.Result.CHECKSUM_DRIFT -> + Result.failure(GetContactsError.ChecksumDrift()) + RpcContactListService.GetFlipcashContactsResponse.Result.UNRECOGNIZED -> + Result.failure(GetContactsError.Unrecognized()) + else -> Result.failure(GetContactsError.Other()) + } + } + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt new file mode 100644 index 000000000..e2c611d83 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt @@ -0,0 +1,42 @@ +package com.flipcash.services.internal.network.services + +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.internal.network.api.ResolverApi +import com.flipcash.services.internal.network.extensions.toPublicKey +import com.flipcash.services.models.ResolveContactError +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.extensions.foldWithSuppression +import com.getcode.opencode.utils.toValidationOrElse +import com.getcode.solana.keys.PublicKey +import javax.inject.Inject +import com.codeinc.flipcash.gen.resolver.v1.ResolverService as RpcResolverService + +internal class ResolverService @Inject constructor( + private val api: ResolverApi, +) { + suspend fun resolve( + owner: KeyPair, + phone: ContactMethod.Phone, + ): Result { + return runCatching { + api.resolve(owner, phone) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcResolverService.ResolveResponse.Result.OK -> + Result.success(response.resolution.address.toPublicKey()) + RpcResolverService.ResolveResponse.Result.NOT_FOUND -> + Result.failure(ResolveContactError.NotFound()) + RpcResolverService.ResolveResponse.Result.DENIED -> + Result.failure(ResolveContactError.Denied()) + RpcResolverService.ResolveResponse.Result.UNRECOGNIZED -> + Result.failure(ResolveContactError.Unrecognized()) + else -> Result.failure(ResolveContactError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { ResolveContactError.Other(cause = it) }) + } + ) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt new file mode 100644 index 000000000..4f4986505 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt @@ -0,0 +1,40 @@ +package com.flipcash.services.internal.repositories + +import com.flipcash.services.internal.network.services.ContactListService +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.repository.ContactListRepository +import com.getcode.ed25519.Ed25519 +import com.getcode.solana.keys.Checksum +import com.getcode.utils.ErrorUtils +import kotlinx.coroutines.flow.Flow + +internal class InternalContactListRepository( + private val service: ContactListService, +) : ContactListRepository { + override suspend fun checkSync( + owner: Ed25519.KeyPair, + clientChecksum: Checksum, + ): Result = service.checkSync(owner, clientChecksum) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun deltaUpload( + owner: Ed25519.KeyPair, + adds: List, + removes: List, + oldChecksum: Checksum, + newChecksum: Checksum, + ): Result = service.deltaUpload(owner, adds, removes, oldChecksum, newChecksum) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun fullUpload( + owner: Ed25519.KeyPair, + phones: Flow>, + expectedChecksum: Checksum, + ): Result = service.fullUpload(owner, phones, expectedChecksum) + .onFailure { ErrorUtils.handleError(it) } + + override fun getFlipcashContacts( + owner: Ed25519.KeyPair, + checksum: Checksum, + ): Flow>> = service.getContacts(owner, checksum) +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalResolverRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalResolverRepository.kt new file mode 100644 index 000000000..2c275ff50 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalResolverRepository.kt @@ -0,0 +1,18 @@ +package com.flipcash.services.internal.repositories + +import com.flipcash.services.internal.network.services.ResolverService +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.repository.ResolverRepository +import com.getcode.ed25519.Ed25519 +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.ErrorUtils + +internal class InternalResolverRepository( + private val service: ResolverService, +) : ResolverRepository { + override suspend fun resolve( + owner: Ed25519.KeyPair, + phone: ContactMethod.Phone, + ): Result = service.resolve(owner, phone) + .onFailure { ErrorUtils.handleError(it) } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index 539286071..ba4455a57 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -1,5 +1,6 @@ package com.flipcash.services.models +import com.getcode.solana.keys.Checksum import com.getcode.utils.CodeServerError import com.getcode.utils.NotifiableError @@ -246,4 +247,58 @@ sealed class ImageModerationError( class UnsupportedFormat: ImageModerationError("Unsupported Format") class Unrecognized : ImageModerationError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : ImageModerationError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class CheckSyncError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : CheckSyncError("Denied") + class OutOfSync(val serverChecksum: Checksum) : CheckSyncError("Out of sync") + class Unrecognized : CheckSyncError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : CheckSyncError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class DeltaUploadError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : DeltaUploadError("Denied") + class ChecksumMismatch : DeltaUploadError("Checksum mismatch") + class ChecksumDrift : DeltaUploadError("Checksum drift") + class TooManyContacts : DeltaUploadError("Too many contacts") + class Unrecognized : DeltaUploadError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : DeltaUploadError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class FullUploadError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : FullUploadError("Denied") + class ChecksumMismatch : FullUploadError("Checksum mismatch") + class TooManyContacts : FullUploadError("Too many contacts") + class Unrecognized : FullUploadError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : FullUploadError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetContactsError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetContactsError("Denied") + class NotFound : GetContactsError("Not found") + class ChecksumDrift : GetContactsError("Checksum drift") + class Unrecognized : GetContactsError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetContactsError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class ResolveContactError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class NotFound : ResolveContactError("Not found") + class Denied : ResolveContactError("Denied") + class Unrecognized : ResolveContactError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : ResolveContactError(message = cause?.message, cause = cause), NotifiableError } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactListRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactListRepository.kt new file mode 100644 index 000000000..c166720a3 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactListRepository.kt @@ -0,0 +1,32 @@ +package com.flipcash.services.repository + +import com.flipcash.services.models.ContactMethod +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.solana.keys.Checksum +import kotlinx.coroutines.flow.Flow + +interface ContactListRepository { + suspend fun checkSync( + owner: KeyPair, + clientChecksum: Checksum, + ): Result + + suspend fun deltaUpload( + owner: KeyPair, + adds: List, + removes: List, + oldChecksum: Checksum, + newChecksum: Checksum, + ): Result + + suspend fun fullUpload( + owner: KeyPair, + phones: Flow>, + expectedChecksum: Checksum, + ): Result + + fun getFlipcashContacts( + owner: KeyPair, + checksum: Checksum, + ): Flow>> +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ResolverRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ResolverRepository.kt new file mode 100644 index 000000000..ed3ec9d00 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ResolverRepository.kt @@ -0,0 +1,9 @@ +package com.flipcash.services.repository + +import com.flipcash.services.models.ContactMethod +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.solana.keys.PublicKey + +interface ResolverRepository { + suspend fun resolve(owner: KeyPair, phone: ContactMethod.Phone): Result +}