From 677961bd73a87e9ceb17445f49dcf31816e34081 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 16:05:27 -0500 Subject: [PATCH 1/6] fix: preserve payment guards and usdt feedback --- .../wallets/send/SendConfirmScreenTest.kt | 36 ++++++++++ .../wallets/usdt/UsdtBridgeDetailsTest.kt | 4 +- .../repositories/PaykitPaymentRequestRepo.kt | 1 - .../java/to/bitkit/repositories/UsdtRepo.kt | 21 ++++-- .../screens/wallets/send/HwSendSignScreen.kt | 31 ++++----- .../screens/wallets/send/HwSendViewModel.kt | 20 +++--- .../screens/wallets/send/SendConfirmScreen.kt | 9 ++- .../wallets/usdt/UsdtActivityDetail.kt | 2 +- .../wallets/usdt/UsdtDepositsScreen.kt | 9 ++- .../wallets/usdt/UsdtSupportActions.kt | 50 +++++++------- .../ui/screens/wallets/usdt/UsdtViewModel.kt | 2 +- .../screens/wallets/usdt/UsdtWalletScreen.kt | 44 +++++++----- .../java/to/bitkit/ui/sheets/SendSheet.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 32 ++++++--- .../PaykitPaymentRequestRepoTest.kt | 19 ++++- .../to/bitkit/repositories/UsdtRepoTest.kt | 58 +++++++--------- .../wallets/send/HwSendViewModelTest.kt | 16 ++++- .../wallets/usdt/UsdtSupportDetailsTest.kt | 18 +++-- .../viewmodels/AppViewModelSendFlowTest.kt | 69 +++++++++++++++++-- changelog.d/next/1506.fixed.md | 1 + journeys/README.md | 7 ++ journeys/paykit-quoted-usdt.xml | 2 +- journeys/usdt-bridge-details.xml | 2 +- journeys/usdt-bridge-support.xml | 6 +- journeys/usdt-payment-links-and-scans.xml | 4 +- 25 files changed, 313 insertions(+), 152 deletions(-) create mode 100644 changelog.d/next/1506.fixed.md diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt index a198312535..7c301dfdc9 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt @@ -12,11 +12,13 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -39,6 +41,7 @@ import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.models.PaykitRequestPricing import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.MethodId import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription @@ -51,6 +54,7 @@ import to.bitkit.ui.screens.subscriptions.SubscriptionFirstPaymentProgress import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.OnchainFeeUi import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState @@ -164,6 +168,38 @@ class SendConfirmScreenTest { } } + @Test + fun preparingUsdtRequestUsesUsdtAccentAndCannotPay() { + val request = PaykitPaymentRequest( + paymentRequestId = "preparing-usdt", + counterparty = "requester", + amount = PaykitAmount(PaykitAsset.USDT, 5_000_000u), + paymentReference = "test-reference", + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf(MethodId.UsdtArbitrum.rawValue), + ) + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + SendConfirmContent( + uiState = SendUiState(payMethod = SendMethod.LIGHTNING), + isNodeRunning = false, + isLoading = false, + showBiometrics = false, + preparingRequest = request, + modifier = Modifier.sheetHeight() + ) + } + } + } + val preparing = composeTestRule.onNodeWithTag("PaymentRequestPreparing") + preparing.assertIsDisplayed().assertIsNotEnabled() + val pixels = preparing.captureToImage().toPixelMap() + assertTrue((0 until pixels.width).any { x -> + (0 until pixels.height).any { y -> pixels[x, y] == Colors.Usdt } + }) + } + @Test fun initialOnchainSubscriptionKeepsReviewLayoutWhilePaying() { val startsAt = Clock.System.now() diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtBridgeDetailsTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtBridgeDetailsTest.kt index a6e2634938..5c1e984466 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtBridgeDetailsTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtBridgeDetailsTest.kt @@ -52,7 +52,7 @@ class UsdtBridgeDetailsTest { private val sourceTx = "0x" + "ab".repeat(32) private val deposit = UsdtDeposit( "xit_4cfc646b-3657-4144-a644-b7880f184686", "bsc", "USDT", 50_000_001u, - sourceTx, "held", null, null, + sourceTx, "held", "standing_route_unavailable", null, ) private val transfer = UsdtTransfer( id = "transfer", sender = address, txHash = sourceTx, userOperationHash = null, @@ -154,6 +154,8 @@ class UsdtBridgeDetailsTest { composeTestRule.onNodeWithTag("UsdtRefundAddress").assertDoesNotExist() composeTestRule.onNodeWithTag("UsdtDepositDetails").performScrollTo().performClick() composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("standing_route_unavailable").assertDoesNotExist() + composeTestRule.onNodeWithText("This deposit needs provider assistance.", substring = true).assertExists() composeTestRule.onNodeWithText("USDT address on BSC").performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithTag("UsdtDepositRefund").assertIsNotEnabled() assertHeight("UsdtRefundAddress", 56f) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 744f2df348..8817212489 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -1796,7 +1796,6 @@ class PaykitPaymentRequestRepo @Inject constructor( } private fun clearStateLocked() { - setPaymentSubmissionActive(false) completedRefreshGeneration = -1L receivedContacts = null expirationJob?.cancel() diff --git a/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt index 1889dc2ac7..9a9916cfff 100644 --- a/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt @@ -19,10 +19,9 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -58,8 +57,9 @@ class UsdtRepo @Inject constructor( private var rateLimitedUntil = 0L private val _state = MutableStateFlow(UsdtWalletState()) val state = _state.asStateFlow() - private val _receivedTxs = MutableSharedFlow(extraBufferCapacity = 8) - val receivedTxs = _receivedTxs.asSharedFlow() + fun consumeReceipt(id: String): Boolean = _state.getAndUpdate { + it.copy(pendingReceipts = it.pendingReceipts.filterNot { receipt -> receipt.id == id }.toImmutableList()) + }.pendingReceipts.any { it.id == id } suspend fun refresh(history: Boolean, force: Boolean = false) = withContext(ioDispatcher) { mutex.withLock { @@ -97,13 +97,18 @@ class UsdtRepo @Inject constructor( } } - private suspend fun announceNewReceipts(previous: List?, current: List) { + private fun announceNewReceipts(previous: List?, current: List) { if (previous == null) return val knownIds = previous.filter { it.isIncoming && it.status == UsdtTransferStatus.CONFIRMED } .map { it.id }.toSet() - current.filter { + val received = current.filter { it.isIncoming && it.status == UsdtTransferStatus.CONFIRMED && it.amount > 0u && it.id !in knownIds - }.forEach { _receivedTxs.emit(it) } + } + _state.update { + it.copy( + pendingReceipts = (it.pendingReceipts + received).distinctBy { receipt -> receipt.id }.toImmutableList() + ) + } } suspend fun waitForTransfer(id: String): Result = withContext(ioDispatcher) { @@ -154,6 +159,7 @@ class UsdtRepo @Inject constructor( address = wallet.receiveAddress(), transfers = wallet.history().toImmutableList(), historyComplete = false, + pendingReceipts = persistentListOf(), pendingDepositIds = persistentSetOf(), ) } @@ -260,6 +266,7 @@ data class UsdtWalletState( val balance: ULong? = null, val transfers: ImmutableList = persistentListOf(), val historyComplete: Boolean = false, + val pendingReceipts: ImmutableList = persistentListOf(), val pendingDepositIds: ImmutableSet = persistentSetOf(), ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt index 80dcb48b94..703136b785 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt @@ -53,7 +53,7 @@ fun HwSendSignScreen( prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean, onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit, - onPaymentSubmissionChange: (Boolean) -> Unit, + beginPaymentSubmission: () -> (() -> Unit), paymentDeadlineAt: Instant?, onBack: () -> Unit, ) { @@ -78,18 +78,24 @@ fun HwSendSignScreen( LaunchedEffect(walletId) { viewModel.warmUp(walletId) } - LaunchedEffect(uiState.isSigning) { - onPaymentSubmissionChange(uiState.isSigning) - } DisposableEffect(viewModel) { onDispose { viewModel.cancel() - onPaymentSubmissionChange(false) } } // Without this the sheet's NavHost pops the route itself, ignoring the guard and skipping onBack BackHandler(onBack = onBackRequest) + val signAndBroadcast = { + viewModel.signAndBroadcast( + request, + prepareContactPayment, + authorizeContactPayment, + onPaymentDeadlineExpired, + beginPaymentSubmission, + ) + } + HwSendSignContent( amountSats = sendUiState.amount, address = sendUiState.address, @@ -97,14 +103,7 @@ fun HwSendSignScreen( hasPendingBroadcast = uiState.hasPendingBroadcast, vendor = vendor, onBack = onBackRequest, - onOpenConnect = { - viewModel.signAndBroadcast( - request, - prepareContactPayment, - authorizeContactPayment, - onPaymentDeadlineExpired - ) - }, + onOpenConnect = signAndBroadcast, ) if (uiState.isPassphraseRequired) { @@ -112,11 +111,9 @@ fun HwSendSignScreen( isVerifying = uiState.isVerifyingPassphrase, onSubmit = { passphrase -> viewModel.submitPassphrase( - request, + walletId, passphrase, - prepareContactPayment, - authorizeContactPayment, - onPaymentDeadlineExpired, + onReady = signAndBroadcast, ) }, onDismiss = viewModel::dismissPassphrase, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 427ad1a4e3..91babd67af 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -91,12 +91,14 @@ class HwSendViewModel @Inject constructor( prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { true }, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true }, onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {}, + beginPaymentSubmission: () -> (() -> Unit) = { {} }, ) { if (pendingResult.value != null || _uiState.value.isSigning || signingJob?.isActive == true) return signingWalletId = request.walletId _uiState.update { it.copy(isSigning = true) } val attempt = ++signingAttempt signingJob = viewModelScope.launch { + val finishSubmission = beginPaymentSubmission() try { runCatching { if (pendingBroadcast?.matches(request) == false) { @@ -179,6 +181,7 @@ class HwSendViewModel @Inject constructor( handleFailure(it, request.walletId) } } finally { + finishSubmission() // A cancelled job can outlive cancel() while a device call returns, and must not // reset the state of a signing attempt started after it. if (signingAttempt == attempt) { @@ -277,11 +280,9 @@ class HwSendViewModel @Inject constructor( } fun submitPassphrase( - request: HwSendRequest, + walletId: String, passphrase: String, - prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { true }, - authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true }, - onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {}, + onReady: () -> Unit, ) { if (passphrase.isEmpty()) return val state = _uiState.value @@ -292,16 +293,11 @@ class HwSendViewModel @Inject constructor( _uiState.update { it.copy(isVerifyingPassphrase = true) } passphraseJob = viewModelScope.launch { try { - hwWalletRepo.reconnectWithPassphrase(request.walletId, passphrase) + hwWalletRepo.reconnectWithPassphrase(walletId, passphrase) .onSuccess { if (!_uiState.value.isPassphraseRequired) return@onSuccess _uiState.update { it.copy(isPassphraseRequired = false) } - signAndBroadcast( - request, - prepareContactPayment, - authorizeContactPayment, - onPaymentDeadlineExpired, - ) + onReady() } .onFailure { error -> if (error is HwPassphraseMismatchError) { @@ -311,7 +307,7 @@ class HwSendViewModel @Inject constructor( description = context.getString(R.string.hardware__passphrase_mismatch), ) } else { - handleFailure(error, request.walletId) + handleFailure(error, walletId) } } } finally { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 525d3cda1d..9e397a416e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -62,6 +62,7 @@ import to.bitkit.models.FeeRate import to.bitkit.models.PaykitAsset import to.bitkit.models.PubkyProfile import to.bitkit.models.TransactionSpeed +import to.bitkit.repositories.MethodId import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.ui.components.AddTagButton import to.bitkit.ui.components.BalanceHeaderView @@ -358,7 +359,13 @@ private fun ContentRunning( val isHardwareFeeLoading = uiState.hardwareWalletId != null && uiState.onchainFeeUi.isLoading val accentColor = when { - isPreparing -> Colors.Brand + preparingRequest != null -> if (preparingRequest.acceptedPaymentEndpointIdentifiers.toSet() == + setOf(MethodId.UsdtArbitrum.rawValue) + ) { + Colors.Usdt + } else { + Colors.White64 + } uiState.payMethod == SendMethod.LIGHTNING -> Colors.Purple else -> Colors.Brand } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt index 43c00dca47..1aedaf3cc5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt @@ -172,7 +172,7 @@ internal fun UsdtActivityDetail( ) ) ) { - UsdtSupportActions(transfer.supportDetails) + UsdtSupportActions(transfer.supportDetails(stringResource(transfer.status.labelResource))) } } if (showDetails && (transfer.txHash != null || canOpenDeposits)) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt index 2b9fb9db1d..e74f0aba38 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt @@ -273,7 +273,10 @@ internal fun UsdtDepositsContent( BodyS(stringResource(R.string.usdt__deposit_tron_refund_note), color = Colors.White64) } DepositSettlement(detail, hideBalance) - UsdtSupportActions(detail.supportDetails, enabled = !state.busy) + UsdtSupportActions( + detail.supportDetails(stringResource(detail.statusResource())), + enabled = !state.busy, + ) } } state.error?.let { BodyS(stringResource(it), color = Colors.Brand) } @@ -477,7 +480,9 @@ private fun UsdtDepositTechnicalDetails( } else { UsdtDepositSummary(detail) } - detail.statusCode?.let { BodyS(it, color = Colors.White64) } + if (detail.statusResource() == R.string.usdt__deposit_needs_attention) { + BodyS(stringResource(R.string.usdt__deposit_attention), color = Colors.White64) + } } @Composable diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportActions.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportActions.kt index 63bbf2e28d..741e3cdc5e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportActions.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportActions.kt @@ -44,30 +44,28 @@ internal fun UsdtSupportActions(details: String, enabled: Boolean = true) { } } -internal val UsdtTransfer.supportDetails: String - get() = buildList { - add("Asset: USDT") - add("Bridge: ${if (orchestra == null) "USDT0" else "Orchestra"}") - add("Source network: Arbitrum One") - add("Recipient network: ${destination.label}") - add("Status: $status") - orchestra?.quoteId?.let { add("Bridge reference: $it") } - txHash?.let { add("Source transaction: $it") } - bridgeGuid?.let { add("LayerZero message: $it") } - orchestra?.destinationTx?.let { add("Destination transaction: $it") } - orchestra?.refundTx?.let { add("Refund transaction: $it") } - }.joinToString("\n") +internal fun UsdtTransfer.supportDetails(statusText: String): String = buildList { + add("Asset: USDT") + add("Bridge: ${if (orchestra == null) "USDT0" else "Orchestra"}") + add("Source network: Arbitrum One") + add("Recipient network: ${destination.label}") + add("Status: $statusText ($status)") + orchestra?.quoteId?.let { add("Bridge reference: $it") } + txHash?.let { add("Source transaction: $it") } + bridgeGuid?.let { add("LayerZero message: $it") } + orchestra?.destinationTx?.let { add("Destination transaction: $it") } + orchestra?.refundTx?.let { add("Refund transaction: $it") } +}.joinToString("\n") -internal val UsdtDepositDetail.supportDetails: String - get() = buildList { - add("Asset: ${deposit.asset}") - add("Bridge: Orchestra") - add("Source network: ${deposit.network}") - add("Recipient network: Arbitrum One") - add("Deposit reference: ${deposit.id}") - add("Status: ${order?.status ?: deposit.status}") - add("Source transaction: ${deposit.sourceTx}") - statusCode?.let { add("Status code: $it") } - order?.destinationTx?.let { add("Destination transaction: $it") } - (order?.refundTx ?: deposit.refundTx)?.let { add("Refund transaction: $it") } - }.joinToString("\n") +internal fun UsdtDepositDetail.supportDetails(statusText: String): String = buildList { + add("Asset: ${deposit.asset}") + add("Bridge: Orchestra") + add("Source network: ${deposit.network}") + add("Recipient network: Arbitrum One") + add("Deposit reference: ${deposit.id}") + add("Status: $statusText (${order?.status ?: deposit.status})") + add("Source transaction: ${deposit.sourceTx}") + statusCode?.let { add("Status code: $it") } + order?.destinationTx?.let { add("Destination transaction: $it") } + (order?.refundTx ?: deposit.refundTx)?.let { add("Refund transaction: $it") } +}.joinToString("\n") diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt index dc013998e8..e73a0076dc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt @@ -59,7 +59,7 @@ class UsdtViewModel @Inject constructor( ) val wallet = repo.state - val receivedTxs = repo.receivedTxs + fun consumeReceipt(id: String) = repo.consumeReceipt(id) val settings = settingsStore.data.catch { if (it is CancellationException) throw it settingsReadFailed(it) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt index 36c03b12be..75769b592c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt @@ -52,7 +52,6 @@ import com.synonym.bitkitcore.UsdtTransfer import com.synonym.bitkitcore.UsdtTransferStatus import com.synonym.bitkitcore.usdtFormatAmount import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection @@ -176,7 +175,7 @@ fun UsdtWalletScreen( viewModel, pending = wallet.transfers.any { it.status == UsdtTransferStatus.PENDING }, ) { - page == UsdtPage.WALLET + page == UsdtPage.WALLET && selectedTransferId == null } val selectedTransfer = wallet.transfers.firstOrNull { it.id == selectedTransferId } if (selectedTransfer != null) { @@ -422,25 +421,36 @@ internal fun UsdtRefreshEffect( ) { val lifecycle = LocalLifecycleOwner.current.lifecycle val app = appViewModel - val handleReceived by rememberUpdatedState(onReceived) val isPending by rememberUpdatedState(pending) val isReceiving by rememberUpdatedState(receiving) + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val receipt = wallet.pendingReceipts.firstOrNull() + val sheet = app?.currentSheet?.collectAsStateWithLifecycle()?.value + val transactionSheet = app?.transactionSheet?.collectAsStateWithLifecycle()?.value + val canReceive = onReceived() && (receiving || sheet == null) && + transactionSheet == NewTransactionSheetDetails.EMPTY + val canPresent by rememberUpdatedState(canReceive) + LaunchedEffect(receipt?.id, canReceive, lifecycle) { + if (receipt != null && canReceive) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + app.showTransactionSheet( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.RECEIVED, + usdtAmount = receipt.amount, + usdtTransferId = receipt.id, + ), + shouldShow = { + canPresent && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) && + app.transactionSheet.value == NewTransactionSheetDetails.EMPTY && + viewModel.consumeReceipt(receipt.id) + }, + ) + } + } + } LaunchedEffect(lifecycle, viewModel, app) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - viewModel.receivedTxs.collect { transfer -> - if ((isReceiving || app?.currentSheet?.value == null) && handleReceived()) { - app?.showTransactionSheet( - NewTransactionSheetDetails( - type = NewTransactionSheetType.ONCHAIN, - direction = NewTransactionSheetDirection.RECEIVED, - usdtAmount = transfer.amount, - usdtTransferId = transfer.id, - ) - ) - } - } - } while (true) { viewModel.refresh(history = true) delay(if (isReceiving || isPending) 10.seconds else 30.seconds) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index ab8b2607a4..d8456d15c7 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -483,7 +483,7 @@ fun SendSheet( walletId, ) }, - onPaymentSubmissionChange = appViewModel::onHardwarePaymentSubmissionChanged, + beginPaymentSubmission = appViewModel::beginHardwarePaymentSubmission, onPaymentDeadlineExpired = { attempted -> appViewModel.onHardwarePaymentDeadlineExpired(attempted, uiState.incomingPaymentRequestId, paymentIdentity, walletId) }, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 61bb8a4436..ceea920039 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -514,15 +514,8 @@ class AppViewModel @Inject constructor( private var paymentRequestIdentity: String? = null private var isPaymentRequestIdentityActivating = false private var isSubmittingPaymentRequest = false - set(value) { - field = value - paykitPaymentRequestRepo.setPaymentSubmissionActive(value || isSoftwarePaymentSubmissionActive) - } private var isSoftwarePaymentSubmissionActive = false - set(value) { - field = value - paykitPaymentRequestRepo.setPaymentSubmissionActive(value || isSubmittingPaymentRequest) - } + private val activePaymentSubmissions = mutableSetOf() private var paykitPaymentRequestPollingJob: Job? = null private var paykitSessionRestoreRetryJob: Job? = null private var paykitContactsRefreshJob: Job? = null @@ -3295,6 +3288,7 @@ class AppViewModel @Inject constructor( ?: throw PaykitPaymentRequestError.RequestUnavailable isSubmittingPaymentRequest = true isSoftwarePaymentSubmissionActive = true + val finishSubmission = beginPaymentSubmission() val reviewedAmount = paykitReviewedAmount val paymentTerms = paykitPaymentTerms try { @@ -3310,6 +3304,7 @@ class AppViewModel @Inject constructor( submissionDeadline = current.incomingPaymentRequest?.submissionDeadline(paymentTerms), ) { authorizeUsdtExecution(current, reviewedAmount, paymentTerms) }.getOrThrow() } finally { + finishSubmission() isSubmittingPaymentRequest = false isSoftwarePaymentSubmissionActive = false } @@ -6441,6 +6436,7 @@ class AppViewModel @Inject constructor( fun showTransactionSheet( details: NewTransactionSheetDetails, + shouldShow: () -> Boolean = { true }, ) = viewModelScope.launch { if (backupRepo.isRestoring.value) return@launch @@ -6454,7 +6450,7 @@ class AppViewModel @Inject constructor( return@launch } - _transactionSheet.update { details } + if (shouldShow()) _transactionSheet.update { details } } fun hideNewTransactionSheet() { @@ -6833,10 +6829,12 @@ class AppViewModel @Inject constructor( } isSoftwarePaymentSubmissionActive = true viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) { + val finishSubmission = beginPaymentSubmission() try { _sendUiState.update { it.copy(shouldConfirmPay = false) } proceedWithPayment(contactPaymentContext) } finally { + finishSubmission() isSubmittingPaymentRequest = false isSoftwarePaymentSubmissionActive = false } @@ -6848,8 +6846,20 @@ class AppViewModel @Inject constructor( super.onCleared() } - fun onHardwarePaymentSubmissionChanged(active: Boolean) { - paykitPaymentRequestRepo.setPaymentSubmissionActive(active || isSoftwarePaymentSubmissionActive) + fun beginHardwarePaymentSubmission(): () -> Unit = beginPaymentSubmission() + + private fun beginPaymentSubmission(): () -> Unit { + val submission = Any() + synchronized(activePaymentSubmissions) { + activePaymentSubmissions.add(submission) + paykitPaymentRequestRepo.setPaymentSubmissionActive(true) + } + return { + synchronized(activePaymentSubmissions) { + activePaymentSubmissions.remove(submission) + paykitPaymentRequestRepo.setPaymentSubmissionActive(activePaymentSubmissions.isNotEmpty()) + } + } } fun hardwarePaymentIdentity(): String? = pubkyRepo.publicKey.value?.let(PubkyPublicKeyFormat::normalized) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 4f4242d180..4282ae7c8d 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher @@ -1337,7 +1338,23 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { runCurrent() verify(paykitSdkService, never()).processOutboundPrivateMessages(any(), any(), anyOrNull()) - assertFalse(paymentSubmissionActive.value) + assertTrue(paymentSubmissionActive.value) + sut.setPaymentSubmissionActive(false) + runCurrent() + verify(paykitSdkService, never()).processOutboundPrivateMessages(any(), any(), anyOrNull()) + } + + @Test + fun `clearing requests keeps background work waiting until the payment ends`() = test { + sut.setPaymentSubmissionActive(true) + val backgroundWork = async { paymentSubmissionActive.first { !it } } + runCurrent() + sut.clear() + runCurrent() + assertTrue(paymentSubmissionActive.value) + assertFalse(backgroundWork.isCompleted) + sut.setPaymentSubmissionActive(false) + backgroundWork.await() } @Test diff --git a/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt index a8a1651821..ab13f442dd 100644 --- a/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt @@ -22,6 +22,7 @@ import org.mockito.kotlin.whenever import to.bitkit.services.UsdtService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds @@ -320,38 +321,31 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.history()).thenAnswer { history } whenever(wallet.refreshTransfers()).thenAnswer { history } val repo = UsdtRepo(service, testDispatcher) - val events = mutableListOf() - val collector = launch { repo.receivedTxs.collect { events.add(it) } } - runCurrent() - try { - repo.refresh(true, force = true).getOrThrow() - runCurrent() - assertTrue(events.isEmpty()) - val received = incoming("new") - val pending = incoming("pending").copy(status = UsdtTransferStatus.PENDING, txHash = null) - history = listOf( - old, received, pending, incoming("sent").copy(isIncoming = false), - incoming("failed").copy(status = UsdtTransferStatus.FAILED), incoming("zero").copy(amount = 0uL) - ) - repo.refresh(true, force = true).getOrThrow() - runCurrent() - assertEquals(listOf(received), events) - repo.refresh(true, force = true).getOrThrow() - runCurrent() - assertEquals(listOf(received), events) - val confirmed = pending.copy(status = UsdtTransferStatus.CONFIRMED, txHash = "pending") - history = history.map { if (it.id == confirmed.id) confirmed else it } - repo.refresh(true, force = true).getOrThrow() - runCurrent() - assertEquals(listOf(received, confirmed), events) - whenever(wallet.receiveAddress()).thenReturn("another wallet") - history = listOf(incoming("restored")) - repo.refresh(true, force = true).getOrThrow() - runCurrent() - assertEquals(listOf(received, confirmed), events) - } finally { - collector.cancel() - } + repo.refresh(true, force = true).getOrThrow() + assertTrue(repo.state.value.pendingReceipts.isEmpty()) + val received = incoming("new") + val pending = incoming("pending").copy(status = UsdtTransferStatus.PENDING, txHash = null) + history = listOf( + old, received, pending, incoming("sent").copy(isIncoming = false), + incoming("failed").copy(status = UsdtTransferStatus.FAILED), incoming("zero").copy(amount = 0uL) + ) + repo.refresh(true, force = true).getOrThrow() + assertEquals(listOf(received), repo.state.value.pendingReceipts) + repo.refresh(true, force = true).getOrThrow() + assertEquals(listOf(received), repo.state.value.pendingReceipts) + val confirmed = pending.copy(status = UsdtTransferStatus.CONFIRMED, txHash = "pending") + history = history.map { if (it.id == confirmed.id) confirmed else it } + repo.refresh(true, force = true).getOrThrow() + assertEquals(listOf(received, confirmed), repo.state.value.pendingReceipts) + assertTrue(repo.consumeReceipt(received.id)) + assertFalse(repo.consumeReceipt(received.id)) + repo.refresh(true, force = true).getOrThrow() + assertEquals(listOf(confirmed), repo.state.value.pendingReceipts) + whenever(wallet.receiveAddress()).thenReturn("another wallet") + history = listOf(incoming("restored")) + repo.refresh(true, force = true).getOrThrow() + assertTrue(repo.state.value.pendingReceipts.isEmpty()) + assertFalse(repo.consumeReceipt(confirmed.id)) } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index c71d629d9e..fe5bb12485 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -282,7 +282,9 @@ class HwSendViewModelTest : BaseUnitTest() { advanceUntilIdle() assertTrue(sut.uiState.value.isPassphraseRequired) - sut.submitPassphrase(request(), "hidden wallet", prepareContactPayment, authorizeContactPayment) + sut.submitPassphrase(WALLET_ID, "hidden wallet") { + sut.signAndBroadcast(request(), prepareContactPayment, authorizeContactPayment) + } advanceUntilIdle() assertEquals(1, preparationCalls) @@ -984,15 +986,25 @@ class HwSendViewModelTest : BaseUnitTest() { org.mockito.kotlin.anyOrNull() ) }.doSuspendableAnswer { resume.await() } - sut.signAndBroadcast(original) + var submissionActive = false + val finishSubmission = { submissionActive = false } + sut.signAndBroadcast( + original, + beginPaymentSubmission = { + submissionActive = true + finishSubmission + }, + ) advanceUntilIdle() assertTrue(sut.uiState.value.isBroadcastUnresolved) sut.cancel() + assertTrue(submissionActive) assertTrue(sut.uiState.value.hasPendingBroadcast) resume.complete(true) advanceUntilIdle() verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + assertFalse(submissionActive) } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportDetailsTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportDetailsTest.kt index 916b410113..95ba9a98b8 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportDetailsTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportDetailsTest.kt @@ -30,14 +30,17 @@ class UsdtSupportDetailsTest { status = UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION, timestamp = 1u, ) - assertTrue(transfer.supportDetails.contains("Bridge: USDT0")) - assertTrue(transfer.supportDetails.contains("LayerZero message: message-id")) - assertFalse(transfer.supportDetails.contains("Destination transaction:")) + assertTrue(transfer.supportDetails("Needs attention").contains("Bridge: USDT0")) + assertTrue( + transfer.supportDetails("Needs attention").contains("Status: Needs attention (BRIDGE_NEEDS_ATTENTION)") + ) + assertTrue(transfer.supportDetails("Needs attention").contains("LayerZero message: message-id")) + assertFalse(transfer.supportDetails("Needs attention").contains("Destination transaction:")) val details = transfer.copy( bridgeGuid = null, orchestra = UsdtOrchestraTransfer("order-id", "funding-address", "destination-tx", "refund-tx", 90u), - ).supportDetails + ).supportDetails("Needs attention") listOf("Orchestra", "Arbitrum One", "Polygon", "order-id", "source-tx", "destination-tx", "refund-tx").forEach { assertTrue(details.contains(it), it) } @@ -49,11 +52,12 @@ class UsdtSupportDetailsTest { fun `deposit support uses current order status and refund`() { val deposit = UsdtDeposit("deposit-id", "tron", "USDT", 100u, "source-tx", "held", "old-code", "deposit-refund") val detail = UsdtDepositDetail(deposit, null) - assertTrue(detail.supportDetails.contains("Status code: old-code")) - assertTrue(detail.supportDetails.contains("Refund transaction: deposit-refund")) + assertTrue(detail.supportDetails("Needs attention").contains("Status code: old-code")) + assertTrue(detail.supportDetails("Needs attention").contains("Refund transaction: deposit-refund")) val details = detail.copy( order = UsdtDepositOrder("refunded", 100u, null, null, "order-refund", null), - ).supportDetails + ).supportDetails("Refunded") + assertTrue(details.contains("Status: Refunded (refunded)")) listOf("Orchestra", "tron", "Arbitrum One", "deposit-id", "source-tx", "refunded", "order-refund").forEach { assertTrue(details.contains(it), it) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a722178112..86f463d2ab 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1036,6 +1036,33 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(NewTransactionSheetDetails.EMPTY, sut.transactionSheet.value) } + @Test + fun `receipt is consumed only when the transaction sheet can be presented`() = test { + val details = NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.RECEIVED, + usdtAmount = 1_000_000u, + usdtTransferId = "receipt", + ) + var consumed = 0 + sut.enabledTransactionSheet(false) + sut.showTransactionSheet(details) { + consumed++ + true + }.join() + assertEquals(0, consumed) + assertEquals(NewTransactionSheetDetails.EMPTY, sut.transactionSheet.value) + sut.enabledTransactionSheet(true) + sut.showTransactionSheet(details) { false }.join() + assertEquals(NewTransactionSheetDetails.EMPTY, sut.transactionSheet.value) + sut.showTransactionSheet(details) { + consumed++ + true + }.join() + assertEquals(1, consumed) + assertEquals(details, sut.transactionSheet.value) + } + @Test fun `critical update check is skipped in debug builds`() = test { whenever(appUpdaterService.getReleaseInfo()).thenReturn(releaseInfo(BuildConfig.VERSION_CODE + 1, true)) @@ -9923,15 +9950,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware submission pauses background work only during the attempt`() = test { setActiveContactPaymentContext(testPublicKey, privatePaymentContext(7uL), paymentRequest()) - sut.onHardwarePaymentSubmissionChanged(true) + val first = sut.beginHardwarePaymentSubmission() + val second = sut.beginHardwarePaymentSubmission() + assertTrue(paymentSubmissionActive.value) + sut.onHardwareSignCancelled() + runCurrent() + assertTrue(paymentSubmissionActive.value) + first() assertTrue(paymentSubmissionActive.value) - sut.onHardwarePaymentSubmissionChanged(false) + second() assertFalse(paymentSubmissionActive.value) sut.hideSheet() - sut.onHardwarePaymentSubmissionChanged(true) + val ordinaryPayment = sut.beginHardwarePaymentSubmission() assertTrue(paymentSubmissionActive.value) - sut.onHardwareSignCancelled() - runCurrent() + ordinaryPayment() assertFalse(paymentSubmissionActive.value) verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) } @@ -12975,6 +13007,33 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitUsdtPayments).send(eq(quote), eq(null), any()) } + @Test + fun `profile and sheet dismissal keep USDT background work paused until submission ends`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + sut.showSheet(Sheet.Send()) + runCurrent() + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = paymentRequest()) + val preparing = CompletableDeferred() + whenever(paykitUsdtPayments.prepare(any(), any(), anyOrNull(), anyOrNull())).doSuspendableAnswer { + preparing.complete(Unit) + awaitCancellation() + } + val send = launch { sut.sendPaykitUsdt(mock()) } + preparing.await() + pubkyPublicKey.value = null + runCurrent() + assertNull(sut.currentSheet.value) + assertTrue(paymentSubmissionActive.value) + sut.hideSheet() + assertTrue(paymentSubmissionActive.value) + send.cancelAndJoin() + assertFalse(paymentSubmissionActive.value) + verify(paykitUsdtPayments, never()).send(any(), anyOrNull(), any()) + } + @Test fun `USDT contact submission resumes background work on failure and cancellation`() = test { setActiveContactPaymentContext(testPublicKey) diff --git a/changelog.d/next/1506.fixed.md b/changelog.d/next/1506.fixed.md new file mode 100644 index 0000000000..c6fdeb2a79 --- /dev/null +++ b/changelog.d/next/1506.fixed.md @@ -0,0 +1 @@ +Preserve payment guards through sign-out, retain USDT receive notifications, and correct USDT payment styling and bridge status messages. diff --git a/journeys/README.md b/journeys/README.md index 8f9d16b3ae..9d7c933a81 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -205,6 +205,13 @@ Two mechanics differ from the Android runner, both hit while checking this corpu *with a usable ref*. Use that to reach an element the snapshot does not list, rather than concluding it is gone. +### USDT full-balance errors + +`usdt-payment-links-and-scans.xml` accepts either the insufficient-balance message or the paymaster +fee-collection message when the entire balance is entered manually. iOS has reached fee collection +first while Android has reported insufficient balance; the failing validation determines the message. +Both must keep payment review closed and allow the user to reduce the amount. + ### Identifiers The vocabulary is deliberately shared, and mostly matches: the whole number pad (`N0`–`N9`, `N000`, diff --git a/journeys/paykit-quoted-usdt.xml b/journeys/paykit-quoted-usdt.xml index cb4c453351..b31eb4d51d 100644 --- a/journeys/paykit-quoted-usdt.xml +++ b/journeys/paykit-quoted-usdt.xml @@ -10,7 +10,7 @@ Select USD and enter 5 followed by the decimal separator without a fractional digit. Verify Continue is enabled. Continue and verify the request review shows 5,00 USD on a comma-decimal locale or 5.00 USD on a dot-decimal locale. Deselect all receiving choices and verify sending the request is disabled. Select only USDT, enter 150,50 USD on the comma-decimal locale, select an expiry and send the request. Verify the sent screen, outgoing request card and request details all show 150,50 USD. Reopen the amount editor on a new request and verify typed trailing zeros are preserved. On the payer using the same comma-decimal locale, verify the incoming request card and details show 150,50 USD. Open the request and verify Amount represents exactly 150.50 USDT with USDT selected as the payment method, the amount cannot be edited, and no Bitcoin funding source is offered. Close without continuing to review. - Reopen the unpaid USDT request and wait for its Amount screen. Tap Back and verify the send sheet closes without showing a Bitcoin confirmation or a Swipe To Pay control. Reopen the same request and verify its amount and USDT payment method are preserved. Close without paying. + Reopen the unpaid USDT request. If Preparing is shown, verify its icons and disabled swipe use USDT teal rather than Bitcoin orange. Wait for its Amount screen. Tap Back and verify the send sheet closes without showing a Bitcoin confirmation or a Swipe To Pay control. Reopen the same request and verify its amount and USDT payment method are preserved. Close without paying. Create a USD request accepting Bitcoin and USDT whose quoted USDT amount is greater than the payer's USDT balance. On the payer, open the request and verify Bitcoin starts selected; USDT remains available for manual selection. Close without continuing. Repeat with a request whose quoted USDT amount is covered by that balance and verify USDT starts selected. This initial choice covers the requested amount only; fees are checked when preparing payment. Create a BTC request accepting Savings and USDT. If Spending is available, deselect it before sending. On the payer's Amount screen, verify Savings and USDT are available but Spending is not. Switch between the accepted sources and verify the displayed amount preserves the request's quoted value and cannot be edited. Close without continuing. Create a USD subscription for 150,50 on a comma-decimal locale. Verify the amount review shows 150,50 USD and editing preserves that amount. Verify Accepted Payments appears below frequency, with USDT selected and any available Savings and Spending chips unselected. Verify the description and icon controls are side by side. Propose it to the linked contact. On the payer, verify the subscription card and details show 150,50 USD, then review its recurrence and USDT-only payment method. Verify the subscription swipe uses USDT teal, then close before subscribing. diff --git a/journeys/usdt-bridge-details.xml b/journeys/usdt-bridge-details.xml index 991ac50aae..a8e02c7a6d 100644 --- a/journeys/usdt-bridge-details.xml +++ b/journeys/usdt-bridge-details.xml @@ -8,7 +8,7 @@ Enter an owned test refund address (id "UsdtRefundAddress") slowly across two background refreshes. Verify the field stays enabled and focused, the keyboard remains open, and every typed character is retained while the provider status updates. Then tap Request refund (id "UsdtDepositRefund"). Verify Confirm Refund shows the exact deposit amount with up to six decimals, network, full refund address and refund fee note. In a comma locale, a 3.5 USDT deposit must display 3,50 USDT. Hidden balances must remain masked, and an unknown amount must remain a dash. Cancel without submitting. Tap Hide details (id "UsdtDepositDetails"), then Details again. Verify the provider status remains separate from wallet activity and the entered refund address is retained. Open completed, refunded and refund-in-progress deposit fixtures. Verify Request Refund is unavailable and the current provider status and available refund transaction are shown. An unsupported asset or network must show support guidance instead of a refund form. - While Cross-Network Deposits is open, inject a confirmed incoming USDT transfer through the isolated fixture. Verify no received sheet interrupts deposit details or refund review. Return to the USDT wallet and verify the received celebration appears once after any active sheet is dismissed. + While Cross-Network Deposits is open, inject a confirmed incoming USDT transfer through the isolated fixture. Verify no received sheet interrupts deposit details or refund review. Return to the USDT wallet and verify the received celebration appears once after any active sheet is dismissed. Dismiss it and revisit deposit details, then return to the wallet; verify the same receipt does not appear again. Open the outgoing delivering USDT activity. Verify the Delivering USDT title, outgoing amount, teal up arrow, yellow Delivering status and actual receiving network. Tap Details (id "UsdtActivityDetails"). Verify the source transaction, sender and recipient addresses, sender and recipient networks, Orchestra provider and bridge reference. Verify Open Block Explorer (id "UsdtActivityExplorer") and Hide details remain reachable. For a delayed, failed, refunding, refunded or support-needed bridge fixture, verify Contact Support (id "UsdtContactSupport") and Copy Details (id "UsdtCopyDetails") appear side by side as small 40-point buttons, matching the Request refund / Hide details row. Verify both remain reachable and their labels are not clipped, without submitting either action. diff --git a/journeys/usdt-bridge-support.xml b/journeys/usdt-bridge-support.xml index dcc416b16b..ce244f067c 100644 --- a/journeys/usdt-bridge-support.xml +++ b/journeys/usdt-bridge-support.xml @@ -4,14 +4,14 @@ Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. Open USDT from Home, then open a cross-network transfer in Activity. Tap Details (id "UsdtActivityDetails"), then verify the source transaction is displayed. For an Orchestra transfer, verify the bridge reference is also displayed. Verify Contact Support and Copy Details use the small 40-point buttons side by side on one row, with neither label clipped. - Tap Copy Details (id "UsdtCopyDetails"). Verify the clipboard contains the bridge, networks, status and available transaction references. + Tap Copy Details (id "UsdtCopyDetails"). Verify the clipboard contains the bridge, networks, a readable status alongside its technical value, and available transaction references. Tap Contact Support (id "UsdtContactSupport"). Verify the report form opens with those details prefilled and remains unsent. Return without submitting. Open USDT Receive, then Network. Verify Cross-Network Deposits (id "UsdtDepositHistory") is a full-width primary button pinned over the bottom of the network list. Scroll the list and verify the rows and artwork pass behind the button while it stays fixed. Scroll to the end and verify the last content can be brought above the button. Repeat with large text and verify the button label is not clipped. Open it, verify rows show the source network and provider status, then open a deposit. - For a deposit whose conversion order is funded, confirming, swapping, bridging or delivering without an error, verify Pending is shown. Refresh after completion and verify Confirmed. An active order with an explicit error, or an unknown status, must show Needs attention. + For a deposit whose conversion order is funded, confirming, swapping, bridging or delivering without an error, verify Pending is shown. Refresh after completion and verify Confirmed. An active order with an explicit error, or an unknown status, must show Needs attention. In Details, verify a readable support message appears instead of raw codes such as standing_route_unavailable. For a deposit eligible for a refund, open Details and enter an owned address on the source network. With large system text enabled, verify the address field grows so the text is not clipped. Open and cancel the refund confirmation without requesting a refund. Scroll to Hide Details and tap it. Verify the refund field and keyboard close and the summary is readable. Reopen Details and verify the entered address remains available. For a refunded deposit, verify the detail shows Refunded, even when an earlier error code is retained. Verify the transaction is labeled Refund transaction in the detail. Verify no pending-refund notice remains after refreshing the completed refund. For a refund in progress, verify "Refund in progress" is shown for the provider's pending acknowledgment. A refund requiring operator intervention must still show Needs attention. - Verify Contact Support and Copy Details use the same small side-by-side buttons in deposit details. Tap Copy Details (id "UsdtCopyDetails"). Verify the clipboard includes the deposit reference, source network and source transaction, plus the current status and any destination or refund transaction. + Verify Contact Support and Copy Details use the same small side-by-side buttons in deposit details. Tap Copy Details (id "UsdtCopyDetails"). Verify the clipboard includes the deposit reference, source network and source transaction, plus the readable current status, its technical value, any provider error code and any destination or refund transaction. Tap Contact Support (id "UsdtContactSupport"). Verify the report form opens with the deposit details prefilled. Return without submitting or requesting a refund. diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index fdbde82a46..95a00f26e6 100644 --- a/journeys/usdt-payment-links-and-scans.xml +++ b/journeys/usdt-payment-links-and-scans.xml @@ -1,5 +1,5 @@ - Use an onboarded wallet with USDT configured and another owned wallet's Arbitrum receive URI. No payment is submitted. Hardware-wallet send remains Bitcoin-only. + Use an onboarded wallet with USDT configured and another owned wallet's Arbitrum receive URI. No payment is submitted. Hardware-wallet send remains Bitcoin-only. A full-balance quote can fail at balance validation or paymaster fee collection; iOS has reached fee collection first while Android has reported insufficient balance. Both must reject before signing. Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. Open an ethereum: USDT receive link with chain 42161, the pinned Arbitrum token, an owned recipient and uint256=10001. Verify the USDT amount screen preserves the recipient and exact input 0.010001 USDT; the review rounds its displayed value to $0.01. Verify no payment occurs without approval. @@ -15,7 +15,7 @@ Open or scan variants with the wrong chain, wrong token, duplicate amount and malformed recipient. Verify an error and no payable review. Check an ordinary Bitcoin or Lightning invoice still opens its existing send flow. On iOS, open a USDT link while Lightning is stopped but the wallet is ready. Verify it routes without starting Lightning. Open two different USDT links while the main scanner sheet is dismissing; verify an older delayed link cannot replace the review that opened first. Enter this wallet’s own Arbitrum address in USDT Send, enter an amount and continue. Verify the error explicitly says you cannot send USDT to your own address, and no payment is submitted. Repeat with MAX. On Android, enter this wallet’s own bare Arbitrum address through Home Send → Enter Manually and verify the self-send message appears while Continue stays disabled. - Enter another owned wallet’s Arbitrum address, manually enter the entire available USDT balance and continue. Verify the error says "Not enough USDT to cover the amount and fees." Reduce the amount, reach review and close without signing. + Enter another owned wallet’s Arbitrum address, manually enter the entire available USDT balance and continue. Verify payment review stays closed and an actionable fee or balance error asks for a smaller amount: "Not enough USDT to cover the amount and fees." or "Unable to pay the transaction fee in USDT. Try a smaller amount." Reduce the amount, reach review and close without signing. With an empty wallet and no pending transfers, turn USDT off in Settings. Copy an owned bare USDT address, return to the app, and verify no automatic payment or enable prompt. Repeat with an amount-bearing USDT payment URI. Copy a valid Bitcoin address and a Lightning invoice in turn; verify their normal clipboard prompts still appear and cancel without paying. With USDT off, explicitly open the same USDT URI and verify the designed Enable USDT modal appears. Decline and verify a quiet return with no stuck spinner. Scan its QR, enable USDT, and verify the original recipient and exact amount resume. Cancel without paying. Turn USDT off again and repeat the explicit scan, decline and enable checks with the owned bare 0x address; verify the recipient resumes with an empty amount. From 05482004d789965e9dc9c475c2e26d810f86a4db Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 17:54:22 -0500 Subject: [PATCH 2/6] docs: clarify paykit amount toggles across rate updates --- journeys/README.md | 8 ++++++++ journeys/paykit-quoted-usdt.xml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/journeys/README.md b/journeys/README.md index 9d7c933a81..7e40dece88 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -205,6 +205,14 @@ Two mechanics differ from the Android runner, both hit while checking this corpu *with a usable ref*. Use that to reach an element the snapshot does not list, rather than concluding it is gone. +### Paykit amount-entry rates + +`paykit-quoted-usdt.xml` preserves the originally entered amount across denomination switches until +the user edits it. Conversion uses the current fresh BTC/USD rate on both platforms. An entered +10 USD must return as exactly 10 USD; its BTC equivalent may change when the rate refreshes. +Require identical converted amounts only with an unchanged rate. The same rule applies to +subscription payment-method switches: preserve the originally entered denomination exactly. + ### USDT full-balance errors `usdt-payment-links-and-scans.xml` accepts either the insufficient-balance message or the paymaster diff --git a/journeys/paykit-quoted-usdt.xml b/journeys/paykit-quoted-usdt.xml index b31eb4d51d..16815eb3a1 100644 --- a/journeys/paykit-quoted-usdt.xml +++ b/journeys/paykit-quoted-usdt.xml @@ -5,7 +5,7 @@ Open a linked contact and create a USD payment request. Verify BTC and USD are the denomination choices and enabled receiving methods start selected. Verify Accept Payment In appears below expiry, with separate compact outlined Savings, Spending and USDT chips for available methods. Savings is orange, Spending is purple and USDT is teal; selected chips have a trailing X. Unselected chips retain their asset color at 50% opacity and show a trailing +. Remove one available method and verify the other selections remain unchanged; tap it again to restore it. With long translated method names on a narrow screen, verify the chips wrap onto another row without clipping their labels. On a comma-decimal device locale, enter 150,50 USD while creating a payment request. Verify the keypad, entered amount, request review and recipient row all show exactly 150,50 USD. Go back and verify the amount and denomination are preserved. On iOS with Classic Bitcoin display, repeat with a BTC amount and verify the keypad and amount use a dot and returning from review preserves the amount. Return to the request amount editor, select USD and replace the amount with 10. Switch to BTC and back to USD four times without editing. - Verify the USD amount is still exactly 10 and each return to BTC shows the same satoshi amount. + Verify every return to USD shows exactly 10. While the BTC/USD rate is unchanged, each return to BTC must show the same satoshi amount. If the rate refreshes between switches, the BTC equivalent may change, but returning to USD must still restore exactly 10. Clear the USD input, then select BTC. Verify the amount is zero and Continue is disabled. Select USD and enter 5 followed by the decimal separator without a fractional digit. Verify Continue is enabled. Continue and verify the request review shows 5,00 USD on a comma-decimal locale or 5.00 USD on a dot-decimal locale. Deselect all receiving choices and verify sending the request is disabled. Select only USDT, enter 150,50 USD on the comma-decimal locale, select an expiry and send the request. Verify the sent screen, outgoing request card and request details all show 150,50 USD. Reopen the amount editor on a new request and verify typed trailing zeros are preserved. From da63d6e6da80a02b8c7e86d12dd1ed1671c2a20b Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 19:34:37 -0500 Subject: [PATCH 3/6] docs: clarify usdt amount screen checks --- journeys/paykit-quoted-usdt.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/paykit-quoted-usdt.xml b/journeys/paykit-quoted-usdt.xml index 16815eb3a1..0f1f612fd0 100644 --- a/journeys/paykit-quoted-usdt.xml +++ b/journeys/paykit-quoted-usdt.xml @@ -9,7 +9,7 @@ Clear the USD input, then select BTC. Verify the amount is zero and Continue is disabled. Select USD and enter 5 followed by the decimal separator without a fractional digit. Verify Continue is enabled. Continue and verify the request review shows 5,00 USD on a comma-decimal locale or 5.00 USD on a dot-decimal locale. Deselect all receiving choices and verify sending the request is disabled. Select only USDT, enter 150,50 USD on the comma-decimal locale, select an expiry and send the request. Verify the sent screen, outgoing request card and request details all show 150,50 USD. Reopen the amount editor on a new request and verify typed trailing zeros are preserved. - On the payer using the same comma-decimal locale, verify the incoming request card and details show 150,50 USD. Open the request and verify Amount represents exactly 150.50 USDT with USDT selected as the payment method, the amount cannot be edited, and no Bitcoin funding source is offered. Close without continuing to review. + On the payer using the same comma-decimal locale, verify the incoming request card and details show 150,50 USD. Open the request and verify Amount represents exactly 150.50 USDT with USDT selected as the payment method, the amount cannot be edited, and no Bitcoin funding source is offered. Verify the title matches direct USDT Send and does not say Bitcoin Amount, including in German. The smaller BTC amount is a wallet-rate equivalent, not a Bitcoin payment option. Close without continuing to review. Reopen the unpaid USDT request. If Preparing is shown, verify its icons and disabled swipe use USDT teal rather than Bitcoin orange. Wait for its Amount screen. Tap Back and verify the send sheet closes without showing a Bitcoin confirmation or a Swipe To Pay control. Reopen the same request and verify its amount and USDT payment method are preserved. Close without paying. Create a USD request accepting Bitcoin and USDT whose quoted USDT amount is greater than the payer's USDT balance. On the payer, open the request and verify Bitcoin starts selected; USDT remains available for manual selection. Close without continuing. Repeat with a request whose quoted USDT amount is covered by that balance and verify USDT starts selected. This initial choice covers the requested amount only; fees are checked when preparing payment. Create a BTC request accepting Savings and USDT. If Spending is available, deselect it before sending. On the payer's Amount screen, verify Savings and USDT are available but Spending is not. Switch between the accepted sources and verify the displayed amount preserves the request's quoted value and cannot be edited. Close without continuing. From fec209b22dacd13277aeffb106283e20e5553b95 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 21:04:45 -0500 Subject: [PATCH 4/6] fix: replace prepared payment routes atomically --- app/src/main/java/to/bitkit/ui/ContentView.kt | 18 +++++++------ .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 +-- .../viewmodels/AppViewModelSendFlowTest.kt | 27 +++++++++++++------ journeys/README.md | 4 +-- journeys/paykit-quoted-usdt.xml | 2 +- journeys/usdt-payment-links-and-scans.xml | 2 +- 6 files changed, 34 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index faaefdfe75..ccc01432ea 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -569,14 +569,16 @@ fun ContentView( when (val sheet = currentSheet) { null -> Unit is Sheet.Send -> { - SendSheet( - appViewModel = appViewModel, - walletViewModel = walletViewModel, - startDestination = sheet.route, - hardwareWalletId = sheet.hardwareWalletId, - preparingRequest = sheet.preparingRequest, - hwSendViewModel = hwSendViewModel, - ) + key(sheet.route) { + SendSheet( + appViewModel = appViewModel, + walletViewModel = walletViewModel, + startDestination = sheet.route, + hardwareWalletId = sheet.hardwareWalletId, + preparingRequest = sheet.preparingRequest, + hwSendViewModel = hwSendViewModel, + ) + } } is Sheet.Receive -> { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ceea920039..2234dd9ad5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4962,9 +4962,8 @@ class AppViewModel @Inject constructor( if (activeIncomingPaymentRequest()?.id != preparation.request.id) return if (!paymentRequestPreparation.compareAndSet(preparation, null)) return _currentSheet.update { - if (it === preparation.sheet) preparation.sheet.copy(preparingRequest = null) else it + if (it === preparation.sheet) preparation.sheet.copy(route = route, preparingRequest = null) else it } - if (route != SendRoute.Confirm) setSendEffect(effect) return } if (fromMainScanner) { diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 86f463d2ab..2fcaa34a16 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -12936,22 +12936,33 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `automatic USDT request finishes preparation and admits amount continue`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", null, null, "") val request = paymentRequest().copy( amount = PaykitAmount(PaykitAsset.USDT, 50_000u), acceptedPaymentEndpointIdentifiers = listOf(endpoint.methodId.rawValue), ) - whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( - Result.success(PublicPaykitPaymentResult.Opened(endpoint.value, endpoints = listOf(endpoint))) - ) - sut.setIsAuthenticated(true) + val resolution = CompletableDeferred>() + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { resolution.await() } pendingPaykitPaymentRequests.value = listOf(request) - enablePaykitUi() - pubkyPublicKey.value = testPublicKey sut.sendEffect.test { runCurrent() - assertEquals(SendEffect.NavigateToAmount, awaitItem()) - assertNull((sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + assertEquals(SendRoute.Confirm, (sut.currentSheet.value as? Sheet.Send)?.route) + expectNoEvents() + sut.currentSheet.test { + awaitItem() + resolution.complete( + Result.success(PublicPaykitPaymentResult.Opened(endpoint.value, endpoints = listOf(endpoint))) + ) + runCurrent() + assertEquals(Sheet.Send(SendRoute.Amount), awaitItem()) + expectNoEvents() + } + expectNoEvents() assertTrue(sut.sendUiState.value.paykitUsesUsdt) sut.setSendEvent(SendEvent.AmountContinue) assertEquals(SendEffect.NavigateToUsdtReview, awaitItem()) diff --git a/journeys/README.md b/journeys/README.md index 7e40dece88..8dcc13fdb8 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -216,8 +216,8 @@ subscription payment-method switches: preserve the originally entered denominati ### USDT full-balance errors `usdt-payment-links-and-scans.xml` accepts either the insufficient-balance message or the paymaster -fee-collection message when the entire balance is entered manually. iOS has reached fee collection -first while Android has reported insufficient balance; the failing validation determines the message. +fee-collection message when the entire balance is entered manually. Either error can occur on +either platform; the failing validation determines the message. Both must keep payment review closed and allow the user to reduce the amount. ### Identifiers diff --git a/journeys/paykit-quoted-usdt.xml b/journeys/paykit-quoted-usdt.xml index 0f1f612fd0..dd7c4e83e9 100644 --- a/journeys/paykit-quoted-usdt.xml +++ b/journeys/paykit-quoted-usdt.xml @@ -10,7 +10,7 @@ Select USD and enter 5 followed by the decimal separator without a fractional digit. Verify Continue is enabled. Continue and verify the request review shows 5,00 USD on a comma-decimal locale or 5.00 USD on a dot-decimal locale. Deselect all receiving choices and verify sending the request is disabled. Select only USDT, enter 150,50 USD on the comma-decimal locale, select an expiry and send the request. Verify the sent screen, outgoing request card and request details all show 150,50 USD. Reopen the amount editor on a new request and verify typed trailing zeros are preserved. On the payer using the same comma-decimal locale, verify the incoming request card and details show 150,50 USD. Open the request and verify Amount represents exactly 150.50 USDT with USDT selected as the payment method, the amount cannot be edited, and no Bitcoin funding source is offered. Verify the title matches direct USDT Send and does not say Bitcoin Amount, including in German. The smaller BTC amount is a wallet-rate equivalent, not a Bitcoin payment option. Close without continuing to review. - Reopen the unpaid USDT request. If Preparing is shown, verify its icons and disabled swipe use USDT teal rather than Bitcoin orange. Wait for its Amount screen. Tap Back and verify the send sheet closes without showing a Bitcoin confirmation or a Swipe To Pay control. Reopen the same request and verify its amount and USDT payment method are preserved. Close without paying. + Reopen the unpaid USDT request. If Preparing is shown, verify its icons and disabled swipe use USDT teal rather than Bitcoin orange. Verify Preparing changes directly to Amount without briefly showing a Bitcoin confirmation, zero BTC or an enabled Swipe To Pay. Tap Back and verify the send sheet closes without showing a Bitcoin confirmation or a Swipe To Pay control. Reopen the same request and verify its amount and USDT payment method are preserved. Close without paying. Create a USD request accepting Bitcoin and USDT whose quoted USDT amount is greater than the payer's USDT balance. On the payer, open the request and verify Bitcoin starts selected; USDT remains available for manual selection. Close without continuing. Repeat with a request whose quoted USDT amount is covered by that balance and verify USDT starts selected. This initial choice covers the requested amount only; fees are checked when preparing payment. Create a BTC request accepting Savings and USDT. If Spending is available, deselect it before sending. On the payer's Amount screen, verify Savings and USDT are available but Spending is not. Switch between the accepted sources and verify the displayed amount preserves the request's quoted value and cannot be edited. Close without continuing. Create a USD subscription for 150,50 on a comma-decimal locale. Verify the amount review shows 150,50 USD and editing preserves that amount. Verify Accepted Payments appears below frequency, with USDT selected and any available Savings and Spending chips unselected. Verify the description and icon controls are side by side. Propose it to the linked contact. On the payer, verify the subscription card and details show 150,50 USD, then review its recurrence and USDT-only payment method. Verify the subscription swipe uses USDT teal, then close before subscribing. diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index 95a00f26e6..89006270c7 100644 --- a/journeys/usdt-payment-links-and-scans.xml +++ b/journeys/usdt-payment-links-and-scans.xml @@ -1,5 +1,5 @@ - Use an onboarded wallet with USDT configured and another owned wallet's Arbitrum receive URI. No payment is submitted. Hardware-wallet send remains Bitcoin-only. A full-balance quote can fail at balance validation or paymaster fee collection; iOS has reached fee collection first while Android has reported insufficient balance. Both must reject before signing. + Use an onboarded wallet with USDT configured and another owned wallet's Arbitrum receive URI. No payment is submitted. Hardware-wallet send remains Bitcoin-only. A full-balance quote can fail at balance validation or paymaster fee collection; either error can occur on either platform, depending on which validation fails first. Both must reject before signing. Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. Open an ethereum: USDT receive link with chain 42161, the pinned Arbitrum token, an owned recipient and uint256=10001. Verify the USDT amount screen preserves the recipient and exact input 0.010001 USDT; the review rounds its displayed value to $0.01. Verify no payment occurs without approval. From b735f400d2647ee121d5bed95a865dd45a9815c7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 22:51:23 -0500 Subject: [PATCH 5/6] fix: preserve payment request back navigation --- .../main/java/to/bitkit/ui/sheets/SendSheet.kt | 3 ++- .../bitkit/ui/sheets/SendSheetNavigationTest.kt | 15 ++++++++++----- changelog.d/next/1506.fixed.md | 2 +- journeys/paykit-quoted-usdt.xml | 4 ++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index d8456d15c7..0614da0c3b 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -109,6 +109,7 @@ internal fun NavController.navigateToCoinSelection() { } internal fun NavController.navigateToSendAmount(startDestination: SendRoute) { + if (popBackStack(SendRoute.Amount, inclusive = false)) return navigateTo(SendRoute.Amount) { if (startDestination == SendRoute.Confirm) { popUpTo(SendRoute.Confirm) { inclusive = true } @@ -341,7 +342,7 @@ fun SendSheet( } else { lightningState.nodeLifecycleState }, - canGoBack = startDestination != SendRoute.Amount, + canGoBack = startDestination != SendRoute.Amount || uiState.isPaymentRequest, onBack = { if (!navController.popBackStack()) { appViewModel.hideSheet() diff --git a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt index 8d84413aef..3bbc8a8181 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt @@ -42,8 +42,8 @@ class SendSheetNavigationTest { } @Test - fun `prepared requests discard confirmation while contact payments retain their previous screen`() { - for (start in listOf(SendRoute.Confirm, SendRoute.ContactSelect)) { + fun `amount review discards confirmations while contact payments retain their previous screen`() { + for (start in listOf(SendRoute.Confirm, SendRoute.Amount, SendRoute.ContactSelect)) { val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { navigatorProvider.addNavigator(ComposeNavigator()) graph = createGraph(startDestination = start) { @@ -57,11 +57,16 @@ class SendSheetNavigationTest { navController.navigateTo(SendRoute.UsdtReview) assertTrue(navController.popBackStack()) assertTrue(navController.currentDestination?.hasRoute() == true) + repeat(2) { + navController.navigateTo(SendRoute.Confirm) + navController.navigateToSendAmount(start) + assertTrue(navController.currentDestination?.hasRoute() == true) + } navController.popBackStack() - if (start == SendRoute.Confirm) { - assertNull(navController.currentDestination) - } else { + if (start == SendRoute.ContactSelect) { assertTrue(navController.currentDestination?.hasRoute() == true) + } else { + assertNull(navController.currentDestination) } } } diff --git a/changelog.d/next/1506.fixed.md b/changelog.d/next/1506.fixed.md index c6fdeb2a79..05571796a0 100644 --- a/changelog.d/next/1506.fixed.md +++ b/changelog.d/next/1506.fixed.md @@ -1 +1 @@ -Preserve payment guards through sign-out, retain USDT receive notifications, and correct USDT payment styling and bridge status messages. +Preserve payment guards through sign-out, retain USDT receive notifications, and correct payment-request navigation, styling and bridge status messages. diff --git a/journeys/paykit-quoted-usdt.xml b/journeys/paykit-quoted-usdt.xml index dd7c4e83e9..f888236bde 100644 --- a/journeys/paykit-quoted-usdt.xml +++ b/journeys/paykit-quoted-usdt.xml @@ -10,8 +10,8 @@ Select USD and enter 5 followed by the decimal separator without a fractional digit. Verify Continue is enabled. Continue and verify the request review shows 5,00 USD on a comma-decimal locale or 5.00 USD on a dot-decimal locale. Deselect all receiving choices and verify sending the request is disabled. Select only USDT, enter 150,50 USD on the comma-decimal locale, select an expiry and send the request. Verify the sent screen, outgoing request card and request details all show 150,50 USD. Reopen the amount editor on a new request and verify typed trailing zeros are preserved. On the payer using the same comma-decimal locale, verify the incoming request card and details show 150,50 USD. Open the request and verify Amount represents exactly 150.50 USDT with USDT selected as the payment method, the amount cannot be edited, and no Bitcoin funding source is offered. Verify the title matches direct USDT Send and does not say Bitcoin Amount, including in German. The smaller BTC amount is a wallet-rate equivalent, not a Bitcoin payment option. Close without continuing to review. - Reopen the unpaid USDT request. If Preparing is shown, verify its icons and disabled swipe use USDT teal rather than Bitcoin orange. Verify Preparing changes directly to Amount without briefly showing a Bitcoin confirmation, zero BTC or an enabled Swipe To Pay. Tap Back and verify the send sheet closes without showing a Bitcoin confirmation or a Swipe To Pay control. Reopen the same request and verify its amount and USDT payment method are preserved. Close without paying. - Create a USD request accepting Bitcoin and USDT whose quoted USDT amount is greater than the payer's USDT balance. On the payer, open the request and verify Bitcoin starts selected; USDT remains available for manual selection. Close without continuing. Repeat with a request whose quoted USDT amount is covered by that balance and verify USDT starts selected. This initial choice covers the requested amount only; fees are checked when preparing payment. + Reopen the unpaid USDT request. If Preparing is shown, verify its icons and disabled swipe use USDT teal rather than Bitcoin orange. Verify Preparing changes directly to Amount without briefly showing a Bitcoin confirmation, zero BTC or an enabled Swipe To Pay. Tap the top-bar Back arrow and verify the send sheet closes without showing a Bitcoin confirmation or a Swipe To Pay control. Reopen the same request and verify its amount and USDT payment method are preserved. Close without paying. + Create a USD request accepting Bitcoin and USDT whose quoted USDT amount is greater than the payer's USDT balance. On the payer, open the request and verify Bitcoin starts selected; USDT remains available for manual selection. Close without continuing. Repeat with a request whose quoted USDT amount is covered by that balance and verify USDT starts selected. This initial choice covers the requested amount only; fees are checked when preparing payment. When both Savings and Spending are accepted, continue with Bitcoin to confirmation and change between them. Verify Amount opens again for review. Tap its top-bar Back arrow and verify the sheet closes without returning to the previous confirmation. Reopen the unpaid request and close without paying. Create a BTC request accepting Savings and USDT. If Spending is available, deselect it before sending. On the payer's Amount screen, verify Savings and USDT are available but Spending is not. Switch between the accepted sources and verify the displayed amount preserves the request's quoted value and cannot be edited. Close without continuing. Create a USD subscription for 150,50 on a comma-decimal locale. Verify the amount review shows 150,50 USD and editing preserves that amount. Verify Accepted Payments appears below frequency, with USDT selected and any available Savings and Spending chips unselected. Verify the description and icon controls are side by side. Propose it to the linked contact. On the payer, verify the subscription card and details show 150,50 USD, then review its recurrence and USDT-only payment method. Verify the subscription swipe uses USDT teal, then close before subscribing. Create a BTC subscription for 12,345 sats and verify available Savings and Spending chips start selected and USDT is unselected. Deselect each selected Bitcoin chip and verify Choose Recipient is disabled when none remain. Select Savings (id "SubscriptionAccept-savings"), then USDT (id "SubscriptionAccept-usdt"); verify the amount converts to USD and only USDT is selected. Select Savings again; verify the amount returns to exactly 12,345 sats and only Savings is selected. If Spending is available, select it (id "SubscriptionAccept-spending") and verify both Bitcoin rails can be selected together, with USDT still unselected. From b66d83f696df134372221b18341b1595d58a84cd Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 02:07:51 -0500 Subject: [PATCH 6/6] docs: clarify payment method review across platforms --- journeys/README.md | 8 ++++++++ journeys/paykit-quoted-usdt.xml | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index 8dcc13fdb8..034d7182b1 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -213,6 +213,14 @@ the user edits it. Conversion uses the current fresh BTC/USD rate on both platfo Require identical converted amounts only with an unchanged rate. The same rule applies to subscription payment-method switches: preserve the originally entered denomination exactly. +### Paykit payment-method review + +In `paykit-quoted-usdt.xml`, Android returns to Amount whenever a quoted request changes payment +method. iOS keeps confirmation open when the exact amount, quote and validity window are unchanged +and still valid; changed or expired terms return to Amount. Verify the selected method and exact +payable amount on both platforms, and close without signing. The optional Savings/Spending +confirmation check requires existing Bitcoin regtest funds and a usable Lightning channel. + ### USDT full-balance errors `usdt-payment-links-and-scans.xml` accepts either the insufficient-balance message or the paymaster diff --git a/journeys/paykit-quoted-usdt.xml b/journeys/paykit-quoted-usdt.xml index f888236bde..51d854d603 100644 --- a/journeys/paykit-quoted-usdt.xml +++ b/journeys/paykit-quoted-usdt.xml @@ -1,5 +1,5 @@ - Two linked Bitkit wallets with Paykit and USDT enabled, sharing enabled by their owners, and a fresh BTC/USD rate. These request-creation and amount-screen checks need no funds and stop before payment review. Funded Arbitrum review, paid history, controlled rates and proof verification are manual tests in Docs/USDT-QA.md; a Bitcoin regtest balance does not fund USDT. + Two linked Bitkit wallets with Paykit and USDT enabled, sharing enabled by their owners, and a fresh BTC/USD rate. Request-creation and amount-screen checks need no funds. The optional Savings/Spending confirmation check needs existing Bitcoin regtest funds and a usable Lightning channel; close without signing. Android reopens Amount after a quoted request changes method; iOS keeps confirmation open when the exact payment terms remain valid and unchanged. Funded Arbitrum review, paid history, controlled rates and proof verification are manual tests in Docs/USDT-QA.md; a Bitcoin regtest balance does not fund USDT. Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. Open a linked contact and create a USD payment request. Verify BTC and USD are the denomination choices and enabled receiving methods start selected. Verify Accept Payment In appears below expiry, with separate compact outlined Savings, Spending and USDT chips for available methods. Savings is orange, Spending is purple and USDT is teal; selected chips have a trailing X. Unselected chips retain their asset color at 50% opacity and show a trailing +. Remove one available method and verify the other selections remain unchanged; tap it again to restore it. With long translated method names on a narrow screen, verify the chips wrap onto another row without clipping their labels. @@ -11,7 +11,7 @@ Deselect all receiving choices and verify sending the request is disabled. Select only USDT, enter 150,50 USD on the comma-decimal locale, select an expiry and send the request. Verify the sent screen, outgoing request card and request details all show 150,50 USD. Reopen the amount editor on a new request and verify typed trailing zeros are preserved. On the payer using the same comma-decimal locale, verify the incoming request card and details show 150,50 USD. Open the request and verify Amount represents exactly 150.50 USDT with USDT selected as the payment method, the amount cannot be edited, and no Bitcoin funding source is offered. Verify the title matches direct USDT Send and does not say Bitcoin Amount, including in German. The smaller BTC amount is a wallet-rate equivalent, not a Bitcoin payment option. Close without continuing to review. Reopen the unpaid USDT request. If Preparing is shown, verify its icons and disabled swipe use USDT teal rather than Bitcoin orange. Verify Preparing changes directly to Amount without briefly showing a Bitcoin confirmation, zero BTC or an enabled Swipe To Pay. Tap the top-bar Back arrow and verify the send sheet closes without showing a Bitcoin confirmation or a Swipe To Pay control. Reopen the same request and verify its amount and USDT payment method are preserved. Close without paying. - Create a USD request accepting Bitcoin and USDT whose quoted USDT amount is greater than the payer's USDT balance. On the payer, open the request and verify Bitcoin starts selected; USDT remains available for manual selection. Close without continuing. Repeat with a request whose quoted USDT amount is covered by that balance and verify USDT starts selected. This initial choice covers the requested amount only; fees are checked when preparing payment. When both Savings and Spending are accepted, continue with Bitcoin to confirmation and change between them. Verify Amount opens again for review. Tap its top-bar Back arrow and verify the sheet closes without returning to the previous confirmation. Reopen the unpaid request and close without paying. + Create a USD request accepting Bitcoin and USDT whose quoted USDT amount is greater than the payer's USDT balance. On the payer, open the request and verify Bitcoin starts selected; USDT remains available for manual selection. Close without continuing. Repeat with a request whose quoted USDT amount is covered by that balance and verify USDT starts selected. This initial choice covers the requested amount only; fees are checked when preparing payment. When both Savings and Spending are accepted and existing regtest funds cover the payment and fees, continue with Bitcoin to confirmation and change between them. On Android, verify Amount opens again for review; tap its top-bar Back arrow and verify the sheet closes without returning to the previous confirmation. On iOS, unchanged valid payment terms keep confirmation open: verify the selected method updates and the exact satoshi amount is preserved, then close without paying. If switching methods changes the payment terms or finds them expired, verify iOS returns to Amount for review. Reopen the unpaid request and close without paying. Create a BTC request accepting Savings and USDT. If Spending is available, deselect it before sending. On the payer's Amount screen, verify Savings and USDT are available but Spending is not. Switch between the accepted sources and verify the displayed amount preserves the request's quoted value and cannot be edited. Close without continuing. Create a USD subscription for 150,50 on a comma-decimal locale. Verify the amount review shows 150,50 USD and editing preserves that amount. Verify Accepted Payments appears below frequency, with USDT selected and any available Savings and Spending chips unselected. Verify the description and icon controls are side by side. Propose it to the linked contact. On the payer, verify the subscription card and details show 150,50 USD, then review its recurrence and USDT-only payment method. Verify the subscription swipe uses USDT teal, then close before subscribing. Create a BTC subscription for 12,345 sats and verify available Savings and Spending chips start selected and USDT is unselected. Deselect each selected Bitcoin chip and verify Choose Recipient is disabled when none remain. Select Savings (id "SubscriptionAccept-savings"), then USDT (id "SubscriptionAccept-usdt"); verify the amount converts to USD and only USDT is selected. Select Savings again; verify the amount returns to exactly 12,345 sats and only Savings is selected. If Spending is available, select it (id "SubscriptionAccept-spending") and verify both Bitcoin rails can be selected together, with USDT still unselected.