Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Byte>(), 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Byte> = 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<Byte> {
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<Byte>): List<Byte> {
Expand All @@ -51,17 +58,50 @@ data class OpenCodePayload(
return data
}

private fun encode(kind: PayloadKind, username: Username): List<Byte> {
val data = MutableList<Byte>(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) }

fun fromList(list: List<Byte>): 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 -> {
Expand All @@ -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)
}
Expand All @@ -87,6 +135,7 @@ enum class PayloadKind(val value: Int) {
Unknown(-1),
Cash(0),
MultiMintCash(1),
Tip(2),
// Login(3),
// RequestPaymentV2(4),
}
Expand Down Expand Up @@ -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.
*/
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Fiat>, Parcelable {
) : Comparable<Fiat>, Parcelable, PayloadValue {

val decimalValue: Double
get() = quarks.toDouble() / MULTIPLIER
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
}
}

Expand All @@ -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
Expand Down
Loading