diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 4119de267f..f76f0be141 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -290,6 +290,22 @@ class ActivityRepo @Inject constructor( suspend fun notifyPaymentActivityChanged() = notifyActivitiesChanged() + suspend fun recordRbfBoost( + original: OnchainActivity, + replacementTxId: String, + feeRate: ULong, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + coreService.activity.recordRbfBoost( + originalActivityId = original.id, + replacementTxId = replacementTxId, + feeRate = feeRate, + walletId = original.walletId, + ) + notifyActivitiesChanged(tagsChanged = true) + } + } + suspend fun shouldShowReceivedSheet(txid: String, value: ULong): Boolean { return coreService.activity.shouldShowReceivedSheet(txid, value) } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 9d4ab907c8..f589d185a1 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -62,6 +62,7 @@ import com.synonym.bitkitcore.upsertActivity import com.synonym.bitkitcore.upsertCjitEntries import com.synonym.bitkitcore.upsertClosedChannels import com.synonym.bitkitcore.upsertInfo +import com.synonym.bitkitcore.upsertOnchainActivityPreservingFeeRate import com.synonym.bitkitcore.upsertOrders import com.synonym.bitkitcore.upsertTransactionDetails import com.synonym.bitkitcore.wipeAllDatabases @@ -114,6 +115,7 @@ import com.synonym.bitkitcore.TxOutput as BitkitCoreTxOutput import com.synonym.bitkitcore.getLnurlInvoiceForPayData as coreGetLnurlInvoiceForPayData import com.synonym.bitkitcore.getTransactionDetails as getBitkitCoreTransactionDetails import com.synonym.bitkitcore.markActivityAsSeen as coreMarkActivityAsSeen +import com.synonym.bitkitcore.recordRbfBoost as coreRecordRbfBoost // region Core @@ -407,6 +409,15 @@ class ActivityService( upsertActivity(activity) } + suspend fun recordRbfBoost( + originalActivityId: String, + replacementTxId: String, + feeRate: ULong, + walletId: String = defaultWalletId, + ) = ServiceQueue.CORE.background { + coreRecordRbfBoost(walletId, originalActivityId, replacementTxId, feeRate) + } + suspend fun upsertList(activities: List) = ServiceQueue.CORE.background { upsertActivities(activities) } @@ -1235,12 +1246,7 @@ class ActivityService( return } - if (existingActivity != null && existingActivity is Activity.Onchain) { - val existingOnchain = existingActivity.v1 - updateActivity(activityId = existingOnchain.id, activity = Activity.Onchain(onChain)) - } else { - upsertActivity(Activity.Onchain(onChain)) - } + upsertOnchainActivityPreservingFeeRate(onChain) } private fun PaymentDirection.toPaymentType(): PaymentType = @@ -1494,7 +1500,7 @@ class ActivityService( isBoosted = false, updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = replacedActivity.id, activity = Activity.Onchain(updatedActivity)) + upsertOnchainActivityPreservingFeeRate(updatedActivity) Logger.info("Marked transaction $txid as replaced", context = TAG) } else { Logger.info( @@ -1554,7 +1560,7 @@ class ActivityService( contact = replacementActivity.contact ?: replacedActivity?.contact, updatedAt = System.currentTimeMillis().toULong() / 1000u, ) - updateActivity(activityId = replacementActivity.id, activity = Activity.Onchain(updatedActivity)) + upsertOnchainActivityPreservingFeeRate(updatedActivity) if (replacedActivity != null) { copyTagsFromReplacedActivity(txid, conflictTxid, replacedActivity.id, replacementActivity.id) @@ -1598,7 +1604,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) + upsertOnchainActivityPreservingFeeRate(updatedActivity) }.onFailure { e -> Logger.error("Error handling onchain transaction reorged for $txid", e, context = TAG) } @@ -1618,7 +1624,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) + upsertOnchainActivityPreservingFeeRate(updatedActivity) }.onFailure { e -> Logger.error("Error handling onchain transaction evicted for $txid", e, context = TAG) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index 37caf5c052..db6288641e 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -18,12 +18,14 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.ext.BoostType import to.bitkit.ext.boostType import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.FeeRate import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.math.BigDecimal import javax.inject.Inject @@ -179,7 +181,7 @@ class BoostTransactionViewModel @Inject constructor( _uiState.update { it.copy(boosting = true) } viewModelScope.launch { - runCatching { + runSuspendCatching { when (currentActivity.v1.txType) { PaymentType.SENT -> handleRbfBoost(currentActivity) PaymentType.RECEIVED -> handleCpfpBoost(currentActivity) @@ -191,12 +193,16 @@ class BoostTransactionViewModel @Inject constructor( } private suspend fun handleRbfBoost(activity: Activity.Onchain) { + val feeRate = _uiState.value.feeRate lightningRepo.bumpFeeByRbf( - satsPerVByte = _uiState.value.feeRate, + satsPerVByte = feeRate, originalTxId = activity.v1.txId ).fold( onSuccess = { newTxId -> - handleBoostSuccess(newTxId, isRBF = true) + handleBoostSuccess( + newTxId, + activityRepo.recordRbfBoost(activity.v1, newTxId, feeRate), + ) }, onFailure = { error -> handleError("RBF boost failed: ${error.message}", error) @@ -211,7 +217,7 @@ class BoostTransactionViewModel @Inject constructor( destinationAddress = walletRepo.getOnchainAddress(), ).fold( onSuccess = { newTxId -> - handleBoostSuccess(newTxId, isRBF = false) + handleBoostSuccess(newTxId, updateCpfpActivity(newTxId)) }, onFailure = { error -> handleError("CPFP boost failed: ${error.message}", error) @@ -219,11 +225,11 @@ class BoostTransactionViewModel @Inject constructor( ) } - private suspend fun handleBoostSuccess(newTxId: Txid, isRBF: Boolean) { + private fun handleBoostSuccess(newTxId: Txid, activityUpdate: Result) { Logger.debug("Boost successful. newTxId: $newTxId", context = TAG) - updateActivity(newTxId = newTxId, isRBF = isRBF) + activityUpdate .onFailure { error -> - Logger.warn("Boost successful but activity update failed", e = error, context = TAG) + Logger.warn("Failed to update activity after successful boost", error, context = TAG) } _uiState.update { it.copy(boosting = false) } @@ -286,22 +292,10 @@ class BoostTransactionViewModel @Inject constructor( } } - /** - * Updates activity based on boost type. - * RBF: Updates current activity with boost data. Event handler will handle replacement. - * CPFP: Updates current activity and appends child txId to parent's boostTxIds. - */ - private suspend fun updateActivity(newTxId: Txid, isRBF: Boolean): Result { - Logger.debug("Updating activity for txId: $newTxId. isRBF: $isRBF", context = TAG) - + private suspend fun updateCpfpActivity(newTxId: Txid): Result { val currentActivity = activity?.v1 - ?: return Result.failure(Exception("Activity required")) - - return if (isRBF) { - handleRBFUpdate(currentActivity) - } else { - handleCPFPUpdate(currentActivity, newTxId) - } + ?: return Result.failure(AppError(message = "Activity required")) + return handleCPFPUpdate(currentActivity, newTxId) } /** @@ -324,31 +318,6 @@ class BoostTransactionViewModel @Inject constructor( ) } - /** - * Handles RBF (Replace By Fee) update by updating current activity to show boost status. - * The event handler (handleOnchainTransactionReplaced) will handle the replacement - * when the OnchainTransactionReplaced event fires. - */ - private suspend fun handleRBFUpdate( - currentActivity: OnchainActivity, - ): Result { - val updatedCurrentActivity = Activity.Onchain( - v1 = currentActivity.copy( - isBoosted = true, - feeRate = _uiState.value.feeRate, - fee = _uiState.value.totalFeeSats, - updatedAt = nowTimestamp().toEpochMilli().toULong() - ) - ) - - activityRepo.updateActivity( - id = updatedCurrentActivity.v1.id, - activity = updatedCurrentActivity - ) - - return Result.success(Unit) - } - private fun handleError(message: String, error: Throwable? = null) { Logger.error(message, error, context = TAG) _uiState.update { diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 61b8b3e102..50d7931089 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -46,6 +46,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime +import kotlin.time.Instant @Suppress("LargeClass") @OptIn(ExperimentalTime::class) @@ -173,6 +174,44 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.allPossibleTags() }.thenReturn(emptyList()) } + @Test + fun `recordRbfBoost passes scoped identifiers and rate to core without rewriting the original`() = test { + val original = createOnchainActivity(walletId = "trezor:test").v1 + whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(200)) + + val result = sut.recordRbfBoost(original, "replacement-tx", 25uL) + + assertTrue(result.isSuccess) + verify(coreService.activity).recordRbfBoost(original.id, "replacement-tx", 25uL, original.walletId) + verify(coreService.activity, never()).update(any(), any()) + assertEquals(200_000L, sut.activitiesChanged.value) + assertEquals(200_000L, sut.activityTagsChanged.value) + } + + @Test + fun `recordRbfBoost propagates storage failure without notifying observers`() = test { + val original = createOnchainActivity().v1 + val failure = AppError(message = "storage unavailable") + whenever(coreService.activity.recordRbfBoost(original.id, "replacement-tx", 25uL, original.walletId)) + .thenAnswer { throw failure } + + val result = sut.recordRbfBoost(original, "replacement-tx", 25uL) + + assertEquals(failure, result.exceptionOrNull()) + assertEquals(0L, sut.activitiesChanged.value) + assertEquals(0L, sut.activityTagsChanged.value) + } + + @Test + fun `recordRbfBoost preserves coroutine cancellation`() = test { + val original = createOnchainActivity().v1 + whenever(coreService.activity.recordRbfBoost(original.id, "replacement-tx", 25uL, original.walletId)) + .thenThrow(CancellationException("cancelled")) + + assertFailsWith { sut.recordRbfBoost(original, "replacement-tx", 25uL) } + assertEquals(0L, sut.activitiesChanged.value) + } + @Test fun `syncActivities success flow`() = test { val payments = listOf(testPaymentDetails) diff --git a/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt b/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt new file mode 100644 index 0000000000..5e029e1066 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt @@ -0,0 +1,132 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.getActivityById +import com.synonym.bitkitcore.getActivityByTxId +import com.synonym.bitkitcore.updateActivity +import com.synonym.bitkitcore.upsertActivity +import com.synonym.bitkitcore.upsertOnchainActivityPreservingFeeRate +import kotlinx.coroutines.flow.flowOf +import org.junit.Test +import org.lightningdevkit.ldknode.ConfirmationStatus +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.whenever +import to.bitkit.async.ServiceQueue +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.ext.create +import to.bitkit.test.BaseUnitTest + +class ActivityServiceRbfTest : BaseUnitTest() { + companion object { + private const val BINDING_CLASS = "com.synonym.bitkitcore.Bitkitcore_androidKt" + private const val WALLET_ID = "wallet0" + private const val TX_ID = "replacement-tx" + private const val ACTIVITY_ID = "stored-payment-id" + } + + private val cacheStore = mock() + private val lightningService = mock() + + @Test + fun `recordRbfBoost calls the atomic core API with the requested wallet and rate`() = test { + ServiceQueue.CORE.background { + val binding = Class.forName(BINDING_CLASS) + val record = binding.methods.single { it.name.startsWith("recordRbfBoost") } + mockStatic(binding).use { native -> + service().recordRbfBoost(ACTIVITY_ID, TX_ID, 25uL, WALLET_ID) + + native.verify { record.invoke(null, WALLET_ID, ACTIVITY_ID, TX_ID, 25L) } + native.verify({ updateActivity(any(), any()) }, never()) + } + } + } + + @Test + fun `payment sync merges an existing transaction through the fee preserving API`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) + ServiceQueue.CORE.background { + mockStatic(Class.forName(BINDING_CLASS)).use { native -> + native.`when` { getActivityById(any(), any()) }.thenReturn(null) + native.`when` { getActivityByTxId(any(), any()) }.thenReturn(activity()) + + service().syncLdkNodePaymentsToActivities(listOf(payment())) + + native.verify { + upsertOnchainActivityPreservingFeeRate( + argThat { walletId == WALLET_ID && txId == TX_ID && feeRate == 25uL && confirmed } + ) + } + native.verify({ updateActivity(any(), any()) }, never()) + native.verify({ upsertActivity(any()) }, never()) + } + } + } + + @Test + fun `reorg events preserve the rate at the core boundary`() = eventTest { + service().handleOnchainTransactionReorged(TX_ID) + } + + @Test + fun `eviction events preserve the rate at the core boundary`() = eventTest { + service().handleOnchainTransactionEvicted(TX_ID) + } + + private fun eventTest(block: suspend () -> Unit) = test { + ServiceQueue.CORE.background { + mockStatic(Class.forName(BINDING_CLASS)).use { native -> + native.`when` { getActivityByTxId(any(), any()) }.thenReturn(activity()) + block() + native.verify { + upsertOnchainActivityPreservingFeeRate(argThat { txId == TX_ID && feeRate == 25uL }) + } + native.verify({ updateActivity(any(), any()) }, never()) + } + } + } + + private fun service() = ActivityService( + coreService = mock(), + cacheStore = cacheStore, + lightningService = lightningService, + settingsStore = mock(), + privatePaykitContactResolver = mock(), + ) + + private fun activity() = OnchainActivity.create( + walletId = WALLET_ID, + id = ACTIVITY_ID, + txType = PaymentType.SENT, + txId = TX_ID, + value = 10_000uL, + fee = 2_500uL, + feeRate = 25uL, + address = "bcrt1qrecipient", + timestamp = 100uL, + updatedAt = 100uL, + ) + + private fun payment() = PaymentDetails( + id = "new-payment-id", + kind = PaymentKind.Onchain( + txid = TX_ID, + status = ConfirmationStatus.Confirmed(blockHash = "block", height = 100u, timestamp = 200uL), + ), + amountMsat = 10_000_000uL, + feePaidMsat = 2_500_000uL, + direction = PaymentDirection.OUTBOUND, + status = PaymentStatus.SUCCEEDED, + latestUpdateTimestamp = 200uL, + ) +} diff --git a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index 46862ec10b..bbd26a3803 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -31,6 +31,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.sheets.BoostTransactionViewModel.Companion.MAX_FEE_RATE +import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -239,6 +240,82 @@ class BoostTransactionViewModelTest : BaseUnitTest() { verify(activityRepo, never()).deleteActivity(any(), any()) } + @Test + fun `successful RBF boost records the replacement rate without a stale row update`() = test { + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(25UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + whenever(lightningRepo.bumpFeeByRbf(mockTxId, 25UL)).thenReturn(Result.success(newTxId)) + whenever(activityRepo.recordRbfBoost(onchainActivity, newTxId, 25UL)).thenReturn(Result.success(Unit)) + sut.setupActivity(activitySent) + + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostSuccess, awaitItem()) + } + + verify(activityRepo).recordRbfBoost(onchainActivity, newTxId, 25UL) + verify(activityRepo, never()).updateActivity(any(), any(), any()) + verify(lightningRepo).syncAsync() + } + + @Test + fun `failed RBF boost does not record replacement metadata`() = test { + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(25UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + whenever(lightningRepo.bumpFeeByRbf(mockTxId, 25UL)) + .thenReturn(Result.failure(AppError(message = "rejected"))) + sut.setupActivity(activitySent) + + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostFailed, awaitItem()) + } + + verify(activityRepo, never()).recordRbfBoost(any(), any(), any()) + verify(activityRepo, never()).updateActivity(any(), any(), any()) + } + + @Test + fun `RBF recording uses the requested rate even if the sheet changes during the node call`() = test { + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(25UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + whenever(lightningRepo.bumpFeeByRbf(mockTxId, 25UL)).thenAnswer { + sut.onChangeAmount(increase = true) + newTxId + } + whenever(activityRepo.recordRbfBoost(onchainActivity, newTxId, 25UL)).thenReturn(Result.success(Unit)) + sut.setupActivity(activitySent) + + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostSuccess, awaitItem()) + } + + assertEquals(26UL, sut.uiState.value.feeRate) + verify(activityRepo).recordRbfBoost(onchainActivity, newTxId, 25UL) + } + + @Test + fun `successful RBF broadcast stays successful if recording fails`() = test { + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(25UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + whenever(lightningRepo.bumpFeeByRbf(mockTxId, 25UL)).thenReturn(Result.success(newTxId)) + whenever(activityRepo.recordRbfBoost(onchainActivity, newTxId, 25UL)) + .thenReturn(Result.failure(AppError(message = "storage unavailable"))) + sut.setupActivity(activitySent) + + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostSuccess, awaitItem()) + } + + verify(lightningRepo).syncAsync() + } + // region estimateTime dynamic tier tests @Test diff --git a/changelog.d/next/1429.fixed.md b/changelog.d/next/1429.fixed.md new file mode 100644 index 0000000000..06a9750739 --- /dev/null +++ b/changelog.d/next/1429.fixed.md @@ -0,0 +1 @@ +Fixed incorrect confirmation estimates for transactions boosted using Replace-by-Fee. diff --git a/journeys/onchain-send/README.md b/journeys/onchain-send/README.md new file mode 100644 index 0000000000..e8ee7b2686 --- /dev/null +++ b/journeys/onchain-send/README.md @@ -0,0 +1,15 @@ +# Onchain send + +`rbf-replacement-fee-rate.xml` is shared with bitkit-ios under the same name and action sequence. +Use a disposable regtest wallet and a recipient belonging to another wallet. Never use mainnet funds. + +Android dev uses staging regtest; iOS E2E builds use the configured regtest backend. Both expose the +journey's identifiers. Both platforms edit custom Boost fees with plus/minus buttons; the journey +only inspects that screen and keeps the recommended rate on both platforms. + +Do not inject the recipient with `adb input text`: long strings can be truncated. Manual reviewers +can paste it normally. If an agent cannot enter the recipient through the written route, stop and +report that step rather than substituting a deep link, which would skip the recipient screens. + +Until the core fix is released, this journey needs the local core SDK exposing `recordRbfBoost` +and `upsertOnchainActivityPreservingFeeRate`; the published 0.5.18 SDK does not contain those APIs. diff --git a/journeys/onchain-send/rbf-replacement-fee-rate.xml b/journeys/onchain-send/rbf-replacement-fee-rate.xml new file mode 100644 index 0000000000..efcff45a0e --- /dev/null +++ b/journeys/onchain-send/rbf-replacement-fee-rate.xml @@ -0,0 +1,28 @@ + + + Requires an English dev app with a disposable native wallet holding at least 100 000 + confirmed Savings sats, and a regtest recipient address belonging to another wallet. Use the + regtest backend in journeys/README.md and do not mine until the confirmation step below. + This is an outgoing RBF boost, not incoming CPFP or a watch-only hardware payment. The + recommended Boost rate is at least the Fast tier, so its replacement should show that tier's + confirmation estimate, not the 1 sat/vB fallback. Ported from the matching iOS journey; + Android dev uses staging regtest, while iOS E2E_BUILD uses its configured regtest backend. + Both platforms keep the recommended fee when opening the pencil and use plus/minus + controls for custom Boost edits. Recipient text can be pasted manually; + agent runs must not type long addresses with adb input text. + + + On Home, tap Send (id "Send"), dismiss any camera permission dialog with Don't Allow, and tap Enter Manually (id "RecipientManual") + Enter the regtest recipient in id "RecipientInput" and tap Continue (id "AddressContinue") + Choose Savings as the source, enter 10 000 sats with the number pad, and tap Continue (id "ContinueAmount") + On the review screen (id "SendConfirm"), open Fee & Speed, choose Custom, use the number pad to enter 1 sat/vB, and return to review with Continue + Swipe the send control (id "GRAB"), approve any fee warning and PIN prompt, wait for Sent (id "SendSuccess"), and tap Close (id "Close") + Open the unconfirmed sent transaction from Activity, note its transaction ID and total fee (id "ActivityFee"), and tap Boost (id "BoostButton") + Wait for the RBF Boost sheet (id "RBFBoost") to load its recommended Fast fee, open the pencil (id "CustomFeeButton"), and note the selected sat/vB rate and confirmation estimate + Swipe the Boost control (id "GRAB") and wait for success (id "BoostSuccessToast") without mining a block + Return to Activity and wait for the replacement transaction to appear; verify its row shows the Fast confirmation estimate from the Boost sheet + Open that row and verify it has a different transaction ID and a higher total fee (id "ActivityFee") than the original + Background and reopen Bitkit, unlock if needed, wait for sync, and verify the replacement's Activity row still shows the Fast estimate + Mine one regtest block, wait for sync, reopen the replacement transaction, and verify it is confirmed with the same higher total fee + +