diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt index 1ead1dad97..1fae035002 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt @@ -1,5 +1,6 @@ package com.flipcash.app.core.bill +import com.flipcash.services.models.UserProfile import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token @@ -94,6 +95,6 @@ sealed interface Scannable { data class TipCard( override val data: List, - val username: String, + val user: UserProfile ) : Scannable } diff --git a/apps/flipcash/shared/tipping/build.gradle.kts b/apps/flipcash/shared/tipping/build.gradle.kts new file mode 100644 index 0000000000..03ab2e5131 --- /dev/null +++ b/apps/flipcash/shared/tipping/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.flipcash.android.feature) +} + +android { + namespace = "${Gradle.flipcashNamespace}.shared.tipping" +} + +dependencies { + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) + + implementation(project(":services:flipcash")) + implementation(project(":services:opencode")) +} diff --git a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt new file mode 100644 index 0000000000..550ad0e72d --- /dev/null +++ b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt @@ -0,0 +1,74 @@ +package com.flipcash.shared.tipping + +import com.flipcash.app.core.bill.Scannable +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.models.UserProfile +import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.core.OpenCodePayload +import com.getcode.opencode.model.core.PayloadKind +import com.getcode.opencode.model.core.UserId +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Orchestrates tipping flows. + * + * Resolves the [UserProfile] needed to render a tip card and assembles the scannable + * [Scannable.TipCard] itself — mirroring how the cash screen builds a [Scannable.Payable] + * bill to present. The tip code encodes the recipient's [ID] (a 16-byte user id). + */ +@Singleton +class TippingCoordinator @Inject constructor( + private val profileController: ProfileController, + private val userManager: UserManager, + private val resolverController: ResolverController, +) { + /** The signed-in user's id ([UserManager.accountId]), or null if unavailable. */ + val currentUserId: ID? + get() = userManager.accountId + + /** + * Resolves the [UserProfile] for [userId] — e.g. a tip counterparty identified by a + * scanned code — so a tip card can be rendered for them. Delegates to the server-backed + * profile fetch and propagates its [Result]. + */ + suspend fun resolveProfile(userId: ID): Result = + profileController.getProfileForUser(userId) + + /** + * The current user's profile, used to build their own tip card. Prefers the cached + * [UserManager.profile] and falls back to a server refresh when it isn't available yet. + */ + suspend fun currentUserProfile(): Result = + userManager.profile?.let { Result.success(it) } + ?: profileController.updateUserProfile() + + /** + * Builds the current user's own scannable tip card — their [currentUserId] encoded into the + * tip code, plus their profile for rendering — the way the cash screen builds a bill to + * present. Fails if there's no signed-in user or their profile can't be resolved. + */ + suspend fun resolveTipCard(): Result { + val userId = currentUserId + ?: return Result.failure(IllegalStateException("No signed-in user to build a tip card for")) + return currentUserProfile().map { tipCard(userId, it) } + } + + /** + * Resolves [userId]'s profile and builds their scannable tip card — for generating a card + * for another user (e.g. a scanned counterparty). + */ + suspend fun resolveTipCard(userId: ID): Result = + resolveProfile(userId).map { tipCard(userId, it) } + + /** + * Assembles the scannable [Scannable.TipCard]: the tip [OpenCodePayload] encoding [userId] + * as the scannable code data, plus [profile] for rendering. + */ + private fun tipCard(userId: ID, profile: UserProfile): Scannable.TipCard { + val payload = OpenCodePayload(kind = PayloadKind.Tip, value = UserId(userId)) + return Scannable.TipCard(data = payload.codeData.toList(), user = profile) + } +} diff --git a/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt new file mode 100644 index 0000000000..6d3dce70d3 --- /dev/null +++ b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt @@ -0,0 +1,68 @@ +package com.flipcash.shared.tipping + +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.models.UserProfile +import com.flipcash.services.user.UserManager +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class TippingCoordinatorTest { + + private val profileController = mockk() + private val userManager = mockk() + private val resolverController = mockk() + private val coordinator = TippingCoordinator(profileController, userManager, resolverController) + + private fun profile(name: String) = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + ) + + @Test + fun `resolveProfile delegates to ProfileController`() = runTest { + val userId = listOf(1, 2, 3) + val expected = profile("Alice") + coEvery { profileController.getProfileForUser(userId) } returns Result.success(expected) + + val result = coordinator.resolveProfile(userId) + + assertSame(expected, result.getOrNull()) + } + + @Test + fun `currentUserProfile returns the cached profile when present`() = runTest { + val cached = profile("Me") + every { userManager.profile } returns cached + + val result = coordinator.currentUserProfile() + + assertSame(cached, result.getOrNull()) + } + + @Test + fun `currentUserProfile refreshes from the server when nothing is cached`() = runTest { + val fetched = profile("Fresh") + every { userManager.profile } returns null + coEvery { profileController.updateUserProfile() } returns Result.success(fetched) + + val result = coordinator.currentUserProfile() + + assertSame(fetched, result.getOrNull()) + } + + @Test + fun `currentUserId reflects UserManager accountId`() { + val id = listOf(9, 9) + every { userManager.accountId } returns id + + assertEquals(id, coordinator.currentUserId) + } +} diff --git a/services/opencode/src/androidTest/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTests.kt b/services/opencode/src/androidTest/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTests.kt index 3804a8b201..99eb964ccf 100644 --- a/services/opencode/src/androidTest/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTests.kt +++ b/services/opencode/src/androidTest/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTests.kt @@ -30,9 +30,10 @@ class OpenCodePayloadTests { @Test fun tipPayloadEncoding() { + val userId: List = (1..16).map { it.toByte() } val payload = OpenCodePayload( kind = PayloadKind.Tip, - value = Username("bob"), + value = UserId(userId), ) val encoded = payload.encode() @@ -42,23 +43,24 @@ class OpenCodePayloadTests { assertEquals(OpenCodePayload.LENGTH, encoded.size) assertEquals(PayloadKind.Tip.value, decoded.kind.value) - // The username is hash-padded on encode and recovered by stripping at the '.' delimiter. - assertEquals("bob", decoded.username) + // The 16-byte user id round-trips. + assertEquals(userId, decoded.userId) // Tip payloads carry no fiat amount and no nonce. assertNull(decoded.fiat) assertEquals(emptyList(), decoded.nonce) } @Test - fun tipPayloadEncodingFullLengthUsername() { - val username = "fifteencharname" // exactly USERNAME_LENGTH (15) - val payload = OpenCodePayload( + fun tipPayloadUserIdIsWrittenAtOffsetOne() { + val userId: List = (1..16).map { it.toByte() } + val encoded = OpenCodePayload( kind = PayloadKind.Tip, - value = Username(username), - ) - - val decoded = OpenCodePayload.Companion.fromList(payload.encode()) + value = UserId(userId), + ).encode() - assertEquals(username, decoded.username) + assertEquals(PayloadKind.Tip.value, encoded[0].toInt()) + assertEquals(userId, encoded.subList(1, 17)) + // Trailing bytes reserved / zero. + assertEquals(listOf(0, 0, 0), encoded.subList(17, 20)) } } \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt index 1ce047aae0..2e251ced64 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt @@ -1,6 +1,5 @@ package com.getcode.opencode.model.core -import com.getcode.crypt.Sha256Hash import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.solana.utils.DataSlice.byteToUnsignedInt import com.getcode.opencode.internal.solana.utils.DataSlice.suffix @@ -9,7 +8,6 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.utils.deriveRendezvousKey import com.kik.scan.Scanner import com.getcode.utils.byteArrayToLong -import com.getcode.utils.encodeBase64 import com.getcode.utils.longToByteArray data class OpenCodePayload( @@ -26,120 +24,41 @@ data class OpenCodePayload( val fiat: Fiat? get() = value as? Fiat - val username: String? - get() = (value as? Username)?.value + val userId: ID? + get() = (value as? UserId)?.value val codeData: ByteArray get() = Scanner.encode(encode().toByteArray()) ?: ByteArray(LENGTH) - fun encode(): List { - return when (value) { - is Fiat -> encode(kind, value, nonce) - is Username -> encode(kind, value) - else -> throw IllegalArgumentException("Unsupported payload value: $value") - } - } - - private fun encode(kind: PayloadKind, fiat: Fiat, nonce: List): List { - val data = MutableList(LENGTH) { 0 } - - data[0] = kind.value.toByte() - - data[1] = fiat.currencyCode.ordinal.toByte() - - fiat.quarks.longToByteArray().forEachIndexed { index, byte -> - data[index + OFFSET_QUARKS] = byte - } - - nonce.toByteArray().forEachIndexed { index, byte -> - data[index + OFFSET_NONCE] = byte - } - - return data - } - - private fun encode(kind: PayloadKind, username: Username): List { - val data = MutableList(LENGTH) { 0 } - data[0] = kind.value.toByte() - - val usernameString = username.value.take(USERNAME_LENGTH) - - // The username that uniquely represents a user's tip code. Cannot be longer than 15 - // bytes. Any additional space is represented by the base64-encoded SHA256 hash of the - // username delimited by a period. - val paddedUsername = usernameString.let { - var padding = "" - val paddingRequired = (USERNAME_LENGTH - it.length) - if (paddingRequired > 0) { - padding = "." - } - - if (paddingRequired > 1) { - val hash = Sha256Hash.hash(usernameString.toByteArray()).encodeBase64() - padding += hash.take(paddingRequired - 1) - } - - "$it$padding" - } - - paddedUsername.toByteArray().forEachIndexed { index, byte -> - data[index + OFFSET_USERNAME] = byte - } - - return data - } + /** Serializes this payload into the fixed [LENGTH]-byte scan frame. */ + fun encode(): List = kind.encode(value, nonce) companion object { const val LENGTH = 20 - const val USERNAME_LENGTH = 15 const val OFFSET_QUARKS = 2 - const val OFFSET_USERNAME = 5 const val OFFSET_NONCE = 10 + const val OFFSET_USER_ID = 1 + const val USER_ID_LENGTH = 16 val Empty by lazy { OpenCodePayload(PayloadKind.Unknown, Fiat.Zero) } + /** Parses a scan frame back into a payload, dispatching on the leading kind byte. */ fun fromList(list: List): OpenCodePayload { - val kind = PayloadKind.entries.find { it.value == list[0].toInt() } ?: PayloadKind.Cash - - val value: PayloadValue = when (kind) { - PayloadKind.Unknown -> Fiat.Zero - PayloadKind.Cash, - PayloadKind.MultiMintCash -> { - // grab currency - val currencyIndex = list[1].byteToUnsignedInt() - val currency = CurrencyCode.entries.toList()[currencyIndex] - - val quarks = list.subList(2, OFFSET_NONCE).toByteArray().byteArrayToLong() - Fiat(currencyCode = currency, quarks = quarks) - } - - PayloadKind.Tip -> { - val usernameBytes = list.suffix(OFFSET_USERNAME) - val usernameWithHash = String(usernameBytes.toByteArray()) - val username = usernameWithHash.substringBeforeLast(".") - Username(username) - } + // `Scanner.decode` drops trailing zero bytes, so a frame that ends in zeros (e.g. a + // user id with trailing zeros) comes back short. Restore the fixed frame first. + val frame = if (list.size < LENGTH) { + list + List(LENGTH - list.size) { 0.toByte() } + } else { + list } - // Tip codes carry a username in place of the nonce; only amount payloads have one. - val nonce = if (kind == PayloadKind.Tip) emptyList() else list.suffix(OFFSET_NONCE) - - return OpenCodePayload(kind, value, nonce) + val kind = PayloadKind.from(frame[0].toInt()) + return OpenCodePayload(kind, kind.decode(frame), kind.decodeNonce(frame)) } } } - -enum class PayloadKind(val value: Int) { - Unknown(-1), - Cash(0), - MultiMintCash(1), - Tip(2), -// Login(3), -// RequestPaymentV2(4), -} - /* Layout 0: Single token supported Cash (USDC) @@ -149,52 +68,21 @@ enum class PayloadKind(val value: Int) { | T | C | Fiat | Nonce | +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ - (T) Type (1 byte) - - The first byte of the data in all Code scan codes is reserved for the scan - code type. This field indicates which type of scan code data is contained - in the scan code. The expected format for each type is outlined below. - - (C) Currency Code (1 bytes) - - This field indicates the currency code for the fiat amount. The value is an - encoded index less than 255 that maps to a currency code in CurrencyCode + (T) Type (1 byte) — the scan code kind (see PayloadKind). + (C) Currency Code (1 byte) — index into CurrencyCode. + Fiat Amount (8 bytes) — quarks as a 64-bit unsigned integer. + Nonce (10 bytes) — regenerated each time a new payment is initiated. - Fiat Amount (8 bytes) + Layout 1: Multi-token supported Cash — same as layout 0. - This field indicates the number of quarks the payment is for. It should be - represented as a 64-bit unsigned integer. - - Nonce (10 bytes) - - This field is an 11-byte randomly-generated nonce. It should be regenerated - each time a new payment is initiated. - - Layout 1: Multi-token supported Cash - - Same as layout 0. - - Layout 5: Tip + Layout 2: Tip 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ - | T | Flags | username | ... remainder (0) | + | T | User ID (16 bytes) | reserved (0) | +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ - (T) Type (1 byte) - - The first byte of the data in all Code scan codes is reserved for the scan - code type. This field indicates which type of scan code data is contained - in the scan code. - - (F) Flags (4 bytes) - - Optional flags may provide additional context on the type of username embedded in - the scan code. - - Username (15 bytes) - - The username that uniquely represents a user's tip code. Cannot be longer than 15 - bytes. Any additional space is padded with the base64-encoded SHA256 hash of the - username, delimited by a period. -*/ \ No newline at end of file + (T) Type (1 byte) — PayloadKind.Tip (2). + User ID (16 bytes) — the recipient's user id (a UUID) so others can tip them. Raw bytes, + no rendezvous keypair. Matches the iOS TipCode.Payload frame. +*/ diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadKind.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadKind.kt new file mode 100644 index 0000000000..b81f1ca016 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadKind.kt @@ -0,0 +1,111 @@ +package com.getcode.opencode.model.core + +import com.getcode.opencode.internal.solana.utils.DataSlice.byteToUnsignedInt +import com.getcode.opencode.internal.solana.utils.DataSlice.suffix +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.utils.byteArrayToLong +import com.getcode.utils.longToByteArray + +/** + * The kind of scan code — and its own codec. Each kind owns how it serializes its + * [PayloadValue] into, and reads it back out of, the fixed [OpenCodePayload.LENGTH]-byte frame. + * The leading [value] byte lets a single scanner dispatch frames by kind. + */ +sealed interface PayloadKind { + /** The leading kind byte, written at offset 0 of the frame. */ + val value: Int + + /** Encodes [value] (and [nonce], where applicable) into the fixed-length scan frame. */ + fun encode(value: PayloadValue, nonce: List): List + + /** Reads this kind's [PayloadValue] out of a (zero-padded) scan [frame]. */ + fun decode(frame: List): PayloadValue + + /** Reads the nonce out of a scan [frame]; empty for kinds that carry none. */ + fun decodeNonce(frame: List): List + + /** + * Cash-family kinds share the fiat frame (currency + quarks + nonce) and differ only in the + * leading kind byte. + */ + sealed class Payment(override val value: Int) : PayloadKind { + override fun encode(value: PayloadValue, nonce: List): List { + val data = MutableList(OpenCodePayload.LENGTH) { 0 } + data[0] = this.value.toByte() + + val fiat = value as Fiat + data[1] = fiat.currencyCode.ordinal.toByte() + fiat.quarks.longToByteArray().forEachIndexed { index, byte -> + data[index + OpenCodePayload.OFFSET_QUARKS] = byte + } + nonce.toByteArray().forEachIndexed { index, byte -> + data[index + OpenCodePayload.OFFSET_NONCE] = byte + } + return data + } + + override fun decode(frame: List): PayloadValue { + val currency = CurrencyCode.entries[frame[1].byteToUnsignedInt()] + val quarks = frame.subList(OpenCodePayload.OFFSET_QUARKS, OpenCodePayload.OFFSET_NONCE) + .toByteArray().byteArrayToLong() + return Fiat(currencyCode = currency, quarks = quarks) + } + + override fun decodeNonce(frame: List): List = + frame.suffix(OpenCodePayload.OFFSET_NONCE) + } + + /** Single-token cash (USDC). */ + data object Cash : Payment(0) + + /** Multi-token cash / gift card. */ + data object MultiMintCash : Payment(1) + + /** + * A profile "tip code": the recipient's [UserId] (a 16-byte UUID) written raw at offset 1, + * with the trailing bytes reserved. Carries no nonce and derives no rendezvous — matches the + * iOS `TipCode.Payload` frame. + */ + data object Tip : PayloadKind { + override val value: Int = 2 + + override fun encode(value: PayloadValue, nonce: List): List { + val data = MutableList(OpenCodePayload.LENGTH) { 0 } + data[0] = this.value.toByte() + + val userId = (value as UserId).value + userId.take(OpenCodePayload.USER_ID_LENGTH).forEachIndexed { index, byte -> + data[index + OpenCodePayload.OFFSET_USER_ID] = byte + } + return data + } + + override fun decode(frame: List): PayloadValue { + val start = OpenCodePayload.OFFSET_USER_ID + return UserId(frame.subList(start, start + OpenCodePayload.USER_ID_LENGTH).toList()) + } + + override fun decodeNonce(frame: List): List = emptyList() + } + + /** + * An unrecognized kind. Encodes as a zeroed cash frame (used by [OpenCodePayload.Empty]) and + * decodes to a zero amount. + */ + data object Unknown : Payment(-1) { + override fun decode(frame: List): PayloadValue = Fiat.Zero + override fun decodeNonce(frame: List): List = emptyList() + } + + companion object { + /** The kind for a leading [value] byte, defaulting to [Cash] for unrecognized bytes. */ + fun from(value: Int): PayloadKind = when (value) { + Cash.value -> Cash + MultiMintCash.value -> MultiMintCash + Tip.value -> Tip + Unknown.value -> Unknown + else -> Cash + } + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadValue.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadValue.kt index d21ff6b263..d206cbd8b0 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadValue.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadValue.kt @@ -2,7 +2,7 @@ package com.getcode.opencode.model.core /** * The polymorphic payload carried by an [OpenCodePayload]. Cash/gift-card codes carry a - * [com.getcode.opencode.model.financial.Fiat] amount; tip codes carry a [Username]. + * [com.getcode.opencode.model.financial.Fiat] amount; tip codes carry a [UserId]. * * Not `sealed`: [com.getcode.opencode.model.financial.Fiat] implements it from another package, * which a sealed interface would forbid. @@ -10,8 +10,7 @@ package com.getcode.opencode.model.core interface PayloadValue /** - * The username that uniquely represents a user's tip code. Serialized into the scan code (max - * [OpenCodePayload.USERNAME_LENGTH] bytes) and used to resolve the recipient when the code is - * scanned. + * The recipient's user id carried by a tip code. Serialized as the raw 16-byte id + * ([OpenCodePayload.USER_ID_LENGTH]) and used to resolve the recipient when the code is scanned. */ -data class Username(val value: String) : PayloadValue +data class UserId(val value: ID) : PayloadValue diff --git a/settings.gradle.kts b/settings.gradle.kts index 66325b9053..6f4c72f32e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -83,6 +83,7 @@ include( ":apps:flipcash:shared:theme", ":apps:flipcash:shared:profile", ":apps:flipcash:shared:blob", + ":apps:flipcash:shared:tipping", ":apps:flipcash:shared:userflags", ":apps:flipcash:shared:workers", ":apps:flipcash:shared:web",