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 @@ -207,6 +207,12 @@ sealed interface MessageContentSerialized {
val tokenName: String = "",
val tokenImageUrl: String = "",
val action: String = "SENT",
// On-chain token quarks of [mint]; [quarks] above is the fiat value. Defaults to null so
// rows stored before this existed still decode.
val tokenQuarks: Long? = null,
// Base58, like [intentId]. Null for rows stored before this existed, and for messages
// that carried no receiver.
val receiver: String? = null,
) : MessageContentSerialized

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,28 @@ class ChatTypeConvertersTest {
assertEquals(com.flipcash.app.persistence.entities.MessageStatus.SENT, result)
}

@Test
fun `a cash row written before token quarks were kept decodes them as null`() {
val legacyJson = """[{"type":"cash","intentId":"abc","quarks":250000,"currencyCode":"USD","mint":"m","action":"SENT"}]"""

val result = converter.fromMessageContentList(legacyJson)!!.single() as MessageContentSerialized.Cash

assertNull(result.tokenQuarks)
assertNull(result.receiver)
assertEquals(250_000L, result.quarks)
}

@Test
fun `cash token quarks roundtrip alongside the fiat quarks`() {
val original = listOf(
MessageContentSerialized.Cash(intentId = "abc", quarks = 250_000L, tokenQuarks = 7_654_321L, receiver = "3mJr7AoUXx2Wqd")
)

val deserialized = converter.fromMessageContentList(converter.toMessageContentList(original))

assertEquals(original, deserialized)
}

// endregion

// region UserProfile
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,8 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this
tokenName = tokenName,
tokenImageUrl = tokenImageUrl,
action = action.name,
tokenQuarks = tokenQuarks,
receiver = receiver?.base58,
)
is MessageContent.Reply -> MessageContentSerialized.Reply(
repliedMessageId = repliedMessageId,
Expand Down Expand Up @@ -447,6 +449,8 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) {
tokenImageUrl = tokenImageUrl,
action = MessageContent.Cash.Action.entries.firstOrNull { it.name == action }
?: MessageContent.Cash.Action.SENT,
tokenQuarks = tokenQuarks,
receiver = receiver?.decodeBase58()?.toList(),
)
is MessageContentSerialized.Reply -> MessageContent.Reply(
repliedMessageId = repliedMessageId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,51 @@ class ChatEntityMapperTest {
assertEquals(true, encrypted.ciphertext.contentEquals(restored.ciphertext))
}

private fun cashMessage(tokenQuarks: Long?, receiver: List<Byte>? = null) = ChatMessage(
messageId = 1,
senderId = listOf(0xAB.toByte()),
content = listOf(
MessageContent.Cash(
intentId = listOf<Byte>(1, 2, 3),
amount = Fiat(quarks = 250_000L),
mint = com.getcode.solana.keys.Mint.usdc,
tokenQuarks = tokenQuarks,
receiver = receiver,
)
),
timestamp = Instant.fromEpochSeconds(1_000),
unreadSeq = 1,
)

@Test
fun `cash token quarks survive the round trip without disturbing the fiat quarks`() {
val restored = mapper.toMessage(mapper.toEntity(CHAT_HEX, cashMessage(tokenQuarks = 7_654_321L)))
.content.single() as MessageContent.Cash

assertEquals(7_654_321L, restored.tokenQuarks)
assertEquals(250_000L, restored.amount.quarks)
}

@Test
fun `cash with unknown token quarks round trips as null`() {
val restored = mapper.toMessage(mapper.toEntity(CHAT_HEX, cashMessage(tokenQuarks = null)))
.content.single() as MessageContent.Cash

assertNull(restored.tokenQuarks)
}

@Test
fun `cash receiver survives the round trip, and its absence stays null`() {
val receiver = listOf<Byte>(9, 8, 7)
val with = mapper.toMessage(mapper.toEntity(CHAT_HEX, cashMessage(1L, receiver)))
.content.single() as MessageContent.Cash
val without = mapper.toMessage(mapper.toEntity(CHAT_HEX, cashMessage(1L)))
.content.single() as MessageContent.Cash

assertEquals(receiver, with.receiver)
assertNull(without.receiver)
}

@Test
fun `unsupported content survives the round trip through the message row`() {
val message = ChatMessage(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ internal fun MessageContent.asContent(): MessagingModel.Content {
.setIntentId(Common.IntentId.newBuilder().setValue(intentId.toByteString()))
.setAmount(
Common.CryptoPaymentAmount.newBuilder()
.setQuarks(amount.quarks)
.setQuarks(tokenQuarks ?: amount.quarks)
.setMint(Common.PublicKey.newBuilder().setValue(mint.bytes.toByteString()))
)
.setVerb(
Expand All @@ -147,6 +147,11 @@ internal fun MessageContent.asContent(): MessagingModel.Content {
MessageContent.Cash.Action.SENT -> MessagingModel.CashContent.Verb.SENT
}
)
.apply {
[email protected]?.let {
setReceiver(Common.UserId.newBuilder().setValue(it.toByteString()))
}
}
)
.build()
is MessageContent.Reply -> MessagingModel.Content.newBuilder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent {
else -> MessageContent.Cash.Action.SENT
},
receiver = if (cash.hasReceiver()) cash.receiver.value.toByteArray().toList() else null,
tokenQuarks = cash.amount.quarks,
)
MessagingModel.Content.TypeCase.REPLY -> MessageContent.Reply(
repliedMessageId = reply.repliedMessageId.value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,18 @@ sealed interface MessageContent {
val tokenName: String = "",
val tokenImageUrl: String = "",
val action: Action = Action.SENT,
/** The group-payment recipient (`CashContent.receiver`); null for DM cash and older messages. */
/**
* Who was paid (`CashContent.receiver`). Always set in a group chat. In a DM it is set on
* new messages and unset on older ones, whose receiver is the DM member who is not the
* sender; null here means the message did not carry one.
*/
val receiver: ID? = null,
/**
* The on-chain token quarks of [mint] (`CryptoPaymentAmount.quarks`), as opposed to
* [amount], which is the native fiat value. Null means unknown: rows stored before this
* was kept have no value, so callers must not read null as zero.
*/
val tokenQuarks: Long? = null,
) : MessageContent {
enum class Action {
SENT,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.flipcash.services.internal.network.extensions

import com.codeinc.flipcash.gen.common.v1.Common
import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel
import com.flipcash.services.models.chat.MessageContent
import com.getcode.solana.keys.Mint
import com.google.protobuf.ByteString
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull

class CashContentMappingTest {

private fun cash(quarks: Long, receiver: ByteArray? = null) = MessagingModel.Content.newBuilder()
.setCash(
MessagingModel.CashContent.newBuilder()
.setIntentId(Common.IntentId.newBuilder().setValue(ByteString.copyFrom(byteArrayOf(1, 2, 3))))
.setAmount(
Common.CryptoPaymentAmount.newBuilder()
.setQuarks(quarks)
.setCurrency("usd")
.setNativeAmount(2.5)
.setMint(Common.PublicKey.newBuilder().setValue(ByteString.copyFrom(Mint.usdc.bytes.toByteArray())))
)
.apply { receiver?.let { setReceiver(Common.UserId.newBuilder().setValue(ByteString.copyFrom(it))) } }
)
.build()

@Test
fun `cash keeps the on-chain token quarks apart from the fiat amount`() {
val mapped = cash(7_654_321L).toMessageContent() as MessageContent.Cash

assertEquals(7_654_321L, mapped.tokenQuarks)
assertEquals(2_500_000L, mapped.amount.quarks)
}

@Test
fun `token quarks are written back to the wire when known`() {
val mapped = cash(7_654_321L).toMessageContent()

assertEquals(7_654_321L, mapped.asContent().cash.amount.quarks)
}

@Test
fun `the receiver is parsed when present and null when absent`() {
val with = cash(1L, receiver = byteArrayOf(9, 8, 7)).toMessageContent() as MessageContent.Cash
val without = cash(1L).toMessageContent() as MessageContent.Cash

assertEquals(listOf<Byte>(9, 8, 7), with.receiver)
assertNull(without.receiver)
}

@Test
fun `the receiver is written back to the wire only when known`() {
val with = cash(1L, receiver = byteArrayOf(9, 8, 7)).toMessageContent().asContent().cash
val without = cash(1L).toMessageContent().asContent().cash

assertEquals(listOf<Byte>(9, 8, 7), with.receiver.value.toByteArray().toList())
assertFalse(without.hasReceiver())
}
}
Loading