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:
parent
39561e1aeb
commit
adee67cf0d
16 changed files with 1410 additions and 12 deletions
80
BLOCKERS.md
80
BLOCKERS.md
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.TimelineItemVideoContent
|
||||
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.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
|
@ -134,6 +136,10 @@ fun TimelineItemEventContentView(
|
|||
modifier = modifier
|
||||
)
|
||||
}
|
||||
is TimelineItemPaymentContent -> TimelineItemPaymentView(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ class TimelineItemContentFactory(
|
|||
senderProfile = senderProfile,
|
||||
content = itemContent,
|
||||
eventId = eventId,
|
||||
isSentByMe = isOutgoing,
|
||||
)
|
||||
}
|
||||
is ProfileChangeContent -> {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class TimelineItemContentMessageFactory(
|
|||
senderId: UserId,
|
||||
senderProfile: ProfileDetails,
|
||||
eventId: EventId?,
|
||||
isSentByMe: Boolean = false,
|
||||
): TimelineItemEventContent {
|
||||
return when (val messageType = content.type) {
|
||||
is EmoteMessageType -> {
|
||||
|
|
@ -256,16 +257,13 @@ class TimelineItemContentMessageFactory(
|
|||
}
|
||||
is TextMessageType -> {
|
||||
val body = messageType.body.trimEnd()
|
||||
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
val formattedBody = dom?.let(::parseHtml)
|
||||
?: textPillificationHelper.pillify(body).safeLinkify()
|
||||
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
TimelineItemTextContent(
|
||||
body = body,
|
||||
htmlDocument = htmlDocument,
|
||||
formattedBody = formattedBody,
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
// Check for Cardano payment events embedded in text messages
|
||||
if (paymentFactory.isPaymentEvent(body)) {
|
||||
paymentFactory.createFromBody(body, isSentByMe)
|
||||
?: createTextContent(body, messageType, content.isEdited)
|
||||
} else {
|
||||
createTextContent(body, messageType, content.isEdited)
|
||||
}
|
||||
}
|
||||
is OtherMessageType -> {
|
||||
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? {
|
||||
val result = if (height != null && width != null) {
|
||||
width.toFloat() / height.toFloat()
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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('.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue