fix(wallet): replace text-marker hack with proper raw event API (room.sendRaw + MsgLikeKind.Other)

- Add Timeline.sendRaw() to send custom Matrix events
- Add CustomEventContent type for receiving custom events
- Update TimelineEventContentMapper to handle MsgLikeKind.Other
- Update TimelineItemContentFactory to intercept payment events
- Rewrite DefaultPaymentEventSender to use sendRaw instead of text markers
- Update TimelineItemContentPaymentFactory to parse raw JSON
- Remove text-marker detection from TimelineItemContentMessageFactory
- Update tests to use raw event API
- Mark raw event SDK blocker as RESOLVED in BLOCKERS.md

Event type: co.sulkta.payment.request (reverse-domain format)
Status updates: co.sulkta.payment.status

Benefits:
- Proper Matrix protocol compliance
- No JSON embedded in text messages
- Events won't be indexed by search
- Clean separation from regular messages
This commit is contained in:
Kayos 2026-03-27 11:45:12 -07:00
parent adee67cf0d
commit f2b95d6b8a
10 changed files with 264 additions and 189 deletions

View file

@ -21,14 +21,10 @@ import kotlinx.serialization.json.Json
/**
* Default implementation of [PaymentEventSender].
*
* Sends payment events as specially formatted text messages that can be
* parsed by wallet-enabled clients while remaining readable for others.
* Sends payment events as custom Matrix events using the raw event API.
*
* Message format:
* ```
* [cardano-payment:v1]{"amount_lovelace":...,"to_address":"...","from_address":"...","tx_hash":"...","status":"...","network":"..."}
* 💰 Sent X ADA
* ```
* Event type: co.sulkta.payment.request
* Event content: JSON-serialized [PaymentEventData]
*/
@Inject
@ContributesBinding(SessionScope::class)
@ -53,13 +49,11 @@ class DefaultPaymentEventSender : PaymentEventSender {
network = network,
)
val fallbackText = "💰 Sent ${TimelineItemPaymentContent.formatAda(signedTx.actualAmount)}"
val messageBody = formatPaymentMessage(paymentData, fallbackText)
val content = json.encodeToString(paymentData)
return timeline.sendMessage(
body = messageBody,
htmlBody = formatPaymentHtml(paymentData, fallbackText),
intentionalMentions = emptyList(),
return timeline.sendRaw(
eventType = PAYMENT_EVENT_TYPE,
content = content,
)
}
@ -69,44 +63,25 @@ class DefaultPaymentEventSender : PaymentEventSender {
newStatus: String,
network: String,
): Result<Unit> {
// Status updates use m.relates_to with m.replace relation
// Since the SDK doesn't expose raw event editing, we send a new event
// with a reference to the original transaction
val statusData = PaymentStatusUpdateData(
txHash = txHash,
status = newStatus,
network = network,
)
val statusText = when (newStatus.lowercase()) {
"confirmed" -> "✅ Payment confirmed"
"failed" -> "❌ Payment failed"
else -> "⏳ Payment $newStatus"
}
val content = json.encodeToString(statusData)
val messageBody = "[cardano-payment-status:v1]${json.encodeToString(statusData)}\n$statusText (tx: ${txHash.take(8)}...)"
return timeline.sendMessage(
body = messageBody,
htmlBody = null,
intentionalMentions = emptyList(),
return timeline.sendRaw(
eventType = STATUS_UPDATE_EVENT_TYPE,
content = content,
)
}
private fun formatPaymentMessage(data: PaymentEventData, fallbackText: String): String {
val jsonPayload = json.encodeToString(data)
return "$PAYMENT_MARKER$jsonPayload\n$fallbackText"
}
private fun formatPaymentHtml(data: PaymentEventData, fallbackText: String): String {
val jsonPayload = json.encodeToString(data)
// Embed payment data in a data attribute for potential future parsing
return """<span data-mx-payment="$PAYMENT_MARKER" data-payment-json='$jsonPayload'>$fallbackText</span>"""
}
companion object {
const val PAYMENT_MARKER = "[cardano-payment:v1]"
const val STATUS_MARKER = "[cardano-payment-status:v1]"
/** Custom event type for Cardano payment requests (reverse-domain format) */
const val PAYMENT_EVENT_TYPE = "co.sulkta.payment.request"
/** Custom event type for payment status updates */
const val STATUS_UPDATE_EVENT_TYPE = "co.sulkta.payment.status"
}
}

View file

@ -9,14 +9,20 @@ package io.element.android.features.wallet.impl.timeline
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.PaymentCardStatus
import io.element.android.features.wallet.api.timeline.TimelineItemPaymentContent
import io.element.android.features.wallet.impl.payment.DefaultPaymentEventSender
import io.element.android.features.wallet.impl.payment.PaymentEventData
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.longOrNull
import timber.log.Timber
/**
* Factory for creating [TimelineItemPaymentContent] from payment event messages.
* Factory for creating [TimelineItemPaymentContent] from raw payment events.
*
* Parses messages that start with the payment marker and extracts the JSON payload.
* Parses custom events with type "co.sulkta.payment.request" and extracts the payment data.
*/
@Inject
class TimelineItemContentPaymentFactory {
@ -26,41 +32,49 @@ class TimelineItemContentPaymentFactory {
}
/**
* Check if a message body contains a payment event.
* Check if an event type is a payment event.
*/
fun isPaymentEvent(body: String): Boolean {
return body.startsWith(PAYMENT_MARKER)
fun isPaymentEventType(eventType: String): Boolean {
return eventType == DefaultPaymentEventSender.PAYMENT_EVENT_TYPE
}
/**
* Check if a message body contains a payment status update.
* Check if an event type is a payment status update.
*/
fun isPaymentStatusUpdate(body: String): Boolean {
return body.startsWith(STATUS_MARKER)
fun isStatusUpdateEventType(eventType: String): Boolean {
return eventType == DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE
}
/**
* Create a [TimelineItemPaymentContent] from a payment message body.
* Create a [TimelineItemPaymentContent] from raw JSON event content.
*
* @param body The message body starting with [PAYMENT_MARKER]
* @param isSentByMe Whether the current user sent this message
* @param rawJson The raw JSON content from the Matrix event
* @param isSentByMe Whether the current user sent this event
* @return The parsed payment content, or null if parsing failed
*/
fun createFromBody(body: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
fun createFromRaw(rawJson: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
return try {
val jsonStart = body.indexOf(PAYMENT_MARKER) + PAYMENT_MARKER.length
val jsonEnd = body.indexOf('\n', jsonStart).takeIf { it != -1 } ?: body.length
val jsonPayload = body.substring(jsonStart, jsonEnd)
val fallbackText = if (jsonEnd < body.length) {
body.substring(jsonEnd + 1).trim()
// Try to parse the content field from the raw event JSON
val eventJson = json.parseToJsonElement(rawJson).jsonObject
val content = eventJson["content"]?.jsonObject ?: eventJson
val data = parsePaymentData(content)
if (data != null) {
TimelineItemPaymentContent(
amountLovelace = data.amountLovelace,
toAddress = data.toAddress,
fromAddress = data.fromAddress,
txHash = data.txHash,
status = parseStatus(data.status),
network = data.network,
isSentByMe = isSentByMe,
fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}",
)
} else {
"Payment"
null
}
val data = json.decodeFromString<PaymentEventData>(jsonPayload)
createFromData(data, isSentByMe, fallbackText)
} catch (e: Exception) {
Timber.w(e, "Failed to parse payment event from body")
Timber.w(e, "Failed to parse payment event from raw JSON")
null
}
}
@ -71,7 +85,6 @@ class TimelineItemContentPaymentFactory {
fun createFromData(
data: PaymentEventData,
isSentByMe: Boolean,
fallbackText: String,
): TimelineItemPaymentContent {
return TimelineItemPaymentContent(
amountLovelace = data.amountLovelace,
@ -81,35 +94,40 @@ class TimelineItemContentPaymentFactory {
status = parseStatus(data.status),
network = data.network,
isSentByMe = isSentByMe,
fallbackText = fallbackText,
fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}",
)
}
/**
* Create a [TimelineItemPaymentContent] from raw JSON.
*
* This is the method called from TimelineItemContentFactory when
* handling UnknownContent (if we had access to raw JSON).
*
* @param rawJson The raw JSON content
* @param isSentByMe Whether the current user sent this event
* @return The parsed payment content, or null if parsing failed
*/
fun createFromRaw(rawJson: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
private fun parsePaymentData(content: JsonObject): PaymentEventData? {
return try {
val data = json.decodeFromString<PaymentEventData>(rawJson)
TimelineItemPaymentContent(
amountLovelace = data.amountLovelace,
toAddress = data.toAddress,
fromAddress = data.fromAddress,
txHash = data.txHash,
status = parseStatus(data.status),
network = data.network,
isSentByMe = isSentByMe,
fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}",
val amountLovelace = content["amount_lovelace"]?.jsonPrimitive?.longOrNull
?: content["amountLovelace"]?.jsonPrimitive?.longOrNull
?: return null
val toAddress = content["to_address"]?.jsonPrimitive?.content
?: content["toAddress"]?.jsonPrimitive?.content
?: return null
val fromAddress = content["from_address"]?.jsonPrimitive?.content
?: content["fromAddress"]?.jsonPrimitive?.content
?: return null
val txHash = content["tx_hash"]?.jsonPrimitive?.content
?: content["txHash"]?.jsonPrimitive?.content
val status = content["status"]?.jsonPrimitive?.content ?: "pending"
val network = content["network"]?.jsonPrimitive?.content ?: "mainnet"
PaymentEventData(
amountLovelace = amountLovelace,
toAddress = toAddress,
fromAddress = fromAddress,
txHash = txHash,
status = status,
network = network,
)
} catch (e: Exception) {
Timber.w(e, "Failed to parse payment event from raw JSON")
Timber.w(e, "Failed to parse payment data from JSON object")
null
}
}
@ -124,7 +142,9 @@ class TimelineItemContentPaymentFactory {
}
companion object {
const val PAYMENT_MARKER = "[cardano-payment:v1]"
const val STATUS_MARKER = "[cardano-payment-status:v1]"
/** Custom event type for Cardano payment requests */
const val PAYMENT_EVENT_TYPE = DefaultPaymentEventSender.PAYMENT_EVENT_TYPE
/** Custom event type for payment status updates */
const val STATUS_UPDATE_EVENT_TYPE = DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE
}
}

View file

@ -8,34 +8,60 @@ package io.element.android.features.wallet.impl.timeline
import com.google.common.truth.Truth.assertThat
import io.element.android.features.wallet.api.PaymentCardStatus
import io.element.android.features.wallet.impl.payment.DefaultPaymentEventSender
import org.junit.Test
class TimelineItemContentPaymentFactoryTest {
private val factory = TimelineItemContentPaymentFactory()
@Test
fun `isPaymentEvent returns true for valid payment marker`() {
val body = "[cardano-payment:v1]{\"amount_lovelace\":10000000}\n💰 Sent 10 ADA"
assertThat(factory.isPaymentEvent(body)).isTrue()
fun `isPaymentEventType returns true for payment event type`() {
assertThat(factory.isPaymentEventType(DefaultPaymentEventSender.PAYMENT_EVENT_TYPE)).isTrue()
assertThat(factory.isPaymentEventType("co.sulkta.payment.request")).isTrue()
}
@Test
fun `isPaymentEvent returns false for regular message`() {
val body = "Hello, this is a regular message"
assertThat(factory.isPaymentEvent(body)).isFalse()
fun `isPaymentEventType returns false for other event types`() {
assertThat(factory.isPaymentEventType("m.room.message")).isFalse()
assertThat(factory.isPaymentEventType("m.room.member")).isFalse()
assertThat(factory.isPaymentEventType("co.other.event")).isFalse()
}
@Test
fun `isPaymentEvent returns false for empty string`() {
assertThat(factory.isPaymentEvent("")).isFalse()
fun `isStatusUpdateEventType returns true for status update event type`() {
assertThat(factory.isStatusUpdateEventType(DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE)).isTrue()
assertThat(factory.isStatusUpdateEventType("co.sulkta.payment.status")).isTrue()
}
@Test
fun `createFromBody parses valid payment event`() {
val body = """[cardano-payment:v1]{"amount_lovelace":10000000,"to_address":"addr_test1abc","from_address":"addr_test1xyz","tx_hash":"hash123","status":"pending","network":"testnet"}
💰 Sent 10 ADA"""
fun `isStatusUpdateEventType returns false for other event types`() {
assertThat(factory.isStatusUpdateEventType("m.room.message")).isFalse()
assertThat(factory.isStatusUpdateEventType("co.sulkta.payment.request")).isFalse()
}
val result = factory.createFromBody(body, isSentByMe = true)
@Test
fun `createFromRaw parses valid payment JSON`() {
val json = """{"amountLovelace":25000000,"toAddress":"addr1","fromAddress":"addr2","txHash":"abc123","status":"confirmed","network":"mainnet"}"""
val result = factory.createFromRaw(json, isSentByMe = false)
assertThat(result).isNotNull()
assertThat(result!!.amountLovelace).isEqualTo(25_000_000)
assertThat(result.amountAda).isEqualTo("25 ADA")
assertThat(result.toAddress).isEqualTo("addr1")
assertThat(result.fromAddress).isEqualTo("addr2")
assertThat(result.txHash).isEqualTo("abc123")
assertThat(result.status).isEqualTo(PaymentCardStatus.CONFIRMED)
assertThat(result.network).isEqualTo("mainnet")
assertThat(result.isTestnet).isFalse()
assertThat(result.isSentByMe).isFalse()
}
@Test
fun `createFromRaw parses snake_case field names`() {
val json = """{"amount_lovelace":10000000,"to_address":"addr_test1abc","from_address":"addr_test1xyz","tx_hash":"hash123","status":"pending","network":"testnet"}"""
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNotNull()
assertThat(result!!.amountLovelace).isEqualTo(10_000_000)
@ -45,25 +71,34 @@ class TimelineItemContentPaymentFactoryTest {
assertThat(result.status).isEqualTo(PaymentCardStatus.PENDING)
assertThat(result.network).isEqualTo("testnet")
assertThat(result.isSentByMe).isTrue()
assertThat(result.fallbackText).isEqualTo("💰 Sent 10 ADA")
}
@Test
fun `createFromBody parses confirmed status`() {
val body = """[cardano-payment:v1]{"amount_lovelace":5000000,"to_address":"addr","from_address":"addr2","tx_hash":"hash","status":"confirmed","network":"mainnet"}"""
fun `createFromRaw parses wrapped event JSON with content field`() {
val json = """{"type":"co.sulkta.payment.request","content":{"amountLovelace":5000000,"toAddress":"addr","fromAddress":"addr2","txHash":"hash","status":"confirmed","network":"mainnet"}}"""
val result = factory.createFromBody(body, isSentByMe = false)
val result = factory.createFromRaw(json, isSentByMe = false)
assertThat(result).isNotNull()
assertThat(result!!.amountLovelace).isEqualTo(5_000_000)
assertThat(result.status).isEqualTo(PaymentCardStatus.CONFIRMED)
}
@Test
fun `createFromRaw parses confirmed status`() {
val json = """{"amountLovelace":5000000,"toAddress":"addr","fromAddress":"addr2","txHash":"hash","status":"confirmed","network":"mainnet"}"""
val result = factory.createFromRaw(json, isSentByMe = false)
assertThat(result).isNotNull()
assertThat(result!!.status).isEqualTo(PaymentCardStatus.CONFIRMED)
assertThat(result.isSentByMe).isFalse()
}
@Test
fun `createFromBody parses failed status`() {
val body = """[cardano-payment:v1]{"amount_lovelace":1000000,"to_address":"a","from_address":"b","tx_hash":null,"status":"failed","network":"testnet"}"""
fun `createFromRaw parses failed status`() {
val json = """{"amountLovelace":1000000,"toAddress":"a","fromAddress":"b","txHash":null,"status":"failed","network":"testnet"}"""
val result = factory.createFromBody(body, isSentByMe = true)
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNotNull()
assertThat(result!!.status).isEqualTo(PaymentCardStatus.FAILED)
@ -71,34 +106,13 @@ class TimelineItemContentPaymentFactoryTest {
}
@Test
fun `createFromBody returns null for malformed JSON`() {
val body = "[cardano-payment:v1]{not valid json}\n💰 Sent 10 ADA"
fun `createFromRaw defaults to pending for unknown status`() {
val json = """{"amountLovelace":1000000,"toAddress":"a","fromAddress":"b","status":"unknown_status","network":"mainnet"}"""
val result = factory.createFromBody(body, isSentByMe = true)
assertThat(result).isNull()
}
@Test
fun `createFromBody returns null for missing marker`() {
val body = """{"amount_lovelace":10000000,"to_address":"addr","from_address":"addr2","status":"pending","network":"testnet"}"""
val result = factory.createFromBody(body, isSentByMe = true)
assertThat(result).isNull()
}
@Test
fun `createFromRaw parses valid JSON`() {
val json = """{"amount_lovelace":25000000,"to_address":"addr1","from_address":"addr2","tx_hash":"abc123","status":"confirmed","network":"mainnet"}"""
val result = factory.createFromRaw(json, isSentByMe = false)
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNotNull()
assertThat(result!!.amountLovelace).isEqualTo(25_000_000)
assertThat(result.amountAda).isEqualTo("25 ADA")
assertThat(result.status).isEqualTo(PaymentCardStatus.CONFIRMED)
assertThat(result.isTestnet).isFalse()
assertThat(result!!.status).isEqualTo(PaymentCardStatus.PENDING)
}
@Test
@ -111,13 +125,30 @@ class TimelineItemContentPaymentFactoryTest {
}
@Test
fun `isPaymentStatusUpdate returns true for valid status marker`() {
val body = "[cardano-payment-status:v1]{\"tx_hash\":\"abc\"}\n✅ Payment confirmed"
assertThat(factory.isPaymentStatusUpdate(body)).isTrue()
fun `createFromRaw returns null for missing required fields`() {
val json = """{"amountLovelace":1000000}"""
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNull()
}
@Test
fun `isPaymentStatusUpdate returns false for regular message`() {
assertThat(factory.isPaymentStatusUpdate("Hello")).isFalse()
fun `createFromRaw returns null for empty JSON`() {
val json = "{}"
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNull()
}
@Test
fun `createFromRaw formats fallback text correctly`() {
val json = """{"amountLovelace":1500000,"toAddress":"a","fromAddress":"b","status":"pending","network":"mainnet"}"""
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNotNull()
assertThat(result!!.fallbackText).isEqualTo("💰 1.5 ADA")
}
}