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 f0e80bd2a8..3dac3522e7 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,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 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 efae576f34..3d2aec8fc8 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,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 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 5f7827505f..831330bd2e 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,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, @@ -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, 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 741f117288..df68e2404f 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,51 @@ class ChatEntityMapperTest { assertEquals(true, encrypted.ciphertext.contentEquals(restored.ciphertext)) } + private fun cashMessage(tokenQuarks: Long?, receiver: List? = null) = 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, + 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(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 121fb8883f..f7a4fd6dc8 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( @@ -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/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 90cbcc44bb..66a94f3370 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 @@ -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, 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 2f992224c6..9e9beb8bae 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,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, 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 0000000000..734c64a7f5 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt @@ -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(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()) + } +}