diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt index 3aa10e605..b85efdc2e 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt @@ -92,6 +92,9 @@ class CodeScanDelegate @Inject constructor( when (codePayload.kind) { PayloadKind.Cash -> onCashScanned(codePayload) PayloadKind.MultiMintCash -> onCashScanned(codePayload) + // TODO(tipping): route tip scans to the tip flow. Deliberately not handled as a cash + // grab — onCashScanned force-unwraps payload.fiat, which is null for a Tip payload. + PayloadKind.Tip -> Unit PayloadKind.Unknown -> Unit } } diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt index 3645a7491..7c737cacf 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt @@ -118,6 +118,7 @@ class SessionControllerEventRoutingTest { tokenCoordinator = tokenCoordinator, contactCoordinator = mockk(relaxed = true), chatCoordinator = mockk(relaxed = true), + blobStorageCoordinator = mockk(relaxed = true), featureFlagController = featureFlagController, appSettingsCoordinator = appSettingsCoordinator, dispatchers = dispatchers, diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index c4dec1126..fd8e18234 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -141,6 +141,7 @@ class SessionControllerGiftCardErrorTest { tokenCoordinator = tokenCoordinator, contactCoordinator = mockk(relaxed = true), chatCoordinator = mockk(relaxed = true), + blobStorageCoordinator = mockk(relaxed = true), featureFlagController = mockk(relaxed = true), appSettingsCoordinator = mockk(relaxed = true), dispatchers = dispatchers, 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 48df559a0..3804a8b20 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 @@ -3,6 +3,7 @@ package com.getcode.opencode.model.core import com.getcode.opencode.model.financial.toFiat import com.getcode.opencode.utils.nonce import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class OpenCodePayloadTests { @@ -26,4 +27,38 @@ class OpenCodePayloadTests { assertEquals(5.00, decoded.fiat?.decimalValue) assertEquals(nonce, decoded.nonce) } + + @Test + fun tipPayloadEncoding() { + val payload = OpenCodePayload( + kind = PayloadKind.Tip, + value = Username("bob"), + ) + + val encoded = payload.encode() + val decoded = OpenCodePayload.Companion.fromList(encoded) + + // -------------------------------------------------------- + + 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) + // 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( + kind = PayloadKind.Tip, + value = Username(username), + ) + + val decoded = OpenCodePayload.Companion.fromList(payload.encode()) + + assertEquals(username, decoded.username) + } } \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactor.kt index 39eb788ea..30e1a047e 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GrabBillTransactor.kt @@ -81,6 +81,7 @@ internal class GrabBillTransactor( PayloadKind.Unknown -> logAndFail(GrabTransactorError.Other(message = "Unknown payload kind")) PayloadKind.Cash -> handleLegacyScan(ownerKey, data) PayloadKind.MultiMintCash -> handleMultiMintScan(ownerKey, data) + PayloadKind.Tip -> logAndFail(GrabTransactorError.Other(message = "Tip payload is not grabbable")) } } 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 f3a24209c..1ce047aae 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,5 +1,6 @@ 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 @@ -8,29 +9,35 @@ 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( val kind: PayloadKind, - val value: Fiat, + val value: PayloadValue, val nonce: List = emptyList(), ) { val rendezvous: KeyPair init { - rendezvous = deriveRendezvousKey(encode(kind = kind, fiat = value, nonce = nonce).toByteArray()) + rendezvous = deriveRendezvousKey(encode().toByteArray()) } val fiat: Fiat? - get() { - return value as? Fiat - } + get() = value as? Fiat + + val username: String? + get() = (value as? Username)?.value val codeData: ByteArray - get() = Scanner.encode(encode().toByteArray()) ?: ByteArray(20) + get() = Scanner.encode(encode().toByteArray()) ?: ByteArray(LENGTH) fun encode(): List { - return encode(kind, value, nonce) + 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 { @@ -51,9 +58,42 @@ data class OpenCodePayload( 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 + } + 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 val Empty by lazy { OpenCodePayload(PayloadKind.Unknown, Fiat.Zero) } @@ -61,7 +101,7 @@ data class OpenCodePayload( fun fromList(list: List): OpenCodePayload { val kind = PayloadKind.entries.find { it.value == list[0].toInt() } ?: PayloadKind.Cash - val value = when (kind) { + val value: PayloadValue = when (kind) { PayloadKind.Unknown -> Fiat.Zero PayloadKind.Cash, PayloadKind.MultiMintCash -> { @@ -72,9 +112,17 @@ data class OpenCodePayload( 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) + } } - val nonce = list.suffix(OFFSET_NONCE) + // 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) } @@ -87,6 +135,7 @@ enum class PayloadKind(val value: Int) { Unknown(-1), Cash(0), MultiMintCash(1), + Tip(2), // Login(3), // RequestPaymentV2(4), } @@ -124,4 +173,28 @@ enum class PayloadKind(val value: Int) { Layout 1: Multi-token supported Cash Same as layout 0. + + Layout 5: 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) 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 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 new file mode 100644 index 000000000..d21ff6b26 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadValue.kt @@ -0,0 +1,17 @@ +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]. + * + * Not `sealed`: [com.getcode.opencode.model.financial.Fiat] implements it from another package, + * which a sealed interface would forbid. + */ +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. + */ +data class Username(val value: String) : PayloadValue diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt index 7b6cde3db..994e14081 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt @@ -5,6 +5,7 @@ import com.flipcash.libs.currency.math.Estimator import com.flipcash.libs.currency.math.MarketState import com.flipcash.libs.currency.math.Valuation import com.getcode.opencode.internal.extensions.fractionDigits +import com.getcode.opencode.model.core.PayloadValue import com.getcode.opencode.utils.roundTo import com.getcode.opencode.utils.toLocaleAwareDoubleOrNull import com.getcode.solana.keys.Mint @@ -21,7 +22,7 @@ import kotlin.math.roundToLong data class Fiat( val quarks: Long, // Changed from ULong to Long to support negative values val currencyCode: CurrencyCode = CurrencyCode.USD -) : Comparable, Parcelable { +) : Comparable, Parcelable, PayloadValue { val decimalValue: Double get() = quarks.toDouble() / MULTIPLIER diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTest.kt index 14211c003..75f3f2db0 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTest.kt @@ -63,8 +63,8 @@ class OpenCodePayloadTest { val decoded = OpenCodePayload.fromList(encoded) assertEquals(PayloadKind.Cash, decoded.kind) - assertEquals(CurrencyCode.USD, decoded.value.currencyCode) - assertEquals(12345L, decoded.value.quarks) + assertEquals(CurrencyCode.USD, decoded.fiat!!.currencyCode) + assertEquals(12345L, decoded.fiat!!.quarks) } @Test @@ -76,8 +76,8 @@ class OpenCodePayloadTest { val decoded = OpenCodePayload.fromList(encoded) assertEquals(PayloadKind.MultiMintCash, decoded.kind) - assertEquals(CurrencyCode.CAD, decoded.value.currencyCode) - assertEquals(99999L, decoded.value.quarks) + assertEquals(CurrencyCode.CAD, decoded.fiat!!.currencyCode) + assertEquals(99999L, decoded.fiat!!.quarks) } @Test @@ -112,8 +112,8 @@ class OpenCodePayloadTest { val encoded = encodePayload(PayloadKind.Cash, fiat, nonce) val decoded = OpenCodePayload.fromList(encoded) - assertEquals(currency, decoded.value.currencyCode, "Currency roundtrip failed for $currency") - assertEquals(42L, decoded.value.quarks, "Quarks roundtrip failed for $currency") + assertEquals(currency, decoded.fiat!!.currencyCode, "Currency roundtrip failed for $currency") + assertEquals(42L, decoded.fiat!!.quarks, "Quarks roundtrip failed for $currency") } } @@ -124,7 +124,7 @@ class OpenCodePayloadTest { val encoded = encodePayload(PayloadKind.MultiMintCash, fiat, nonce) val decoded = OpenCodePayload.fromList(encoded) - assertEquals(1_000_000_000L, decoded.value.quarks) + assertEquals(1_000_000_000L, decoded.fiat!!.quarks) } // endregion