From 8afca05151c2b159f3e463b60b75bf638a7c6f43 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 17 Oct 2026 19:50:55 -0400 Subject: [PATCH 1/2] fix(chat): keep the token quarks on cash messages Incoming cash messages mapped nativeAmount, currency and mint from CryptoPaymentAmount but dropped its quarks, the on-chain token amount of the mint. Send Back needs it to return the exact amount received. MessageContent.Cash gains tokenQuarks: Long?, parsed from the proto and persisted in MessageContentSerialized.Cash. It is nullable and defaulted because rows stored before this have no value; callers must treat null as unknown, not zero. The existing quarks field (the fiat value) is unchanged, so no Room migration is needed. The reverse proto mapping writes tokenQuarks when known and falls back to the fiat quarks as before. --- .../converters/ChatTypeConverters.kt | 3 ++ .../converters/ChatTypeConvertersTest.kt | 21 ++++++++++ .../sources/mapper/chat/ChatEntityMapper.kt | 2 + .../mapper/chat/ChatEntityMapperTest.kt | 32 +++++++++++++++ .../network/extensions/LocalToProtobuf.kt | 2 +- .../network/extensions/ProtobufToLocal.kt | 1 + .../services/models/chat/MessageContent.kt | 6 +++ .../extensions/CashContentMappingTest.kt | 41 +++++++++++++++++++ 8 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index f0e80bd2a..38358761a 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -207,6 +207,9 @@ 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, ) : MessageContentSerialized @Serializable diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt index efae576f3..1aa97c33f 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -165,6 +165,27 @@ 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) + 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) + ) + + val deserialized = converter.fromMessageContentList(converter.toMessageContentList(original)) + + assertEquals(original, deserialized) + } + // endregion // region UserProfile diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 5f7827505..7cc8f076c 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -399,6 +399,7 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this tokenName = tokenName, tokenImageUrl = tokenImageUrl, action = action.name, + tokenQuarks = tokenQuarks, ) is MessageContent.Reply -> MessageContentSerialized.Reply( repliedMessageId = repliedMessageId, @@ -447,6 +448,7 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { tokenImageUrl = tokenImageUrl, action = MessageContent.Cash.Action.entries.firstOrNull { it.name == action } ?: MessageContent.Cash.Action.SENT, + tokenQuarks = tokenQuarks, ) is MessageContentSerialized.Reply -> MessageContent.Reply( repliedMessageId = repliedMessageId, diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 741f11728..03a069573 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -177,6 +177,38 @@ class ChatEntityMapperTest { assertEquals(true, encrypted.ciphertext.contentEquals(restored.ciphertext)) } + private fun cashMessage(tokenQuarks: Long?) = ChatMessage( + messageId = 1, + senderId = listOf(0xAB.toByte()), + content = listOf( + MessageContent.Cash( + intentId = listOf(1, 2, 3), + amount = Fiat(quarks = 250_000L), + mint = com.getcode.solana.keys.Mint.usdc, + tokenQuarks = tokenQuarks, + ) + ), + 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 `unsupported content survives the round trip through the message row`() { val message = ChatMessage( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index 121fb8883..3850979e8 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -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( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 246f049f4..734c5d252 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -198,6 +198,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, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index 2f992224c..2e3a6c38f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -21,6 +21,12 @@ sealed interface MessageContent { val action: Action = Action.SENT, /** The group-payment recipient (`CashContent.receiver`); null for DM cash and older messages. */ 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, diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt new file mode 100644 index 000000000..aab8b9892 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt @@ -0,0 +1,41 @@ +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 + +class CashContentMappingTest { + + private fun cash(quarks: Long) = 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()))) + ) + ) + .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) + } +} From 1d972e666eb200e5880bd3dc20ef0dbc0ab34b90 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 17 Oct 2026 21:36:57 -0400 Subject: [PATCH 2/2] fix(chat): keep the receiver on stored cash messages The cash receiver was parsed from the proto but never stored, so it was lost on the DB round trip. That matters in groups, where it names who was paid. MessageContentSerialized.Cash gains a nullable base58 receiver, encoded like intentId, so rows stored before this still decode to null. The reverse proto mapping now sets the receiver when known. The KDoc on MessageContent.Cash.receiver now matches the proto: always set in a group, set on new DM messages and unset on older ones. --- .../converters/ChatTypeConverters.kt | 3 +++ .../converters/ChatTypeConvertersTest.kt | 3 ++- .../sources/mapper/chat/ChatEntityMapper.kt | 2 ++ .../mapper/chat/ChatEntityMapperTest.kt | 15 +++++++++++- .../network/extensions/LocalToProtobuf.kt | 5 ++++ .../services/models/chat/MessageContent.kt | 6 ++++- .../extensions/CashContentMappingTest.kt | 23 ++++++++++++++++++- 7 files changed, 53 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index 38358761a..3dac3522e 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -210,6 +210,9 @@ sealed interface MessageContentSerialized { // 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 diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt index 1aa97c33f..3d2aec8fc 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -172,13 +172,14 @@ class ChatTypeConvertersTest { 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) + MessageContentSerialized.Cash(intentId = "abc", quarks = 250_000L, tokenQuarks = 7_654_321L, receiver = "3mJr7AoUXx2Wqd") ) val deserialized = converter.fromMessageContentList(converter.toMessageContentList(original)) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 7cc8f076c..831330bd2 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -400,6 +400,7 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this tokenImageUrl = tokenImageUrl, action = action.name, tokenQuarks = tokenQuarks, + receiver = receiver?.base58, ) is MessageContent.Reply -> MessageContentSerialized.Reply( repliedMessageId = repliedMessageId, @@ -449,6 +450,7 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { 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, diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 03a069573..df68e2404 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -177,7 +177,7 @@ class ChatEntityMapperTest { assertEquals(true, encrypted.ciphertext.contentEquals(restored.ciphertext)) } - private fun cashMessage(tokenQuarks: Long?) = ChatMessage( + private fun cashMessage(tokenQuarks: Long?, receiver: List? = null) = ChatMessage( messageId = 1, senderId = listOf(0xAB.toByte()), content = listOf( @@ -186,6 +186,7 @@ class ChatEntityMapperTest { amount = Fiat(quarks = 250_000L), mint = com.getcode.solana.keys.Mint.usdc, tokenQuarks = tokenQuarks, + receiver = receiver, ) ), timestamp = Instant.fromEpochSeconds(1_000), @@ -209,6 +210,18 @@ class ChatEntityMapperTest { assertNull(restored.tokenQuarks) } + @Test + fun `cash receiver survives the round trip, and its absence stays null`() { + val receiver = listOf(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( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index 3850979e8..f7a4fd6dc 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -147,6 +147,11 @@ internal fun MessageContent.asContent(): MessagingModel.Content { MessageContent.Cash.Action.SENT -> MessagingModel.CashContent.Verb.SENT } ) + .apply { + this@asContent.receiver?.let { + setReceiver(Common.UserId.newBuilder().setValue(it.toByteString())) + } + } ) .build() is MessageContent.Reply -> MessagingModel.Content.newBuilder() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index 2e3a6c38f..9e9beb8ba 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -19,7 +19,11 @@ 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 diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt index aab8b9892..734c64a7f 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt @@ -7,10 +7,12 @@ 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) = MessagingModel.Content.newBuilder() + 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)))) @@ -21,6 +23,7 @@ class CashContentMappingTest { .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() @@ -38,4 +41,22 @@ class CashContentMappingTest { 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(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(9, 8, 7), with.receiver.value.toByteArray().toList()) + assertFalse(without.hasReceiver()) + } }