feat(wallet): payment card timeline item and raw event handling (Tasks 7+8)

Task 7: Timeline Payment Card
- TimelineItemPaymentView integration with TimelineItemEventContentView
- Payment card rendering for both sender and recipient perspectives
- Unit tests for TimelineItemPaymentContent

Task 8: Raw Event Handling
- Modified TimelineItemContentMessageFactory to intercept payment events
- Added isSentByMe parameter propagation through content factories
- FakePaymentEventSender for testing
- Unit tests for TimelineItemContentPaymentFactory

SDK Limitation Workaround:
Since matrix-rust-sdk doesn't expose raw event sending or UnknownContent
raw JSON, payment events are encoded as text messages with a marker:
[cardano-payment:v1]{...json...}

This falls back gracefully for non-wallet clients while enabling
rich payment card rendering for wallet-enabled clients.
This commit is contained in:
Kayos 2026-03-27 11:08:03 -07:00
parent 39561e1aeb
commit adee67cf0d
16 changed files with 1410 additions and 12 deletions

View file

@ -126,9 +126,85 @@
--- ---
## Task 4-8: Pending ## Task 4-6: See PHASE1-PLAN.md
See PHASE1-PLAN.md for full task breakdown. ---
## Task 7: Timeline Payment Card ✅ COMPLETE
### Completed
- ✅ **PaymentCardStatus.kt** — Enum for PENDING/CONFIRMED/FAILED states
- ✅ **TimelineItemPaymentContent.kt** — Data class implementing TimelineItemEventContent
- amountLovelace, addresses, txHash, status, network, isSentByMe
- Computed properties: amountAda, isTestnet, truncatedTxHash, explorerUrl
- Companion formatAda() helper
- ✅ **TimelineItemPaymentView.kt** — Compose UI for payment card
- Cardano icon (₳ symbol)
- Amount in ADA (formatted from lovelace)
- Status chip with spinner (pending), checkmark (confirmed), X (failed)
- Testnet badge when applicable
- Truncated tx hash (tappable → CardanoScan)
- View on explorer link for confirmed transactions
- @PreviewsDayNight with multiple preview states
- ✅ **TimelineItemPaymentContentTest.kt** — Unit tests for content model
- ✅ **Integration with TimelineItemEventContentView.kt**
### Design Notes
- Payment cards use different colors for sent (primary) vs received (surface)
- Explorer URLs: preprod.cardanoscan.io for testnet, cardanoscan.io for mainnet
- Tx hash truncated to first 8 + last 8 chars for display
---
## Task 8: Raw Event Handling ✅ COMPLETE
### Completed
- ✅ **PaymentEventSender.kt** — Interface for sending payment events
- ✅ **DefaultPaymentEventSender.kt** — Implementation
- Sends payment as formatted text message with JSON payload
- Format: `[cardano-payment:v1]{...json...}\n💰 Sent X ADA`
- HTML body includes data-payment attribute for future parsing
- Status updates use separate marker: `[cardano-payment-status:v1]`
- ✅ **TimelineItemContentPaymentFactory.kt** — Parser for payment messages
- `isPaymentEvent(body)` — Detects payment marker
- `isPaymentStatusUpdate(body)` — Detects status update marker
- `createFromBody(body, isSentByMe)` — Parses text message body
- `createFromRaw(json, isSentByMe)` — Parses raw JSON (for future SDK extension)
- Graceful error handling — returns null on malformed JSON
- ✅ **TimelineItemContentMessageFactory.kt** — Modified to intercept payments
- Added paymentFactory dependency
- Added isSentByMe parameter to create()
- TextMessageType checks for payment marker before creating text content
- ✅ **TimelineItemContentFactory.kt** — Passes isSentByMe to message factory
- ✅ **FakePaymentEventSender.kt** — Test fake
- ✅ **TimelineItemContentPaymentFactoryTest.kt** — Unit tests
### SDK Limitations & Approach
The Matrix Rust SDK does NOT expose:
- Raw event sending (`room.sendRawEvent()`)
- Raw JSON access for UnknownContent
**Workaround implemented:**
Instead of custom event types, we encode payment data in standard text messages:
```
[cardano-payment:v1]{"amount_lovelace":10000000,"to_address":"...","from_address":"...","tx_hash":"...","status":"pending","network":"testnet"}
💰 Sent 10 ADA
```
This approach:
- Works with existing SDK (no fork needed)
- Falls back gracefully (non-wallet clients see "💰 Sent 10 ADA")
- Can be upgraded to proper custom events when SDK exposes raw event APIs
### m.replace Status Updates
**Decision:** Due to SDK limitations (no direct access to m.replace relations), status updates are sent as new messages rather than event replacements.
**Future improvement:** When SDK exposes event relations, refactor to use m.replace for cleaner status update thread.
### Potential Issues
- ⚠️ Status updates create new timeline events (not ideal, but works)
- ⚠️ Payment messages may be indexed by search (contains JSON)
- ⚠️ Very long addresses in JSON may hit message length limits (unlikely in practice)
--- ---

View file

@ -30,6 +30,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.wallet.api.timeline.TimelineItemPaymentContent
import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentView
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@ -134,6 +136,10 @@ fun TimelineItemEventContentView(
modifier = modifier modifier = modifier
) )
} }
is TimelineItemPaymentContent -> TimelineItemPaymentView(
content = content,
modifier = modifier
)
is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble") is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble")
} }
} }

View file

@ -78,6 +78,7 @@ class TimelineItemContentFactory(
senderProfile = senderProfile, senderProfile = senderProfile,
content = itemContent, content = itemContent,
eventId = eventId, eventId = eventId,
isSentByMe = isOutgoing,
) )
} }
is ProfileChangeContent -> { is ProfileChangeContent -> {

View file

@ -73,6 +73,7 @@ class TimelineItemContentMessageFactory(
senderId: UserId, senderId: UserId,
senderProfile: ProfileDetails, senderProfile: ProfileDetails,
eventId: EventId?, eventId: EventId?,
isSentByMe: Boolean = false,
): TimelineItemEventContent { ): TimelineItemEventContent {
return when (val messageType = content.type) { return when (val messageType = content.type) {
is EmoteMessageType -> { is EmoteMessageType -> {
@ -256,16 +257,13 @@ class TimelineItemContentMessageFactory(
} }
is TextMessageType -> { is TextMessageType -> {
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) // Check for Cardano payment events embedded in text messages
val formattedBody = dom?.let(::parseHtml) if (paymentFactory.isPaymentEvent(body)) {
?: textPillificationHelper.pillify(body).safeLinkify() paymentFactory.createFromBody(body, isSentByMe)
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) ?: createTextContent(body, messageType, content.isEdited)
TimelineItemTextContent( } else {
body = body, createTextContent(body, messageType, content.isEdited)
htmlDocument = htmlDocument, }
formattedBody = formattedBody,
isEdited = content.isEdited,
)
} }
is OtherMessageType -> { is OtherMessageType -> {
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
@ -279,6 +277,23 @@ class TimelineItemContentMessageFactory(
} }
} }
private fun createTextContent(
body: String,
messageType: TextMessageType,
isEdited: Boolean,
): TimelineItemTextContent {
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedBody = dom?.let(::parseHtml)
?: textPillificationHelper.pillify(body).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
return TimelineItemTextContent(
body = body,
htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = isEdited,
)
}
private fun aspectRatioOf(width: Long?, height: Long?): Float? { private fun aspectRatioOf(width: Long?, height: Long?): Float? {
val result = if (height != null && width != null) { val result = if (height != null && width != null) {
width.toFloat() / height.toFloat() width.toFloat() / height.toFloat()

View file

@ -0,0 +1,103 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.impl.cardano.CardanoNetwork
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
import io.element.android.features.wallet.impl.slash.Lovelace
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
/**
* Presenter for the payment confirmation screen.
*/
class PaymentConfirmationPresenter @AssistedInject constructor(
@Assisted private val recipientAddress: String,
@Assisted private val amountLovelace: Lovelace,
private val matrixClient: MatrixClient,
private val walletManager: CardanoWalletManager,
private val cardanoClient: CardanoClient,
) : Presenter<PaymentConfirmationState> {
@AssistedFactory
interface Factory {
fun create(recipientAddress: String, amountLovelace: Lovelace): PaymentConfirmationPresenter
}
companion object {
private const val ESTIMATED_TX_SIZE_BYTES = 350
}
@Composable
override fun present(): PaymentConfirmationState {
val sessionId = matrixClient.sessionId
var senderAddress by remember { mutableStateOf("") }
var senderBalanceLovelace by remember { mutableStateOf<Lovelace?>(null) }
var estimatedFeeLovelace by remember { mutableStateOf<Lovelace?>(null) }
var isFeeLoading by remember { mutableStateOf(true) }
var feeError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
walletManager.getAddress(sessionId).onSuccess { address ->
senderAddress = address
}
val address = walletManager.getAddress(sessionId).getOrNull()
if (address != null) {
cardanoClient.getBalance(address).onSuccess { balance ->
senderBalanceLovelace = balance
}
}
cardanoClient.getProtocolParameters().onSuccess { params ->
val fee = params.minFeeA * ESTIMATED_TX_SIZE_BYTES + params.minFeeB
estimatedFeeLovelace = fee
isFeeLoading = false
}.onFailure {
estimatedFeeLovelace = 200_000L
feeError = "Could not estimate exact fee"
isFeeLoading = false
}
}
val totalLovelace = estimatedFeeLovelace?.let { amountLovelace + it }
val insufficientFunds = senderBalanceLovelace != null &&
totalLovelace != null &&
senderBalanceLovelace!! < totalLovelace
return PaymentConfirmationState(
recipientAddress = recipientAddress,
recipientAddressDisplay = PaymentConfirmationState.truncateAddress(recipientAddress),
amountLovelace = amountLovelace,
amountAda = PaymentConfirmationState.formatAda(amountLovelace),
estimatedFeeLovelace = estimatedFeeLovelace,
estimatedFeeAda = estimatedFeeLovelace?.let { PaymentConfirmationState.formatAda(it) },
totalLovelace = totalLovelace,
totalAda = totalLovelace?.let { PaymentConfirmationState.formatAda(it) },
senderAddress = senderAddress,
senderBalanceLovelace = senderBalanceLovelace,
insufficientFunds = insufficientFunds,
isTestnet = CardanoNetworkConfig.NETWORK == CardanoNetwork.TESTNET,
isFeeLoading = isFeeLoading,
feeError = feeError,
eventSink = {},
)
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import io.element.android.features.wallet.impl.slash.Lovelace
/**
* State for the payment confirmation screen.
*/
data class PaymentConfirmationState(
val recipientAddress: String,
val recipientAddressDisplay: String,
val amountLovelace: Lovelace,
val amountAda: String,
val estimatedFeeLovelace: Lovelace?,
val estimatedFeeAda: String?,
val totalLovelace: Lovelace?,
val totalAda: String?,
val senderAddress: String,
val senderBalanceLovelace: Lovelace?,
val insufficientFunds: Boolean,
val isTestnet: Boolean,
val isFeeLoading: Boolean,
val feeError: String?,
val eventSink: (PaymentFlowEvents) -> Unit,
) {
companion object {
fun truncateAddress(address: String): String {
if (address.length <= 20) return address
return "${address.take(8)}...${address.takeLast(6)}"
}
fun formatAda(lovelace: Lovelace): String {
val ada = lovelace / 1_000_000.0
return String.format("%.6f", ada).trimEnd('0').trimEnd('.')
}
}
}

View file

@ -0,0 +1,178 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import android.view.WindowManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
/**
* Payment confirmation screen.
*
* FLAG_SECURE is applied to prevent screenshots of transaction details.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentConfirmationView(
state: PaymentConfirmationState,
onConfirm: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
// FLAG_SECURE to prevent screenshots of payment details
val view = LocalView.current
DisposableEffect(Unit) {
val window = (view.context as? android.app.Activity)?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
}
Scaffold(
modifier = modifier.fillMaxSize().systemBarsPadding().imePadding(),
topBar = {
TopAppBar(
title = { Text("Confirm Payment") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (state.isTestnet) { TestnetWarningCard() }
Spacer(modifier = Modifier.height(8.dp))
AmountCard(amountAda = state.amountAda)
TransactionDetailsCard(state)
if (state.insufficientFunds) {
InsufficientFundsCard(balanceLovelace = state.senderBalanceLovelace, requiredLovelace = state.totalLovelace)
}
Spacer(modifier = Modifier.weight(1f))
Button(
text = "Send",
onClick = { state.eventSink(PaymentFlowEvents.ConfirmPayment); onConfirm() },
enabled = !state.isFeeLoading && !state.insufficientFunds,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
leadingIcon = { Icon(Icons.Default.Send, contentDescription = null) },
)
}
}
}
@Composable
private fun TestnetWarningCard(modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("⚠️", style = MaterialTheme.typography.titleMedium)
Text("Testnet transaction — no real ADA", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onTertiaryContainer)
}
}
}
@Composable
private fun AmountCard(amountAda: String, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
Column(modifier = Modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Amount", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f))
Text("$amountAda ADA", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer)
}
}
}
@Composable
private fun TransactionDetailsCard(state: PaymentConfirmationState, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
DetailRow(label = "To", value = state.recipientAddressDisplay)
HorizontalDivider()
DetailRow(label = "Network fee", value = if (state.isFeeLoading) null else state.estimatedFeeAda?.let { "~$it ADA" } ?: "Unknown", isLoading = state.isFeeLoading)
state.feeError?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) }
HorizontalDivider()
DetailRow(label = "Total", value = state.totalAda?.let { "$it ADA" } ?: "", isBold = true)
}
}
}
@Composable
private fun DetailRow(label: String, value: String?, isBold: Boolean = false, isLoading: Boolean = false, modifier: Modifier = Modifier) {
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(label, style = if (isBold) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (isLoading) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
else Text(value ?: "", style = if (isBold) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium, fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun InsufficientFundsCard(balanceLovelace: Long?, requiredLovelace: Long?, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Insufficient funds", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onErrorContainer)
val balanceAda = balanceLovelace?.let { PaymentConfirmationState.formatAda(it) } ?: "?"
val requiredAda = requiredLovelace?.let { PaymentConfirmationState.formatAda(it) } ?: "?"
Text("You have $balanceAda ADA but need $requiredAda ADA (including fee)", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f))
}
}
}
@PreviewsDayNight
@Composable
internal fun PaymentConfirmationViewPreview(@PreviewParameter(PaymentConfirmationStateProvider::class) state: PaymentConfirmationState) {
ElementPreview { PaymentConfirmationView(state = state, onConfirm = {}, onBack = {}) }
}
internal class PaymentConfirmationStateProvider : PreviewParameterProvider<PaymentConfirmationState> {
override val values = sequenceOf(
PaymentConfirmationState(
recipientAddress = "addr_test1qp2fg770ddmqxxduasjsas8rgimrhknmqjn43mj74g7ta2tjt0n5nh4t5xqf6lp5mwfpksj9csjg9s4kgfhvwj7m7dcq9qf7zj",
recipientAddressDisplay = "addr_tes...q9qf7zj", amountLovelace = 10_000_000L, amountAda = "10",
estimatedFeeLovelace = 180_000L, estimatedFeeAda = "0.18", totalLovelace = 10_180_000L, totalAda = "10.18",
senderAddress = "addr_test1q...", senderBalanceLovelace = 100_000_000L, insufficientFunds = false,
isTestnet = true, isFeeLoading = false, feeError = null, eventSink = {},
),
)
}

View file

@ -0,0 +1,177 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.impl.cardano.CardanoNetwork
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
import io.element.android.features.wallet.impl.slash.Lovelace
import io.element.android.features.wallet.impl.slash.ParsedPayCommand
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import java.math.BigDecimal
/**
* Presenter for the payment entry screen.
*/
class PaymentEntryPresenter @AssistedInject constructor(
@Assisted private val roomId: RoomId,
@Assisted private val parsedCommand: ParsedPayCommand?,
private val matrixClient: MatrixClient,
private val walletManager: CardanoWalletManager,
private val cardanoClient: CardanoClient,
) : Presenter<PaymentEntryState> {
@AssistedFactory
interface Factory {
fun create(roomId: RoomId, parsedCommand: ParsedPayCommand?): PaymentEntryPresenter
}
companion object {
private const val LOVELACE_PER_ADA = 1_000_000L
private const val MIN_AMOUNT_LOVELACE = 1_000_000L
private const val MAX_ADA_SUPPLY = 45_000_000_000L
private val CARDANO_ADDRESS_REGEX = "^addr(_test)?1[a-zA-Z0-9]+$".toRegex()
private val MATRIX_USER_REGEX = "^@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+$".toRegex()
}
@Composable
override fun present(): PaymentEntryState {
val (prefillAmount, prefillRecipient) = remember(parsedCommand) {
extractPrefills(parsedCommand)
}
var amountInput by remember { mutableStateOf(prefillAmount?.let { formatLovelaceInput(it) } ?: "") }
var recipientInput by remember { mutableStateOf(prefillRecipient ?: "") }
var senderAddress by remember { mutableStateOf<String?>(null) }
var senderBalanceLovelace by remember { mutableStateOf<Lovelace?>(null) }
var recipientResolutionState by remember { mutableStateOf<RecipientResolutionState>(RecipientResolutionState.NotNeeded) }
LaunchedEffect(Unit) {
val sessionId = matrixClient.sessionId
walletManager.initialize(sessionId)
senderAddress = walletManager.getAddress(sessionId).getOrNull()
senderAddress?.let { address ->
cardanoClient.getBalance(address).onSuccess { balance ->
senderBalanceLovelace = balance
}
}
}
val parsedAmountLovelace = parseAmountInput(amountInput)
val amountError = validateAmount(parsedAmountLovelace, amountInput)
val isCardanoAddress = CARDANO_ADDRESS_REGEX.matches(recipientInput)
val isMatrixUser = MATRIX_USER_REGEX.matches(recipientInput)
val recipientError = validateRecipient(recipientInput, isCardanoAddress, isMatrixUser)
LaunchedEffect(recipientInput, isMatrixUser, isCardanoAddress) {
recipientResolutionState = when {
recipientInput.isBlank() -> RecipientResolutionState.NotNeeded
isCardanoAddress -> RecipientResolutionState.NotNeeded
isMatrixUser -> RecipientResolutionState.NeedsManualEntry(
matrixUserId = recipientInput,
displayName = null
)
else -> RecipientResolutionState.NotNeeded
}
}
val isValidRecipient = isCardanoAddress
val canContinue = parsedAmountLovelace != null &&
parsedAmountLovelace >= MIN_AMOUNT_LOVELACE &&
amountError == null &&
isValidRecipient &&
recipientError == null
fun handleEvent(event: PaymentFlowEvents) {
when (event) {
is PaymentFlowEvents.AmountChanged -> amountInput = event.amount
is PaymentFlowEvents.RecipientChanged -> recipientInput = event.recipient
else -> Unit
}
}
val senderBalanceAda = senderBalanceLovelace?.let { balance ->
String.format("%.6f", balance / 1_000_000.0).trimEnd('0').trimEnd('.')
}
return PaymentEntryState(
amountInput = amountInput,
recipientInput = recipientInput,
prefillAmount = prefillAmount,
prefillRecipient = prefillRecipient,
parsedAmountLovelace = parsedAmountLovelace,
isValidRecipient = isValidRecipient,
recipientResolutionState = recipientResolutionState,
senderAddress = senderAddress,
senderBalanceAda = senderBalanceAda,
isTestnet = CardanoNetworkConfig.NETWORK == CardanoNetwork.TESTNET,
amountError = amountError,
recipientError = recipientError,
canContinue = canContinue,
eventSink = ::handleEvent,
)
}
private fun extractPrefills(command: ParsedPayCommand?): Pair<Lovelace?, String?> {
return when (command) {
is ParsedPayCommand.WithAddressRecipient -> command.amount to command.address
is ParsedPayCommand.WithMatrixRecipient -> command.amount to command.matrixUserId.value
is ParsedPayCommand.AmountOnly -> command.amount to null
else -> null to null
}
}
private fun formatLovelaceInput(lovelace: Lovelace): String {
val ada = lovelace / 1_000_000.0
return String.format("%.6f", ada).trimEnd('0').trimEnd('.')
}
private fun parseAmountInput(input: String): Lovelace? {
if (input.isBlank()) return null
return try {
val decimal = BigDecimal(input.trim())
if (decimal <= BigDecimal.ZERO) return null
val lovelace = decimal.multiply(BigDecimal(LOVELACE_PER_ADA))
lovelace.toLong()
} catch (e: Exception) {
null
}
}
private fun validateAmount(lovelace: Lovelace?, input: String): String? {
if (input.isBlank()) return null
if (lovelace == null) return "Invalid amount"
if (lovelace < MIN_AMOUNT_LOVELACE) return "Minimum amount is 1 ADA"
if (lovelace > MAX_ADA_SUPPLY * LOVELACE_PER_ADA) return "Amount too large"
return null
}
private fun validateRecipient(input: String, isCardanoAddress: Boolean, isMatrixUser: Boolean): String? {
if (input.isBlank()) return null
if (!isCardanoAddress && !isMatrixUser) {
return "Enter a Cardano address (addr1...) or Matrix user (@user:server)"
}
if (isCardanoAddress && input.length < 50) {
return "Address too short"
}
return null
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import io.element.android.features.wallet.impl.slash.Lovelace
/**
* State for the payment entry screen.
*/
data class PaymentEntryState(
val amountInput: String,
val recipientInput: String,
val prefillAmount: Lovelace?,
val prefillRecipient: String?,
val parsedAmountLovelace: Lovelace?,
val isValidRecipient: Boolean,
val recipientResolutionState: RecipientResolutionState,
val senderAddress: String?,
val senderBalanceAda: String?,
val isTestnet: Boolean,
val amountError: String?,
val recipientError: String?,
val canContinue: Boolean,
val eventSink: (PaymentFlowEvents) -> Unit,
) {
val parsedAmountAda: String?
get() = parsedAmountLovelace?.let { lovelace ->
val ada = lovelace / 1_000_000.0
String.format("%.6f", ada).trimEnd('0').trimEnd('.')
}
}
/**
* State of resolving a Matrix user ID to a Cardano address.
*/
sealed interface RecipientResolutionState {
data object NotNeeded : RecipientResolutionState
data class NeedsManualEntry(val matrixUserId: String, val displayName: String?) : RecipientResolutionState
data class Resolved(val address: String) : RecipientResolutionState
data class Error(val message: String) : RecipientResolutionState
}

View file

@ -0,0 +1,201 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentEntryView(
state: PaymentEntryState,
onContinue: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding(),
topBar = {
TopAppBar(
title = { Text("Send Payment") },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(Icons.Default.Close, contentDescription = "Cancel")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (state.isTestnet) {
TestnetWarningCard()
}
state.senderBalanceAda?.let { balance ->
BalanceInfoCard(balanceAda = balance)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = state.amountInput,
onValueChange = { state.eventSink(PaymentFlowEvents.AmountChanged(it)) },
label = { Text("Amount (ADA)") },
placeholder = { Text("0.00") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
isError = state.amountError != null,
supportingText = state.amountError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.recipientInput,
onValueChange = { state.eventSink(PaymentFlowEvents.RecipientChanged(it)) },
label = { Text("Recipient") },
placeholder = { Text("addr1... or @user:server") },
isError = state.recipientError != null,
supportingText = state.recipientError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
when (val resolution = state.recipientResolutionState) {
is RecipientResolutionState.NeedsManualEntry -> {
MatrixUserNeedsAddressCard(
matrixUserId = resolution.matrixUserId,
displayName = resolution.displayName,
)
}
is RecipientResolutionState.Error -> {
Text(resolution.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
else -> Unit
}
Spacer(modifier = Modifier.weight(1f))
Button(
text = "Continue",
onClick = {
state.eventSink(PaymentFlowEvents.Continue)
onContinue()
},
enabled = state.canContinue,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
}
}
}
@Composable
private fun TestnetWarningCard(modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("⚠️", style = MaterialTheme.typography.titleMedium)
Text("Testnet transaction — no real ADA", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onTertiaryContainer)
}
}
}
@Composable
private fun BalanceInfoCard(balanceAda: String, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Available balance", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("$balanceAda ADA", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun MatrixUserNeedsAddressCard(matrixUserId: String, displayName: String?, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
val name = displayName ?: matrixUserId.substringBefore(":").removePrefix("@")
Text("$name hasn't linked a wallet yet", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer)
Text("Enter their Cardano address manually above", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f))
}
}
}
@PreviewsDayNight
@Composable
internal fun PaymentEntryViewPreview(@PreviewParameter(PaymentEntryStateProvider::class) state: PaymentEntryState) {
ElementPreview { PaymentEntryView(state = state, onContinue = {}, onCancel = {}) }
}
internal class PaymentEntryStateProvider : PreviewParameterProvider<PaymentEntryState> {
override val values = sequenceOf(
PaymentEntryState(
amountInput = "", recipientInput = "", prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = null, isValidRecipient = false, recipientResolutionState = RecipientResolutionState.NotNeeded,
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, canContinue = false, eventSink = {},
),
)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
/**
* Events for the payment flow state machine.
*/
sealed interface PaymentFlowEvents {
// Entry screen events
data class AmountChanged(val amount: String) : PaymentFlowEvents
data class RecipientChanged(val recipient: String) : PaymentFlowEvents
data object Continue : PaymentFlowEvents
data object Cancel : PaymentFlowEvents
// Confirmation screen events
data object ConfirmPayment : PaymentFlowEvents
data object GoBack : PaymentFlowEvents
// Authentication events
data class AuthenticationResult(val success: Boolean, val errorMessage: String? = null) : PaymentFlowEvents
// Progress screen events
data object Done : PaymentFlowEvents
data object RetryPayment : PaymentFlowEvents
data object ViewOnExplorer : PaymentFlowEvents
}

View file

@ -0,0 +1,151 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.api.PaymentRequest
import io.element.android.features.wallet.api.PaymentStatusPoller
import io.element.android.features.wallet.api.TransactionBuilder
import io.element.android.features.wallet.api.TxStatus
import io.element.android.features.wallet.impl.cardano.CardanoNetwork
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
import io.element.android.features.wallet.impl.slash.Lovelace
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import timber.log.Timber
/**
* Presenter for the payment progress screen.
*/
class PaymentProgressPresenter @AssistedInject constructor(
@Assisted private val recipientAddress: String,
@Assisted private val amountLovelace: Lovelace,
private val matrixClient: MatrixClient,
private val walletManager: CardanoWalletManager,
private val transactionBuilder: TransactionBuilder,
private val cardanoClient: CardanoClient,
private val paymentStatusPoller: PaymentStatusPoller,
) : Presenter<PaymentProgressState> {
@AssistedFactory
interface Factory {
fun create(recipientAddress: String, amountLovelace: Lovelace): PaymentProgressPresenter
}
companion object {
private const val TAG = "PaymentProgressPresenter"
private const val TIMEOUT_THRESHOLD_MS = 10 * 60 * 1000L
}
@Composable
override fun present(): PaymentProgressState {
val sessionId = matrixClient.sessionId
var txHash by remember { mutableStateOf<String?>(null) }
var txStatus by remember { mutableStateOf(TxStatus.PENDING) }
var submissionState by remember { mutableStateOf<SubmissionState>(SubmissionState.Submitting) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var submissionStartTime by remember { mutableStateOf(0L) }
LaunchedEffect(Unit) {
submissionStartTime = System.currentTimeMillis()
submissionState = SubmissionState.Submitting
val senderAddress = walletManager.getAddress(sessionId).getOrNull()
if (senderAddress == null) {
submissionState = SubmissionState.Failed("Could not get wallet address")
errorMessage = "Failed to load wallet address"
return@LaunchedEffect
}
val request = PaymentRequest(
fromAddress = senderAddress,
toAddress = recipientAddress,
amountLovelace = amountLovelace,
sessionId = sessionId,
)
Timber.tag(TAG).d("Building and signing transaction...")
transactionBuilder.buildAndSign(request).onSuccess { signedTx ->
Timber.tag(TAG).d("Transaction built successfully, hash: ${signedTx.txHash}")
txHash = signedTx.txHash
cardanoClient.submitTx(signedTx.txCbor).onSuccess { submittedHash ->
Timber.tag(TAG).i("Transaction submitted: $submittedHash")
submissionState = SubmissionState.Pending
}.onFailure { error ->
Timber.tag(TAG).e(error, "Failed to submit transaction")
submissionState = SubmissionState.Failed("Failed to submit transaction")
errorMessage = error.message ?: "Transaction submission failed"
}
}.onFailure { error ->
Timber.tag(TAG).e(error, "Failed to build transaction")
submissionState = SubmissionState.Failed("Failed to build transaction")
errorMessage = error.message ?: "Transaction build failed"
}
}
val currentTxHash = txHash
LaunchedEffect(currentTxHash) {
if (currentTxHash == null) return@LaunchedEffect
if (submissionState !is SubmissionState.Pending) return@LaunchedEffect
Timber.tag(TAG).d("Starting to poll for confirmation: $currentTxHash")
paymentStatusPoller.pollUntilConfirmed(currentTxHash).collect { status ->
txStatus = status
when (status) {
TxStatus.CONFIRMED -> {
Timber.tag(TAG).i("Transaction confirmed: $currentTxHash")
submissionState = SubmissionState.Confirmed
}
TxStatus.FAILED -> {
Timber.tag(TAG).w("Transaction failed: $currentTxHash")
submissionState = SubmissionState.Failed("Transaction failed on chain")
errorMessage = "Transaction was rejected by the network"
}
TxStatus.PENDING -> {
val elapsed = System.currentTimeMillis() - submissionStartTime
if (elapsed > TIMEOUT_THRESHOLD_MS) {
Timber.tag(TAG).w("Transaction taking too long: $currentTxHash")
submissionState = SubmissionState.TakingTooLong
}
}
}
}
}
val explorerUrl = txHash?.let {
"${CardanoNetworkConfig.EXPLORER_BASE_URL}/transaction/$it"
}
return PaymentProgressState(
txHash = txHash,
txHashDisplay = txHash?.let { PaymentProgressState.truncateTxHash(it) },
explorerUrl = explorerUrl,
amountLovelace = amountLovelace,
amountAda = PaymentConfirmationState.formatAda(amountLovelace),
recipientAddress = recipientAddress,
txStatus = txStatus,
submissionState = submissionState,
errorMessage = errorMessage,
isTestnet = CardanoNetworkConfig.NETWORK == CardanoNetwork.TESTNET,
eventSink = {},
)
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.payment
import io.element.android.features.wallet.api.TxStatus
import io.element.android.features.wallet.impl.slash.Lovelace
/**
* State for the payment progress screen.
*/
data class PaymentProgressState(
val txHash: String?,
val txHashDisplay: String?,
val explorerUrl: String?,
val amountLovelace: Lovelace,
val amountAda: String,
val recipientAddress: String,
val txStatus: TxStatus,
val submissionState: SubmissionState,
val errorMessage: String?,
val isTestnet: Boolean,
val eventSink: (PaymentFlowEvents) -> Unit,
) {
companion object {
fun truncateTxHash(txHash: String): String {
if (txHash.length <= 20) return txHash
return "${txHash.take(8)}...${txHash.takeLast(6)}"
}
}
}
/**
* State of the transaction submission and confirmation process.
*/
sealed interface SubmissionState {
data object Submitting : SubmissionState
data object Pending : SubmissionState
data object Confirmed : SubmissionState
data class Failed(val reason: String) : SubmissionState
data object TakingTooLong : SubmissionState
}

View file

@ -0,0 +1,123 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.timeline
import com.google.common.truth.Truth.assertThat
import io.element.android.features.wallet.api.PaymentCardStatus
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()
}
@Test
fun `isPaymentEvent returns false for regular message`() {
val body = "Hello, this is a regular message"
assertThat(factory.isPaymentEvent(body)).isFalse()
}
@Test
fun `isPaymentEvent returns false for empty string`() {
assertThat(factory.isPaymentEvent("")).isFalse()
}
@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"""
val result = factory.createFromBody(body, isSentByMe = true)
assertThat(result).isNotNull()
assertThat(result!!.amountLovelace).isEqualTo(10_000_000)
assertThat(result.toAddress).isEqualTo("addr_test1abc")
assertThat(result.fromAddress).isEqualTo("addr_test1xyz")
assertThat(result.txHash).isEqualTo("hash123")
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"}"""
val result = factory.createFromBody(body, 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"}"""
val result = factory.createFromBody(body, isSentByMe = true)
assertThat(result).isNotNull()
assertThat(result!!.status).isEqualTo(PaymentCardStatus.FAILED)
assertThat(result.txHash).isNull()
}
@Test
fun `createFromBody returns null for malformed JSON`() {
val body = "[cardano-payment:v1]{not valid json}\n💰 Sent 10 ADA"
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)
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()
}
@Test
fun `createFromRaw returns null for invalid JSON`() {
val json = "not valid json"
val result = factory.createFromRaw(json, isSentByMe = true)
assertThat(result).isNull()
}
@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()
}
@Test
fun `isPaymentStatusUpdate returns false for regular message`() {
assertThat(factory.isPaymentStatusUpdate("Hello")).isFalse()
}
}

View file

@ -0,0 +1,132 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
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.api.timeline.TimelineItemPaymentContent
import org.junit.Test
class TimelineItemPaymentContentTest {
@Test
fun `amountAda formats whole number correctly`() {
val content = createContent(amountLovelace = 10_000_000)
assertThat(content.amountAda).isEqualTo("10 ADA")
}
@Test
fun `amountAda formats decimal correctly`() {
val content = createContent(amountLovelace = 5_500_000)
assertThat(content.amountAda).isEqualTo("5.5 ADA")
}
@Test
fun `amountAda formats small amounts correctly`() {
val content = createContent(amountLovelace = 1_000)
assertThat(content.amountAda).isEqualTo("0.001 ADA")
}
@Test
fun `amountAda formats zero correctly`() {
val content = createContent(amountLovelace = 0)
assertThat(content.amountAda).isEqualTo("0 ADA")
}
@Test
fun `isTestnet returns true for testnet`() {
val content = createContent(network = "testnet")
assertThat(content.isTestnet).isTrue()
}
@Test
fun `isTestnet returns true for preprod`() {
val content = createContent(network = "preprod")
assertThat(content.isTestnet).isTrue()
}
@Test
fun `isTestnet returns true for preview`() {
val content = createContent(network = "preview")
assertThat(content.isTestnet).isTrue()
}
@Test
fun `isTestnet returns false for mainnet`() {
val content = createContent(network = "mainnet")
assertThat(content.isTestnet).isFalse()
}
@Test
fun `truncatedTxHash returns null when txHash is null`() {
val content = createContent(txHash = null)
assertThat(content.truncatedTxHash).isNull()
}
@Test
fun `truncatedTxHash truncates long hash`() {
val content = createContent(txHash = "abc123def456789012345678901234567890xyz")
assertThat(content.truncatedTxHash).isEqualTo("abc123de...01234xyz")
}
@Test
fun `truncatedTxHash keeps short hash intact`() {
val content = createContent(txHash = "shorthash")
assertThat(content.truncatedTxHash).isEqualTo("shorthash")
}
@Test
fun `explorerUrl returns testnet URL for testnet`() {
val content = createContent(txHash = "abc123", network = "testnet")
assertThat(content.explorerUrl).isEqualTo("https://preprod.cardanoscan.io/transaction/abc123")
}
@Test
fun `explorerUrl returns mainnet URL for mainnet`() {
val content = createContent(txHash = "abc123", network = "mainnet")
assertThat(content.explorerUrl).isEqualTo("https://cardanoscan.io/transaction/abc123")
}
@Test
fun `explorerUrl returns null when txHash is null`() {
val content = createContent(txHash = null)
assertThat(content.explorerUrl).isNull()
}
@Test
fun `type returns m_payment_cardano`() {
val content = createContent()
assertThat(content.type).isEqualTo("m.payment.cardano")
}
@Test
fun `formatAda companion function works correctly`() {
assertThat(TimelineItemPaymentContent.formatAda(1_000_000)).isEqualTo("1 ADA")
assertThat(TimelineItemPaymentContent.formatAda(1_500_000)).isEqualTo("1.5 ADA")
assertThat(TimelineItemPaymentContent.formatAda(100_000_000)).isEqualTo("100 ADA")
}
private fun createContent(
amountLovelace: Long = 10_000_000,
toAddress: String = "addr_test1abc",
fromAddress: String = "addr_test1xyz",
txHash: String? = "hash123",
status: PaymentCardStatus = PaymentCardStatus.PENDING,
network: String = "testnet",
isSentByMe: Boolean = true,
fallbackText: String = "💰 Sent 10 ADA",
) = TimelineItemPaymentContent(
amountLovelace = amountLovelace,
toAddress = toAddress,
fromAddress = fromAddress,
txHash = txHash,
status = status,
network = network,
isSentByMe = isSentByMe,
fallbackText = fallbackText,
)
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.test
import io.element.android.features.wallet.api.PaymentEventSender
import io.element.android.features.wallet.api.PaymentRequest
import io.element.android.features.wallet.api.SignedTransaction
import io.element.android.libraries.matrix.api.timeline.Timeline
/**
* Fake implementation of [PaymentEventSender] for testing.
*/
class FakePaymentEventSender : PaymentEventSender {
var sentPayments = mutableListOf<SentPayment>()
var sentStatusUpdates = mutableListOf<SentStatusUpdate>()
var sendPaymentResult: Result<Unit> = Result.success(Unit)
var sendStatusUpdateResult: Result<Unit> = Result.success(Unit)
override suspend fun sendPaymentEvent(
timeline: Timeline,
request: PaymentRequest,
signedTx: SignedTransaction,
network: String,
): Result<Unit> {
sentPayments.add(
SentPayment(
request = request,
signedTx = signedTx,
network = network,
)
)
return sendPaymentResult
}
override suspend fun sendStatusUpdate(
timeline: Timeline,
txHash: String,
newStatus: String,
network: String,
): Result<Unit> {
sentStatusUpdates.add(
SentStatusUpdate(
txHash = txHash,
newStatus = newStatus,
network = network,
)
)
return sendStatusUpdateResult
}
fun reset() {
sentPayments.clear()
sentStatusUpdates.clear()
sendPaymentResult = Result.success(Unit)
sendStatusUpdateResult = Result.success(Unit)
}
data class SentPayment(
val request: PaymentRequest,
val signedTx: SignedTransaction,
val network: String,
)
data class SentStatusUpdate(
val txHash: String,
val newStatus: String,
val network: String,
)
}