Skip to content

Commit 9fb9f78

Browse files
committed
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.
1 parent dc90a6a commit 9fb9f78

20 files changed

Lines changed: 785 additions & 0 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
syntax = "proto3";
2+
3+
package flipcash.contact.v1;
4+
5+
import "contact/v1/model.proto";
6+
import "common/v1/common.proto";
7+
import "phone/v1/model.proto";
8+
import "validate/validate.proto";
9+
10+
option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/contact/v1;contactpb";
11+
option java_package = "com.codeinc.flipcash.gen.contact.v1";
12+
option objc_class_prefix = "FPBContactV1";
13+
14+
// ContactList manages a user's contact list and surfaces which contacts are
15+
// Flipcash users.
16+
//
17+
// Sync model:
18+
// - The client maintains a 32-byte XOR-of-SHA256 checksum over its current
19+
// contact set, and an OS-specific cursor for incremental change discovery.
20+
// The cursor is local-only and never leaves the device.
21+
// - Steady state: client computes a delta from the OS cursor, sends it via
22+
// DeltaUpload with old/new checksums for compare-and-swap.
23+
// - Recovery: on first install, after OS history truncation, or after
24+
// CHECKSUM_DRIFT, the client uses FullUpload to replace the server's state
25+
// wholesale.
26+
service ContactList {
27+
// CheckSync compares the client's checksum to the server's. Cheap, used
28+
// on app foreground to decide whether any upload is needed.
29+
rpc CheckSync(CheckSyncRequest) returns (CheckSyncResponse);
30+
31+
// DeltaUpload applies a delta under compare-and-swap on the checksum.
32+
// Safe to retry indefinitely with the same payload.
33+
rpc DeltaUpload(DeltaUploadRequest) returns (DeltaUploadResponse);
34+
35+
// FullUpload replaces the user's contact set entirely. Used when
36+
// a delta cannot be constructed or CHECKSUM_DRIFT was returned.
37+
rpc FullUpload(stream FullUploadRequest) returns (FullUploadResponse);
38+
39+
// GetFlipcashContacts gets the set of contacts that are on Flipcash
40+
rpc GetFlipcashContacts(GetFlipcashContactsRequest) returns (stream GetFlipcashContactsResponse);
41+
}
42+
43+
44+
message CheckSyncRequest {
45+
common.v1.Auth auth = 1 [(validate.rules).message.required = true];
46+
47+
// XOR-of-SHA256 over the client's current set of normalized E.164 phones.
48+
common.v1.Hash client_checksum = 2 [(validate.rules).message.required = true];
49+
}
50+
51+
message CheckSyncResponse {
52+
enum Result {
53+
OK = 0;
54+
DENIED = 1;
55+
OUT_OF_SYNC = 2;
56+
}
57+
Result result = 1;
58+
59+
// Authoritative server-side checksum. Clients persist this and use it
60+
// as the basis for the next DeltaUpload.old_checksum.
61+
common.v1.Hash server_checksum = 2;
62+
}
63+
64+
message DeltaUploadRequest {
65+
common.v1.Auth auth = 1 [(validate.rules).message.required = true];
66+
67+
repeated phone.v1.PhoneNumber adds = 2 [(validate.rules).repeated.max_items = 1000];
68+
69+
repeated phone.v1.PhoneNumber removes = 3 [(validate.rules).repeated.max_items = 1000];
70+
71+
// The checksum the client expected the server to have *before* applying
72+
// this delta. Server applies only if stored == old_checksum.
73+
common.v1.Hash old_checksum = 4 [(validate.rules).message.required = true];
74+
75+
// The checksum the client computes for the state *after* applying this
76+
// delta. Server persists this on success. Used to detect retries: if
77+
// stored == new_checksum, the server treats the request as a no-op.
78+
common.v1.Hash new_checksum = 5 [(validate.rules).message.required = true];
79+
}
80+
81+
message DeltaUploadResponse {
82+
enum Result {
83+
OK = 0;
84+
DENIED = 1;
85+
// Server's recomputed checksum did not match expected_checksum.
86+
CHECKSUM_MISMATCH = 2;
87+
// Stored checksum matched neither old_checksum nor new_checksum.
88+
// Client should call FullUpload to reconcile.
89+
CHECKSUM_DRIFT = 3;
90+
TOO_MANY_CONTACTS = 4;
91+
}
92+
Result result = 1;
93+
}
94+
95+
message FullUploadRequest {
96+
common.v1.Auth auth = 1 [(validate.rules).message.required = true];
97+
98+
// The complete current contact set. Server replaces stored state with
99+
// this list in one transaction.
100+
repeated phone.v1.PhoneNumber phones = 2 [(validate.rules).repeated.max_items = 1000];
101+
102+
// XOR-of-SHA256 over the client's current set of normalized E.164 phones.
103+
// Sent on the last streamed request to indicate the end of the upload.
104+
common.v1.Hash expected_checksum = 3 [(validate.rules).message.required = true];
105+
}
106+
107+
message FullUploadResponse {
108+
enum Result {
109+
OK = 0;
110+
DENIED = 1;
111+
// Server's recomputed checksum did not match expected_checksum.
112+
CHECKSUM_MISMATCH = 2;
113+
TOO_MANY_CONTACTS = 3;
114+
}
115+
Result result = 1;
116+
}
117+
118+
message GetFlipcashContactsRequest {
119+
common.v1.Auth auth = 1 [(validate.rules).message.required = true];
120+
121+
common.v1.Hash checksum = 2 [(validate.rules).message.required = true];
122+
}
123+
124+
message GetFlipcashContactsResponse {
125+
enum Result {
126+
OK = 0;
127+
DENIED = 1;
128+
NOT_FOUND = 2;
129+
// Server checksum doesn't match client checksum.
130+
CHECKSUM_DRIFT = 3;
131+
}
132+
Result result = 1;
133+
134+
repeated FlipcashContact contacts = 2 [(validate.rules).repeated.max_items = 1000];
135+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
syntax = "proto3";
2+
3+
package flipcash.contact.v1;
4+
5+
import "phone/v1/model.proto";
6+
import "validate/validate.proto";
7+
8+
option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/contact/v1;contactpb";
9+
option java_package = "com.codeinc.flipcash.gen.contact.v1";
10+
option objc_class_prefix = "FPBContactV1";
11+
12+
message FlipcashContact {
13+
phone.v1.PhoneNumber phone = 1 [(validate.rules).message.required = true];
14+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
syntax = "proto3";
2+
3+
package flipcash.resolver.v1;
4+
5+
option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/resolver/v1;resolverpb";
6+
option java_package = "com.codeinc.flipcash.gen.resolver.v1";
7+
option objc_class_prefix = "FPBResolverV1";
8+
9+
import "common/v1/common.proto";
10+
import "phone/v1/model.proto";
11+
import "validate/validate.proto";
12+
13+
// Identifier wraps a real-world identifier that can be resolved to a
14+
// payment destination address.
15+
message Identifier {
16+
oneof kind {
17+
option (validate.required) = true;
18+
19+
phone.v1.PhoneNumber phone = 1;
20+
}
21+
}
22+
23+
// Resolution contains a payment destiation address mapping for an
24+
// Identifier
25+
message Resolution {
26+
oneof kind {
27+
option (validate.required) = true;
28+
29+
common.v1.PublicKey address = 1;
30+
}
31+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
syntax = "proto3";
2+
3+
package flipcash.resolver.v1;
4+
5+
option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/resolver/v1;resolverpb";
6+
option java_package = "com.codeinc.flipcash.gen.resolver.v1";
7+
option objc_class_prefix = "FPBResolverV1";
8+
9+
import "common/v1/common.proto";
10+
import "resolver/v1/model.proto";
11+
import "validate/validate.proto";
12+
13+
// Resolver maps a real-world identifier (phone number, etc.) to a payment
14+
// destination address.
15+
service Resolver {
16+
// Resolve looks up the payment destination address for the given identifier.
17+
rpc Resolve(ResolveRequest) returns (ResolveResponse);
18+
}
19+
20+
message ResolveRequest {
21+
common.v1.Auth auth = 1 [(validate.rules).message.required = true];
22+
23+
Identifier identifier = 2 [(validate.rules).message.required = true];
24+
}
25+
26+
message ResolveResponse {
27+
Result result = 1;
28+
enum Result {
29+
OK = 0;
30+
NOT_FOUND = 1;
31+
DENIED = 2;
32+
}
33+
34+
// The resolved payment destination address. Set when result == OK.
35+
Resolution resolution = 2;
36+
}

libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ typealias Seed16 = Key16
66

77
typealias Seed32 = Key32
88
typealias Hash = Key32
9+
typealias Checksum = Key32
910

1011
typealias PrivateKey = Key64
1112

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.flipcash.services.controllers
2+
3+
import com.flipcash.services.models.ContactMethod
4+
import com.flipcash.services.repository.ContactListRepository
5+
import com.flipcash.services.user.UserManager
6+
import com.getcode.solana.keys.Checksum
7+
import kotlinx.coroutines.flow.Flow
8+
import javax.inject.Inject
9+
10+
class ContactListController @Inject constructor(
11+
private val repository: ContactListRepository,
12+
private val userManager: UserManager,
13+
) {
14+
suspend fun checkSync(clientChecksum: Checksum): Result<Checksum> {
15+
val owner = userManager.accountCluster?.authority?.keyPair
16+
?: return Result.failure(Throwable("No account cluster in UserManager"))
17+
return repository.checkSync(owner, clientChecksum)
18+
}
19+
20+
suspend fun deltaUpload(
21+
adds: List<ContactMethod.Phone>,
22+
removes: List<ContactMethod.Phone>,
23+
oldChecksum: Checksum,
24+
newChecksum: Checksum,
25+
): Result<Unit> {
26+
val owner = userManager.accountCluster?.authority?.keyPair
27+
?: return Result.failure(Throwable("No account cluster in UserManager"))
28+
return repository.deltaUpload(owner, adds, removes, oldChecksum, newChecksum)
29+
}
30+
31+
suspend fun fullUpload(
32+
phones: Flow<List<ContactMethod.Phone>>,
33+
expectedChecksum: Checksum,
34+
): Result<Unit> {
35+
val owner = userManager.accountCluster?.authority?.keyPair
36+
?: return Result.failure(Throwable("No account cluster in UserManager"))
37+
return repository.fullUpload(owner, phones, expectedChecksum)
38+
}
39+
40+
fun getFlipcashContacts(checksum: Checksum): Flow<Result<List<ContactMethod.Phone>>> {
41+
val owner = userManager.accountCluster?.authority?.keyPair
42+
?: throw IllegalStateException("No account cluster in UserManager")
43+
return repository.getFlipcashContacts(owner, checksum)
44+
}
45+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.flipcash.services.controllers
2+
3+
import com.flipcash.services.models.ContactMethod
4+
import com.flipcash.services.repository.ResolverRepository
5+
import com.flipcash.services.user.UserManager
6+
import com.getcode.solana.keys.PublicKey
7+
import javax.inject.Inject
8+
9+
class ResolverController @Inject constructor(
10+
private val repository: ResolverRepository,
11+
private val userManager: UserManager,
12+
) {
13+
suspend fun resolve(phone: ContactMethod.Phone): Result<PublicKey> {
14+
val owner = userManager.accountCluster?.authority?.keyPair
15+
?: return Result.failure(Throwable("No account cluster in UserManager"))
16+
return repository.resolve(owner, phone)
17+
}
18+
}

services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,35 @@ import com.flipcash.services.internal.domain.UserProfileMapper
1212
import com.flipcash.services.internal.network.services.AccountService
1313
import com.flipcash.services.internal.network.services.ActivityFeedService
1414
import com.flipcash.services.internal.network.services.EmailVerificationService
15+
import com.flipcash.services.internal.network.services.ContactListService
1516
import com.flipcash.services.internal.network.services.ModerationService
1617
import com.flipcash.services.internal.network.services.PhoneVerificationService
1718
import com.flipcash.services.internal.network.services.ProfileService
1819
import com.flipcash.services.internal.network.services.PurchaseService
1920
import com.flipcash.services.internal.network.services.PushService
21+
import com.flipcash.services.internal.network.services.ResolverService
2022
import com.flipcash.services.internal.network.services.SettingsService
2123
import com.flipcash.services.internal.network.services.ThirdPartyService
2224
import com.flipcash.services.internal.repositories.InternalAccountRepository
2325
import com.flipcash.services.internal.repositories.InternalActivityFeedRepository
26+
import com.flipcash.services.internal.repositories.InternalContactListRepository
2427
import com.flipcash.services.internal.repositories.InternalContactVerificationRepository
2528
import com.flipcash.services.internal.repositories.InternalModerationRepository
2629
import com.flipcash.services.internal.repositories.InternalProfileRepository
2730
import com.flipcash.services.internal.repositories.InternalPurchaseRepository
2831
import com.flipcash.services.internal.repositories.InternalPushRepository
32+
import com.flipcash.services.internal.repositories.InternalResolverRepository
2933
import com.flipcash.services.internal.repositories.InternalSettingsRepository
3034
import com.flipcash.services.internal.repositories.InternalThirdPartyRepository
3135
import com.flipcash.services.repository.AccountRepository
3236
import com.flipcash.services.repository.ActivityFeedRepository
37+
import com.flipcash.services.repository.ContactListRepository
3338
import com.flipcash.services.repository.ContactVerificationRepository
3439
import com.flipcash.services.repository.ModerationRepository
3540
import com.flipcash.services.repository.ProfileRepository
3641
import com.flipcash.services.repository.PurchaseRepository
3742
import com.flipcash.services.repository.PushRepository
43+
import com.flipcash.services.repository.ResolverRepository
3844
import com.flipcash.services.repository.SettingsRepository
3945
import com.flipcash.services.repository.ThirdPartyRepository
4046
import com.getcode.opencode.ProtocolConfig
@@ -108,6 +114,16 @@ internal object FlipcashModule {
108114
}
109115
}
110116

117+
@Provides
118+
internal fun providesContactListRepository(
119+
service: ContactListService,
120+
): ContactListRepository = InternalContactListRepository(service)
121+
122+
@Provides
123+
internal fun providesResolverRepository(
124+
service: ResolverService,
125+
): ResolverRepository = InternalResolverRepository(service)
126+
111127
@Provides
112128
internal fun providesAccountRepository(
113129
service: AccountService,

services/flipcash/src/main/kotlin/com/flipcash/services/internal/extensions/ByteArray.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package com.flipcash.services.internal.extensions
22

3+
import com.getcode.solana.keys.Checksum
34
import com.getcode.solana.keys.Mint
45
import com.getcode.solana.keys.PublicKey
56

67
internal fun ByteArray.toHash(): com.getcode.solana.keys.Hash {
78
return com.getcode.solana.keys.Hash(this.toList())
89
}
910

11+
internal fun ByteArray.toChecksum(): Checksum {
12+
return Checksum(this.toList())
13+
}
14+
1015
internal fun ByteArray.toPublicKey(): PublicKey {
1116
return PublicKey(this.toList())
1217
}

0 commit comments

Comments
 (0)