diff --git a/app/src/androidTest/java/to/bitkit/services/OnchainBackupRestoreDeviceTest.kt b/app/src/androidTest/java/to/bitkit/services/OnchainBackupRestoreDeviceTest.kt new file mode 100644 index 0000000000..f3185bd155 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/services/OnchainBackupRestoreDeviceTest.kt @@ -0,0 +1,92 @@ +package to.bitkit.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.WorkManager +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import to.bitkit.data.backup.VssBackupClient +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.models.BackupCategory +import to.bitkit.models.WalletBackupV1 +import to.bitkit.repositories.BackupRepo +import to.bitkit.repositories.OnchainSendAttemptStore +import javax.inject.Inject +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Opt-in remote backup check; run only on an owned funded test wallet. */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class OnchainBackupRestoreDeviceTest { + @get:Rule val hiltRule: HiltAndroidRule = HiltAndroidRule(this) + @Inject lateinit var backups: BackupRepo + @Inject lateinit var vss: VssBackupClient + @Inject lateinit var attempts: OnchainSendAttemptStore + @Inject lateinit var keychain: Keychain + + @Test + fun remoteWalletBackupRestoresTheOriginalAcceptedPayment(): Unit = runBlocking { + assumeTrue(InstrumentationRegistry.getArguments().getString("ownedWalletVss") == "true") + withTimeout(180_000) { + val context = ApplicationProvider.getApplicationContext() + Env.initAppStoragePath(context.filesDir.absolutePath) + hiltRule.inject() + WorkManager.initialize(context, Configuration.Builder().build()) + vss.setup().getOrThrow() + if (backups.hasPendingWalletRestore()) { + backups.performFullRestoreFromLatestBackup().getOrThrow() + } + val original = assertNotNull(attempts.current()) + assertTrue(original.hasPositiveEvidence && original.localFollowupComplete) + assertTrue(original.requestId == null && !original.isTransfer) + vss.setup(original.walletIndex).getOrThrow() + try { + keychain.upsertString( + Keychain.Key.ONCHAIN_SEND_ATTEMPT.name, + Json.encodeToString(original.copy(localFollowupComplete = false)), + original.walletIndex, + ) + assertTrue(assertNotNull(attempts.current()).blocksNextSend) + // Upload the complete owned fixture first: restoring must not rewind unrelated categories. + for (category in BackupCategory.entries.filterNot { it == BackupCategory.LIGHTNING_CONNECTIONS }) { + backups.triggerBackup(category).getOrThrow() + } + val remote = assertNotNull(vss.getObject(BackupCategory.WALLET.name).getOrThrow()) + val payload = Json.decodeFromString(remote.value.decodeToString()) + val wire = assertNotNull(payload.paykitPaymentState?.activeOnchainAttempt) + assertEquals(original.attemptId, wire.attemptId) + assertEquals(original.txid, wire.txid) + keychain.delete(Keychain.Key.ONCHAIN_SEND_ATTEMPT.name, original.walletIndex) + assertNull(attempts.current()) + backups.performFullRestoreFromLatestBackup().getOrThrow() + val restored = assertNotNull(attempts.current()) + assertEquals(original.attemptId, restored.attemptId) + assertEquals(original.walletIndex, restored.walletIndex) + assertEquals(original.walletId, restored.walletId) + assertEquals(original.txid, restored.txid) + assertEquals(original.amountSats, restored.amountSats) + assertEquals(original.originalInputs, restored.originalInputs) + assertEquals(original.candidateTxids, restored.candidateTxids) + assertTrue(restored.hasPositiveEvidence && restored.localFollowupComplete) + } finally { + attempts.restoreActive(original) + attempts.markLocalFollowupComplete(original.attemptId, original.walletIndex) + } + } + } +} diff --git a/app/src/androidTest/java/to/bitkit/services/PreparedOnchainRecoveryFixtureTest.kt b/app/src/androidTest/java/to/bitkit/services/PreparedOnchainRecoveryFixtureTest.kt new file mode 100644 index 0000000000..107228a412 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/services/PreparedOnchainRecoveryFixtureTest.kt @@ -0,0 +1,136 @@ +package to.bitkit.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.models.WalletScope +import to.bitkit.repositories.OnchainSendAttemptStore +import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.WalletRepo +import to.bitkit.test.annotations.CoreServiceIntegration +import to.bitkit.test.annotations.DeviceIntegration +import java.io.File +import javax.inject.Inject + +/** Disposable-device fixture: real signed receipt, deliberately seeded Pending, never a native fault claim. */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +@DeviceIntegration +@CoreServiceIntegration +class PreparedOnchainRecoveryFixtureTest { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var lightningService: LightningService + + @Inject + lateinit var coreService: CoreService + + @Inject + lateinit var keychain: Keychain + + @Inject + lateinit var walletRepo: WalletRepo + + @Inject + lateinit var store: OnchainSendAttemptStore + + @Inject + lateinit var settingsStore: SettingsStore + + @Test + fun verifySharedVssBindingVectors() { + val mnemonic = List(11) { "abandon" }.plus("about").joinToString(" ") + val vectors = mapOf( + "bitcoin" to "28f258542d91310274942356e7777d8978bac6dbd3fadbfbfe5d663b19f1f6c5", + "testnet" to "0ce1e7952991cf5ce84bfa135dbc194b3d55a7f2fc69807091c4ca1d6eaac809", + "signet" to "966319adba79fb70afbf6eb1c7bde0cf93433e08d6f2f832c30467b812005fbe", + "regtest" to "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0", + ) + vectors.forEach { (network, expected) -> + val storeId = com.synonym.vssclient.vssDeriveStoreId("bitkit_v1_$network", mnemonic, null) + val binding = java.security.MessageDigest.getInstance("SHA-256") + .digest(storeId.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + org.junit.Assert.assertEquals(expected, binding) + } + } + + @Test + fun verifyPersistedOriginalWinnerAfterUi() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + Env.initAppStoragePath(context.filesDir.absolutePath) + hiltRule.inject() + val attempt = requireNotNull(store.current()) + val expectedTxid = requireNotNull(InstrumentationRegistry.getArguments().getString("expectedWinnerTxid")) + org.junit.Assert.assertEquals(expectedTxid, attempt.txid) + org.junit.Assert.assertTrue(attempt.hasPositiveEvidence) + org.junit.Assert.assertTrue(attempt.localFollowupComplete) + org.junit.Assert.assertTrue(attempt.candidateTxids.contains(expectedTxid)) + org.junit.Assert.assertFalse(attempt.originalInputs.isNullOrEmpty()) + File(context.getExternalFilesDir(null), "recovery-final-native-receipt.json") + .writeText(Json.encodeToString(attempt)) + } + + @Test + fun seedPreparedOriginalForRecoveryUi() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + Env.initAppStoragePath(context.filesDir.absolutePath) + hiltRule.inject() + // Refuse a pre-existing wallet; the runner must use an owned, disposable app install. + check(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) == null) + walletRepo.createWallet(null).getOrThrow() + keychain.upsertString(Keychain.Key.PIN.name, "1234") // Public disposable-fixture PIN only. + settingsStore.update { + it.copy( + isPinEnabled = true, + isPinForPaymentsEnabled = true, + isBiometricEnabled = false, + electrumServer = InstrumentationRegistry.getArguments().getString("fixtureElectrumUrl") + ?: it.electrumServer, + ) + } + lightningService.setup(walletIndex = 0) + try { + lightningService.start() + // Let the native initial background sync settle before forcing another sync in this fixture. + delay(15_000) + lightningService.sync() + val depositAddress = lightningService.newAddress() + coreService.blocktank.regtestMine(1u) + coreService.blocktank.regtestDeposit(depositAddress, 100_000uL) + coreService.blocktank.regtestMine(6u) + delay(15_000) + lightningService.sync() + val recipient = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" + val isMax = InstrumentationRegistry.getArguments().getString("recoveryMode") == "max" + val original = store.admit( + walletId = WalletScope.default, requestId = null, orderId = null, address = recipient, + amountSats = 1_000uL, isMaxAmount = isMax, feeRateSatsPerVByte = 1uL, + isTransfer = false, channelId = null, tags = emptyList(), beforeSendAttempt = {}, + ) + val prepared = lightningService.prepareOnchainSend(recipient, 1_000uL, 1uL, isMaxAmount = isMax) + store.retainPreparedReceipt(original.attemptId, 0, prepared.receipt, false) + // No original broadcast: this seeds the uncertainty UI using actual native receipt provenance. + val pending = store.recordOutcome(original.attemptId, OnchainSendOutcome.Unknown(prepared.receipt.txid), 0) + File(context.getExternalFilesDir(null), "recovery-original-native-receipt.json") + .writeText(Json.encodeToString(pending)) + } finally { + lightningService.stop() + } + // Retain this disposable fixture for the real app UI journey; owned-device teardown removes it afterward. + } +} diff --git a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt index ddec667793..573360540f 100644 --- a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt @@ -14,12 +14,14 @@ import org.junit.Test import org.junit.runner.RunWith import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.WalletRepo import to.bitkit.test.annotations.CoreServiceIntegration import to.bitkit.test.annotations.DeviceIntegration import javax.inject.Inject import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -148,11 +150,12 @@ class TxBumpingTests { val lowFeeRate = 1uL // 1 sat/vbyte (very low) println("Sending $sendAmount sats to $destinationAddress with low fee rate of $lowFeeRate sat/vbyte") - val originalTxId = lightningService.send( + val outcome = lightningService.prepareOnchainSend( address = destinationAddress, sats = sendAmount, satsPerVByte = lowFeeRate, - ) + ).broadcast() + val originalTxId = assertIs(outcome).txid lightningService.sync() diff --git a/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt b/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt index b93fd0da34..a8a3c45604 100644 --- a/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt @@ -15,12 +15,14 @@ import org.junit.runner.RunWith import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.WalletRepo import to.bitkit.test.annotations.CoreServiceIntegration import to.bitkit.test.annotations.DeviceIntegration import javax.inject.Inject import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail @@ -203,12 +205,13 @@ class UtxoSelectionTests { val feeRate = 1uL // 1 sat/vbyte println("Sending $sendAmount sats to $destinationAddress using specific UTXOs") - val txId = lightningService.send( + val outcome = lightningService.prepareOnchainSend( address = destinationAddress, sats = sendAmount, satsPerVByte = feeRate, utxosToSpend = utxosToSpend - ) + ).broadcast() + val txId = assertIs(outcome).txid assertTrue(txId.isNotEmpty(), "Transaction ID should not be empty") println("Transaction sent successfully with txid: $txId") diff --git a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt index 1c9a3634de..8a252ae923 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt @@ -69,9 +69,9 @@ class DrawerMenuWidgetsTest { rootNavController = navController, hasSeenWidgetsIntro = false, hasSeenShopIntro = true, + profileIdentityExists = flowOf(false), onBeforeNavigate = {}, showWidgets = true, - profileIdentityExists = flowOf(false), ) } } @@ -102,9 +102,9 @@ class DrawerMenuWidgetsTest { rootNavController = navController, hasSeenWidgetsIntro = true, hasSeenShopIntro = true, + profileIdentityExists = flowOf(false), onBeforeNavigate = {}, showWidgets = true, - profileIdentityExists = flowOf(false), onOpenWidgetsHome = { openWidgetsHome.value = true }, ) if (openWidgetsHome.value) { @@ -139,9 +139,9 @@ class DrawerMenuWidgetsTest { rootNavController = navController, hasSeenWidgetsIntro = true, hasSeenShopIntro = true, + profileIdentityExists = flowOf(false), onBeforeNavigate = {}, showWidgets = false, - profileIdentityExists = flowOf(false), onOpenWidgetsHome = { error("Should not request home widgets page") }, onOpenWidgetsSheet = { openWidgetsSheet.value = true }, ) @@ -179,9 +179,9 @@ class DrawerMenuWidgetsTest { rootNavController = navController, hasSeenWidgetsIntro = true, hasSeenShopIntro = true, + profileIdentityExists = flowOf(true), onBeforeNavigate = {}, showWidgets = true, - profileIdentityExists = flowOf(true), isPaykitEnabled = true, ) } @@ -204,9 +204,9 @@ class DrawerMenuWidgetsTest { rootNavController = navController, hasSeenWidgetsIntro = true, hasSeenShopIntro = true, + profileIdentityExists = flowOf(true), onBeforeNavigate = {}, showWidgets = true, - profileIdentityExists = flowOf(true), isPaykitEnabled = false, ) } diff --git a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt index 8df160fece..079a727c1e 100644 --- a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt @@ -61,8 +61,8 @@ class MnemonicInputFieldTest { onPositionChange = {}, onBackspaceInEmpty = { backspaceInEmptyCount++ }, focusRequester = focusRequester, - isFocused = false, index = 0, + isFocused = true, ) } } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt new file mode 100644 index 0000000000..300e704c60 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt @@ -0,0 +1,181 @@ +package to.bitkit.ui.screens.wallets.send + +import android.content.Context +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.lifecycle.SavedStateHandle +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import to.bitkit.data.SettingsStore +import to.bitkit.data.WidgetsStore +import to.bitkit.data.backup.VssStoreIdProvider +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.models.ActiveOnchainAttemptBackup +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.ContactPaymentSettingsRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainSendAttemptStore +import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.WidgetsRepo +import to.bitkit.ui.LocalSettingsViewModel +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.viewmodels.SettingsViewModel +import java.io.File +import javax.inject.Inject +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** Owned-device integration: exact observation is injected after an independent read-only backend check. */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class SendPendingObservationDeviceTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createComposeRule() + + @Inject lateinit var store: OnchainSendAttemptStore + + @Inject lateinit var keychain: Keychain + + @Inject lateinit var lightningRepo: LightningRepo + + @Inject lateinit var activityRepo: ActivityRepo + + @Inject lateinit var settingsStore: SettingsStore + + @Inject lateinit var pubkyRepo: PubkyRepo + + @Inject lateinit var contactPaymentSettingsRepo: ContactPaymentSettingsRepo + + @Inject lateinit var publicPaykitRepo: PublicPaykitRepo + + @Inject lateinit var privatePaykitRepo: PrivatePaykitRepo + + @Inject lateinit var widgetsStore: WidgetsStore + + @Inject lateinit var widgetsRepo: WidgetsRepo + + @Inject lateinit var vssStoreIdProvider: VssStoreIdProvider + + @Test + fun restoredOriginalObservationNavigatesWithoutRetryOrNativeSend() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + Env.initAppStoragePath(context.filesDir.absolutePath) + hiltRule.inject() + val original = requireNotNull(store.current()) + val expected = requireNotNull(InstrumentationRegistry.getArguments().getString("expectedBackendTxid")) + assertEquals(expected, original.txid) + assertTrue(original.hasPositiveEvidence && original.localFollowupComplete) + assertTrue(original.requestId == null && !original.isTransfer) + assertTrue(!lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) + val binding = vssStoreIdProvider.getBackupWalletBinding(original.walletIndex) + val wire = ActiveOnchainAttemptBackup.from(original, Env.network.name.lowercase(), binding) + val restored = wire.restored(Env.network.name.lowercase(), binding, original.walletId, original.walletIndex) + assertTrue( + runCatching { + wire.restored(Env.network.name.lowercase(), "22".repeat(32), original.walletId, original.walletIndex) + }.isFailure, + ) + val attemptKey = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + val savedAttempt = requireNotNull(keychain.loadString(attemptKey, original.walletIndex)) + try { + // Test-only storage seam: production restore deliberately cannot downgrade positive evidence. + keychain.upsertString( + attemptKey, + Json.encodeToString(restored.copy(evidence = OnchainSendEvidence.Unknown)), + original.walletIndex, + ) + val pending = requireNotNull(store.current()) + assertTrue(pending.isUnresolved && !pending.hasPositiveEvidence && !pending.localFollowupComplete) + assertTrue(pending.restoredFromBackup) + assertEquals(original.attemptId, pending.attemptId) + assertEquals(original.originalInputs, store.current()?.originalInputs) + assertEquals(original.candidateTxids, store.current()?.candidateTxids) + val vm = SendPendingViewModel(PendingPaymentRepo(), activityRepo, lightningRepo) + val settings = SettingsViewModel( + context, + settingsStore, + pubkyRepo, + contactPaymentSettingsRepo, + widgetsStore, + widgetsRepo, + ) + var recovered by mutableStateOf?>(null) + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalSettingsViewModel provides settings) { + if (recovered == null) { + SendPendingScreen( + paymentHash = expected, + amount = 1L, // Production VM must restore the original receipt's amount. + observeResolution = false, + isOnchain = true, + onPaymentSuccess = { _, _ -> error("Lightning route must not complete") }, + onPaymentError = { error("No payment failed") }, + onClose = {}, + onViewDetails = {}, + viewModel = vm, + retryOriginal = { _, _, _ -> error("Observation must not invoke retry") }, + onRecovered = { txid, amount -> recovered = txid to amount }, + savedStateHandle = SavedStateHandle(), + onNavigateToPin = { error("Observation must not request spend authentication") }, + ) + } else { + Text("Original payment recovered") + } + } + } + } + composeTestRule.waitUntil(10_000) { vm.uiState.value.recoveryAttempt != null } + composeTestRule.onNodeWithText("Payment Pending").assertIsDisplayed() + screenshot(context, "current-pending-observation-integration.png") + // The runner independently checked this exact txid, inputs and amount. This injects the local seam; + // it does not claim a native event, broadcast fault, new acceptance or full application navigation. + val observed = assertNotNull(store.observeExactTransaction(expected)) + store.markLocalFollowupComplete(observed.attemptId, observed.walletIndex) + composeTestRule.waitUntil(10_000) { recovered != null } + assertEquals(expected to original.amountSats.toLong(), recovered) + composeTestRule.onNodeWithText("Original payment recovered").assertIsDisplayed() + screenshot(context, "current-recovered-observation-integration.png") + File(context.getExternalFilesDir(null), "current-observation-integration.json") + .writeText(Json.encodeToString(requireNotNull(store.current()))) + } finally { + keychain.upsertString(attemptKey, savedAttempt, original.walletIndex) + assertEquals(savedAttempt, keychain.loadString(attemptKey, original.walletIndex)) + } + assertEquals(original, store.current()) + } + + private fun screenshot(context: Context, name: String) { + val destination = File(context.getExternalFilesDir(null), name) + Thread.sleep(500) + val bitmap = requireNotNull(InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()) + destination.outputStream().use { output -> + check(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, output)) + } + bitmap.recycle() + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt new file mode 100644 index 0000000000..89dda8dccb --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt @@ -0,0 +1,153 @@ +package to.bitkit.ui.screens.wallets.send + +import android.graphics.Bitmap +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import java.io.File +import kotlin.test.assertEquals + +@ComposeUi +class SendPendingScreenTest { + companion object { + private const val SCREENSHOT_FRAME_WAIT_MS = 500L + } + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun unresolvedOnchainContentStaysPendingWhenVisibilityIsToggled() { + // Component rendering only; this fixture does not reload the durable attempt or attempt another send. + var visible by mutableStateOf(true) + var closeCount = 0 + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + if (visible) { + SendPendingContent( + amount = 1_000L, + isOnchain = true, + activityId = null, + txid = "ab".repeat(32), + refusalReason = "Test backend refusal", + onClose = { closeCount++; visible = false }, + onViewDetails = { error("Unresolved send has no activity") }, + ) + } + } + } + } + assertUnresolved() + saveScreenshot("ln112-babysit-pending-refused-component.png") + composeTestRule.onNodeWithText("Close").performClick() + composeTestRule.runOnIdle { assertEquals(1, closeCount); visible = true } + assertUnresolved() + saveScreenshot("ln112-babysit-pending-refused-visible-component.png") + } + + @Test + fun exactCandidateWithLocalActivityOffersDetailsWithoutClaimingAcceptance() { + var detailsId: String? = null + val txid = "cd".repeat(32) + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + SendPendingContent(amount = 1_000L, isOnchain = true, activityId = "queued-local-activity", + txid = txid, onClose = {}, onViewDetails = { detailsId = it }) + } + } + } + composeTestRule.onNodeWithText(txid, substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Payment Pending").assertIsDisplayed() + composeTestRule.onNodeWithText("Payment Sent").assertDoesNotExist() + saveScreenshot("ln112-babysit-pending-details-component.png") + composeTestRule.onNodeWithText("Details").performClick() + composeTestRule.runOnIdle { assertEquals("queued-local-activity", detailsId) } + } + + @Test + fun recoveryErrorKeepsAllActionsInsideCompactViewport() { + var retries = 0 + var closes = 0 + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + Box(Modifier.requiredSize(360.dp, 640.dp).testTag("pending-viewport")) { + SendPendingContent( + amount = 99_890L, + isOnchain = true, + activityId = null, + txid = "ab".repeat(32), + onClose = { closes++ }, + onViewDetails = {}, + canRetry = true, + recoveryError = "The original payment could not be retried. Its funds remain protected. " + + "There may be insufficient funds for the chosen fee. " + + "Try another fee or check the transaction status.", + onRetry = { retries++ }, + ) + } + } + } + } + val viewport = composeTestRule.onNodeWithTag("pending-viewport").fetchSemanticsNode().boundsInRoot + listOf("Retry original payment", "Details", "Close").forEach { text -> + val node = composeTestRule.onNodeWithText(text) + node.assertIsDisplayed() + val bounds = node.fetchSemanticsNode().boundsInRoot + check(bounds.top >= viewport.top && bounds.bottom <= viewport.bottom) { + "$text is clipped: $bounds outside $viewport" + } + } + val retryBounds = composeTestRule.onNodeWithText("Retry original payment").fetchSemanticsNode().boundsInRoot + val closeBounds = composeTestRule.onNodeWithText("Close").fetchSemanticsNode().boundsInRoot + check(retryBounds.bottom < closeBounds.top) { "Retry and Close overlap: $retryBounds / $closeBounds" } + composeTestRule.onNodeWithText("The original payment could not be retried.", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Retry original payment").performClick() + composeTestRule.onNodeWithText("Close").performClick() + composeTestRule.runOnIdle { assertEquals(1, retries); assertEquals(1, closes) } + saveScreenshot("ln112-pending-compact-error-component.png") + } + + private fun assertUnresolved() { + composeTestRule.onNodeWithText("Test backend refusal", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("ab".repeat(32), substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Payment Pending").assertIsDisplayed() + composeTestRule.onNodeWithText("Other payments remain blocked", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Details").assertIsNotEnabled() + composeTestRule.onNodeWithText("Retry").assertDoesNotExist() + composeTestRule.onNodeWithText("Payment Sent").assertDoesNotExist() + } + + private fun saveScreenshot(name: String) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + composeTestRule.waitForIdle() + instrumentation.waitForIdleSync() + // Semantics may be committed before the emulator compositor presents that frame. + android.os.SystemClock.sleep(SCREENSHOT_FRAME_WAIT_MS) + val image = requireNotNull(instrumentation.uiAutomation.takeScreenshot()) + File(instrumentation.targetContext.getExternalFilesDir(null), name).outputStream().use { + check(image.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + image.recycle() + } +} diff --git a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt index 5f88f13a94..35c7bc524b 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt @@ -5,6 +5,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError +import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -30,11 +31,21 @@ class VssStoreIdProvider @Inject constructor( ) cacheMap[walletIndex] = storeId - Logger.info("VSS store id setup for wallet[$walletIndex]: '$storeId'", context = TAG) + Logger.info("VSS store id setup for wallet[$walletIndex]", context = TAG) return storeId } } + fun getBackupWalletBinding(walletIndex: Int): String { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name, walletIndex) + ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name, walletIndex) + val indexedStoreId = vssDeriveStoreId(Env.vssStoreIdPrefix, mnemonic, passphrase) + check(indexedStoreId == getVssStoreId()) { "Backup namespace does not match original indexed wallet" } + return MessageDigest.getInstance("SHA-256").digest(indexedStoreId.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + } + fun clearCache() { cacheMap.clear() } diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 1a8df74c5d..193f2f5d23 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -52,10 +52,15 @@ class Keychain @Inject constructor( fun loadString(key: String): String? = load(key)?.decodeToString() + fun loadString(key: String, walletIndex: Int): String? = + loadIndexed(key, stringPreferencesKey("${key}_$walletIndex"))?.decodeToString() + + fun load(key: String): ByteArray? = loadIndexed(key, key.indexed) + @Suppress("TooGenericExceptionCaught") - fun load(key: String): ByteArray? { + private fun loadIndexed(key: String, indexedKey: Preferences.Key): ByteArray? { try { - return snapshot[key.indexed]?.fromBase64()?.let { + return snapshot[indexedKey]?.fromBase64()?.let { keyStore.decrypt(it) } } catch (c: CancellationException) { @@ -131,11 +136,16 @@ class Keychain @Inject constructor( Logger.info("Saved value for key '$key'", context = TAG) } + suspend fun upsertString(key: String, value: String) = upsertIndexed(key, value, key.indexed) + + suspend fun upsertString(key: String, value: String, walletIndex: Int) = + upsertIndexed(key, value, stringPreferencesKey("${key}_$walletIndex")) + @Suppress("TooGenericExceptionCaught") - suspend fun upsertString(key: String, value: String) { + private suspend fun upsertIndexed(key: String, value: String, indexedKey: Preferences.Key) { try { val encryptedValue = keyStore.encrypt(value.toByteArray()) - keychain.edit { it[key.indexed] = encryptedValue.toBase64() } + keychain.edit { it[indexedKey] = encryptedValue.toBase64() } } catch (c: CancellationException) { throw c } catch (t: Throwable) { @@ -144,10 +154,14 @@ class Keychain @Inject constructor( Logger.info("Upserted value for key '$key'", context = TAG) } + suspend fun delete(key: String) = deleteIndexed(key, key.indexed) + + suspend fun delete(key: String, walletIndex: Int) = deleteIndexed(key, stringPreferencesKey("${key}_$walletIndex")) + @Suppress("TooGenericExceptionCaught") - suspend fun delete(key: String) { + private suspend fun deleteIndexed(key: String, indexedKey: Preferences.Key) { try { - keychain.edit { it.remove(key.indexed) } + keychain.edit { it.remove(indexedKey) } } catch (c: CancellationException) { throw c } catch (t: Throwable) { @@ -236,6 +250,7 @@ class Keychain @Inject constructor( PAYKIT_RECOVERY_BACKUP, PAYKIT_PENDING_BACKUP_RESTORE, PAYKIT_PENDING_PAYMENT_PROOFS, + ONCHAIN_SEND_ATTEMPT, PAYKIT_ACCEPTED_PAYMENT_REQUESTS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, PUBKY_SECRET_KEY, diff --git a/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt index 192354dc73..076410ed36 100644 --- a/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt @@ -2,6 +2,10 @@ package to.bitkit.ext import com.synonym.bitkitcore.BroadcastException import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull fun Throwable.isBroadcastConnectivityFailure(): Boolean = generateSequence(this) { it.cause }.any { @@ -11,3 +15,59 @@ fun Throwable.isBroadcastConnectivityFailure(): Boolean = fun Throwable.isDefiniteHardwarePreBroadcastFailure(): Boolean = generateSequence(this) { it.cause }.any { it is BroadcastException.InvalidHex || it is BroadcastException.InvalidTransaction } + +// A recognized node refusal permits leaving the sheet, but never proves non-delivery. +fun Throwable.isHardwareBroadcastRefusalForNavigation(): Boolean = + isDefiniteHardwarePreBroadcastFailure() || generateSequence(this) { it.cause }.any { + if (it !is BroadcastException.ElectrumException) return@any false + val details = it.errorDetails + val prefix = "broadcast failed: " + if (!details.startsWith(prefix, ignoreCase = true)) return@any false + val reason = details.drop(prefix.length) + val serverPrefix = "electrum server error: " + val message = if (reason.startsWith(serverPrefix, ignoreCase = true)) { + refusalMessageFromJson(reason.drop(serverPrefix.length)) ?: return@any false + } else { + reason + } + message.isKnownHardwareRefusal() + } + +private fun refusalMessageFromJson(payload: String): String? = runCatching { + when (val element = Json.parseToJsonElement(payload)) { + is JsonPrimitive -> element.takeIf { it.isString }?.contentOrNull + is JsonObject -> (element["message"] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull + else -> null + } +}.getOrNull() + +private fun String.isKnownHardwareRefusal(): Boolean { + val rpcPrefix = "sendrawtransaction RPC error: " + val message = if (startsWith(rpcPrefix, ignoreCase = true)) { + refusalMessageFromJson(drop(rpcPrefix.length)) ?: return false + } else if (startsWith("sendrawtransaction RPC error ", ignoreCase = true)) { + val coded = drop("sendrawtransaction RPC error ".length) + val code = coded.substringBefore(": ") + if (code !in listOf("-25", "-26") || !coded.startsWith("$code: ")) return false + coded.drop(code.length + 2) + } else { + this + } + // Match the complete Bitcoin Core replacement refusal; unknown suffixes remain guarded. + val replacementRefusal = Regex( + """insufficient fee, rejecting replacement [0-9a-f]{64}; new feerate """ + + """[0-9]+\.[0-9]{8} btc/kvb <= old feerate [0-9]+\.[0-9]{8} btc/kvb""", + ) + if (replacementRefusal.matches(message.lowercase())) return true + val reasons = listOf( + "min relay fee not met", + "mempool min fee not met", + "bad-txns-inputs-missingorspent", + "txn-mempool-conflict", + "non-final", + ) + return message.lineSequence().any { line -> + val reason = line.trim().lowercase() + reasons.any { known -> reason == known || reason.startsWith("$known,") || reason.startsWith("$known ") } + } +} diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt new file mode 100644 index 0000000000..89c4c9956c --- /dev/null +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -0,0 +1,192 @@ +package to.bitkit.models + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.repositories.OnchainSendInput +import to.bitkit.repositories.OnchainTransferContext +import to.bitkit.repositories.PaykitPaymentProofKind +import to.bitkit.repositories.PaykitPaymentRequestId + +/** Shared v1: one active operation; integer strings avoid cross-platform precision loss. */ +@Serializable +@Suppress("LongParameterList") +data class ActiveOnchainAttemptBackup( + val version: Int = 1, + val wallet: Wallet, + val attemptId: String, + val requestId: PaykitPaymentRequestId? = null, + val orderId: String? = null, + val payerIdentity: String? = null, + val address: String, + val amountSats: String, + val isMaxAmount: Boolean, + val status: String, + val txid: String? = null, + val rejectionReason: String? = null, + val originalInputs: List? = null, + val candidateTxids: List = emptyList(), + val feeRateSatsPerVByte: String, + val candidateFeeRates: Map? = null, + val followup: Followup? = null, + val transfer: Transfer? = null, +) { + @Serializable + data class Wallet(val kind: String = "software", val network: String, val binding: String, val sourceIndex: String) + + @Serializable + data class Input(val txid: String, val vout: String) + + @Serializable + data class Followup( + val feeSats: String, + val tags: List, + val contact: JsonElement? = null, + val createdAtMillis: String, + val channelId: String? = null, + ) + + @Serializable + data class Transfer( + val txTotalSats: String, + val preTransferOnchainSats: String, + val originalOrderClientBalanceSats: String, + val originalOrderFeeSats: String, + ) + + @Suppress("LongMethod", "CyclomaticComplexMethod") + fun restored(network: String, binding: String, walletId: String, destinationIndex: Int): OnchainSendAttempt { + require(version == 1 && wallet.kind == "software") + require(wallet.network in setOf("bitcoin", "testnet", "signet", "regtest") && wallet.network == network) + require(wallet.binding.matches(HEX) && wallet.binding == binding) + require(unsigned(wallet.sourceIndex) <= Int.MAX_VALUE.toULong() && destinationIndex >= 0) + require(attemptId.matches(Regex("[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}"))) + require(address.isNotBlank() && unsigned(amountSats) > 0uL) + require(requestId == null || orderId == null) + require( + requestId == null || + (payerIdentity != null && PubkyPublicKeyFormat.normalized(payerIdentity) == payerIdentity) + ) + require(unsigned(feeRateSatsPerVByte) <= UInt.MAX_VALUE.toULong()) + require(candidateTxids.distinct().size == candidateTxids.size && candidateTxids.all { it.matches(HEX) }) + val restoredFeeRates = candidateFeeRates.orEmpty().mapValues { (txid, rate) -> + require(txid.matches(HEX) && txid in candidateTxids) + unsigned(rate).also { require(it > 0uL && it <= UInt.MAX_VALUE.toULong()) } + } + val inputs = originalInputs?.map { input -> + require(input.txid.matches(HEX) && unsigned(input.vout) <= UInt.MAX_VALUE.toULong()) + OnchainSendInput(input.txid, unsigned(input.vout).toUInt()) + } + require(!inputs.isNullOrEmpty() && inputs.distinct().size == inputs.size) + require(unsigned(feeRateSatsPerVByte) > 0uL) + require(candidateTxids.isNotEmpty() && txid in candidateTxids) + val evidence = when (status) { + "pending" -> OnchainSendEvidence.Pending + "accepted" -> OnchainSendEvidence.Accepted.also { require(txid != null && txid in candidateTxids) } + "rejected" -> OnchainSendEvidence.Rejected + "unknown" -> OnchainSendEvidence.Unknown + else -> error("Unsupported active operation status") + } + require(candidateTxids.drop(1).all { it in restoredFeeRates }) + restoredFeeRates[candidateTxids.first()]?.let { require(it == unsigned(feeRateSatsPerVByte)) } + requireNotNull(followup).let { + unsigned(it.feeSats) + unsigned(it.createdAtMillis) + it.contact?.let { contact -> + require(contact is JsonPrimitive && contact.isString && contact.content.isNotBlank()) + } + } + val transferContext = transfer?.let { + require(orderId != null) + require(unsigned(it.txTotalSats) <= Long.MAX_VALUE.toULong()) + require(unsigned(it.preTransferOnchainSats) <= Long.MAX_VALUE.toULong()) + val originalFee = unsigned(it.originalOrderFeeSats) + val recipientAmount = unsigned(amountSats) + require(recipientAmount <= unsigned(it.txTotalSats)) + require( + if (isMaxAmount) { + recipientAmount >= originalFee + } else { + recipientAmount == originalFee + } + ) + OnchainTransferContext( + unsigned(it.txTotalSats), + unsigned(it.preTransferOnchainSats), + unsigned(it.originalOrderClientBalanceSats), + unsigned(it.originalOrderFeeSats), + ) + } + require(orderId == null || transferContext != null) + return OnchainSendAttempt( + walletId = walletId, walletIndex = destinationIndex, attemptId = attemptId, + requestId = requestId, orderId = orderId, payerIdentity = payerIdentity, + address = address, amountSats = unsigned(amountSats), isMaxAmount = isMaxAmount, + feeRateSatsPerVByte = unsigned(feeRateSatsPerVByte), isTransfer = orderId != null, + channelId = followup?.channelId, tags = followup?.tags.orEmpty(), evidence = evidence, + txid = txid, refusalReason = rejectionReason, localFollowupComplete = false, + originalInputs = inputs, candidateTxids = candidateTxids, candidateFeeRates = restoredFeeRates, + transferContext = transferContext, + backupFollowup = followup, restoredFromBackup = true, + ) + } + + fun validateProofs(proofs: List, defaultWalletId: String) { + val originalRequest = requestId ?: return + val proof = proofs.single { it.requestId == originalRequest && it.identity == payerIdentity } + check(proof.kind == PaykitPaymentProofKind.Onchain.type && proof.paymentStarted) + check(proof.onchainWalletId == null || proof.onchainWalletId == defaultWalletId) + check(proof.onchainAddress == address && proof.onchainAmountSats?.toString() == amountSats) + check(proof.paymentIdentifier == null || proof.paymentIdentifier in candidateTxids) + check( + proof.proofData == null || (proof.proofData == proof.paymentIdentifier && proof.proofData in candidateTxids) + ) + if (proof.onchainAcceptanceVerified) { + check(status == "accepted" && txid != null && proof.paymentIdentifier == txid && proof.proofData == txid) + } + } + + companion object { + private val HEX = Regex("[0-9a-f]{64}") + private fun unsigned(value: String): ULong { + require(value.matches(Regex("0|[1-9][0-9]*"))) + return value.toULong() + } + + fun from(attempt: OnchainSendAttempt, network: String, binding: String): ActiveOnchainAttemptBackup { + require(attempt.walletId == WalletScope.default) + val wire = ActiveOnchainAttemptBackup( + wallet = Wallet(network = network, binding = binding, sourceIndex = attempt.walletIndex.toString()), + attemptId = attempt.attemptId, requestId = attempt.requestId, orderId = attempt.orderId, + payerIdentity = attempt.payerIdentity, + address = attempt.address, + amountSats = attempt.amountSats.toString(), + isMaxAmount = attempt.isMaxAmount, + status = when (attempt.evidence) { + OnchainSendEvidence.Pending -> "pending" + OnchainSendEvidence.Accepted, OnchainSendEvidence.Observed -> "accepted" + OnchainSendEvidence.Rejected -> "rejected" + OnchainSendEvidence.Unknown -> "unknown" + }, + txid = attempt.txid, rejectionReason = attempt.refusalReason, + originalInputs = attempt.originalInputs?.map { Input(it.txid, it.vout.toString()) }, + candidateTxids = attempt.candidateTxids, feeRateSatsPerVByte = attempt.feeRateSatsPerVByte.toString(), + candidateFeeRates = attempt.candidateFeeRates.takeIf { it.isNotEmpty() } + ?.mapValues { it.value.toString() }, + followup = attempt.backupFollowup, + transfer = attempt.transferContext?.let { + Transfer( + it.txTotalSats.toString(), + it.preTransferOnchainSats.toString(), + requireNotNull(it.originalOrderClientBalanceSats).toString(), + requireNotNull(it.originalOrderFeeSats).toString(), + ) + }, + ) + wire.restored(network, binding, attempt.walletId, attempt.walletIndex) + return wire + } + } +} diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index 205a881e1f..efbdb50c7e 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -16,6 +16,7 @@ import kotlin.time.Instant data class PaykitPaymentStateBackup( val subscriptions: Map, val pendingProofs: List, + val activeOnchainAttempt: ActiveOnchainAttemptBackup? = null, val acceptedOneTimeRequests: Map>? = null, ) { @Serializable @@ -42,7 +43,7 @@ data class PaykitPaymentStateBackup( val identity: String, val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, - val paymentAppId: String, + val paymentAppId: String = "", val kind: String, val paymentStarted: Boolean, val paymentIdentifier: String? = null, @@ -52,7 +53,22 @@ data class PaykitPaymentStateBackup( val onchainAmountSats: ULong? = null, val onchainWalletId: String? = null, val onchainMatchingTransactionIdsBeforeAttempt: Set, + val onchainAcceptanceVerified: Boolean = false, + val hardwareSignedTransaction: String? = null, + val hardwareMiningFeeSats: ULong? = null, + val hardwareFeeRate: ULong? = null, + val hardwareTotalSpent: ULong? = null, + val hardwareDispatchAttempted: Boolean? = null, + val privatePaymentListVersion: ULong? = null, + val previousPrivatePaymentListVersion: ULong? = null, ) { + init { + require( + previousPrivatePaymentListVersion == null || + (privatePaymentListVersion != null && previousPrivatePaymentListVersion < privatePaymentListVersion) + ) { "Invalid private payment-list boundary" } + } + constructor(proof: PendingPaykitPaymentProof) : this( identity = proof.identity, requestId = proof.requestId, @@ -67,6 +83,14 @@ data class PaykitPaymentStateBackup( onchainAmountSats = proof.onchainAmountSats, onchainWalletId = proof.onchainWalletId, onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt, + onchainAcceptanceVerified = proof.onchainAcceptanceVerified, + hardwareSignedTransaction = proof.hardwareSignedTransaction, + hardwareMiningFeeSats = proof.hardwareMiningFeeSats, + hardwareFeeRate = proof.hardwareFeeRate, + hardwareTotalSpent = proof.hardwareTotalSpent, + hardwareDispatchAttempted = proof.hardwareDispatchAttempted, + privatePaymentListVersion = proof.privatePaymentListVersion, + previousPrivatePaymentListVersion = proof.previousPrivatePaymentListVersion ) fun restored() = PendingPaykitPaymentProof( @@ -83,6 +107,14 @@ data class PaykitPaymentStateBackup( onchainAmountSats = onchainAmountSats, onchainWalletId = onchainWalletId ?: WalletScope.default, onchainMatchingTransactionIdsBeforeAttempt = onchainMatchingTransactionIdsBeforeAttempt, + onchainAcceptanceVerified = onchainAcceptanceVerified, + hardwareSignedTransaction = hardwareSignedTransaction, + hardwareMiningFeeSats = hardwareMiningFeeSats, + hardwareFeeRate = hardwareFeeRate, + hardwareTotalSpent = hardwareTotalSpent, + hardwareDispatchAttempted = hardwareDispatchAttempted, + privatePaymentListVersion = privatePaymentListVersion, + previousPrivatePaymentListVersion = previousPrivatePaymentListVersion ) } } diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index af0023109b..479b414c2b 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -209,6 +209,31 @@ class ActivityRepo @Inject constructor( walletId: String = WalletScope.default, ): OnchainActivity? = coreService.activity.getOnchainActivityByTxId(txid, walletId) + /** Local follow-up after the original hardware wallet independently observed this exact outgoing tx. */ + suspend fun completeObservedHardwarePayment( + walletId: String, + txid: String, + address: String, + amountSats: ULong, + fee: ULong, + feeRate: ULong, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + require(walletId != WalletScope.default) + coreService.activity.createSentOnchainActivityFromSendResult( + txid = txid, address = address, amount = amountSats, fee = fee, feeRate = feeRate, + isTransfer = false, channelId = null, walletId = walletId, + ) + // The Core writer logs storage errors internally. Read back before completing the proof. + val activity = getOnchainActivityByTxId(txid, walletId) + check(activity?.walletId == walletId && activity.txId.equals(txid, true) && + activity.txType == PaymentType.SENT) { "Original hardware payment activity is not durable" } + val tagsChanged = coreService.activity.restoreSentOnchainTags(txid, walletId) + notifyPaymentActivityChanged() + if (tagsChanged) notifyActivitiesChanged(tagsChanged = true) + } + } + /** * Checks if a transaction is inbound (received) by looking up the payment direction. */ @@ -504,14 +529,15 @@ class ActivityRepo @Inject constructor( ) return@runCatching } - if (PubkyPublicKeyFormat.matches(activity.contact(), normalizedKey)) { - return@runCatching + val updatedAt = nowTimestamp().epochSecond.toULong() + val updatedActivity = if (PubkyPublicKeyFormat.matches(activity.contact(), normalizedKey)) { + activity + } else { + activity.withContact(normalizedKey, updatedAt).also { + updateActivity(it.rawId(), it).getOrThrow() + } } - cacheStore.setActivityContactDetached(activity.rawId(), walletId, detached = false) - val updatedAt = nowTimestamp().epochSecond.toULong() - val updatedActivity = activity.withContact(normalizedKey, updatedAt) - updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() updateReplacementContactIfNeeded(updatedActivity, normalizedKey, updatedAt, walletId) }.onFailure { Logger.error("Failed to set contact for payment '$forPaymentId'", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index f759ce09fc..4512fdda2a 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -38,14 +38,18 @@ import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.backup.VssBackupClientLdk +import to.bitkit.data.backup.VssStoreIdProvider +import to.bitkit.data.entities.TransferEntity import to.bitkit.data.hwWalletNames import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin import to.bitkit.di.IoDispatcher import to.bitkit.di.json +import to.bitkit.env.Env import to.bitkit.ext.formatPlural import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus @@ -55,11 +59,15 @@ import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.SettingsBackupV1 import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 +import to.bitkit.models.WalletScope import to.bitkit.models.WidgetsBackupV1 +import to.bitkit.models.toLdkNetwork +import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.services.PaykitSdkService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger +import to.bitkit.utils.NetworkValidationHelper import to.bitkit.utils.jsonLogOf import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject @@ -100,6 +108,7 @@ class BackupRepo @Inject constructor( private val hwWalletStore: HwWalletStore, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, + private val coreService: CoreService, private val pubkyRepo: PubkyRepo, private val paykitSdkService: PaykitSdkService, private val privatePaykitRepo: Provider, @@ -109,6 +118,10 @@ class BackupRepo @Inject constructor( private val paykitPaymentRequestRepo: Provider, private val paykitPresentationStore: PaykitPaymentRequestPresentationStore, private val keychain: Keychain, + private val onchainSendAttemptStore: OnchainSendAttemptStore, + private val transferRepo: Provider, + private val lightningRepo: Provider, + private val vssStoreIdProvider: VssStoreIdProvider, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val lightningService: LightningService, private val clock: Clock, @@ -356,6 +369,7 @@ class BackupRepo @Inject constructor( dataListenerJobs.add(observeBackupChanges(privatePaykitRepo.get().backupStateVersion, BackupCategory.WALLET)) dataListenerJobs.add(observeBackupChanges(paykitSdkService.backupStateVersion, BackupCategory.WALLET)) dataListenerJobs.add(observeBackupChanges(paykitPaymentProofStore.backupStateVersion, BackupCategory.WALLET)) + dataListenerJobs.add(observeBackupChanges(onchainSendAttemptStore.backupStateVersion, BackupCategory.WALLET)) dataListenerJobs.add(observeBackupChanges(paykitPresentationStore.backupStateVersion, BackupCategory.WALLET)) dataListenerJobs.add( observeBackupChanges( @@ -414,7 +428,7 @@ class BackupRepo @Inject constructor( scope.launch { failedBackupRequired -= category cacheStore.updateBackupStatus(category) { - it.copy(required = currentTimeMillis()) + it.copy(required = maxOf(currentTimeMillis(), it.required + 1, it.synced + 1)) } Logger.verbose("Marked backup required for: '$category'", context = TAG) } @@ -499,28 +513,24 @@ class BackupRepo @Inject constructor( suspend fun triggerBackup(category: BackupCategory): Result = withContext(ioDispatcher) { Logger.debug("Backup starting for: '$category'", context = TAG) - val backupRequired = currentTimeMillis() + var backupRequired = currentTimeMillis() runningBackups += category failedBackupRequired -= category try { cacheStore.updateBackupStatus(category) { + backupRequired = maxOf(backupRequired, it.required, it.synced + 1) it.copy(running = true, required = backupRequired) } - val data = runSuspendCatching { getBackupDataBytes(category) } .getOrElse { markBackupFailed(category, backupRequired, it) return@withContext Result.failure(it) } - vssBackupClient.putObject(key = category.name, data = data) .onSuccess { withContext(NonCancellable) { cacheStore.updateBackupStatus(category) { - it.copy( - running = false, - synced = currentTimeMillis(), - ) + it.copy(running = false, synced = backupRequired) } runningBackups -= category failedBackupRequired -= category @@ -640,11 +650,42 @@ class BackupRepo @Inject constructor( check(!keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) { "Wallet backup restore is incomplete" } - val transfers = db.transferDao().getAll() + lateinit var transfers: List val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow() - val paykitSdkBackupState = privatePaykitRepo.get().backupSnapshot().getOrThrow() + val privateRepo = privatePaykitRepo.get() + val privateVersion = privateRepo.backupStateVersion.value + val sdkVersion = paykitSdkService.backupStateVersion.value + val paykitSdkBackupState = privateRepo.backupSnapshot().getOrThrow() val watchOnlyAccountSnapshot = watchOnlyAccountStore.backupSnapshot() + val walletIndex = backupWalletIndex() + val (snapshotAttempt, snapshotProofs) = onchainSendAttemptStore.backupSnapshot(walletIndex) { + // Follow-up writes its transfer before completing the guarded attempt. Capture the + // transfer under that same attempt lock so a backup cannot omit both. + transfers = db.transferDao().getAll() + paykitPaymentProofRepo.get().backupSnapshot() + } + // Every unsigned local guard is unrecoverable without its receipt. Defer the entire + // snapshot until signing finishes; never omit only the guard or related Shop proof. + check( + snapshotAttempt?.let { + !it.restoredFromBackup && it.evidence == OnchainSendEvidence.Pending && it.txid == null && + it.originalInputs == null && it.candidateTxids.isEmpty() + } != true + ) { "Waiting for signed receipt before wallet backup" } + check(privateRepo.backupStateVersion.value == privateVersion && + paykitSdkService.backupStateVersion.value == sdkVersion + ) { "Private payment state changed during wallet snapshot" } + val proofs = snapshotProofs + val active = snapshotAttempt?.let { attempt -> + val wire = ActiveOnchainAttemptBackup.from( + attempt, + Env.network.name.lowercase(), + vssStoreIdProvider.getBackupWalletBinding(walletIndex), + ) + validateActiveProof(wire, proofs) + wire + } val payload = WalletBackupV1( createdAt = currentTimeMillis(), transfers = transfers, @@ -654,7 +695,8 @@ class BackupRepo @Inject constructor( watchOnlyAccountAllocationState = watchOnlyAccountSnapshot.allocationState, paykitPaymentState = PaykitPaymentStateBackup( subscriptions = paykitPresentationStore.backupSnapshot(), - pendingProofs = paykitPaymentProofRepo.get().backupSnapshot(), + pendingProofs = proofs, + activeOnchainAttempt = active, acceptedOneTimeRequests = paykitPresentationStore.acceptedOneTimeBackupSnapshot(), ), ) @@ -792,7 +834,28 @@ class BackupRepo @Inject constructor( private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long { keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, String(dataBytes)) val parsed = json.decodeFromString(String(dataBytes)) + var restoredAttempt: OnchainSendAttempt? = null parsed.paykitPaymentState?.let { + // Validate every proof before writing a guard or replacing existing payment state. + it.pendingProofs.forEach { proof -> proof.restored() } + val walletIndex = backupWalletIndex() + it.activeOnchainAttempt?.let { wire -> + validateActiveProof(wire, it.pendingProofs) + val attempt = wire.restored( + Env.network.name.lowercase(), + vssStoreIdProvider.getBackupWalletBinding(walletIndex), + WalletScope.default, + walletIndex, + ) + val recipient = coreService.validateBitcoinAddress(attempt.address) + require(!NetworkValidationHelper.isNetworkMismatch(recipient.network.toLdkNetwork(), Env.network)) { + "Restored payment recipient is on another network" + } + check(backupWalletIndex() == walletIndex) { "Backup wallet changed during restore" } + onchainSendAttemptStore.restoreActive(attempt) + restoredAttempt = attempt + check(backupWalletIndex() == walletIndex) { "Backup wallet changed during restore" } + } paykitPaymentRequestRepo.get().clear() paykitPresentationStore.restoreBackup(it.subscriptions) paykitPaymentProofRepo.get().restoreBackup(it.pendingProofs) @@ -816,10 +879,46 @@ class BackupRepo @Inject constructor( pubkyRepo.publicKey.value?.let { paykitPaymentRequestRepo.get().activate(it) } } keychain.delete(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) + // Restore can finish while the node is already Running: no new lifecycle/event is guaranteed. + restoredAttempt?.let { restored -> + onchainSendAttemptStore.current()?.takeIf { + it.attemptId == restored.attemptId && it.walletIndex == restored.walletIndex + } + }?.takeIf { it.hasPositiveEvidence }?.let { attempt -> + if (attempt.isTransfer) { + transferRepo.get().resumeAcceptedFunding(attempt).onFailure { + Logger.warn("Restored accepted funding remains pending local follow-up", it, context = TAG) + } + } else if (attempt.requestId != null && !attempt.localFollowupComplete) { + runSuspendCatching { paykitPaymentProofRepo.get().reconcile() }.onFailure { + Logger.warn("Restored accepted Shop payment remains pending local follow-up", it, context = TAG) + } + } else if (attempt.requestId == null && !attempt.localFollowupComplete) { + runSuspendCatching { + lightningRepo.get().completeAcceptedOrdinaryFollowup(requireNotNull(attempt.txid)) + }.onFailure { + Logger.warn("Restored accepted payment remains pending local follow-up", it, context = TAG) + } + } + } Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) return parsed.createdAt } + // Match the actual custom VSS namespace selected by Keychain, then verify its derived binding. + private suspend fun backupWalletIndex(): Int { + val index = db.configDao().getAll().first().firstOrNull()?.walletIndex ?: 0L + require(index in 0..Int.MAX_VALUE.toLong()) + return index.toInt() + } + + private fun validateActiveProof( + wire: ActiveOnchainAttemptBackup, + proofs: List, + ) { + wire.validateProofs(proofs, WalletScope.default) + } + suspend fun getLatestBackupTime(): ULong? = withContext(ioDispatcher) { runCatching { withTimeout(VSS_TIMESTAMP_TIMEOUT) { diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 6f03f11476..18af1857fa 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -884,6 +884,57 @@ class HwWalletRepo @Inject constructor( } } + suspend fun broadcastFundingAtBoundary( + signedTx: HwFundingSignedTx, + paymentDeadlineAt: Instant?, + beforeNativeBroadcast: suspend () -> Unit, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val txId = trezorRepo.broadcastRawTxAtBoundary( + signedTx.serializedTx, paymentDeadlineAt, beforeNativeBroadcast + ).getOrThrow() + HwFundingBroadcastResult(txId, signedTx.miningFeeSats, signedTx.feeRate, signedTx.totalSpent) + } + } + + /** Fresh backend observation of this exact transaction in the original hardware wallet. */ + suspend fun observeExactTransaction( + walletId: String, + txid: String, + originalAddress: String? = null, + originalAmountSats: ULong? = null, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + require(walletId != WalletScope.default && txid.matches(Regex("[0-9a-fA-F]{64}"))) + val account = getFundingAccount(walletId).getOrThrow() + val detail = trezorRepo.getTransactionDetail( + extendedKey = account.xpub, + txid = txid, + network = Env.network.toCoreNetwork(), + scriptType = account.accountType, + ).getOrThrow() + if (!detail.txid.equals(txid, ignoreCase = true) || detail.sent == 0uL) { + return@runSuspendCatching false + } + if (originalAddress != null || originalAmountSats != null) { + val address = requireNotNull(originalAddress).also { require(it.isNotBlank()) } + val amount = requireNotNull(originalAmountSats).also { require(it > 0uL) } + val recipientOutputs = detail.outputs.filter { it.address == address } + if (recipientOutputs.isEmpty()) return@runSuspendCatching false + val recipientAmount = recipientOutputs.fold(0uL) { total, output -> + require(output.value <= ULong.MAX_VALUE - total) { "Observed recipient amount overflow" } + total + output.value + } + if (recipientAmount != amount) return@runSuspendCatching false + val fee = requireNotNull(detail.fee) + val rate = requireNotNull(detail.feeRate).also { require(it.isFinite() && it >= 0.0) } + activityRepo.completeObservedHardwarePayment(walletId, txid, address, amount, fee, ceil(rate).toULong()) + .getOrThrow() + } + true + } + } + suspend fun disconnectStaleSession(walletId: String): Result = withContext(NonCancellable) { withContext(ioDispatcher) { runSuspendCatching { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 6c5297b164..26b1b27980 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -17,6 +17,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async @@ -39,6 +40,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonPrimitive import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.BestBlock @@ -51,6 +53,7 @@ import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.OutPoint import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentHash import org.lightningdevkit.ldknode.PaymentId @@ -68,7 +71,6 @@ import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis -import to.bitkit.ext.nowTimestamp import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats @@ -128,6 +130,7 @@ class LightningRepo @Inject constructor( private val lnurlService: LnurlService, private val cacheStore: CacheStore, private val preActivityMetadataRepo: PreActivityMetadataRepo, + private val onchainSendAttemptStore: OnchainSendAttemptStore, private val connectivityRepo: ConnectivityRepo, private val vssBackupClientLdk: VssBackupClientLdk, private val urlValidator: UrlValidator, @@ -159,6 +162,7 @@ class LightningRepo @Inject constructor( private val syncMutex = Mutex() private val syncPending = AtomicBoolean(false) private val syncRetryJob = AtomicReference(null) + private val ordinaryFollowupRetryJob = AtomicReference(null) private val pendingStopJob = AtomicReference(null) private val pendingStopLock = Any() private val lifecycleMutex = Mutex() @@ -192,6 +196,7 @@ class LightningRepo @Inject constructor( if (_lightningState.value.nodeLifecycleState.isRunning()) { connectToTrustedPeers() + scheduleOrdinaryFollowupRetry() } // Start retry loop if sync is failing @@ -512,6 +517,7 @@ class LightningRepo @Inject constructor( private suspend fun adoptRunningNode(): Result { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + scheduleOrdinaryFollowupRetry() return lightningService.startEventListener(::onEvent).onFailure { Logger.warn("Failed to start event listener", it, context = TAG) } @@ -555,6 +561,30 @@ class LightningRepo @Inject constructor( } private suspend fun onEvent(event: Event) { + val observedTxid = when (event) { + is Event.OnchainTransactionReceived -> event.txid + is Event.OnchainTransactionConfirmed -> event.txid + else -> null + } + if (observedTxid != null) { + runSuspendCatching { + val observed = onchainSendAttemptStore.observeExactTransaction( + observedTxid, isConfirmed = event is Event.OnchainTransactionConfirmed, + ) + val attempt = observed ?: onchainSendAttemptStore.current() + if (attempt != null && attempt.txid.equals(observedTxid, ignoreCase = true) && + attempt.hasPositiveEvidence && !attempt.localFollowupComplete && + !attempt.isTransfer && attempt.requestId == null + ) { + finishOnchainSendLocally(attempt) + onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + } + .onFailure { + Logger.warn("Failed to record exact on-chain transaction observation", it, context = TAG) + scheduleOrdinaryFollowupRetry() + } + } handleLdkEvent(event) recordProbeOutcome(event) val settledReceiveInvoice: SettledReceiveInvoice? @@ -1478,10 +1508,23 @@ class LightningRepo @Inject constructor( tags: List = emptyList(), beforeSendAttempt: suspend () -> Unit = {}, onBroadcast: suspend (Txid) -> Unit = {}, + requestId: PaykitPaymentRequestId? = null, + orderId: String? = null, + transferContext: OnchainTransferContext? = null, + payerIdentity: String? = null, paymentDeadlineAt: Instant? = null, - ): Result = executeWhenNodeRunning("sendOnChain") { + contactPublicKey: String? = null, + ): Result = executeWhenNodeRunning("sendOnChain") { require(address.isNotEmpty()) { "Send address cannot be empty" } + // Recovery must remain reachable when observing the original transaction makes sync fail. + val retainedAttempt = runSuspendCatching { onchainSendAttemptStore.current() }.getOrElse { + return@executeWhenNodeRunning Result.failure(it) + } + if (retainedAttempt?.blocksNextSend == true) { + return@executeWhenNodeRunning Result.failure(OnchainSendBlockedError(retainedAttempt)) + } + // Ensure wallet is synced before sending to have up-to-date state ensureSyncedBeforeSend().onFailure { return@executeWhenNodeRunning Result.failure(it) @@ -1503,37 +1546,338 @@ class LightningRepo @Inject constructor( Logger.debug("UTXOs selected to spend: $utxosForSend", context = TAG) - beforeSendAttempt() - val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount, paymentDeadlineAt) - onBroadcast(txId) + val attempt = runSuspendCatching { + onchainSendAttemptStore.admit( + walletId = WalletScope.default, + requestId = requestId, + orderId = orderId, + address = address, + amountSats = sats, + isMaxAmount = isMaxAmount, + feeRateSatsPerVByte = satsPerVByte, + isTransfer = isTransfer, + channelId = channelId, + tags = tags, + transferContext = transferContext, + beforeSendAttempt = beforeSendAttempt, + payerIdentity = payerIdentity, + contactPublicKey = contactPublicKey, + ) + }.getOrElse { + val error = if (it is OnchainSendBlockedError || it is OnchainSendAttemptUnreadableError) { + it + } else { + OnchainSendNotDispatchedError(it) + } + return@executeWhenNodeRunning Result.failure(error) + } + val preparedResult = try { + runSuspendCatching { + lightningService.prepareOnchainSend( + address, + sats, + satsPerVByte, + utxosForSend, + isMaxAmount, + attempt.walletIndex, + paymentDeadlineAt, + ) + .also { prepared -> + if (!isMaxAmount && utxosForSend != null) { + check( + prepared.receipt.inputs.toSet() == utxosForSend.map { + OnchainSendInput(it.outpoint.txid, it.outpoint.vout) + }.toSet() + ) + } + onchainSendAttemptStore.retainPreparedReceipt( + attempt.attemptId, + attempt.walletIndex, + prepared.receipt.copy(feeRateSatsPerVByte = satsPerVByte), + isRecovery = false, + ) + } + } + } catch (cancelled: CancellationException) { + // Release only the exact empty pre-dispatch guard. A retained receipt remains protected. + withContext(NonCancellable) { + runSuspendCatching { + onchainSendAttemptStore.releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) + }.onFailure { Logger.warn("Failed to release cancelled preparation", it, context = TAG) } + } + throw cancelled + } + val prepared = preparedResult.getOrElse { + // Preparation and receipt persistence never dispatch. A failed save leaves the preguard if cleanup fails. + runSuspendCatching { onchainSendAttemptStore.releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) } + return@executeWhenNodeRunning Result.failure(OnchainSendNotDispatchedError(it)) + } + var outcomePersisted = true + var dispatchStarted = false + val outcome = runSuspendCatching { + onchainSendAttemptStore.broadcastPreparedCandidate( + attempt.attemptId, + attempt.walletIndex, + prepared.receipt.txid, + broadcast = { beforeDispatch -> + prepared.broadcast { + beforeDispatch() + dispatchStarted = true + } + }, + ) + }.getOrElse { error -> + if (error is OnchainSendNotDispatchedError) { + runSuspendCatching { + onchainSendAttemptStore.releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) + }.onFailure { Logger.warn("Failed to clear expired preparation", it, context = TAG) } + return@executeWhenNodeRunning Result.failure(error) + } + val winnerResult = runSuspendCatching { onchainSendAttemptStore.current() } + val winner = winnerResult.getOrNull() + val releasedWithoutDispatch = !dispatchStarted && winnerResult.isSuccess && winner == null + if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && + winner.hasPositiveEvidence + ) { + outcomePersisted = false + OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) + } else if (releasedWithoutDispatch && + lightningService.currentWalletIndex == attempt.walletIndex + ) { + // current() released the exact local preparation whose dispatch marker was never written. + return@executeWhenNodeRunning Result.failure(OnchainSendNotDispatchedError(error)) + } else { + return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, prepared.receipt.txid)) + } + } + val recorded = runSuspendCatching { + onchainSendAttemptStore.recordOutcome(attempt.attemptId, outcome, attempt.walletIndex) + }.getOrElse { error -> + outcomePersisted = false + val winner = runSuspendCatching { onchainSendAttemptStore.current() }.getOrNull() + if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && + winner.hasPositiveEvidence + ) { + winner + } else { + // Native acceptance is not durable local completion. Preserve the signed receipt + // and Pending until the exact winner can be persisted or independently observed. + return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, prepared.receipt.txid)) + } + } + val winningOutcome = if (recorded.hasPositiveEvidence) { + OnchainSendOutcome.Accepted(requireNotNull(recorded.txid)) + } else { + outcome + } + if (winningOutcome !is OnchainSendOutcome.Accepted) return@executeWhenNodeRunning Result.success(winningOutcome) + val txId = winningOutcome.txid + runSuspendCatching { onBroadcast(txId) } + .onFailure { Logger.warn("Failed to continue accepted on-chain send", it, context = TAG) } + + runSuspendCatching { finishOnchainSendLocally(recorded) } + .onFailure { Logger.warn("Failed to finish accepted on-chain send locally", it, context = TAG) } + if (!outcomePersisted) { + runSuspendCatching { + val durable = requireNotNull(onchainSendAttemptStore.currentDurable()) + check(durable.attemptId == recorded.attemptId && durable.walletId == recorded.walletId && + durable.hasPositiveEvidence && durable.txid.equals(txId, true)) { + "Accepted on-chain outcome repair is not durable" + } + }.getOrElse { + return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(it, txId)) + } + } + runSuspendCatching { syncState() } + .onFailure { Logger.warn("Failed to sync after accepted on-chain send", it, context = TAG) } + Result.success(winningOutcome.copy(amountSats = recorded.amountSats.takeIf { recorded.isMaxAmount })) + } + + private val recoveryCoordinator by lazy { + OnchainSendCoordinator( + onchainSendAttemptStore, + object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = + error("Initial sends use sendOnChain admission") + + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, + ): PreparedOnchainSend { + val inputs = requireNotNull(attempt.originalInputs) + // The saved signed receipt is the input identity. Native preparation resolves + // the actual values and spendability; never replace missing inputs from a fresh list. + val selected = inputs.map { input -> + SpendableUtxo(OutPoint(input.txid, input.vout), 0uL) + } + return lightningService.prepareOnchainSend( + attempt.address, + attempt.amountSats, + feeRateSatsPerVByte, + selected, + false, + attempt.walletIndex, + paymentDeadlineAt, + ) + } + }, + bgDispatcher + ) + } + suspend fun retryOriginalOnchainSend( + attemptId: String, + walletId: String, + feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant? = null, + approvePrepared: suspend (OnchainPreparedReceipt) -> Unit = {}, + authorizeOriginal: suspend (OnchainSendAttempt) -> Unit, + ): Result = executeWhenNodeRunning("retryOriginalOnchainSend") { + val result = recoveryCoordinator.retryOriginal( + attemptId, walletId, feeRateSatsPerVByte, paymentDeadlineAt, approvePrepared, authorizeOriginal, + ) + val accepted = result.getOrNull() as? OnchainSendOutcome.Accepted + if (accepted != null) { + runSuspendCatching { + onchainSendAttemptStore.current()?.takeIf { it.hasPositiveEvidence }?.let { + finishOnchainSendLocally( + it + ) + } + }.onFailure { Logger.warn("Failed to finish recovered on-chain send locally", it, context = TAG) } + runSuspendCatching { + val durable = requireNotNull(onchainSendAttemptStore.currentDurable()) + check(durable.attemptId == attemptId && durable.walletId == walletId && + durable.hasPositiveEvidence && durable.txid.equals(accepted.txid, true)) { + "Recovered on-chain outcome is not durable" + } + }.getOrElse { + return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(it, accepted.txid)) + } + } + result + } + + private suspend fun finishOnchainSendLocally(attempt: OnchainSendAttempt) { + val originalFollowup = attempt.backupFollowup + check( + !attempt.restoredFromBackup || originalFollowup != null + ) { "Original local follow-up context is unavailable" } + val originalContact = originalFollowup?.contact?.let { + require(it is JsonPrimitive && it.isString && it.content.isNotBlank()) { "Invalid original contact" } + it.content + } + val txId = requireNotNull(attempt.txid) { "On-chain send has no transaction id" } + val isSuccessor = attempt.candidateTxids.isNotEmpty() && !txId.equals(attempt.candidateTxids.first(), true) + val needsExactFee = attempt.verifiedWinningFeeSats == null && (isSuccessor || + (attempt.candidateTxids.isNotEmpty() && originalFollowup?.feeSats?.toULongOrNull() == 0uL)) + val fee = attempt.verifiedWinningFeeSats ?: if (needsExactFee) { + requireNotNull(lightningService.observedOriginalSendFee(attempt)) { + "Winning transaction fee is unavailable" + }.also { onchainSendAttemptStore.retainWinningFee(attempt.attemptId, attempt.walletIndex, txId, it) } + } else { + originalFollowup?.feeSats?.toULong() ?: 0uL + } val preActivityMetadata = PreActivityMetadata( - walletId = WalletScope.default, + walletId = attempt.walletId, paymentId = txId, - createdAt = nowTimestamp().toEpochMilli().toULong(), - tags = tags, + createdAt = originalFollowup?.createdAtMillis?.toULong() ?: nowMillis().toULong(), + tags = attempt.tags, paymentHash = null, txId = txId, - address = address, + address = attempt.address, isReceive = false, - feeRate = satsPerVByte, - isTransfer = isTransfer, - channelId = channelId ?: "", + feeRate = attempt.winningFeeRateSatsPerVByte, + isTransfer = attempt.isTransfer, + channelId = attempt.channelId ?: "", ) - preActivityMetadataRepo.addPreActivityMetadata(preActivityMetadata) - + preActivityMetadataRepo.addPreActivityMetadata(preActivityMetadata).getOrThrow() coreService.activity.createSentOnchainActivityFromSendResult( txid = txId, - address = address, - amount = sats, - fee = 0u, - feeRate = satsPerVByte, - isTransfer = isTransfer, - channelId = channelId, + address = attempt.address, + amount = attempt.amountSats, + fee = fee, + feeRate = attempt.winningFeeRateSatsPerVByte, + isTransfer = attempt.isTransfer, + channelId = attempt.channelId, + walletId = attempt.walletId, ) + if (originalContact != null) { + coreService.activity.restoreSentOnchainContact(txId, attempt.walletId, originalContact) + } + if (needsExactFee || attempt.verifiedWinningFeeSats != null) { + coreService.activity.repairVerifiedSentOnchainFee(txId, attempt.walletId, fee, attempt.winningFeeRateSatsPerVByte) + } + check(coreService.activity.getOnchainActivityByTxId(txId, attempt.walletId) != null) { + "Accepted on-chain transaction has no durable local activity" + } + } - syncState() - Result.success(txId) + private fun scheduleOrdinaryFollowupRetry() { + val previous = ordinaryFollowupRetryJob.get() + if (previous?.isActive == true) return + val retry = scope.launch(start = CoroutineStart.LAZY) { + repeat(3) { + delay(1.seconds) + if (!_lightningState.value.nodeLifecycleState.isRunning()) return@launch + val attempt = runSuspendCatching { onchainSendAttemptStore.current() }.getOrNull() ?: return@launch + if (!attempt.hasPositiveEvidence || attempt.localFollowupComplete || + attempt.isTransfer || attempt.requestId != null + ) return@launch + completeAcceptedOrdinaryFollowup(requireNotNull(attempt.txid)) + } + } + if (ordinaryFollowupRetryJob.compareAndSet(previous, retry)) retry.start() else retry.cancel() + } + + suspend fun completeAcceptedOrdinaryFollowup(txid: String) { + val attempt = onchainSendAttemptStore.current() + if (attempt != null && !attempt.isTransfer && attempt.requestId == null && + attempt.txid.equals(txid, ignoreCase = true) && attempt.hasPositiveEvidence + ) { + runSuspendCatching { + finishOnchainSendLocally(attempt) + onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + }.onFailure { Logger.warn("Failed to finish accepted ordinary send locally", it, context = TAG) } + } + } + + suspend fun completeAcceptedTransferFollowup(orderId: String, txid: String) { + val attempt = onchainSendAttemptStore.current() + if (attempt?.orderId == orderId && attempt.txid.equals(txid, ignoreCase = true) && + attempt.hasPositiveEvidence + ) { + finishOnchainSendLocally(attempt) + onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + } + + // A change signal only: consumers must reread and validate the original attempt. + val onchainSendAttemptUpdates get() = onchainSendAttemptStore.backupStateVersion + + suspend fun currentOnchainSendAttempt(): OnchainSendAttempt? = onchainSendAttemptStore.current() + + suspend fun releaseInterruptedShopPreparation( + removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean, + ): Boolean = onchainSendAttemptStore.releaseInterruptedShopPreparation(removeOriginalProof) + + suspend fun finishAcceptedShopActivity(requestId: PaykitPaymentRequestId, txid: String) { + val attempt = onchainSendAttemptStore.current() + if (attempt?.requestId == requestId && attempt.txid.equals(txid, ignoreCase = true) && + attempt.hasPositiveEvidence + ) { + finishOnchainSendLocally(attempt) + } + } + + suspend fun completeAcceptedShopFollowup(requestId: PaykitPaymentRequestId, txid: String) { + val attempt = onchainSendAttemptStore.current() + if (attempt?.requestId == requestId && attempt.txid.equals(txid, ignoreCase = true) && + attempt.hasPositiveEvidence + ) { + finishOnchainSendLocally(attempt) + onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } } suspend fun determineUtxosToSpend( @@ -1722,6 +2066,7 @@ class LightningRepo @Inject constructor( balances = getBalances(), ) } + if (_lightningState.value.nodeLifecycleState.isRunning()) scheduleOrdinaryFollowupRetry() } private fun logNodeSupportSummary(reason: String) { diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt new file mode 100644 index 0000000000..abb23cefbf --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -0,0 +1,575 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.nowMillis +import to.bitkit.models.PaykitPaymentStateBackup +import to.bitkit.models.ActiveOnchainAttemptBackup +import to.bitkit.models.WalletScope +import to.bitkit.services.LightningService +import to.bitkit.utils.AppError +import to.bitkit.utils.ServiceError +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +sealed interface OnchainSendOutcome { + val txid: String + + data class Accepted(override val txid: String, val amountSats: ULong? = null) : OnchainSendOutcome + data class Rejected(override val txid: String, val reason: String) : OnchainSendOutcome + data class Unknown(override val txid: String) : OnchainSendOutcome +} + +@Serializable +enum class OnchainSendEvidence { + Pending, + Accepted, + Rejected, + Unknown, + Observed, +} + +@Serializable +data class OnchainTransferContext( + val txTotalSats: ULong, + val preTransferOnchainSats: ULong, + val originalOrderClientBalanceSats: ULong? = null, + val originalOrderFeeSats: ULong? = null, +) + +@Serializable +data class OnchainSendInput(val txid: String, val vout: UInt) + +data class OnchainPreparedReceipt( + val txid: String, + val inputs: List, + val address: String, + val amountSats: ULong, + val feeRateSatsPerVByte: ULong? = null, + val miningFeeSats: ULong? = null, +) + +@Serializable +@Suppress("LongParameterList") +data class OnchainSendAttempt( + val walletId: String, + val attemptId: String, + val requestId: PaykitPaymentRequestId?, + val orderId: String?, + val address: String, + val amountSats: ULong, + val isMaxAmount: Boolean, + val feeRateSatsPerVByte: ULong, + val isTransfer: Boolean, + val channelId: String?, + val tags: List, + val evidence: OnchainSendEvidence = OnchainSendEvidence.Pending, + val txid: String? = null, + val refusalReason: String? = null, + val localFollowupComplete: Boolean = false, + val walletIndex: Int = 0, + val transferContext: OnchainTransferContext? = null, + val payerIdentity: String? = null, + val originalInputs: List? = null, + val candidateTxids: List = emptyList(), + val candidateFeeRates: Map = emptyMap(), + val backupFollowup: ActiveOnchainAttemptBackup.Followup? = null, + val restoredFromBackup: Boolean = false, + val preparationPending: Boolean = false, + val verifiedWinningFeeSats: ULong? = null, + // Write-ahead boundary: false is proof that this original prepared payment never dispatched. + val initialDispatchAttempted: Boolean = true, +) { + val winningFeeRateSatsPerVByte: ULong + get() = candidateFeeRates[txid?.lowercase()] ?: feeRateSatsPerVByte.takeIf { + candidateTxids.isEmpty() || txid.equals(candidateTxids.first(), ignoreCase = true) + } ?: error("Winning candidate fee rate is unavailable") + + val isUnresolved: Boolean + get() = evidence == OnchainSendEvidence.Pending || + evidence == OnchainSendEvidence.Rejected || + evidence == OnchainSendEvidence.Unknown + + val hasPositiveEvidence: Boolean + get() = evidence == OnchainSendEvidence.Accepted || evidence == OnchainSendEvidence.Observed + + val blocksNextSend: Boolean + get() = isUnresolved || (hasPositiveEvidence && !localFollowupComplete) +} + +class OnchainSendBlockedError(val attempt: OnchainSendAttempt? = null) : + AppError("A previous on-chain send is unresolved or this payment was already sent.") + +class OnchainSendAttemptUnreadableError(cause: Throwable) : AppError("Failed to read on-chain send attempt", cause) + +class OnchainSendNotDispatchedError(cause: Throwable) : AppError("On-chain send did not reach the backend", cause) + +class OnchainSendPendingError(cause: Throwable, val txid: String? = null) : + AppError("On-chain send outcome is unknown; do not send again", cause) + +@OptIn(ExperimentalTime::class) +@Singleton +class OnchainSendAttemptStore @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val keychain: Keychain, + private val lightningService: LightningService, + private val clock: Clock, +) { + companion object { + private val KEY = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + } + + private val mutex = Mutex() + private val _backupStateVersion = MutableStateFlow(0L) + val backupStateVersion = _backupStateVersion.asStateFlow() + + // One known positive result per existing wallet guard; entries disappear after a successful durable write. + private val retainedPositive = mutableMapOf() + private val inFlightPreparations = mutableSetOf() + + suspend fun current(): OnchainSendAttempt? = withContext(ioDispatcher) { + mutex.withLock { + releaseInterruptedUnsubmittedPreparation(loadWithRetainedAccepted(lightningService.currentWalletIndex)) + } + } + + /** Read only restart-safe evidence, excluding a positive result retained after a failed write. */ + suspend fun currentDurable(): OnchainSendAttempt? = withContext(ioDispatcher) { + mutex.withLock { releaseInterruptedUnsubmittedPreparation(load(lightningService.currentWalletIndex)) } + } + + @Suppress("LongParameterList") + suspend fun admit( + walletId: String, + requestId: PaykitPaymentRequestId?, + orderId: String?, + address: String, + amountSats: ULong, + isMaxAmount: Boolean, + feeRateSatsPerVByte: ULong, + isTransfer: Boolean, + channelId: String?, + tags: List, + transferContext: OnchainTransferContext? = null, + beforeSendAttempt: suspend () -> Unit, + payerIdentity: String? = null, + contactPublicKey: String? = null, + ): OnchainSendAttempt = withContext(ioDispatcher) { + mutex.withLock { + val walletIndex = lightningService.currentWalletIndex + val previous = releaseInterruptedUnsubmittedPreparation(load(walletIndex)) + if (previous != null && ( + previous.walletId != walletId || + previous.blocksNextSend || + (requestId != null && previous.requestId == requestId) || + (orderId != null && previous.orderId == orderId) + ) + ) { + throw OnchainSendBlockedError(loadWithRetainedAccepted(walletIndex) ?: previous) + } + val attempt = OnchainSendAttempt( + walletId = walletId, + attemptId = UUID.randomUUID().toString(), + requestId = requestId, + orderId = orderId, + address = address, + amountSats = amountSats, + isMaxAmount = isMaxAmount, + feeRateSatsPerVByte = feeRateSatsPerVByte, + isTransfer = isTransfer, + channelId = channelId, + tags = tags, + walletIndex = walletIndex, + transferContext = transferContext, + payerIdentity = payerIdentity, + preparationPending = requestId != null, + initialDispatchAttempted = false, + backupFollowup = ActiveOnchainAttemptBackup.Followup( + feeSats = "0", + tags = tags, + createdAtMillis = nowMillis(clock).toString(), + channelId = channelId, + contact = contactPublicKey?.let(::JsonPrimitive), + ), + ) + persist(attempt) + inFlightPreparations += attempt.attemptId + var callbackCompleted = false + try { + beforeSendAttempt() + callbackCompleted = true + } finally { + if (!callbackCompleted) inFlightPreparations -= attempt.attemptId + } + attempt + } + } + + private suspend fun releaseInterruptedUnsubmittedPreparation(current: OnchainSendAttempt?): OnchainSendAttempt? { + if (current == null || current.attemptId in inFlightPreparations || current.restoredFromBackup || + current.walletId != WalletScope.default || current.requestId != null || + current.evidence != OnchainSendEvidence.Pending || current.initialDispatchAttempted + ) return current + keychain.delete(KEY, current.walletIndex) + _backupStateVersion.update { it + 1 } + return null + } + + suspend fun releaseInterruptedShopPreparation( + removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean, + ): Boolean = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(lightningService.currentWalletIndex) ?: return@withLock false + if (current.attemptId in inFlightPreparations) return@withLock false + if (current.initialDispatchAttempted || current.restoredFromBackup || current.requestId == null) { + return@withLock false + } + if (current.walletId != WalletScope.default || current.payerIdentity.isNullOrBlank()) return@withLock false + if (current.evidence != OnchainSendEvidence.Pending) return@withLock false + if (!removeOriginalProof(current)) return@withLock false + keychain.delete(KEY, current.walletIndex) + _backupStateVersion.update { it + 1 } + true + } + } + + suspend fun retainPreparedReceipt( + attemptId: String, + walletIndex: Int, + receipt: OnchainPreparedReceipt, + isRecovery: Boolean, + ): OnchainSendAttempt = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(walletIndex) + if (current?.attemptId != attemptId || walletIndex != lightningService.currentWalletIndex || + current.hasPositiveEvidence + ) { + throw OnchainSendBlockedError(current) + } + require(receipt.txid.matches(Regex("[0-9a-fA-F]{64}"))) + require(receipt.inputs.isNotEmpty() && receipt.inputs.distinct().size == receipt.inputs.size) + require(receipt.inputs.all { it.txid.matches(Regex("[0-9a-fA-F]{64}")) }) + require(receipt.address == current.address) + if (current.requestId != null) require(!current.payerIdentity.isNullOrBlank()) + require(receipt.amountSats > 0uL) + if (isRecovery) { + requireNotNull(current.originalInputs) + require(current.candidateTxids.isNotEmpty()) + require(receipt.inputs.toSet() == current.originalInputs.toSet()) + require(receipt.amountSats == current.amountSats) + } else { + require(current.originalInputs == null && current.candidateTxids.isEmpty() && current.txid == null) + require((current.isMaxAmount && current.requestId == null) || receipt.amountSats == current.amountSats) + if (current.isTransfer && current.isMaxAmount) { + val context = requireNotNull(current.transferContext) + val originalFee = requireNotNull(context.originalOrderFeeSats) + val miningFee = requireNotNull(receipt.miningFeeSats) + require(miningFee <= context.txTotalSats) + require(receipt.amountSats >= originalFee && receipt.amountSats <= context.txTotalSats - miningFee) + } + } + val feeRate = receipt.feeRateSatsPerVByte ?: current.feeRateSatsPerVByte + if (isRecovery) { + require(OnchainRecoveryFeeRate.isValid(feeRate)) + } else { + require(feeRate > 0uL && feeRate <= UInt.MAX_VALUE.toULong()) + } + val existingFeeRate = current.candidateFeeRates[receipt.txid.lowercase()] + require(existingFeeRate == null || existingFeeRate == feeRate) + current.copy( + amountSats = receipt.amountSats, + originalInputs = current.originalInputs ?: receipt.inputs, + candidateTxids = (current.candidateTxids + receipt.txid.lowercase()).distinct(), + candidateFeeRates = current.candidateFeeRates + (receipt.txid.lowercase() to feeRate), + txid = if (isRecovery) current.txid else receipt.txid.lowercase(), + evidence = if (isRecovery) current.evidence else OnchainSendEvidence.Pending, + refusalReason = if (isRecovery) current.refusalReason else null, + preparationPending = false, + ).also { + persist(it) + // Keep a live initial preparation protected until its queued dispatch exits. + if (isRecovery) inFlightPreparations -= attemptId + } + } + } + + suspend fun discardUnsubmittedRetryCandidate(original: OnchainSendAttempt, txid: String) = + withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val candidate = txid.lowercase() + if (candidate in original.candidateTxids) return@withLock + val current = loadWithRetainedAccepted(original.walletIndex) + if (current?.attemptId != original.attemptId || current.walletId != original.walletId) { + throw OnchainSendBlockedError(current) + } + if (candidate !in current.candidateTxids || + current.hasPositiveEvidence && current.txid.equals(candidate, ignoreCase = true) + ) return@withLock + check(current.candidateTxids.containsAll(original.candidateTxids)) + persist(current.copy( + txid = if (current.hasPositiveEvidence) current.txid else original.txid, + evidence = if (current.hasPositiveEvidence) current.evidence else original.evidence, + refusalReason = if (current.hasPositiveEvidence) current.refusalReason else original.refusalReason, + candidateTxids = current.candidateTxids - candidate, + candidateFeeRates = current.candidateFeeRates - candidate, + )) + } + } + + suspend fun broadcastPreparedCandidate( + attemptId: String, + walletIndex: Int, + txid: String, + broadcast: suspend (beforeDispatch: suspend () -> Unit) -> OnchainSendOutcome, + ): OnchainSendOutcome = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(walletIndex) + if (current?.attemptId != attemptId || walletIndex != lightningService.currentWalletIndex) { + throw OnchainSendBlockedError(current) + } + if (current.hasPositiveEvidence) return@withLock OnchainSendOutcome.Accepted(requireNotNull(current.txid)) + require(txid.lowercase() in current.candidateTxids) + val firstSubmission = !current.initialDispatchAttempted + if (!firstSubmission) { + persist(current.copy(txid = txid.lowercase(), evidence = OnchainSendEvidence.Pending, refusalReason = null)) + } + try { + broadcast { + // Called on the native queue after wallet/deadline validation, while this mutex is owned. + val live = loadWithRetainedAccepted(walletIndex) + check(live?.attemptId == attemptId && !live.hasPositiveEvidence) + persist(live.copy(initialDispatchAttempted = true)) + inFlightPreparations -= attemptId + } + } catch (error: ServiceError.PaymentDeadlineExpired) { + // The native queue checks the deadline before the durable dispatch boundary. + // Any earlier submission or imported backup remains uncertain and guarded. + if (!firstSubmission || loadWithRetainedAccepted(walletIndex)?.initialDispatchAttempted != false || + current.restoredFromBackup || current.evidence != OnchainSendEvidence.Pending || current.candidateTxids.size != 1 + ) throw error + persist(current.copy( + txid = null, + originalInputs = null, + candidateTxids = emptyList(), + candidateFeeRates = emptyMap(), + preparationPending = current.requestId != null, + )) + // Shop proof cleanup still precedes guard deletion through the existing preparation path. + throw OnchainSendNotDispatchedError(error) + } finally { + inFlightPreparations -= attemptId + } + } + } + + suspend fun recordOutcome(attemptId: String, outcome: OnchainSendOutcome, walletIndex: Int): OnchainSendAttempt = + withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(walletIndex) + if (current?.attemptId != attemptId) throw OnchainSendBlockedError() + if (current.candidateTxids.isNotEmpty() && + outcome.txid.lowercase() !in current.candidateTxids + ) { + throw OnchainSendBlockedError(current) + } + if (current.hasPositiveEvidence) return@withLock current + val evidence = when (outcome) { + is OnchainSendOutcome.Accepted -> OnchainSendEvidence.Accepted + is OnchainSendOutcome.Rejected -> OnchainSendEvidence.Rejected + is OnchainSendOutcome.Unknown -> OnchainSendEvidence.Unknown + } + val recorded = current.copy( + evidence = evidence, + txid = outcome.txid, + refusalReason = (outcome as? OnchainSendOutcome.Rejected)?.reason, + initialDispatchAttempted = true, + ) + if (outcome is OnchainSendOutcome.Accepted) retainedPositive[walletIndex] = recorded + persist(recorded) + retainedPositive.remove(walletIndex) + recorded + } + } + + suspend fun releaseBeforeDispatch(attemptId: String, walletIndex: Int) = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + inFlightPreparations -= attemptId + val current = loadWithRetainedAccepted(walletIndex) + if (current?.attemptId == attemptId && current.evidence == OnchainSendEvidence.Pending && + current.candidateTxids.isEmpty() && !current.preparationPending + ) { + keychain.delete(KEY, walletIndex) + _backupStateVersion.update { it + 1 } + } + } + } + + suspend fun retainWinningFee(attemptId: String, walletIndex: Int, txid: String, feeSats: ULong) = + withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(walletIndex) + check(current?.attemptId == attemptId && walletIndex == lightningService.currentWalletIndex && + current.hasPositiveEvidence && current.txid.equals(txid, true)) + val followup = requireNotNull(current.backupFollowup) + persist(current.copy( + backupFollowup = followup.copy(feeSats = feeSats.toString()), + verifiedWinningFeeSats = feeSats, + )) + } + } + + suspend fun markLocalFollowupComplete(attemptId: String, walletIndex: Int) = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(walletIndex) + if (current?.attemptId == attemptId && current.hasPositiveEvidence) { + check( + !current.restoredFromBackup || current.backupFollowup != null + ) { + "Original local follow-up context is unavailable" + } + persist(current.copy(localFollowupComplete = true)) + retainedPositive.remove(walletIndex) + } + } + } + + suspend fun observeExactTransaction(txid: String, isConfirmed: Boolean = true): OnchainSendAttempt? = + withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(lightningService.currentWalletIndex) ?: return@withLock null + val matchesCandidate = current.txid.equals(txid, ignoreCase = true) || + txid.lowercase() in current.candidateTxids + if (!matchesCandidate || !current.isUnresolved) return@withLock null + // A queued Received event cannot choose between same-input recovery candidates. + if (!isConfirmed && current.candidateTxids.size > 1) return@withLock null + val observed = current.copy(evidence = OnchainSendEvidence.Observed, txid = txid.lowercase()) + retainedPositive[current.walletIndex] = observed + persist(observed) + retainedPositive.remove(current.walletIndex) + observed + } + } + + suspend fun backupSnapshot(walletIndex: Int): OnchainSendAttempt? = withContext(ioDispatcher) { + mutex.withLock { loadWithRetainedAccepted(walletIndex)?.takeIf { it.blocksNextSend } } + } + + // Admission and receipt retention take this mutex before the proof mutex. Capture both + // under the same ordering so a pre-dispatch backup cannot mix an empty guard with a sent proof. + suspend fun backupSnapshot( + walletIndex: Int, + captureProofs: suspend () -> List, + ): Pair> = withContext(ioDispatcher) { + mutex.withLock { + val attempt = releaseInterruptedUnsubmittedPreparation(loadWithRetainedAccepted(walletIndex)) + attempt?.takeIf { it.blocksNextSend } to captureProofs() + } + } + + suspend fun restoreActive(attempt: OnchainSendAttempt) = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val existing = loadWithRetainedAccepted(attempt.walletIndex) + if (existing?.attemptId == attempt.attemptId) { + // A restore retry may replay an older receipt after fee/activity persistence progressed. + // Normalize only progress fields; recipient, payer, inputs and original context must match. + val sameOperation = existing.copy( + evidence = attempt.evidence, + txid = attempt.txid, + refusalReason = attempt.refusalReason, + localFollowupComplete = attempt.localFollowupComplete, + candidateTxids = attempt.candidateTxids, + candidateFeeRates = attempt.candidateFeeRates, + backupFollowup = existing.backupFollowup?.copy(feeSats = attempt.backupFollowup?.feeSats.orEmpty()), + restoredFromBackup = attempt.restoredFromBackup, + preparationPending = attempt.preparationPending, + verifiedWinningFeeSats = attempt.verifiedWinningFeeSats, + initialDispatchAttempted = attempt.initialDispatchAttempted, + ) == attempt + check(sameOperation && existing.candidateTxids.containsAll(attempt.candidateTxids)) { + "Cannot change a restored on-chain operation's original context" + } + persist( + if (existing.restoredFromBackup) { + existing + } else { + existing.copy( + restoredFromBackup = attempt.restoredFromBackup, + localFollowupComplete = false, + preparationPending = false, + ) + } + ) + return@withLock + } + check(existing == null || !existing.blocksNextSend) { + "Cannot replace an existing active on-chain operation" + } + persist(attempt.copy(localFollowupComplete = false, preparationPending = false)) + retainedPositive.remove(attempt.walletIndex) + } + } + + private fun load(walletIndex: Int): OnchainSendAttempt? { + val value = keychain.loadString(KEY, walletIndex) ?: return null + return runCatching { + Json.decodeFromString(value).also { attempt -> + require(attempt.walletId.isNotBlank() && attempt.attemptId.isNotBlank() && attempt.walletIndex == walletIndex) + require(attempt.evidence == OnchainSendEvidence.Pending || + attempt.txid?.matches(Regex("[0-9a-fA-F]{64}")) == true) + require(!attempt.localFollowupComplete || attempt.hasPositiveEvidence) + require(attempt.candidateTxids.distinct().size == attempt.candidateTxids.size) + require(attempt.candidateTxids.all { it.matches(Regex("[0-9a-f]{64}")) }) + if (attempt.originalInputs != null) { + require(attempt.originalInputs.isNotEmpty()) + require(attempt.originalInputs.distinct().size == attempt.originalInputs.size) + require(attempt.originalInputs.all { it.txid.matches(Regex("[0-9a-fA-F]{64}")) }) + require(attempt.candidateTxids.isNotEmpty()) + require(attempt.txid?.lowercase() in attempt.candidateTxids) + } else { + require(attempt.candidateTxids.isEmpty()) + } + } + } + .getOrElse { throw OnchainSendAttemptUnreadableError(it) } + } + + private fun loadWithRetainedAccepted(walletIndex: Int): OnchainSendAttempt? { + val persisted = load(walletIndex) + val retained = retainedPositive[walletIndex] ?: return persisted + if (persisted?.attemptId != retained.attemptId || persisted.walletId != retained.walletId || + ( + persisted.txid != null && !persisted.txid.equals(retained.txid, ignoreCase = true) && + retained.txid?.lowercase() !in persisted.candidateTxids + ) + ) { + retainedPositive.remove(walletIndex) + return persisted + } + if (persisted.hasPositiveEvidence) { + retainedPositive.remove(walletIndex) + return persisted + } + return retained + } + + private suspend fun persist(attempt: OnchainSendAttempt) { + keychain.upsertString(KEY, Json.encodeToString(attempt), attempt.walletIndex) + _backupStateVersion.update { it + 1 } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt new file mode 100644 index 0000000000..cee54cb8e4 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt @@ -0,0 +1,227 @@ +package to.bitkit.repositories + +import kotlin.time.Instant +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching + +/** Rust converts sat/vB to sat/kwu by multiplying by 250; validate before the unchecked FFI factory. */ +object OnchainRecoveryFeeRate { + // Match the normal custom-fee field and iOS ceiling. + val maximum: ULong = 999uL + + fun isValid(rate: ULong): Boolean = rate > 0uL && rate <= maximum + + fun parse(input: String): ULong? = input.toULongOrNull()?.takeIf(::isValid) +} + +/** One native prepared transaction; its direct submission is cached even if it fails. */ +class PreparedOnchainSend( + val receipt: OnchainPreparedReceipt, + private val submit: suspend (beforeDispatch: suspend () -> Unit) -> OnchainSendOutcome, +) { + private val mutex = Mutex() + private var result: Result? = null + + suspend fun broadcast(beforeDispatch: suspend () -> Unit = {}): OnchainSendOutcome = withContext(NonCancellable) { + mutex.withLock { + val cached = result ?: runSuspendCatching { submit(beforeDispatch) }.also { result = it } + cached.getOrThrow() + } + } +} + +/** Recovery always uses the original actual recipient amount and exact inputs, including an initial Max send. */ +interface OnchainPreparedSender { + suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend + suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant? = null): PreparedOnchainSend +} + +/** Thin boundary for the additive native prepare API, implemented in LightningService once matching bindings exist. */ +interface OnchainPreparationProtocol { + suspend fun prepareFixed( + address: String, + amountSats: ULong, + feeRateSatsPerVByte: ULong, + inputs: List?, + walletIndex: Int, + paymentDeadlineAt: Instant? = null, + ): PreparedOnchainSend + + suspend fun prepareMax( + address: String, + retainReserves: Boolean, + feeRateSatsPerVByte: ULong, + walletIndex: Int, + paymentDeadlineAt: Instant? = null, + ): PreparedOnchainSend +} + +class OnchainPreparedSenderAdapter( + private val native: OnchainPreparationProtocol, + private val initialInputs: List? = null, +) : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend { + val prepared = if (attempt.isMaxAmount) { + native.prepareMax(attempt.address, true, attempt.feeRateSatsPerVByte, attempt.walletIndex) + } else { + native.prepareFixed( + attempt.address, + attempt.amountSats, + attempt.feeRateSatsPerVByte, + initialInputs, + attempt.walletIndex, + ) + } + if (!attempt.isMaxAmount && initialInputs != null) { + require(initialInputs.isNotEmpty() && initialInputs.distinct().size == initialInputs.size) + require(prepared.receipt.inputs.toSet() == initialInputs.toSet()) + } + return prepared + } + + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?): PreparedOnchainSend { + val inputs = requireNotNull(attempt.originalInputs) + require(inputs.isNotEmpty() && inputs.distinct().size == inputs.size) + return native.prepareFixed( + attempt.address, + attempt.amountSats, + feeRateSatsPerVByte, + inputs, + attempt.walletIndex, + paymentDeadlineAt, + ) + } +} + +class OnchainSendCoordinator( + private val store: OnchainSendAttemptStore, + private val sender: OnchainPreparedSender, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + private val mutex = Mutex() + + suspend fun sendInitial(attempt: OnchainSendAttempt): Result = withContext(ioDispatcher) { + runSuspendCatching { + mutex.withLock { + val original = store.current() + if (original?.attemptId != attempt.attemptId || original.walletId != attempt.walletId) { + throw OnchainSendBlockedError(original) + } + if (original.hasPositiveEvidence || original.candidateTxids.isNotEmpty()) { + throw OnchainSendBlockedError(original) + } + val prepared = sender.prepareInitial(original) + store.retainPreparedReceipt( + original.attemptId, + original.walletIndex, + prepared.receipt.copy(feeRateSatsPerVByte = original.feeRateSatsPerVByte), + isRecovery = false, + ) + submit(original, prepared) + } + } + } + + suspend fun retryOriginal( + attemptId: String, + walletId: String, + feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant? = null, + approvePrepared: suspend (OnchainPreparedReceipt) -> Unit = {}, + authorizeOriginal: suspend (OnchainSendAttempt) -> Unit, + ): Result = withContext(ioDispatcher) { + val result = runSuspendCatching { + mutex.withLock { + val attempt = store.current() + if (attempt?.attemptId != attemptId || attempt.walletId != walletId) { + throw OnchainSendBlockedError(attempt) + } + if (attempt.hasPositiveEvidence) { + return@withLock OnchainSendOutcome.Accepted(requireNotNull(attempt.txid)) + } + if (!attempt.isUnresolved || attempt.originalInputs.isNullOrEmpty() || + attempt.candidateTxids.isEmpty() + ) { + throw OnchainSendBlockedError(attempt) + } + require(OnchainRecoveryFeeRate.isValid(feeRateSatsPerVByte)) + val prepared = sender.prepareRecovery(attempt, feeRateSatsPerVByte, paymentDeadlineAt) + approvePrepared(prepared.receipt.copy(feeRateSatsPerVByte = feeRateSatsPerVByte)) + val retained = store.retainPreparedReceipt( + attempt.attemptId, + attempt.walletIndex, + prepared.receipt.copy(feeRateSatsPerVByte = feeRateSatsPerVByte), + isRecovery = true, + ) + try { + authorizeOriginal(retained) + } catch (error: Throwable) { + store.discardUnsubmittedRetryCandidate(attempt, prepared.receipt.txid) + throw error + } + submit(retained, prepared, recoveryOriginal = attempt) + } + } + if (result.isFailure) { + val winner = runSuspendCatching { + if (result.exceptionOrNull() is OnchainSendPendingError) { + store.currentDurable() + } else { + store.current() + } + }.getOrNull() + if (winner?.attemptId == attemptId && winner.walletId == walletId && winner.hasPositiveEvidence) { + return@withContext Result.success(OnchainSendOutcome.Accepted(requireNotNull(winner.txid))) + } + } + result + } + + private suspend fun submit( + attempt: OnchainSendAttempt, + prepared: PreparedOnchainSend, + recoveryOriginal: OnchainSendAttempt? = null, + ): OnchainSendOutcome { + var nativeDispatchEntered = false + val outcome = runSuspendCatching { + store.broadcastPreparedCandidate( + attempt.attemptId, + attempt.walletIndex, + prepared.receipt.txid, + ) { beforeDispatch -> + prepared.broadcast { + beforeDispatch() + nativeDispatchEntered = true + } + } + }.getOrElse { error -> + if (!nativeDispatchEntered && recoveryOriginal != null) { + store.discardUnsubmittedRetryCandidate(recoveryOriginal, prepared.receipt.txid) + } + val winner = runSuspendCatching { store.current() }.getOrNull() + if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && winner.hasPositiveEvidence) { + return OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) + } + throw OnchainSendPendingError(error, prepared.receipt.txid) + } + val recorded = runSuspendCatching { + store.recordOutcome(attempt.attemptId, outcome, attempt.walletIndex) + }.getOrElse { error -> + val winner = runSuspendCatching { store.currentDurable() }.getOrNull() + if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && winner.hasPositiveEvidence) { + return OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) + } + throw OnchainSendPendingError(error, outcome.txid) + } + return if (recorded.hasPositiveEvidence) { + OnchainSendOutcome.Accepted(requireNotNull(recorded.txid)) + } else { + outcome + } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 34cfc8a739..2d6983e556 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -1,8 +1,6 @@ package to.bitkit.repositories -import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.ActivityFilter -import com.synonym.bitkitcore.PaymentType +import dagger.Lazy import com.synonym.paykit.BillingPeriod import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CoroutineDispatcher @@ -18,6 +16,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -35,16 +34,19 @@ import to.bitkit.di.IoDispatcher import to.bitkit.ext.fromHex import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.WalletScope import to.bitkit.services.PaykitSdkService import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError +import to.bitkit.utils.SignedTransactionId import to.bitkit.utils.asNodeException import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant /** New proof kinds must be readable on both platforms before either platform writes them to a wallet backup. */ @@ -61,12 +63,18 @@ enum class PaykitPaymentProofKind(val type: String) { } } +data class RetainedHardwareOnchainPayment( + val signedTx: HwFundingSignedTx, + val hasAttemptedBroadcast: Boolean, + val isRefusedForNavigation: Boolean = false, +) + @Serializable data class PendingPaykitPaymentProof( val identity: String, val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, - val paymentAppId: String, + val paymentAppId: String = "", val kind: PaykitPaymentProofKind, val paymentStarted: Boolean = false, val paymentIdentifier: String? = null, @@ -76,6 +84,17 @@ data class PendingPaykitPaymentProof( val onchainAmountSats: ULong? = null, val onchainWalletId: String = WalletScope.default, val onchainMatchingTransactionIdsBeforeAttempt: Set = emptySet(), + val onchainAcceptanceVerified: Boolean = false, + val hardwareDispatchDenied: Boolean = false, + val hardwareDispatchAttempted: Boolean? = null, + // Local navigation hint only; wallet backup restore deliberately defaults to conservative navigation. + val hardwareRefusedForNavigation: Boolean = false, + val hardwareSignedTransaction: String? = null, + val hardwareMiningFeeSats: ULong? = null, + val hardwareFeeRate: ULong? = null, + val hardwareTotalSpent: ULong? = null, + val privatePaymentListVersion: ULong? = null, + val previousPrivatePaymentListVersion: ULong? = null, ) data class PaykitOnchainPaymentProofResolution( @@ -83,50 +102,23 @@ data class PaykitOnchainPaymentProofResolution( val requestId: PaykitPaymentRequestId, val transactionId: String, val walletId: String = WalletScope.default, + val amountSats: ULong? = null, ) -@Singleton -class PaykitOnchainPaymentProofLookup @Inject constructor( - private val activityRepo: ActivityRepo, -) { - suspend fun existingTransactionIds( - address: String, - amountSats: ULong, - walletId: String = WalletScope.default, - ): Set = matchingTransactionIds(address, amountSats, walletId).mapTo(mutableSetOf(), String::lowercase) - - suspend fun transactionId( - address: String, - amountSats: ULong, - excluding: Set, - walletId: String = WalletScope.default, - ): String? = matchingTransactionIds(address, amountSats, walletId).lastOrNull { it.lowercase() !in excluding } - - private suspend fun matchingTransactionIds(address: String, amountSats: ULong, walletId: String): List = - activityRepo.getActivities( - walletId = walletId, - filter = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT, - ).getOrThrow().mapNotNull { activity -> - val onchain = (activity as? Activity.Onchain)?.v1 ?: return@mapNotNull null - onchain.txId.takeIf { - onchain.doesExist && onchain.address == address && onchain.value == amountSats - } - } -} - @Singleton @Suppress("TooManyFunctions") class PaykitPaymentProofRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val paykitSdkService: PaykitSdkService, private val lightningRepo: LightningRepo, - private val onchainPaymentLookup: PaykitOnchainPaymentProofLookup, private val store: PaykitPaymentProofStore, + private val hwWalletRepo: HwWalletRepo, + private val privatePaykitRepo: Lazy, ) { companion object { private const val TAG = "PaykitPaymentProofRepo" private const val HASH_BYTE_COUNT = 32 + private val HARDWARE_OBSERVATION_TIMEOUT = 15.seconds } private val operationMutex = Mutex() @@ -140,6 +132,8 @@ class PaykitPaymentProofRepo @Inject constructor( identity = publicKey, completedProofKinds = proofs.filter { it.proofData != null }.associate { it.requestId to it.kind }, inFlightRequestIds = proofs.filter { it.paymentStarted }.mapTo(mutableSetOf()) { it.requestId }, + reopenableHardwareRequestIds = proofs.filter { it.isReopenableHardwareReceipt(publicKey) } + .mapTo(mutableSetOf()) { it.requestId }, ) } } @@ -152,10 +146,15 @@ class PaykitPaymentProofRepo @Inject constructor( val identity: String, val completedProofKinds: Map, val inFlightRequestIds: Set, + val reopenableHardwareRequestIds: Set, ) suspend fun backupSnapshot(): List = withContext(ioDispatcher) { - operationMutex.withLock { store.load().map { PaykitPaymentStateBackup.Proof(it) } } + operationMutex.withLock { + val proofs = store.load() + check(proofs.none { it.hardwareDispatchDenied }) { "Hardware dispatch denial cleanup is pending" } + proofs.map { PaykitPaymentStateBackup.Proof(it) } + } } suspend fun restoreBackup(proofs: List) = withContext(ioDispatcher) { @@ -172,12 +171,27 @@ class PaykitPaymentProofRepo @Inject constructor( kind: PaykitPaymentProofKind, ): Result = withContext(ioDispatcher) { runSuspendCatching { + releaseInterruptedShopPreparation() + val onchainAttempt = lightningRepo.currentOnchainSendAttempt() + if (onchainAttempt?.requestId == request.id) throw PaykitPaymentRequestError.OperationInProgress operationMutex.withLock { val proof = pendingProof(request, paymentEndpointIdentifier, paymentAppId, kind) val currentProofs = loadProofs() - if (currentProofs.any { it.isStartedFor(proof.identity, request.id) }) { + if (currentProofs.any { + it.isStartedFor(proof.identity, request.id) || + (PubkyPublicKeyFormat.matches(it.identity, proof.identity) && + PubkyPublicKeyFormat.matches(it.requestId.counterparty, request.counterparty) && + it.hardwareSignedTransaction != null && !it.onchainAcceptanceVerified) + } + ) { throw PaykitPaymentRequestError.OperationInProgress } + val alreadyPaid = paykitSdkService.paymentRequests().any { record -> + record.paymentRequestId == request.paymentRequestId && + PubkyPublicKeyFormat.matches(record.counterparty, request.counterparty) && + record.paymentProofs.any { it.billingPeriod.matches(request.billingPeriod) } + } + if (alreadyPaid) throw PaykitPaymentRequestError.OperationInProgress val proofs = currentProofs .filterNot { it.isUnstartedFor(proof.identity, request.id) } + proof @@ -194,9 +208,14 @@ class PaykitPaymentProofRepo @Inject constructor( ): Result = withContext(ioDispatcher) { runSuspendCatching { if (!paymentHash.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable + val onchainAttempt = lightningRepo.currentOnchainSendAttempt() + if (onchainAttempt?.requestId == request.id) throw PaykitPaymentRequestError.OperationInProgress val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable operationMutex.withLock { val proofs = loadProofs().toMutableList() + if (proofs.any { it.isStartedFor(identity, request.id) }) { + throw PaykitPaymentRequestError.OperationInProgress + } val index = proofs.indexOfLast { PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id && @@ -227,16 +246,21 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, address: String, walletId: String = WalletScope.default, + privatePaymentListVersion: ULong? = null, + signedTx: HwFundingSignedTx? = null, + previousPrivatePaymentListVersion: ULong? = null, ): Result = withContext(ioDispatcher) { runSuspendCatching { val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable - val existingTransactionIds = onchainPaymentLookup.existingTransactionIds( - address, - request.amountSats, - walletId, - ) + val signedTxid = signedTx?.let { + if (walletId == WalletScope.default) throw PaykitPaymentRequestError.RequestUnavailable + SignedTransactionId.fromHex(it.serializedTx) + } operationMutex.withLock { val proofs = loadProofs().toMutableList() + if (proofs.any { it.isStartedFor(identity, request.id) }) { + throw PaykitPaymentRequestError.OperationInProgress + } val index = proofs.indexOfLast { PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id && @@ -251,7 +275,16 @@ class PaykitPaymentProofRepo @Inject constructor( onchainAddress = address, onchainAmountSats = request.amountSats, onchainWalletId = walletId, - onchainMatchingTransactionIdsBeforeAttempt = existingTransactionIds, + privatePaymentListVersion = privatePaymentListVersion, + previousPrivatePaymentListVersion = previousPrivatePaymentListVersion.takeIf { + privatePaymentListVersion != null + }, + paymentIdentifier = signedTxid, + hardwareSignedTransaction = signedTx?.serializedTx, + hardwareMiningFeeSats = signedTx?.miningFeeSats, + hardwareFeeRate = signedTx?.feeRate, + hardwareTotalSpent = signedTx?.totalSpent, + hardwareDispatchAttempted = signedTx?.let { false }, ) persist(proofs) } @@ -292,57 +325,161 @@ class PaykitPaymentProofRepo @Inject constructor( } } + suspend fun captureOriginalOnchainPayer(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { + runSuspendCatching { + val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable + operationMutex.withLock { + val proof = loadProofs().singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Onchain && !it.paymentStarted + } ?: throw PaykitPaymentRequestError.RequestUnavailable + requireNotNull(PubkyPublicKeyFormat.normalized(proof.identity)) + } + } + } + + suspend fun verifyOriginalOnchainPayer(request: PaykitPaymentRequest, payerIdentity: String): Result = + withContext(ioDispatcher) { + runSuspendCatching { + val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable + if (!PubkyPublicKeyFormat.matches(identity, payerIdentity)) { + throw PaykitPaymentRequestError.RequestUnavailable + } + operationMutex.withLock { + check( + loadProofs().any { + it.isStartedFor(payerIdentity, request.id) && it.kind == PaykitPaymentProofKind.Onchain + } + ) + } + } + } + + /** Authorize a successor against the existing payer proof without preparing or consuming private context again. */ + suspend fun authorizeOnchainRecovery(attempt: OnchainSendAttempt): Result = withContext(ioDispatcher) { + runSuspendCatching { + val current = lightningRepo.currentOnchainSendAttempt() + operationMutex.withLock { + val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable + if (current != attempt || !attempt.isUnresolved) { + throw PaykitPaymentRequestError.OperationInProgress + } + if (attempt.originalInputs.isNullOrEmpty() || attempt.candidateTxids.isEmpty()) { + throw PaykitPaymentRequestError.OperationInProgress + } + if (attempt.walletId != WalletScope.default || attempt.payerIdentity == null || + !PubkyPublicKeyFormat.matches(identity, attempt.payerIdentity) + ) { + throw PaykitPaymentRequestError.OperationInProgress + } + val proof = loadProofs().singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.matchesOriginalShopAttempt(attempt) + } ?: throw PaykitPaymentRequestError.RequestUnavailable + if (proof.proofData != null || proof.onchainAcceptanceVerified) { + throw PaykitPaymentRequestError.OperationInProgress + } + } + } + } + + suspend fun completeRecoveredOnchainPayment(request: PaykitPaymentRequest, attempt: OnchainSendAttempt): Boolean = + withContext(ioDispatcher) { + val proof = operationMutex.withLock { + loadProofs().singleOrNull { it.matchesOriginalShopAttempt(attempt) } + } ?: return@withContext false + val txid = attempt.txid ?: return@withContext false + completeOnchainPayment(request, txid, proof.paymentEndpointIdentifier, proof.paymentAppId) + } + suspend fun completeOnchainPayment( request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String, paymentAppId: String, - ) = withContext(ioDispatcher) { + acceptedOutcome: OnchainSendOutcome.Accepted? = null, + ): Boolean = withContext(ioDispatcher) { if (!txid.isHex(HASH_BYTE_COUNT)) { Logger.warn("Ignored a Paykit on-chain proof with an invalid transaction id", context = TAG) - return@withContext + return@withContext false } - val prepared = operationMutex.withLock { + val identity = currentIdentity() ?: return@withContext false + if (acceptedOutcome != null && !acceptedOutcome.txid.equals(txid, ignoreCase = true)) return@withContext false + val attempt = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() } + .getOrElse { return@withContext false } + val hasCandidateFamily = attempt?.candidateTxids?.isNotEmpty() == true + fun hasPositiveEvidence(proof: PendingPaykitPaymentProof): Boolean = + if (hasCandidateFamily) { + attempt.matchesPositiveShopProof(proof) && attempt?.txid.equals(txid, ignoreCase = true) + } else { + acceptedOutcome != null || + (attempt.matchesPositiveShopProof(proof) && attempt?.txid.equals(txid, ignoreCase = true)) + } + val fallbackProof = if (hasCandidateFamily) { + null + } else { runSuspendCatching { - loadProofs().filter { - it.requestId == request.id && it.paymentAppId == paymentAppId && - it.paymentEndpointIdentifier == paymentEndpointIdentifier && - it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && - it.paymentIdentifier == null && it.proofData == null - }.singleOrNull() + pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain) }.getOrNull() } - val identity = prepared?.identity ?: currentIdentity() ?: return@withContext - val fallbackProof = prepared ?: runSuspendCatching { - pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain) + // Admission acquires attempt-store then proof mutex; never acquire the store while holding this mutex. + val original = runSuspendCatching { + operationMutex.withLock { + loadProofs().lastOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id && it.paymentAppId == paymentAppId && + it.paymentEndpointIdentifier == paymentEndpointIdentifier && + it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && it.proofData == null && + ( + it.paymentIdentifier == null || + (hasCandidateFamily && it.matchesOriginalShopAttempt(requireNotNull(attempt))) + ) + } + } }.getOrNull() - operationMutex.withLock { + if (original == null && hasCandidateFamily) return@withContext false + val evidenceProof = original ?: fallbackProof ?: return@withContext false + if (!hasPositiveEvidence(evidenceProof)) return@withContext false + if (runSuspendCatching { lightningRepo.finishAcceptedShopActivity(request.id, txid) }.isFailure) { + return@withContext false + } + val completed = operationMutex.withLock { val completion = runSuspendCatching { val proofs = loadProofs().toMutableList() val index = proofs.indexOfLast { PubkyPublicKeyFormat.matches(it.identity, identity) && - it.requestId == request.id && + it.requestId == request.id && it.paymentAppId == paymentAppId && + it.paymentEndpointIdentifier == paymentEndpointIdentifier && it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && - it.paymentIdentifier == null && + ( + it.paymentIdentifier == null || + (hasCandidateFamily && it.matchesOriginalShopAttempt(requireNotNull(attempt))) + ) && it.proofData == null } + if (hasCandidateFamily && index < 0) return@runSuspendCatching false + if (original != null && (index < 0 || proofs[index] != original)) return@runSuspendCatching false + val originalProof = if (index >= 0) proofs[index] else fallbackProof + if (originalProof == null || !hasPositiveEvidence(originalProof)) return@runSuspendCatching false val proof = if (index >= 0) { proofs[index].copy( paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), + onchainAcceptanceVerified = true, ) } else { pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain).copy( paymentStarted = true, paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), + onchainAcceptanceVerified = true, ) } + if (!hasPositiveEvidence(proof)) return@runSuspendCatching false if (index >= 0) proofs[index] = proof else proofs += proof - persistAndSubmit(listOf(proof), proofs) - publishOnchainResolution(proof, txid) + val retained = persistAndSubmit(listOf(proof), proofs) + if (retained) publishOnchainResolution(proof, txid) + retained } completion.onFailure { Logger.warn( @@ -351,17 +488,28 @@ class PaykitPaymentProofRepo @Inject constructor( context = TAG, ) } - if (completion.isFailure && fallbackProof != null) { + val canAttemptFallback = completion.isFailure && !hasCandidateFamily + if (canAttemptFallback && fallbackProof != null && hasPositiveEvidence(fallbackProof)) { val proof = fallbackProof.copy( paymentStarted = true, paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), + onchainAcceptanceVerified = true, ) - runSuspendCatching { submitReady(proof) } + val delivered = runSuspendCatching { submitReady(proof) } .onFailure { Logger.warn("Failed to complete a Paykit on-chain payment proof", it, context = TAG) } - publishOnchainResolution(proof, txid) + .getOrDefault(false) + if (delivered) publishOnchainResolution(proof, txid) + delivered + } else { + completion.getOrDefault(false) } } + if (completed) { + runSuspendCatching { lightningRepo.completeAcceptedShopFollowup(request.id, txid) } + .onFailure { Logger.warn("Failed to finish accepted Shop send locally", it, context = TAG) } + } + completed } suspend fun failLightningPayment(paymentHash: String) = removeProofs { @@ -387,9 +535,279 @@ class PaykitPaymentProofRepo @Inject constructor( return true } + suspend fun retainHardwareOnchainCandidate( + requestId: PaykitPaymentRequestId, + walletId: String, + txid: String, + identity: String?, + address: String, + amountSats: ULong, + signedTx: HwFundingSignedTx? = null, + ): Boolean = withContext(ioDispatcher) { + if (walletId == WalletScope.default || !txid.isHex(HASH_BYTE_COUNT)) return@withContext false + if (signedTx != null && + runCatching { SignedTransactionId.fromHex(signedTx.serializedTx) } + .getOrNull()?.equals(txid, true) != true + ) { + return@withContext false + } + val originalIdentity = identity?.let(PubkyPublicKeyFormat::normalized) ?: return@withContext false + operationMutex.withLock { + if (!PubkyPublicKeyFormat.matches(currentIdentity(), originalIdentity)) return@withLock false + val proofs = loadProofs() + val original = proofs.singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, originalIdentity) && + it.requestId == requestId && + it.onchainWalletId == walletId && + it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && + !it.hardwareDispatchDenied && + it.onchainAddress == address && + it.onchainAmountSats == amountSats && + it.proofData == null && + !it.onchainAcceptanceVerified && + (it.paymentIdentifier == null || it.paymentIdentifier.equals(txid, true)) && + (it.hardwareSignedTransaction == null || it.hardwareSignedTransaction == signedTx?.serializedTx) + } ?: return@withLock false + // Lookup identity only: never acceptance, a Sent activity, or a delivered proof. + persist( + proofs.map { + if (it == original) { + it.copy( + paymentIdentifier = txid.lowercase(), + hardwareSignedTransaction = signedTx?.serializedTx ?: it.hardwareSignedTransaction, + hardwareMiningFeeSats = signedTx?.miningFeeSats ?: it.hardwareMiningFeeSats, + hardwareFeeRate = signedTx?.feeRate ?: it.hardwareFeeRate, + hardwareTotalSpent = signedTx?.totalSpent ?: it.hardwareTotalSpent + ) + } else { + it + } + } + ) + true + } + } + + suspend fun markHardwareOnchainDispatch( + requestId: PaykitPaymentRequestId, + walletId: String, + txid: String, + identity: String?, + address: String, + amountSats: ULong, + ): Boolean = withContext(ioDispatcher) { + operationMutex.withLock { + if (!PubkyPublicKeyFormat.matches(currentIdentity(), identity)) return@withLock false + val proofs = loadProofs() + val original = proofs.singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == requestId && + it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && !it.hardwareDispatchDenied && !it.onchainAcceptanceVerified && + it.proofData == null && it.paymentIdentifier.equals(txid, true) && + it.onchainAddress == address && it.onchainAmountSats == amountSats && + it.retainedSignedHardwareReceipt() != null + } ?: return@withLock false + persist( + proofs.map { + if (it == original) { + it.copy(hardwareDispatchAttempted = true, hardwareRefusedForNavigation = false) + } else { + it + } + } + ) + true + } + } + + // All captured payment fields are required to keep this hint bound to the original signed receipt. + @Suppress("LongParameterList", "CyclomaticComplexMethod") + suspend fun markHardwareRefusedForNavigation( + requestId: PaykitPaymentRequestId, + walletId: String, + txid: String, + identity: String?, + address: String, + amountSats: ULong, + ): Boolean = withContext(ioDispatcher) { + operationMutex.withLock { + if (!PubkyPublicKeyFormat.matches(currentIdentity(), identity)) return@withLock false + val proofs = loadProofs() + val original = proofs.singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == requestId && + it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && it.hardwareDispatchAttempted == true && + !it.hardwareDispatchDenied && !it.onchainAcceptanceVerified && it.proofData == null && + it.paymentIdentifier.equals(txid, true) && it.onchainAddress == address && + it.onchainAmountSats == amountSats && it.retainedSignedHardwareReceipt() != null + } ?: return@withLock false + persist(proofs.map { if (it == original) it.copy(hardwareRefusedForNavigation = true) else it }) + true + } + } + + suspend fun hasRetainedHardwareOnchainPayment(walletId: String): Boolean = withContext(ioDispatcher) { + operationMutex.withLock { + loadProofs().any { + it.onchainWalletId == walletId && walletId != WalletScope.default && + it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && + !it.onchainAcceptanceVerified && it.proofData == null && + it.retainedSignedHardwareReceipt() != null + } + } + } + + suspend fun retainedHardwareOnchainRequest(request: PaykitPaymentRequest): PendingPaykitPaymentProof? = + withContext(ioDispatcher) { + operationMutex.withLock { + val identity = currentIdentity() ?: return@withLock null + loadProofs().singleOrNull { + it.isReopenableHardwareReceipt(identity) && it.matchesHardwareRetryRequest(request) + } + } + } + + suspend fun retainedHardwareOnchainPayment( + requestId: PaykitPaymentRequestId, + walletId: String, + identity: String?, + address: String, + amountSats: ULong, + ): RetainedHardwareOnchainPayment? = withContext(ioDispatcher) { + operationMutex.withLock { + if (!PubkyPublicKeyFormat.matches(currentIdentity(), identity)) return@withLock null + val proof = loadProofs().singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == requestId && + it.onchainWalletId == walletId && + walletId != WalletScope.default && + it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && + !it.hardwareDispatchDenied && + !it.onchainAcceptanceVerified && + it.proofData == null && + it.onchainAddress == address && + it.onchainAmountSats == amountSats + } ?: return@withLock null + proof.retainedSignedHardwareReceipt()?.let { + proof.privatePaymentListVersion?.let { version -> + val consumption = privatePaykitRepo.get().consumePrivatePaymentList( + proof.requestId.counterparty, + PrivatePaykitPaymentContext( + paymentAppsByEndpoint = mapOf(proof.paymentEndpointIdentifier to proof.paymentAppId), + paymentListVersion = version, + ), + ) + val error = consumption.exceptionOrNull() + if (error != null && error !is PrivatePaykitError.PaymentListAlreadyConsumed) throw error + } + RetainedHardwareOnchainPayment( + it, + proof.hardwareDispatchAttempted != false, + proof.hardwareRefusedForNavigation, + ) + } + } + } + + suspend fun clearHardwareOnchainCandidateBeforeDispatch( + requestId: PaykitPaymentRequestId, + walletId: String, + txid: String, + identity: String?, + address: String, + amountSats: ULong, + hasAttemptedBroadcast: Boolean, + ): Boolean = withContext(ioDispatcher) { + if (hasAttemptedBroadcast || walletId == WalletScope.default || !txid.isHex(HASH_BYTE_COUNT)) { + return@withContext false + } + val originalIdentity = identity?.let(PubkyPublicKeyFormat::normalized) ?: return@withContext false + operationMutex.withLock { + runSuspendCatching { + if (!PubkyPublicKeyFormat.matches(currentIdentity(), originalIdentity)) return@runSuspendCatching false + val proofs = loadProofs() + val original = proofs.singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, originalIdentity) && it.requestId == requestId && + it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && it.onchainAddress == address && it.onchainAmountSats == amountSats && + it.paymentIdentifier.equals(txid, true) && it.proofData == null && + !it.onchainAcceptanceVerified + } ?: return@runSuspendCatching false + // Only a definite first queued dispatch denial permits the existing preparation cleanup. + // Keep exact identity and receipt until private cleanup and proof removal both finish. + persist(proofs.map { if (it == original) it.copy(hardwareDispatchDenied = true) else it }) + true + }.onFailure { Logger.warn("Failed to clear denied hardware candidate", it, context = TAG) } + .getOrDefault(false) + } + } + + suspend fun completeHardwareOnchainPayment( + requestId: PaykitPaymentRequestId, + walletId: String, + txid: String, + identity: String? = null, + ): Boolean = withContext(ioDispatcher) { + if (walletId == WalletScope.default || !txid.isHex(HASH_BYTE_COUNT)) return@withContext false + val originalIdentity = identity?.let(PubkyPublicKeyFormat::normalized) ?: return@withContext false + operationMutex.withLock { + runSuspendCatching { + val proofs = loadProofs().toMutableList() + val index = proofs.indices.singleOrNull { index -> + val proof = proofs[index] + PubkyPublicKeyFormat.matches(proof.identity, originalIdentity) && + proof.requestId == requestId && proof.onchainWalletId == walletId && + proof.kind == PaykitPaymentProofKind.Onchain && proof.paymentStarted && !proof.hardwareDispatchDenied + } ?: return@runSuspendCatching false + val original = proofs[index] + if ((original.paymentIdentifier != null && !original.paymentIdentifier.equals(txid, true)) || + (original.proofData != null && !original.proofData.equals(txid, true)) + ) return@runSuspendCatching false + // This id identifies the original lookup; it is not yet a payment proof or acceptance evidence. + val pending = original.copy(paymentIdentifier = txid.lowercase()) + proofs[index] = pending + persist(proofs) + reconcileHardwareOnchainProof(pending) + }.onFailure { Logger.warn("Failed to retain or observe the original hardware Shop payment", it, context = TAG) } + .getOrDefault(false) + } + } + + suspend fun failHardwareOnchainPaymentBeforeDispatch( + request: PaykitPaymentRequest, + walletId: String, + identity: String, + hasAttemptedBroadcast: Boolean, + ): Boolean = withContext(ioDispatcher) { + if (hasAttemptedBroadcast || walletId == WalletScope.default) return@withContext false + val originalIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return@withContext false + operationMutex.withLock { + runSuspendCatching { + if (currentIdentity() != originalIdentity) return@runSuspendCatching false + val proofs = loadProofs() + val original = proofs.singleOrNull { + val definitelyUnsent = it.paymentIdentifier == null || it.hardwareDispatchDenied || + (it.hardwareDispatchAttempted == false && it.retainedSignedHardwareReceipt() != null) + PubkyPublicKeyFormat.matches(it.identity, originalIdentity) && it.requestId == request.id && + it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && definitelyUnsent && it.proofData == null && !it.onchainAcceptanceVerified + } ?: return@runSuspendCatching false + // Release the captured private version while its proof still makes cleanup retryable. + releaseDeniedPrivateConsumption(original) + // Only the caller's definite first-dispatch authorization denial permits this removal. + persist(proofs - original) + true + }.onFailure { Logger.warn("Failed to clear denied hardware preparation", it, context = TAG) } + .getOrDefault(false) + } + } + suspend fun failOnchainPayment(request: PaykitPaymentRequest) { - removeRequestProofs(request) { + removeRequestProofs(request, releasePrivateBoundary = true) { it.kind == PaykitPaymentProofKind.Onchain && + it.onchainWalletId == WalletScope.default && it.paymentStarted && it.paymentIdentifier == null && it.proofData == null @@ -432,9 +850,101 @@ class PaykitPaymentProofRepo @Inject constructor( }.onFailure { Logger.warn("Failed to prepare Paykit subscription cancellation", it, context = TAG) } } + private suspend fun releaseInterruptedShopPreparation() { + runSuspendCatching { + lightningRepo.releaseInterruptedShopPreparation { attempt -> + operationMutex.withLock { + val proofs = loadProofs() + val original = proofs.filter { + it.requestId == attempt.requestId && + PubkyPublicKeyFormat.matches(it.identity, requireNotNull(attempt.payerIdentity)) + } + val safe = original.all { + it.kind == PaykitPaymentProofKind.Onchain && it.onchainWalletId == attempt.walletId && + it.paymentIdentifier == null && it.proofData == null && !it.onchainAcceptanceVerified && + ( + !it.paymentStarted || + (it.onchainAddress == attempt.address && it.onchainAmountSats == attempt.amountSats) + ) + } + if (!safe) return@withLock false + for (proof in original) releaseDeniedPrivateConsumption(proof) + if (original.isNotEmpty()) persist(proofs - original.toSet()) + true + } + } + }.onFailure { Logger.warn("Failed to clear interrupted Shop preparation", it, context = TAG) } + } + + private suspend fun releaseDeniedPrivateConsumption(proof: PendingPaykitPaymentProof) { + val version = proof.privatePaymentListVersion ?: return + privatePaykitRepo.get().releasePrivatePaymentListVersion( + proof.requestId.counterparty, + version, + proof.previousPrivatePaymentListVersion, + ).getOrThrow() + } + + private suspend fun finishDeniedHardwarePreparations(proofs: List) { + runSuspendCatching { + operationMutex.withLock { + if (proofs.none { it.hardwareDispatchDenied }) return@withLock + val identity = currentIdentity() ?: return@withLock + val denied = proofs.filter { + it.hardwareDispatchDenied && it.onchainWalletId != WalletScope.default && + it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && + it.paymentIdentifier?.isHex(HASH_BYTE_COUNT) == true && it.proofData == null && + !it.onchainAcceptanceVerified && PubkyPublicKeyFormat.matches(it.identity, identity) + } + for (proof in denied) { + if (proof !in loadProofs()) continue + releaseDeniedPrivateConsumption(proof) + // Removal follows idempotent exact-version release. A crash leaves the denial for retry. + persist(loadProofs() - proof) + } + } + }.onFailure { Logger.warn("Denied hardware payment cleanup remains pending", it, context = TAG) } + } + suspend fun reconcile() = withContext(ioDispatcher) { + releaseInterruptedShopPreparation() + val attempt = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() } + .onFailure { + Logger.warn("Failed to read on-chain send evidence during proof reconciliation", it, context = TAG) + } + .getOrNull() + if (attempt != null && attempt.hasPositiveEvidence && attempt.requestId != null) { + runSuspendCatching { finishAlreadyQueuedOnchainProof(attempt) } + .onFailure { Logger.warn("Failed to check queued Shop proof", it, context = TAG) } + } if (!store.hasPendingProofs()) return@withContext + // Snapshot immutable proofs, finish their exact accepted activity without the proof lock, + // then revalidate the same record under the lock before marking or delivering its proof. + val activityReady = mutableSetOf() + val snapshot = runSuspendCatching { + operationMutex.withLock { loadProofs().also { if (it.isEmpty()) persist(emptyList()) } } + }.getOrElse { return@withContext } + if (snapshot.isEmpty()) return@withContext + finishDeniedHardwarePreparations(snapshot) + snapshot.filter { it.kind == PaykitPaymentProofKind.Onchain && it.onchainWalletId == WalletScope.default } + .forEach { proof -> + val txid = proof.proofData ?: attempt?.txid + val validCompletedProof = proof.paymentStarted && proof.proofData?.isHex(HASH_BYTE_COUNT) == true && + proof.paymentIdentifier.equals(proof.proofData, ignoreCase = true) + val positive = if (proof.proofData == null) { + attempt.matchesPositiveShopProof(proof) + } else { + validCompletedProof && (proof.onchainAcceptanceVerified || attempt.matchesPositiveShopProof(proof)) + } + if (positive && txid != null && runSuspendCatching { + lightningRepo.finishAcceptedShopActivity(proof.requestId, txid) + }.isSuccess + ) { + activityReady += proof + } + } + var completedShopTxid: String? = null operationMutex.withLock { runSuspendCatching { val storedProofs = loadProofs() @@ -456,7 +966,8 @@ class PaykitPaymentProofRepo @Inject constructor( } proofs.forEach { proof -> - runSuspendCatching { reconcileProof(proof, payments) } + runSuspendCatching { reconcileProof(proof, payments, attempt, proof in activityReady) } + .onSuccess { if (it && proof.onchainWalletId == WalletScope.default) completedShopTxid = attempt?.txid } .onFailure { Logger.warn( "Failed to reconcile a pending Paykit payment proof", @@ -467,16 +978,55 @@ class PaykitPaymentProofRepo @Inject constructor( } }.onFailure { Logger.error("Failed to reconcile pending Paykit payment proofs", it, context = TAG) } } + val requestId = attempt?.requestId + val txid = completedShopTxid + if (requestId != null && txid != null) { + runSuspendCatching { lightningRepo.completeAcceptedShopFollowup(requestId, txid) } + .onFailure { Logger.warn("Failed to finish reconciled Shop send locally", it, context = TAG) } + } } private suspend fun reconcileProof( proof: PendingPaykitPaymentProof, payments: List, - ) { - when { - proof.proofData != null -> submitReady(proof) - proof.kind == PaykitPaymentProofKind.Onchain && proof.paymentStarted -> reconcileOnchainProof(proof) - proof.kind == PaykitPaymentProofKind.Lightning -> reconcileLightningProof(proof, payments) + attempt: OnchainSendAttempt?, + activityReady: Boolean, + ): Boolean { + if (proof.hardwareDispatchDenied) return false + if (proof.kind == PaykitPaymentProofKind.Onchain && proof.onchainWalletId == WalletScope.default && + !activityReady + ) { + return false + } + return when { + proof.kind == PaykitPaymentProofKind.Onchain && proof.proofData != null -> { + if (!proof.paymentStarted || !proof.proofData.isHex(HASH_BYTE_COUNT) || + !proof.paymentIdentifier.equals(proof.proofData, ignoreCase = true) + ) return false + if (proof.onchainWalletId != WalletScope.default) return reconcileHardwareOnchainProof(proof) + if (proof.onchainAcceptanceVerified != true) { + if (!attempt.matchesPositiveShopProof(proof)) return false + val proofs = loadProofs().toMutableList() + val index = proofs.indexOf(proof) + if (index < 0) return false + val verified = proof.copy(onchainAcceptanceVerified = true) + proofs[index] = verified + return persistAndSubmit(listOf(verified), proofs) + } + submitReady(proof) + attempt.matchesPositiveShopProof(proof) + } + proof.proofData != null -> { + submitReady(proof) + false + } + proof.kind == PaykitPaymentProofKind.Onchain && proof.paymentStarted -> + reconcileOnchainProof(proof, attempt) + proof.kind == PaykitPaymentProofKind.Lightning -> { + reconcileLightningProof(proof, payments) + false + } + else -> false } } @@ -510,24 +1060,80 @@ class PaykitPaymentProofRepo @Inject constructor( } } - private suspend fun reconcileOnchainProof(proof: PendingPaykitPaymentProof) { - val address = proof.onchainAddress ?: return - val amountSats = proof.onchainAmountSats ?: return - val txid = onchainPaymentLookup.transactionId( - address, - amountSats, - excluding = proof.onchainMatchingTransactionIdsBeforeAttempt, - walletId = proof.onchainWalletId, - ) ?: return - if (!txid.isHex(HASH_BYTE_COUNT)) return + private suspend fun reconcileOnchainProof( + proof: PendingPaykitPaymentProof, + attempt: OnchainSendAttempt?, + ): Boolean { + if (proof.onchainWalletId != WalletScope.default) return reconcileHardwareOnchainProof(proof) + if (!attempt.matchesPositiveShopProof(proof)) return false + val txid = attempt?.txid ?: return false + + val proofs = loadProofs().toMutableList() + val index = proofs.indexOf(proof) + if (index < 0) return false + val completed = proof.copy( + paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), onchainAcceptanceVerified = true, + ) + proofs[index] = completed + val retained = persistAndSubmit(listOf(completed), proofs) + if (retained) publishOnchainResolution(proof, txid) + return retained + } + private suspend fun reconcileHardwareOnchainProof(proof: PendingPaykitPaymentProof): Boolean { + val txid = proof.paymentIdentifier?.takeIf { it.isHex(HASH_BYTE_COUNT) } ?: return false + if (!proof.paymentStarted || proof.onchainWalletId == WalletScope.default || + (proof.proofData != null && !proof.proofData.equals(txid, true)) + ) return false + // A restored accepted proof can be newer than the independently uploaded activity backup. + val address = proof.onchainAddress?.takeIf { it.isNotBlank() } ?: return false + val amount = proof.onchainAmountSats ?: return false + val observed = withTimeoutOrNull(HARDWARE_OBSERVATION_TIMEOUT) { + hwWalletRepo.observeExactTransaction(proof.onchainWalletId, txid, address, amount).getOrDefault(false) + } == true + if (!observed) return false val proofs = loadProofs().toMutableList() val index = proofs.indexOf(proof) - if (index < 0) return - val completed = proof.copy(paymentIdentifier = txid.lowercase(), proofData = txid.lowercase()) + if (index < 0) return false + val completed = proof.copy(proofData = txid.lowercase(), onchainAcceptanceVerified = true) proofs[index] = completed - persistAndSubmit(listOf(completed), proofs) - publishOnchainResolution(proof, txid) + val retained = persistAndSubmit(listOf(completed), proofs) + if (retained) publishOnchainResolution(completed, txid) + // Hardware completion never acknowledges an unrelated node-wallet guard. + return retained + } + + private fun PendingPaykitPaymentProof.matchesOriginalShopAttempt(attempt: OnchainSendAttempt): Boolean = + paymentStarted && kind == PaykitPaymentProofKind.Onchain && requestId == attempt.requestId && + attempt.payerIdentity != null && PubkyPublicKeyFormat.matches(identity, attempt.payerIdentity) && + onchainWalletId == attempt.walletId && onchainAddress == attempt.address && + onchainAmountSats == attempt.amountSats && + (paymentIdentifier == null || attempt.candidateTxids.any { it.equals(paymentIdentifier, true) }) + + private fun OnchainSendAttempt?.matchesPositiveShopProof(proof: PendingPaykitPaymentProof): Boolean = + this != null && hasPositiveEvidence && requestId == proof.requestId && + walletId == proof.onchainWalletId && txid?.isHex(HASH_BYTE_COUNT) == true && + ( + candidateTxids.isEmpty() || + (candidateTxids.any { it.equals(txid, true) } && proof.matchesOriginalShopAttempt(this)) + ) && + (proof.proofData == null || proof.proofData.equals(txid, ignoreCase = true)) + + private suspend fun finishAlreadyQueuedOnchainProof(attempt: OnchainSendAttempt) { + val requestId = attempt.requestId ?: return + val txid = attempt.txid?.takeIf { it.isHex(HASH_BYTE_COUNT) } ?: return + val record = paykitSdkService.paymentRequests().firstOrNull { + it.paymentRequestId == requestId.paymentRequestId && + PubkyPublicKeyFormat.matches(it.counterparty, requestId.counterparty) + } ?: return + val expected = proofJson(PaykitPaymentProofKind.Onchain, txid.lowercase()).proofValues() + if (record.paymentProofs.any { + it.billingPeriod?.startsAt == requestId.billingPeriodStartsAt && + it.proof.exportText().proofValues() == expected + } + ) { + lightningRepo.completeAcceptedShopFollowup(requestId, txid) + } } private fun publishOnchainResolution(proof: PendingPaykitPaymentProof, txid: String) { @@ -536,6 +1142,7 @@ class PaykitPaymentProofRepo @Inject constructor( requestId = proof.requestId, transactionId = txid.lowercase(), walletId = proof.onchainWalletId, + amountSats = proof.onchainAmountSats, ) _onchainPaymentResolutions.update { resolutions -> if (resolution in resolutions) resolutions else resolutions + resolution @@ -555,6 +1162,9 @@ class PaykitPaymentProofRepo @Inject constructor( ?.let(PubkyPublicKeyFormat::normalized) private suspend fun submitReady(proof: PendingPaykitPaymentProof): Boolean { + // Missing legacy app provenance must remain readable, never guessed for delivery. + if (proof.paymentAppId.isBlank()) return false + if (proof.kind == PaykitPaymentProofKind.Onchain && proof.onchainAcceptanceVerified != true) return false val proofData = proof.proofData ?: return false val identityStatus = paykitSdkService.identityStatus() if ( @@ -611,6 +1221,7 @@ class PaykitPaymentProofRepo @Inject constructor( private suspend fun removeRequestProofs( request: PaykitPaymentRequest, + releasePrivateBoundary: Boolean = false, predicate: (PendingPaykitPaymentProof) -> Boolean, ) = withContext(ioDispatcher) { runSuspendCatching { @@ -627,7 +1238,12 @@ class PaykitPaymentProofRepo @Inject constructor( PubkyPublicKeyFormat.matches(it.identity, targetIdentity) && predicate(it) } - if (remaining != proofs) persist(remaining) + if (remaining != proofs) { + if (releasePrivateBoundary) { + for (proof in proofs - remaining.toSet()) releaseDeniedPrivateConsumption(proof) + } + persist(remaining) + } } }.onFailure { Logger.warn("Failed to clear a pending Paykit payment proof", it, context = TAG) } } @@ -641,7 +1257,7 @@ class PaykitPaymentProofRepo @Inject constructor( private suspend fun persistAndSubmit( completedProofs: List, allProofs: List, - ) { + ): Boolean { val didPersist = runSuspendCatching { persist(allProofs) } .onFailure { Logger.warn( @@ -658,15 +1274,16 @@ class PaykitPaymentProofRepo @Inject constructor( hasUndeliveredProof = hasUndeliveredProof || !wasDelivered } if (!didPersist && hasUndeliveredProof) { - runSuspendCatching { persist(allProofs) } + return runSuspendCatching { persist(allProofs) } .onFailure { Logger.warn( "Failed to retain a completed Paykit payment proof for retry", it, context = TAG, ) - } + }.isSuccess } + return didPersist || !hasUndeliveredProof } private suspend fun pendingProof( @@ -760,3 +1377,25 @@ private fun String.matchesPaymentHash(paymentHash: String): Boolean { private fun String.isHex(byteCount: Int): Boolean = hexBytes()?.size == byteCount private fun String.hexBytes(): ByteArray? = runCatching { fromHex() }.getOrNull() + +internal fun PendingPaykitPaymentProof.retainedSignedHardwareReceipt(): HwFundingSignedTx? { + val raw = hardwareSignedTransaction ?: return null + val txid = runCatching { SignedTransactionId.fromHex(raw) }.getOrNull() ?: return null + if (!txid.equals(paymentIdentifier, true)) return null + if (hardwareMiningFeeSats == null || hardwareFeeRate == null || hardwareTotalSpent == null) return null + return HwFundingSignedTx(raw, hardwareMiningFeeSats, hardwareFeeRate, hardwareTotalSpent) +} + +@Suppress("CyclomaticComplexMethod") +internal fun PendingPaykitPaymentProof.isReopenableHardwareReceipt(identity: String): Boolean = + PubkyPublicKeyFormat.matches(this.identity, identity) && kind == PaykitPaymentProofKind.Onchain && + paymentStarted && onchainWalletId != WalletScope.default && hardwareDispatchAttempted == true && + hardwareRefusedForNavigation && !hardwareDispatchDenied && !onchainAcceptanceVerified && proofData == null && + billingPeriod == null && !onchainAddress.isNullOrBlank() && onchainAmountSats != null && + hardwareFeeRate != null && hardwareFeeRate in 1uL..UInt.MAX_VALUE.toULong() && + hardwareMiningFeeSats != null && hardwareMiningFeeSats <= Long.MAX_VALUE.toULong() && + retainedSignedHardwareReceipt() != null + +internal fun PendingPaykitPaymentProof.matchesHardwareRetryRequest(request: PaykitPaymentRequest): Boolean = + requestId == request.id && billingPeriod == request.billingPeriod && onchainAmountSats == request.amountSats && + paymentEndpointIdentifier in request.acceptedPaymentEndpointIdentifiers diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt index 58cbb92caa..364b8e6d33 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt @@ -39,6 +39,7 @@ class PaykitPaymentProofStore @Inject constructor( identity: String, ): Map = load() .filter { PubkyPublicKeyFormat.matches(it.identity, identity) && it.proofData != null } + .filter { it.kind != PaykitPaymentProofKind.Onchain || it.onchainAcceptanceVerified } .associate { it.requestId to it.kind } fun inFlightRequestIds(identity: String): Set = load() diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index c993539ef4..bc24602548 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -94,6 +94,7 @@ data class PaykitPaymentRequest( val billingPeriod: PaykitBillingPeriod? = null, val paymentProofKind: PaykitPaymentProofKind? = null, val paymentDeadlineAt: Instant? = null, + val isPaymentInFlight: Boolean = false, ) { enum class ParseFailure( val logValue: String, @@ -123,6 +124,9 @@ data class PaykitPaymentRequest( billingPeriod?.startsAt?.toString(), ) + val canDismiss: Boolean + get() = !isPaymentInFlight + val requiresAcceptance: Boolean get() = billingPeriod == null && lifecycleState == PaymentRequestLifecycleState.PROPOSED @@ -1168,6 +1172,9 @@ class PaykitPaymentRequestRepo @Inject constructor( val locallyInFlightRequestIds = expectedIdentity ?.let(paymentProofStore::inFlightRequestIds) .orEmpty() + val reopenableHardwareProofs = expectedIdentity?.let { identity -> + paymentProofStore.load().filter { it.isReopenableHardwareReceipt(identity) } + }.orEmpty() val allSubscriptions = records.mapNotNull(PaymentRequestRecord::toPaykitSubscription) .map { it.withExpiredLifecycle(subscriptionNow) } val blockedSubscriptionIds = allSubscriptions.mapNotNull { subscription -> @@ -1245,9 +1252,16 @@ class PaykitPaymentRequestRepo @Inject constructor( } } }.filter { - isLocallyPayable(it) && it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds + isLocallyPayable(it) && it.id !in locallyCompletedRequestIds && + ( + it.id !in locallyInFlightRequestIds || reopenableHardwareProofs.any { proof -> + proof.matchesHardwareRetryRequest(it) + } + ) } - val incoming = (dueRequests + oneTimeIncoming).sortedBy { it.createdAt } + val incoming = (dueRequests + oneTimeIncoming) + .map { it.copy(isPaymentInFlight = it.id in locallyInFlightRequestIds) } + .sortedBy { it.createdAt } val oneTimeHistory = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) }.map { request -> val proofKind = locallyCompletedProofKinds[request.id] ?: return@map request request.copy( @@ -1256,6 +1270,12 @@ class PaykitPaymentRequestRepo @Inject constructor( ) } val history = (recurringHistory + oneTimeHistory) + .map { + it.copy( + isPaymentInFlight = it.direction == PaykitPaymentRequestDirection.Incoming && + it.id in locallyInFlightRequestIds, + ) + } .sortedByDescending { it.createdAt } if (!isCurrentState(generation, expectedIdentity) || expectedIdentity == null) return pruneAcceptedOneTimeRequestIds(records, expectedIdentity, generation) @@ -1423,6 +1443,13 @@ class PaykitPaymentRequestRepo @Inject constructor( val current = _pendingRequests.value.firstOrNull { it.id == request.id } ?: throw PaykitPaymentRequestError.RequestUnavailable + if (resultingState in listOf( + PaymentRequestLifecycleState.CANCELED, + PaymentRequestLifecycleState.REJECTED, + ) && activeIdentity?.let { current.id in paymentProofStore.inFlightRequestIds(it) } == true + ) { + throw PaykitPaymentRequestError.OperationInProgress + } actionVersion++ operation(current) if (resultingState != PaymentRequestLifecycleState.ACCEPTED) processPendingMessages() diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index c2528fc434..5c3a7d3e84 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -214,7 +214,8 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( it in savedKeys }, ) - if (next == current) return@locked + // Keep failed deletion hidden immediately, but retry the durable write even when + // the filtered in-memory ledger already matches the requested cleanup. ledger = next persist(next) notifyBackupStateChanged() @@ -232,7 +233,8 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( contactAssignments = current.contactAssignments.filterKeys { it !in removedKeys }, contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { it !in removedKeys }, ) - if (next == current) return@locked + // Keep failed deletion hidden immediately, but retry the durable write even when + // the filtered in-memory ledger already matches the requested cleanup. ledger = next persist(next) notifyBackupStateChanged() diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index b2f6aa8681..1a71bdbba8 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -531,6 +531,7 @@ class PrivatePaykitRepo @Inject constructor( result } + @Suppress("TooGenericExceptionCaught") suspend fun consumePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, @@ -544,9 +545,16 @@ class PrivatePaykitRepo @Inject constructor( throw PrivatePaykitError.PaymentListAlreadyConsumed } + val previousEndpoints = contactState.remoteEndpoints contactState.consumedPrivatePaymentListVersion = paymentListVersion contactState.remoteEndpoints = emptyList() - persistState(markWalletBackup = true) + try { + persistState(markWalletBackup = true) + } catch (error: Throwable) { + contactState.consumedPrivatePaymentListVersion = consumedVersion + contactState.remoteEndpoints = previousEndpoints + throw error + } Logger.info( "Consumed private Paykit payment list version $paymentListVersion " + "for '${redacted(normalizedKey)}'", @@ -560,16 +568,34 @@ class PrivatePaykitRepo @Inject constructor( suspend fun releasePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, + ): Result = releasePrivatePaymentListVersion( + publicKey, + context.paymentListVersion, + context.previousPaymentListVersion, + ) + + suspend fun releasePrivatePaymentListVersion( + publicKey: String, + paymentListVersion: ULong?, + previousPaymentListVersion: ULong? = null, ): Result = withContext(serializedDispatcher) { runSuspendCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey - val paymentListVersion = context.paymentListVersion ?: return@runSuspendCatching + if (paymentListVersion == null) return@runSuspendCatching val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching val consumedVersion = contactState.consumedPrivatePaymentListVersion if (consumedVersion != paymentListVersion) return@runSuspendCatching - contactState.consumedPrivatePaymentListVersion = null - persistState(markWalletBackup = true) + if (previousPaymentListVersion != null && previousPaymentListVersion >= paymentListVersion) { + throw PaykitPaymentRequestError.RequestUnavailable + } + contactState.consumedPrivatePaymentListVersion = previousPaymentListVersion + try { + persistState(markWalletBackup = true) + } catch (error: Throwable) { + contactState.consumedPrivatePaymentListVersion = consumedVersion + throw error + } Logger.info( "Released private Paykit payment list version '$paymentListVersion' " + "for '${redacted(normalizedKey)}'", @@ -842,6 +868,7 @@ class PrivatePaykitRepo @Inject constructor( paymentAppsByEndpoint = privatePayable.distinctBy { it.methodId } .associate { it.methodId.rawValue to requireNotNull(it.appId) }, paymentListVersion = paymentListVersion, + previousPaymentListVersion = consumedVersion.takeIf { paymentListVersion != null }, ), ) } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 1dce23798a..2316dcecd4 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -87,6 +87,7 @@ internal val PublicPaykitPaymentResult.incomingPaymentRequestFailureReason: data class PrivatePaykitPaymentContext( val paymentAppsByEndpoint: Map, val paymentListVersion: ULong?, + val previousPaymentListVersion: ULong? = null, ) @OptIn(ExperimentalTime::class) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index e1eac9bb8c..8deaba1877 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -9,12 +9,22 @@ import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PendingSweepBalance +import to.bitkit.async.appScope +import to.bitkit.data.CacheStore import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher @@ -25,6 +35,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.TransferType import to.bitkit.models.WalletScope import to.bitkit.services.CoreService +import to.bitkit.utils.AppError import to.bitkit.utils.BlockTimeHelpers import to.bitkit.utils.Logger import java.util.UUID @@ -36,6 +47,7 @@ import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) @Singleton +@Suppress("LongParameterList") class TransferRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, @@ -43,9 +55,95 @@ class TransferRepo @Inject constructor( private val coreService: CoreService, private val transferDao: TransferDao, private val clock: Clock, + private val cacheStore: CacheStore, + private val connectivityRepo: ConnectivityRepo, ) { private val lastOrdersFetchMs = AtomicLong(0L) + private val fundingMutex = Mutex() + private val repoScope = appScope(bgDispatcher, TAG) + + init { + repoScope.launch { + lightningRepo.lightningState.map { it.nodeLifecycleState.isRunning() }.distinctUntilChanged() + .collectLatest { running -> if (running) resumeAcceptedFunding() } + } + repoScope.launch { + lightningRepo.nodeEvents.collect { event -> + if (event is Event.OnchainTransactionReceived || event is Event.OnchainTransactionConfirmed) { + resumeAcceptedFunding() + } + } + } + repoScope.launch { + connectivityRepo.isOnline.map { it == ConnectivityState.CONNECTED }.distinctUntilChanged().drop(1) + .collectLatest { connected -> + if (connected && lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) { + resumeAcceptedFunding() + } + } + } + } + + /** Uses only the bounded attempt's positive evidence and original order/balance context. */ + suspend fun resumeAcceptedFunding(expectedAttempt: OnchainSendAttempt? = null): Result = runSuspendCatching { + val attempt = lightningRepo.currentOnchainSendAttempt() ?: return@runSuspendCatching + if (expectedAttempt != null) { + check( + attempt.attemptId == expectedAttempt.attemptId && + attempt.walletIndex == expectedAttempt.walletIndex && attempt.walletId == expectedAttempt.walletId + ) { "Restored funding operation is no longer current" } + } + if (!attempt.isTransfer || !attempt.hasPositiveEvidence || attempt.localFollowupComplete) { + return@runSuspendCatching + } + val txid = requireNotNull(attempt.txid) + val orderId = requireNotNull(attempt.orderId) + val order = blocktankRepo.fetchOrders(listOf(orderId)).getOrThrow().firstOrNull { it.id == orderId } + ?: throw AppError("Original funding order is unavailable") + check(order.payment?.onchain?.address == attempt.address) { "Original funding order address changed" } + val context = requireNotNull(attempt.transferContext) { "Original funding context is unavailable" } + check( + order.clientBalanceSat == context.originalOrderClientBalanceSats && + order.feeSat == context.originalOrderFeeSats + ) { "Original funding order terms changed or are unavailable" } + check(lightningRepo.currentOnchainSendAttempt() == attempt) { "Original funding operation changed" } + persistAcceptedFunding(order, txid, context).getOrThrow() + lightningRepo.completeAcceptedTransferFollowup(orderId, txid) + }.onFailure { Logger.warn("Failed to resume accepted funding", it, context = TAG) } + + /** Serialized across startup/events and the confirmation screen; never broadcasts. */ + suspend fun persistAcceptedFunding( + order: IBtOrder, + txid: String, + originalContext: OnchainTransferContext?, + ): Result = withContext(bgDispatcher) { + runSuspendCatching { + fundingMutex.withLock { + val context = requireNotNull( + originalContext + ) { "Accepted transfer is missing its original balance context" } + val existing = transferDao.getByFundingTxId(txid) + check(existing == null || existing.lspOrderId == order.id) { + "Funding transaction is already assigned to another order" + } + val paid = cacheStore.data.first().paidOrders[order.id] + check(paid == null || paid == txid) { "Order is already assigned to another funding transaction" } + if (existing == null) { + createTransfer( + type = TransferType.TO_SPENDING, + amountSats = order.clientBalanceSat.toLong(), + fundingTxId = txid, + lspOrderId = order.id, + txTotalSats = context.txTotalSats.toLong(), + preTransferOnchainSats = context.preTransferOnchainSats.toLong(), + ).getOrThrow() + } + cacheStore.addPaidOrder(orderId = order.id, txId = txid) + } + } + } + val activeTransfers: Flow> = transferDao.getActiveTransfers() val forceCloseRemainingDuration: Flow = combine( diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 1c2e0dd355..8831d90d9c 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -12,6 +12,7 @@ import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.EventListener import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult +import com.synonym.bitkitcore.TransactionDetail import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorDeviceInfo @@ -449,6 +450,18 @@ class TrezorRepo @Inject constructor( } } + suspend fun getTransactionDetail( + extendedKey: String, + txid: String, + network: BitkitCoreNetwork, + scriptType: AccountType, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + awaitSetup() + trezorService.getTransactionDetail(extendedKey, currentElectrumUrl(), txid, network, scriptType) + } + } + suspend fun getTransactionHistory( extendedKey: String, network: BitkitCoreNetwork = Env.network.toCoreNetwork(), @@ -615,6 +628,22 @@ class TrezorRepo @Inject constructor( } } + suspend fun broadcastRawTxAtBoundary( + serializedTx: String, + paymentDeadlineAt: Instant?, + beforeNativeBroadcast: suspend () -> Unit, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + awaitSetup() + trezorService.broadcastRawTxAtBoundary( + serializedTx, currentElectrumUrl(), paymentDeadlineAt, beforeNativeBroadcast + ) + }.onFailure { + Logger.error("Trezor broadcastRawTx failed", it, context = TAG) + _state.update { s -> s.copy(error = trezorErrorMessage(it)) } + } + } + suspend fun disconnect(): Result = withContext(ioDispatcher) { val deviceId = _state.value.connectedDeviceId() TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=$deviceId") diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 2c96b03aee..5b3288c043 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1544,6 +1544,48 @@ class ActivityService( } } + /** Apply saved original hardware tags once after exact outgoing activity verification. */ + suspend fun restoreSentOnchainTags(txid: String, walletId: String): Boolean = + ServiceQueue.CORE.background { + val activity = requireNotNull(getActivityByTxId(walletId = walletId, txId = txid)) + check(activity.walletId == walletId && activity.txId.equals(txid, true) && + activity.txType == PaymentType.SENT) + val metadata = com.synonym.bitkitcore.getAllPreActivityMetadata().filter { + it.walletId == walletId && !it.isReceive && it.paymentId.equals(txid, true) && + it.txId?.equals(txid, true) == true && it.tags.isNotEmpty() + } + if (metadata.isEmpty()) return@background false + val existingTags = getTags(walletId = walletId, activityId = activity.id) + val newTags = metadata.flatMap { it.tags }.distinct().filter { it.isNotBlank() && it !in existingTags } + if (newTags.isNotEmpty()) addTags(walletId = walletId, activityId = activity.id, tags = newTags) + com.synonym.bitkitcore.upsertPreActivityMetadata(metadata.map { it.copy(tags = emptyList()) }) + newTags.isNotEmpty() + } + + /** Restore original attribution only when no contact has been saved for the accepted transaction. */ + suspend fun restoreSentOnchainContact(txid: String, walletId: String, contact: String) = + ServiceQueue.CORE.background { + val existing = requireNotNull(getOnchainActivityByTxId(txid, walletId)) + check(existing.walletId == walletId && existing.txId == txid && existing.txType == PaymentType.SENT) + if (cacheStore.data.first().isContactDetached(existing.id, walletId)) return@background + if (existing.contact == null) { + upsertActivity(Activity.Onchain(existing.copy(contact = contact))) + } + check(cacheStore.data.first().isContactDetached(existing.id, walletId) || + requireNotNull(getOnchainActivityByTxId(txid, walletId)).contact != null) + } + + /** Called only after exact original-node transaction/prevout verification, to repair an existing placeholder. */ + suspend fun repairVerifiedSentOnchainFee(txid: String, walletId: String, fee: ULong, feeRate: ULong) = + ServiceQueue.CORE.background { + val existing = requireNotNull(getOnchainActivityByTxId(txid, walletId)) + check(existing.walletId == walletId && existing.txId == txid && existing.txType == PaymentType.SENT) + val updated = existing.copy(fee = fee, feeRate = feeRate) + if (updated != existing) upsertActivity(Activity.Onchain(updated)) + val restored = requireNotNull(getOnchainActivityByTxId(txid, walletId)) + check(restored.fee == fee && restored.feeRate == feeRate) + } + suspend fun handleOnchainTransactionReceived(txid: String, details: TransactionDetails) { ServiceQueue.CORE.background { runCatching { diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index a4d19d2ad3..80cf654b01 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -37,6 +37,7 @@ import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.OnchainSendResult import org.lightningdevkit.ldknode.OnchainWalletAccount import org.lightningdevkit.ldknode.OnchainWalletAccountConfig import org.lightningdevkit.ldknode.PaymentDetails @@ -46,6 +47,7 @@ import org.lightningdevkit.ldknode.PublicKey import org.lightningdevkit.ldknode.ScoringFeeParameters import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid +import org.lightningdevkit.ldknode.TransactionDetails import org.lightningdevkit.ldknode.defaultConfig import to.bitkit.async.BaseCoroutineScope import to.bitkit.async.ServiceQueue @@ -68,6 +70,11 @@ import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType +import to.bitkit.repositories.OnchainPreparedReceipt +import to.bitkit.repositories.OnchainSendInput +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.PreparedOnchainSend import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.LdkLogWriter @@ -144,6 +151,7 @@ class LightningService internal constructor( private val loggerLdk: LoggerLdk, private val watchOnlyAccountLifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator, private val ldkQueue: CoroutineContext, + private val onchainFeeRateFactory: (ULong) -> FeeRate = { FeeRate.fromSatPerVbUnchecked(it) }, private val clock: Clock = Clock.System, ) : BaseCoroutineScope(bgDispatcher, TAG) { @@ -954,40 +962,75 @@ class LightningService internal constructor( } } - suspend fun send( + /** Prepare without dispatch; the caller must durably retain the receipt before calling broadcast. */ + suspend fun prepareOnchainSend( address: Address, sats: ULong, satsPerVByte: ULong, utxosToSpend: List? = null, isMaxAmount: Boolean = false, + walletIndex: Int = currentWalletIndex, paymentDeadlineAt: Instant? = null, - ): Txid { - val node = this.node ?: throw ServiceError.NodeNotSetup() - - Logger.info( - "Sending $sats sats to $address, satsPerVByte=$satsPerVByte, isMaxAmount = $isMaxAmount", - context = TAG, + ): PreparedOnchainSend = callOnchainSend { + ensurePaymentDeadline(paymentDeadlineAt) + require(satsPerVByte > 0uL && satsPerVByte <= UInt.MAX_VALUE.toULong()) + if (currentWalletIndex != walletIndex) throw ServiceError.NodeNotSetup() + val originalNode = node ?: throw ServiceError.NodeNotSetup() + val prepared = if (isMaxAmount) { + originalNode.onchainPayment().prepareSendAllToAddress( + address, + true, + onchainFeeRateFactory(satsPerVByte), + ) + } else { + originalNode.onchainPayment().prepareSendToAddress( + address, + sats, + onchainFeeRateFactory(satsPerVByte), + utxosToSpend, + ) + } + val receipt = OnchainPreparedReceipt( + prepared.txid(), + prepared.inputs().map { OnchainSendInput(it.txid, it.vout) }, + address, + prepared.recipientAmountSats(), + miningFeeSats = prepared.miningFeeSats(), ) + PreparedOnchainSend(receipt) { beforeDispatch -> + callOnchainSend { + // Dispatch only through the handle that signed the original receipt. + if (currentWalletIndex != walletIndex || node !== originalNode) throw ServiceError.NodeNotSetup() + ensurePaymentDeadline(paymentDeadlineAt) + beforeDispatch() + when (val result = prepared.broadcast()) { + is OnchainSendResult.Accepted -> OnchainSendOutcome.Accepted(result.txid) + is OnchainSendResult.Rejected -> OnchainSendOutcome.Rejected(result.txid, result.reason) + is OnchainSendResult.Unknown -> OnchainSendOutcome.Unknown(result.txid) + } + } + } + } - return ServiceQueue.LDK.background(ldkQueue) { - ensurePaymentDeadline(paymentDeadlineAt) - if (isMaxAmount) { - node.onchainPayment().sendAllToAddress( - address = address, - retainReserve = true, - feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), - ) - } else { - node.onchainPayment().sendToAddress( - address = address, - amountSats = sats, - feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), - utxosToSpend = utxosToSpend, - ) + suspend fun observedOriginalSendFee(attempt: OnchainSendAttempt): ULong? { + check(attempt.hasPositiveEvidence && attempt.walletIndex == currentWalletIndex) + val originalNode = node ?: throw ServiceError.NodeNotSetup() + val txid = requireNotNull(attempt.txid) + check(txid.lowercase() in attempt.candidateTxids) + return callOnchainSend { + check(currentWalletIndex == attempt.walletIndex && node === originalNode) + val details = originalNode.getTransactionDetails(txid) ?: return@callOnchainSend null + val fee = exactOriginalSendFee(details, requireNotNull(attempt.originalInputs)) { + originalNode.getTransactionDetails(it) } + check(currentWalletIndex == attempt.walletIndex && node === originalNode) + fee } } + internal suspend fun callOnchainSend(block: suspend () -> T): T = + ServiceQueue.LDK.background(ldkQueue) { runSuspendCatching { block() } }.getOrThrow() + suspend fun send(bolt11: String, sats: ULong? = null, paymentDeadlineAt: Instant? = null): PaymentId { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -1428,3 +1471,25 @@ class TrustedPeerForceCloseException : AppError( ) class NetworkGraphCacheDeleteError : AppError("Failed to delete network graph cache") + +/** Exact signed transaction inputs/prevouts, never an estimate or a backend-absence inference. */ +internal fun exactOriginalSendFee( + details: TransactionDetails, + originalInputs: List, + previous: (String) -> TransactionDetails?, +): ULong? { + val inputs = details.inputs.map { OnchainSendInput(it.txid, it.vout) } + check(inputs.isNotEmpty() && inputs.distinct().size == inputs.size && inputs.toSet() == originalInputs.toSet()) + fun checkedSum(values: List): ULong = values.fold(0uL) { sum, value -> + check(value >= 0 && ULong.MAX_VALUE - sum >= value.toULong()) + sum + value.toULong() + } + val inputValues = inputs.map { input -> + val parent = previous(input.txid) ?: return null + parent.outputs.single { it.n == input.vout }.value + } + val inputTotal = checkedSum(inputValues) + val outputTotal = checkedSum(details.outputs.map { it.value }) + check(inputTotal >= outputTotal) + return inputTotal - outputTotal +} diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index cb258786b1..c71fc385fc 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -7,6 +7,7 @@ import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.EventListener import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult +import com.synonym.bitkitcore.TransactionDetail import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorDeviceInfo @@ -26,6 +27,7 @@ import com.synonym.bitkitcore.onchainComposeTransaction import com.synonym.bitkitcore.onchainGetAccountInfo import com.synonym.bitkitcore.onchainGetAddressInfo import com.synonym.bitkitcore.onchainGetTransactionHistory +import com.synonym.bitkitcore.onchainGetTransactionDetail import com.synonym.bitkitcore.onchainStartWatcher import com.synonym.bitkitcore.onchainStopAllWatchers import com.synonym.bitkitcore.onchainStopWatcher @@ -254,6 +256,32 @@ class TrezorService @Inject constructor( } } + suspend fun broadcastRawTxAtBoundary( + serializedTx: String, + electrumUrl: String, + paymentDeadlineAt: Instant?, + beforeNativeBroadcast: suspend () -> Unit, + ): String = ServiceQueue.CORE.background { + if (paymentDeadlineAt != null && clock.now() > paymentDeadlineAt) { + throw ServiceError.PaymentDeadlineExpired() + } + beforeNativeBroadcast() + if (paymentDeadlineAt != null && clock.now() > paymentDeadlineAt) { + throw ServiceError.PaymentDeadlineExpired() + } + onchainBroadcastRawTx(serializedTx = serializedTx, electrumUrl = electrumUrl) + } + + suspend fun getTransactionDetail( + extendedKey: String, + electrumUrl: String, + txid: String, + network: BitkitCoreNetwork, + scriptType: AccountType, + ): TransactionDetail = ServiceQueue.CORE.background { + onchainGetTransactionDetail(extendedKey, electrumUrl, txid, network, scriptType) + } + suspend fun getTransactionHistory( extendedKey: String, electrumUrl: String, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 335a210554..8a41a6bcd2 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -822,7 +822,11 @@ private fun RootNavHost( val scope = rememberCoroutineScope() LaunchedEffect(Unit) { transferViewModel.transferEffects.collect { effect -> - navController.navigateForTransferEffect(effect) + if (effect is TransferEffect.OnFundingPending) { + appViewModel.showPendingTransfer(effect.attempt) + } else { + navController.navigateForTransferEffect(effect) + } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt index 172131cc33..46411d7fa6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt @@ -231,9 +231,10 @@ internal fun IncomingPaymentRequestDetailsContent( ) { SecondaryButton( text = stringResource(R.string.wallet__payment_request_dismiss), - enabled = !isDismissing, + enabled = !isDismissing && request.canDismiss, isLoading = isDismissing, onClick = { + if (!request.canDismiss) return@SecondaryButton val dismiss = onDismiss ?: return@SecondaryButton isDismissing = true scope.launch { diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index 90a5523e75..e1216b18d0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -599,7 +599,7 @@ internal fun PaymentRequestCard( SecondaryButton( text = stringResource(R.string.wallet__payment_request_dismiss), onClick = { - if (isBusy || onDismiss == null) return@SecondaryButton + if (isBusy || onDismiss == null || !request.canDismiss) return@SecondaryButton isDismissingLocally = true scope.launch { try { @@ -610,7 +610,7 @@ internal fun PaymentRequestCard( } }, isLoading = isBusy, - enabled = !isBusy, + enabled = !isBusy && request.canDismiss && onDismiss != null, icon = { Icon( painter = painterResource(R.drawable.ic_x), 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 84d4b3861e..80dcb48b94 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 @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwWalletVendor import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BodySSB @@ -46,13 +47,13 @@ private const val SEND_SIGN_VISUAL_TOP_RATIO = 0.54f fun HwSendSignScreen( walletId: String, sendUiState: SendUiState, + paymentIdentity: String?, satsPerVByte: ULong, viewModel: HwSendViewModel, - prepareContactPayment: suspend () -> Boolean, + prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean, onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit, onPaymentSubmissionChange: (Boolean) -> Unit, - onBroadcastAttemptChange: (Boolean) -> Unit, paymentDeadlineAt: Instant?, onBack: () -> Unit, ) { @@ -67,20 +68,12 @@ fun HwSendSignScreen( amountSats = sendUiState.amount, satsPerVByte = satsPerVByte, tags = sendUiState.selectedTags, - paymentDeadlineAt = paymentDeadlineAt, paymentRequestId = sendUiState.incomingPaymentRequestId, + paymentIdentity = paymentIdentity, + paymentDeadlineAt = paymentDeadlineAt, ) val onBackRequest: () -> Unit = { if (uiState.canLeave) onBack() } - val signAndBroadcast: () -> Unit = { - viewModel.signAndBroadcast( - request, - prepareContactPayment, - authorizeContactPayment, - onPaymentDeadlineExpired, - onBroadcastAttemptChange, - ) - } LaunchedEffect(walletId) { viewModel.warmUp(walletId) @@ -104,14 +97,27 @@ fun HwSendSignScreen( hasPendingBroadcast = uiState.hasPendingBroadcast, vendor = vendor, onBack = onBackRequest, - onOpenConnect = signAndBroadcast, + onOpenConnect = { + viewModel.signAndBroadcast( + request, + prepareContactPayment, + authorizeContactPayment, + onPaymentDeadlineExpired + ) + }, ) if (uiState.isPassphraseRequired) { HwPassphrasePromptSheet( isVerifying = uiState.isVerifyingPassphrase, onSubmit = { passphrase -> - viewModel.submitPassphrase(walletId, passphrase, signAndBroadcast) + viewModel.submitPassphrase( + request, + passphrase, + prepareContactPayment, + authorizeContactPayment, + onPaymentDeadlineExpired, + ) }, 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 9d45f78a4f..427ad1a4e3 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 @@ -10,8 +10,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,7 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import to.bitkit.R import to.bitkit.ext.isBroadcastConnectivityFailure -import to.bitkit.ext.isDefiniteHardwarePreBroadcastFailure +import to.bitkit.ext.isHardwareBroadcastRefusalForNavigation import to.bitkit.ext.isHwDeviceBusy import to.bitkit.ext.isHwFirmwareError import to.bitkit.ext.isHwSessionFailure @@ -37,8 +35,11 @@ import to.bitkit.repositories.HwPassphraseMismatchError import to.bitkit.repositories.HwPassphraseRequiredError import to.bitkit.repositories.HwWalletMismatchError import to.bitkit.repositories.HwWalletRepo -import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitPaymentRequestError +import to.bitkit.repositories.PaykitPaymentProofRepo +import to.bitkit.utils.SignedTransactionId import to.bitkit.services.CoreService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.HwErrorPresenter @@ -56,6 +57,7 @@ class HwSendViewModel @Inject constructor( private val preActivityMetadataRepo: PreActivityMetadataRepo, private val coreService: CoreService, private val activityRepo: ActivityRepo, + private val paykitPaymentProofRepo: PaykitPaymentProofRepo, private val clock: Clock = Clock.System, ) : ViewModel() { private companion object { @@ -86,20 +88,59 @@ class HwSendViewModel @Inject constructor( fun signAndBroadcast( request: HwSendRequest, - prepareContactPayment: suspend () -> Boolean = { true }, + prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { true }, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true }, onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {}, - onBroadcastAttemptChanged: (Boolean) -> Unit = {}, ) { - if (_uiState.value.isSigning || signingJob?.isActive == true) return - if (pendingBroadcast?.matches(request) == false) return + 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 { try { runCatching { + if (pendingBroadcast?.matches(request) == false) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = HwErrorPresenter.userMessage( + context, + PaykitPaymentRequestError.OperationInProgress, + ), + ) + return@runCatching + } var pending = pendingBroadcast?.takeIf { it.matches(request) } + if (pending == null && request.paymentRequestId != null) { + val restored = paykitPaymentProofRepo.retainedHardwareOnchainPayment( + request.paymentRequestId, + request.walletId, + request.paymentIdentity, + request.address, + request.amountSats + ) + if (restored != null) { + pending = + PendingHwSendBroadcast( + request, + restored.signedTx, + isPreparedForBroadcast = true, + hasAttemptedBroadcast = restored.hasAttemptedBroadcast, + isRefusedForNavigation = restored.isRefusedForNavigation, + ) + pendingBroadcast = pending + _uiState.update { + it.copy( + hasPendingBroadcast = true, + isBroadcastUnresolved = + restored.hasAttemptedBroadcast && !restored.isRefusedForNavigation, + ) + } + } + } + if (pending == null && paykitPaymentProofRepo.hasRetainedHardwareOnchainPayment(request.walletId)) { + throw PaykitPaymentRequestError.OperationInProgress + } if (pending == null && hwWalletRepo.needsPassphrase(request.walletId)) { _uiState.update { it.copy(isPassphraseRequired = true) } return@runCatching @@ -117,20 +158,22 @@ class HwSendViewModel @Inject constructor( } var payment = checkNotNull(pending) { "Hardware payment was not prepared" } if (payment.isPreparedForBroadcast.not()) { - if (!prepareContactPayment()) return@runCatching + if (!prepareContactPayment(payment.signedTx)) return@runCatching payment = payment.copy(isPreparedForBroadcast = true) pendingBroadcast = payment } if (!authorizeBroadcast(payment, authorizeContactPayment, onPaymentDeadlineExpired)) { return@runCatching } - currentCoroutineContext().ensureActive() - val result = broadcast(payment, onPaymentDeadlineExpired, onBroadcastAttemptChanged) - ?: return@runCatching - currentCoroutineContext().ensureActive() + val result = broadcast(payment, onPaymentDeadlineExpired) ?: return@runCatching + if (signingAttempt != attempt) return@runCatching runSuspendCatching { persistResult(request, result) } .onFailure { Logger.error("Failed to persist hardware send result", it, context = TAG) } - pendingResult.update { HwSendResult(request.walletId, result.txId, request.amountSats) } + if (signingAttempt != attempt) return@runCatching + pendingResult.update { + HwSendResult(request.walletId, result.txId, request.amountSats, + request.paymentRequestId, request.paymentIdentity) + } }.onFailure { if (it is CancellationException && it !is TimeoutCancellationException) throw it handleFailure(it, request.walletId) @@ -152,43 +195,93 @@ class HwSendViewModel @Inject constructor( onPaymentDeadlineExpired: suspend (Boolean) -> Unit, ): Boolean { if (payment.request.paymentDeadlineAt?.let { clock.now() > it } == true) { + _uiState.update { it.copy(isBroadcastUnresolved = payment.blocksNavigation) } onPaymentDeadlineExpired(payment.hasAttemptedBroadcast) return false } - return authorizeContactPayment(payment.hasAttemptedBroadcast) + val authorized = authorizeContactPayment(payment.hasAttemptedBroadcast) + if (!authorized && payment.blocksNavigation && + payment.request.paymentDeadlineAt?.let { clock.now() > it } == true + ) { + _uiState.update { it.copy(isBroadcastUnresolved = true) } + } + return authorized } private suspend fun broadcast( payment: PendingHwSendBroadcast, onPaymentDeadlineExpired: suspend (Boolean) -> Unit, - onBroadcastAttemptChanged: (Boolean) -> Unit, ): HwFundingBroadcastResult? { - val wasBroadcastUnresolved = _uiState.value.isBroadcastUnresolved + payment.request.paymentRequestId?.let { requestId -> + val request = payment.request + // Protect the only signed receipt while its durable lookup is being saved as well. + _uiState.update { it.copy(isBroadcastUnresolved = true) } + val retained = runSuspendCatching { + paykitPaymentProofRepo.retainHardwareOnchainCandidate( + requestId, + request.walletId, + SignedTransactionId.fromHex(payment.signedTx.serializedTx), + request.paymentIdentity, + request.address, + request.amountSats, + payment.signedTx + ) + }.onFailure { Logger.warn("Failed to retain signed hardware receipt", it, context = TAG) } + .getOrDefault(false) + if (!retained) { + // No dispatch occurred. Keep the exact signed transaction available for a save retry; + // Back/cancel must not discard it while the durable Shop preparation remains started. + _uiState.update { it.copy(isBroadcastUnresolved = true) } + return null + } + } _uiState.update { it.copy(isBroadcastUnresolved = true) } - pendingBroadcast = payment.copy(hasAttemptedBroadcast = true) - onBroadcastAttemptChanged(true) return withTimeout(BROADCAST_TIMEOUT) { - hwWalletRepo.broadcastFunding(payment.signedTx, payment.request.paymentDeadlineAt) + val request = payment.request + val requestId = request.paymentRequestId + if (requestId == null) { + pendingBroadcast = payment.copy(hasAttemptedBroadcast = true, isRefusedForNavigation = false) + hwWalletRepo.broadcastFunding(payment.signedTx, request.paymentDeadlineAt) + } else { + hwWalletRepo.broadcastFundingAtBoundary(payment.signedTx, request.paymentDeadlineAt) { + check( + paykitPaymentProofRepo.markHardwareOnchainDispatch( + requestId, + request.walletId, + SignedTransactionId.fromHex(payment.signedTx.serializedTx), + request.paymentIdentity, + request.address, + request.amountSats, + ) + ) { "Original hardware payment is no longer eligible for dispatch" } + pendingBroadcast = payment.copy(hasAttemptedBroadcast = true, isRefusedForNavigation = false) + } + } }.getOrElse { error -> if (generateSequence(error) { it.cause }.any { it is ServiceError.PaymentDeadlineExpired }) { - pendingBroadcast = payment - onBroadcastAttemptChanged(payment.hasAttemptedBroadcast) - _uiState.update { it.copy(isBroadcastUnresolved = wasBroadcastUnresolved) } - onPaymentDeadlineExpired(payment.hasAttemptedBroadcast) + val request = payment.request + val cleared = request.paymentRequestId?.let { requestId -> + !payment.hasAttemptedBroadcast && paykitPaymentProofRepo.clearHardwareOnchainCandidateBeforeDispatch( + requestId, request.walletId, SignedTransactionId.fromHex(payment.signedTx.serializedTx), + request.paymentIdentity, request.address, request.amountSats, payment.hasAttemptedBroadcast, + ) + } ?: !payment.hasAttemptedBroadcast + val unresolved = payment.hasAttemptedBroadcast || !cleared + pendingBroadcast = payment.copy(hasAttemptedBroadcast = unresolved) + _uiState.update { it.copy(isBroadcastUnresolved = unresolved && !payment.isRefusedForNavigation) } + onPaymentDeadlineExpired(unresolved) return null } - if (error.isDefiniteHardwarePreBroadcastFailure()) { - pendingBroadcast = payment - onBroadcastAttemptChanged(payment.hasAttemptedBroadcast) - } throw error } } fun submitPassphrase( - walletId: String, + request: HwSendRequest, passphrase: String, - onVerified: () -> Unit, + prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { true }, + authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true }, + onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {}, ) { if (passphrase.isEmpty()) return val state = _uiState.value @@ -199,11 +292,16 @@ class HwSendViewModel @Inject constructor( _uiState.update { it.copy(isVerifyingPassphrase = true) } passphraseJob = viewModelScope.launch { try { - hwWalletRepo.reconnectWithPassphrase(walletId, passphrase) + hwWalletRepo.reconnectWithPassphrase(request.walletId, passphrase) .onSuccess { if (!_uiState.value.isPassphraseRequired) return@onSuccess _uiState.update { it.copy(isPassphraseRequired = false) } - onVerified() + signAndBroadcast( + request, + prepareContactPayment, + authorizeContactPayment, + onPaymentDeadlineExpired, + ) } .onFailure { error -> if (error is HwPassphraseMismatchError) { @@ -213,7 +311,7 @@ class HwSendViewModel @Inject constructor( description = context.getString(R.string.hardware__passphrase_mismatch), ) } else { - handleFailure(error, walletId) + handleFailure(error, request.walletId) } } } finally { @@ -238,13 +336,43 @@ class HwSendViewModel @Inject constructor( signingJob?.cancel() signingJob = null signingAttempt++ - pendingBroadcast = null - _uiState.update { it.copy(isSigning = false, isConnectingDevice = false, hasPendingBroadcast = false) } + // A refused Shop payment may leave the sheet, but its signed operation stays guarded. + if (pendingBroadcast?.let { it.request.paymentRequestId != null && it.hasAttemptedBroadcast } != true) { + pendingBroadcast = null + } + _uiState.update { + it.copy(isSigning = false, isConnectingDevice = false, hasPendingBroadcast = pendingBroadcast != null) + } val walletId = signingWalletId ?: return signingWalletId = null viewModelScope.launch { hwWalletRepo.disconnectStaleSession(walletId) } } + fun completeReconciledBroadcast( + walletId: String?, + txid: String?, + onCompleted: (HwSendResult) -> Unit = {}, + ): Boolean { + val result = pendingResult.value?.takeIf { + it.walletId == walletId && it.txId.equals(txid, ignoreCase = true) + } ?: pendingBroadcast?.takeIf { + pendingResult.value == null && it.hasAttemptedBroadcast && it.request.paymentRequestId != null && + it.request.walletId == walletId && + SignedTransactionId.fromHex(it.signedTx.serializedTx).equals(txid, ignoreCase = true) + }?.let { + HwSendResult(it.request.walletId, requireNotNull(txid).lowercase(), it.request.amountSats, + it.request.paymentRequestId, it.request.paymentIdentity) + } ?: return false + // Exact observation owns completion; a late native response belongs to the old attempt. + signingAttempt++ + signingJob?.cancel() + signingJob = null + _uiState.update { it.copy(isSigning = false, isConnectingDevice = false) } + completeBroadcast() + onCompleted(result) + return true + } + fun completeBroadcast() { pendingBroadcast = null signingWalletId = null @@ -257,37 +385,6 @@ class HwSendViewModel @Inject constructor( } } - fun resolveBroadcast(walletId: String, requestId: PaykitPaymentRequestId, transactionId: String): Boolean { - val pending = pendingBroadcast ?: return false - if (!pending.hasAttemptedBroadcast) return false - if (pending.request.walletId != walletId || pending.request.paymentRequestId != requestId) return false - if (pendingResult.value != null) return true - - // Reconciliation has confirmed payment. Stop retries and use the normal completion path. - signingJob?.cancel() - val attempt = ++signingAttempt - _uiState.update { it.copy(isSigning = true) } - signingJob = viewModelScope.launch { - try { - val result = HwFundingBroadcastResult( - txId = transactionId, - miningFeeSats = pending.signedTx.miningFeeSats, - feeRate = pending.signedTx.feeRate, - totalSpent = pending.signedTx.totalSpent, - ) - runSuspendCatching { persistResult(pending.request, result) } - .onFailure { Logger.error("Failed to persist hardware send result", it, context = TAG) } - pendingResult.update { HwSendResult(walletId, transactionId, pending.request.amountSats) } - } finally { - if (signingAttempt == attempt) { - _uiState.update { it.copy(isSigning = false, isConnectingDevice = false) } - signingJob = null - } - } - } - return true - } - private suspend fun prepareSignedTransaction( walletId: String, address: String, @@ -357,6 +454,9 @@ class HwSendViewModel @Inject constructor( walletId = request.walletId, ) } + // A Core txid alone is not positive evidence for a Shop payment. Its original proof + // completes local activity after a fresh exact outgoing transaction observation. + if (request.paymentRequestId != null) return coreService.activity.createSentOnchainActivityFromSendResult( txid = result.txId, address = request.address, @@ -374,7 +474,29 @@ class HwSendViewModel @Inject constructor( } private suspend fun handleFailure(error: Throwable, walletId: String) { - _uiState.update { it.copy(isBroadcastUnresolved = false) } + val retainedShopPayment = pendingBroadcast?.let { + it.request.paymentRequestId != null && it.hasAttemptedBroadcast + } == true + // This changes navigation only: a protocol refusal never releases the durable wallet guard. + val refused = retainedShopPayment && error.isHardwareBroadcastRefusalForNavigation() + if (refused) pendingBroadcast = pendingBroadcast?.copy(isRefusedForNavigation = true) + if (refused) { + val payment = requireNotNull(pendingBroadcast) + val request = payment.request + runSuspendCatching { + paykitPaymentProofRepo.markHardwareRefusedForNavigation( + requireNotNull(request.paymentRequestId), + request.walletId, + SignedTransactionId.fromHex(payment.signedTx.serializedTx), + request.paymentIdentity, + request.address, + request.amountSats, + ) + }.onFailure { Logger.warn("Failed to save hardware refusal navigation state", it, context = TAG) } + } + _uiState.update { + it.copy(isBroadcastUnresolved = retainedShopPayment && pendingBroadcast?.blocksNavigation == true) + } when { error.isHwUserCancellation() -> { Logger.info("Hardware send cancelled on device for '$walletId'", context = TAG) @@ -408,7 +530,7 @@ class HwSendViewModel @Inject constructor( description = context.getString(R.string.wallet__payment_timeout), ) else -> { - if (pendingBroadcast?.hasAttemptedBroadcast != true) { + if (pendingBroadcast != null && !retainedShopPayment) { pendingBroadcast = null _uiState.update { it.copy(hasPendingBroadcast = false) } } @@ -437,7 +559,8 @@ data class HwSendUiState( ) { /** * Whether the sign sheet may be dismissed. Connecting or unlocking can be abandoned, and leaving - * cancels it; once the device is asked to sign, or a broadcast may have gone out, it cannot. + * cancels it. A definite refusal may dismiss the sheet while the signed payment remains guarded. + * Active signing and uncertain broadcasts still prevent dismissal. */ val canLeave: Boolean get() = (!isSigning || isConnectingDevice) && !isBroadcastUnresolved @@ -447,6 +570,8 @@ data class HwSendResult( val walletId: String, val txId: String, val amountSats: ULong, + val paymentRequestId: PaykitPaymentRequestId? = null, + val paymentIdentity: String? = null, ) data class HwSendRequest( @@ -455,8 +580,9 @@ data class HwSendRequest( val amountSats: ULong, val satsPerVByte: ULong, val tags: List, - val paymentDeadlineAt: Instant? = null, val paymentRequestId: PaykitPaymentRequestId? = null, + val paymentIdentity: String? = null, + val paymentDeadlineAt: Instant? = null, ) private data class PendingHwSendBroadcast( @@ -464,6 +590,11 @@ private data class PendingHwSendBroadcast( val signedTx: HwFundingSignedTx, val isPreparedForBroadcast: Boolean = false, val hasAttemptedBroadcast: Boolean = false, + val isRefusedForNavigation: Boolean = false, ) { - fun matches(request: HwSendRequest): Boolean = this.request == request + val blocksNavigation: Boolean get() = hasAttemptedBroadcast && !isRefusedForNavigation + + fun matches(request: HwSendRequest): Boolean = this.request == request || + hasAttemptedBroadcast && this.request.paymentRequestId != null && + this.request.copy(satsPerVByte = request.satsPerVByte, tags = request.tags) == request } 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 e39418165b..3805fe021c 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 @@ -567,7 +567,7 @@ private fun OnChainDetails( color = if (uiState.hardwareWalletId != null) Colors.Blue else Colors.Brand, enabled = uiState.canSwitchFundingSource && interactionsEnabled, isLoading = uiState.isFundingSourceLoading, - clickable = interactionsEnabled, + clickable = interactionsEnabled && !uiState.isRetainedHardwarePayment, icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchFundingSource }, onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, modifier = Modifier.testTag("SendConfirmAssetButton") @@ -608,7 +608,9 @@ private fun OnChainDetails( modifier = Modifier .weight(1f) .fillMaxHeight() - .clickableAlpha(enabled = interactionsEnabled) { onEvent(SendEvent.SpeedAndFee) } + .clickableAlpha(enabled = interactionsEnabled && !uiState.isRetainedHardwarePayment) { + onEvent(SendEvent.SpeedAndFee) + } ) { SendCell(caption = stringResource(R.string.wallet__send_fee_and_speed)) { Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt index 0a42cfab7b..ad7178e3b7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt @@ -23,6 +23,7 @@ import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainRecoveryFeeRate import to.bitkit.repositories.WalletRepo import to.bitkit.ui.components.KEY_DELETE import to.bitkit.ui.shared.toast.ToastEventBus @@ -30,7 +31,7 @@ import to.bitkit.viewmodels.SendUiState import javax.inject.Inject private const val MAX_DIGITS = 3 -private const val MAX_VALUE = 999u +private val MAX_VALUE = OnchainRecoveryFeeRate.maximum.toUInt() private const val MAX_RATIO = 0.5 @HiltViewModel diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index d5f0a9f5cc..afe13bbb41 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -15,47 +15,84 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.filterNotNull import to.bitkit.R +import to.bitkit.models.WalletScope +import to.bitkit.repositories.OnchainRecoveryFeeRate +import to.bitkit.repositories.OnchainPreparedReceipt +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PendingPaymentResolution +import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberBiometricAuthSupported +import to.bitkit.viewmodels.SanityWarning + +const val RECOVERY_PIN_CHECK_RESULT_KEY = "RECOVERY_PIN_CHECK_RESULT_KEY" @Composable fun SendPendingScreen( paymentHash: String, amount: Long, observeResolution: Boolean = true, + isOnchain: Boolean = false, onPaymentSuccess: (String, Long) -> Unit, onPaymentError: (PendingPaymentResolution.Failure) -> Unit, onClose: () -> Unit, onViewDetails: (String) -> Unit, viewModel: SendPendingViewModel, + walletId: String = WalletScope.default, + refusalReason: String? = null, + retryOriginal: suspend (OnchainSendAttempt, ULong, suspend (OnchainPreparedReceipt, List) -> Unit) -> Result, + onRecovered: (String, Long) -> Unit, + savedStateHandle: SavedStateHandle, + onNavigateToPin: () -> Unit, + onRecoveredTransfer: (OnchainSendAttempt) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - if (observeResolution) { - LaunchedEffect(Unit) { viewModel.init(paymentHash, amount) } + val txid = paymentHash.takeIf { isOnchain && it.matches(Regex("[0-9a-fA-F]{64}")) } + LaunchedEffect(Unit) { + if (isOnchain) { + viewModel.initOnchain(txid, amount, walletId) + } else if (observeResolution) { + viewModel.init(paymentHash, amount) + } } uiState.resolution?.takeIf { observeResolution }?.let { resolution -> @@ -71,21 +108,193 @@ fun SendPendingScreen( } } - Content( - amount = if (observeResolution) uiState.amount else amount, - activityId = uiState.activityId, - onClose = onClose, - onViewDetails = onViewDetails, + uiState.recoveredTxid?.let { winner -> + LaunchedEffect(winner) { onRecovered(winner, uiState.amount) } + } + uiState.recoveredTransfer?.let { winner -> + LaunchedEffect(winner.attemptId, winner.txid) { onRecoveredTransfer(winner) } + } + RecoveryAuthorization( + uiState, + savedStateHandle, + onNavigateToPin, + viewModel, + { rate, approve -> viewModel.retryOriginal(rate) { attempt, fee -> retryOriginal(attempt, fee, approve) } }, + ) { onRetry -> + SendPendingContent( + amount = if (observeResolution || isOnchain) uiState.amount else amount, + isOnchain = isOnchain, + activityId = uiState.activityId, + txid = uiState.currentTxid ?: txid, + refusalReason = refusalReason, + onClose = onClose, + onViewDetails = onViewDetails, + canRetry = uiState.recoveryAttempt != null, + isRecovering = uiState.isRecovering, + recoveryError = uiState.recoveryErrorMessage(), + onRetry = onRetry, + ) + } +} + +@Composable +private fun SendPendingUiState.recoveryErrorMessage(): String? = recoveryError?.let { + stringResource( + if (invalidFeeRate) { + R.string.wallet__send_pending__retry_invalid_fee + } else { + R.string.wallet__send_pending__retry_error + } ) } @Composable -private fun Content( +private fun RecoveryAuthorization( + uiState: SendPendingUiState, + savedStateHandle: SavedStateHandle, + onNavigateToPin: () -> Unit, + viewModel: SendPendingViewModel, + retryOriginal: (ULong, suspend (OnchainPreparedReceipt, List) -> Unit) -> Unit, + content: @Composable (() -> Unit) -> Unit, +) { + var showFeeApproval by remember { mutableStateOf(false) } + var feeInput by rememberSaveable { mutableStateOf("") } + val settings = settingsViewModel ?: return + val isPinEnabled by settings.isPinEnabled.collectAsStateWithLifecycle() + val pinForPayments by settings.isPinForPaymentsEnabled.collectAsStateWithLifecycle() + val isBiometricEnabled by settings.isBiometricEnabled.collectAsStateWithLifecycle() + val isBiometrySupported = rememberBiometricAuthSupported() + LaunchedEffect(savedStateHandle) { + savedStateHandle.getStateFlow(RECOVERY_PIN_CHECK_RESULT_KEY, null) + .filterNotNull().collect { successful -> + savedStateHandle.remove(RECOVERY_PIN_CHECK_RESULT_KEY) + viewModel.answerRecoveryPin(successful) + } + } + LaunchedEffect(uiState.recoveryApproval) { + if (viewModel.openRecoveryPin()) onNavigateToPin() + } + when (val approval = uiState.recoveryApproval) { + is RecoveryApproval.Fee -> AlertDialog( + onDismissRequest = { viewModel.answerRecoveryApproval(false) }, + title = { BodyM(stringResource(R.string.wallet__send_pending__retry_title)) }, + text = { + Column { + SelectionContainer { BodyM(approval.receipt.address, color = Colors.White64) } + BalanceHeaderView(sats = approval.receipt.amountSats.toLong()) + VerticalSpacer(16.dp) + BodyM(stringResource(R.string.wallet__send_fee_total) + .replace("{feeSats}", requireNotNull(approval.receipt.miningFeeSats).toString())) + BodyM("${approval.receipt.feeRateSatsPerVByte} " + stringResource(R.string.common__sat_vbyte)) + } + }, + confirmButton = { + PrimaryButton(text = stringResource(R.string.wallet__send_pending__retry_authorize), + onClick = { viewModel.answerRecoveryApproval(true) }) + }, + dismissButton = { + SecondaryButton(text = stringResource(R.string.common__cancel), + onClick = { viewModel.answerRecoveryApproval(false) }) + }, + ) + is RecoveryApproval.Warning -> AppAlertDialog( + title = stringResource(R.string.common__are_you_sure), + text = stringResource(approval.warning.message), + confirmText = stringResource(R.string.wallet__send_yes), + dismissText = stringResource(R.string.common__cancel), + onConfirm = { viewModel.answerRecoveryApproval(true) }, + onDismiss = { viewModel.answerRecoveryApproval(false) }, + ) + RecoveryApproval.Biometrics -> BiometricsView( + onSuccess = { viewModel.answerRecoveryApproval(true) }, + onFailure = { viewModel.useRecoveryPin() }, + ) + else -> Unit + } + if (showFeeApproval) { + uiState.recoveryAttempt?.let { original -> + RecoveryFeeDialog(original, feeInput, { feeInput = it }, { showFeeApproval = false }) { rate -> + showFeeApproval = false + retryOriginal(rate) { receipt, warnings -> + viewModel.approveRecoveryFee(receipt, warnings, isPinEnabled && pinForPayments, + isBiometricEnabled && isBiometrySupported) + } + } + } + } + content { + feeInput = uiState.recoveryAttempt?.feeRateSatsPerVByte?.toString().orEmpty() + showFeeApproval = true + } +} + +@Composable +private fun RecoveryFeeDialog( + original: OnchainSendAttempt, + feeInput: String, + onFeeChange: (String) -> Unit, + onClose: () -> Unit, + onAuthorize: (ULong) -> Unit, +) { + AlertDialog( + onDismissRequest = onClose, + title = { BodyM(stringResource(R.string.wallet__send_pending__retry_title)) }, + text = { + Column { + BodyM(stringResource(R.string.wallet__send_pending__retry_description)) + original.let { original -> + VerticalSpacer(16.dp) + SelectionContainer { BodyM(original.address, color = Colors.White64) } + BalanceHeaderView(sats = original.amountSats.toLong()) + } + VerticalSpacer(16.dp) + TextInput( + value = feeInput, + onValueChange = onFeeChange, + placeholder = stringResource(R.string.common__sat_vbyte), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = feeInput.isNotBlank() && OnchainRecoveryFeeRate.parse(feeInput) == null, + supportingText = { + if (feeInput.isNotBlank() && OnchainRecoveryFeeRate.parse(feeInput) == null) { + BodyM(stringResource(R.string.wallet__send_pending__retry_invalid_fee)) + } + }, + ) + } + }, + confirmButton = { + PrimaryButton( + text = stringResource(R.string.common__continue), + enabled = OnchainRecoveryFeeRate.parse(feeInput) != null, + onClick = { + val rate = OnchainRecoveryFeeRate.parse(feeInput) ?: return@PrimaryButton + onAuthorize(rate) + }, + ) + }, + dismissButton = { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onClose, + ) + }, + ) +} + +@Composable +internal fun SendPendingContent( amount: Long, + isOnchain: Boolean, activityId: String?, onClose: () -> Unit, onViewDetails: (String) -> Unit, modifier: Modifier = Modifier, + txid: String? = null, + refusalReason: String? = null, + canRetry: Boolean = false, + isRecovering: Boolean = false, + recoveryError: String? = null, + onRetry: () -> Unit = {}, ) { Column( modifier = modifier @@ -100,16 +309,58 @@ private fun Content( .fillMaxSize() .padding(horizontal = 16.dp) ) { - VerticalSpacer(16.dp) - BalanceHeaderView(sats = amount, modifier = Modifier.fillMaxWidth()) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + VerticalSpacer(16.dp) + BalanceHeaderView(sats = amount, modifier = Modifier.fillMaxWidth()) + recoveryError?.let { + VerticalSpacer(16.dp) + BodyM(it, color = Colors.White64) + } - VerticalSpacer(32.dp) - BodyM(stringResource(R.string.wallet__send_pending__description), color = Colors.White64) + VerticalSpacer(32.dp) + BodyM( + stringResource( + if (isOnchain) { + R.string.wallet__send_pending__onchain_description + } else { + R.string.wallet__send_pending__description + }, + ), + color = Colors.White64, + ) - FillHeight() - HourglassAnimation(modifier = Modifier.align(Alignment.CenterHorizontally)) - FillHeight() + if (isOnchain) { + refusalReason?.let { + VerticalSpacer(16.dp) + BodyM(stringResource(R.string.wallet__send_pending__refusal, it), color = Colors.White64) + } + txid?.let { + VerticalSpacer(16.dp) + SelectionContainer { + BodyM(stringResource(R.string.wallet__send_pending__txid, it), color = Colors.White64) + } + } + } + VerticalSpacer(32.dp) + HourglassAnimation(modifier = Modifier.align(Alignment.CenterHorizontally)) + VerticalSpacer(16.dp) + } + VerticalSpacer(16.dp) + if (canRetry) { + SecondaryButton( + text = stringResource(R.string.wallet__send_pending__retry_title), + enabled = !isRecovering, + onClick = onRetry, + modifier = Modifier.fillMaxWidth(), + ) + VerticalSpacer(16.dp) + } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SecondaryButton( text = stringResource(R.string.wallet__send_details), @@ -154,8 +405,9 @@ private fun HourglassAnimation(modifier: Modifier = Modifier) { private fun Preview() { AppThemeSurface { BottomSheetPreview { - Content( + SendPendingContent( amount = 50_000L, + isOnchain = false, activityId = null, onClose = {}, onViewDetails = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index 301d622f01..e510d36947 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.wallets.send +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.ActivityFilter @@ -10,17 +11,30 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import to.bitkit.ext.rawId +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainRecoveryFeeRate +import to.bitkit.repositories.OnchainPreparedReceipt +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.utils.Logger +import to.bitkit.viewmodels.SanityWarning import javax.inject.Inject @HiltViewModel class SendPendingViewModel @Inject constructor( private val pendingPaymentRepo: PendingPaymentRepo, private val activityRepo: ActivityRepo, + private val lightningRepo: LightningRepo, ) : ViewModel() { companion object { @@ -30,6 +44,8 @@ class SendPendingViewModel @Inject constructor( private val _uiState = MutableStateFlow(SendPendingUiState()) val uiState = _uiState.asStateFlow() + private var recoveryApproval: CompletableDeferred? = null + private var recoveryPinOpened = false private var isInitialized = false fun init(paymentHash: String, amount: Long) { @@ -44,7 +60,163 @@ class SendPendingViewModel @Inject constructor( observeResolution(paymentHash) } + // Local activity is only a Details target; it cannot resolve or acknowledge an on-chain send. + fun initOnchain(txid: String?, amount: Long, walletId: String = WalletScope.default) { + if (isInitialized) return + isInitialized = true + _uiState.update { it.copy(amount = amount) } + viewModelScope.launch { + runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.onSuccess { attempt -> + if (attempt?.walletId == walletId && + (attempt.txid.equals(txid, true) || attempt.candidateTxids.any { it.equals(txid, true) }) + ) { + _uiState.update { it.copy(amount = attempt.amountSats.toLong()) } + } + val matchesOriginalWallet = walletId == WalletScope.default && attempt?.walletId == walletId + val hasOriginalReceipt = !attempt?.originalInputs.isNullOrEmpty() && + attempt?.candidateTxids?.any { it.equals(txid, true) } == true + if (matchesOriginalWallet && hasOriginalReceipt && attempt != null) { + if (attempt.isUnresolved) _uiState.update { it.copy(recoveryAttempt = attempt) } + observeOriginalCompletion(attempt) + } + } + } + if (txid == null || !txid.matches(Regex("[0-9a-fA-F]{64}"))) return + viewModelScope.launch { + activityRepo.findActivityByPaymentId(txid, ActivityFilter.ONCHAIN, PaymentType.SENT, true, walletId) + .onSuccess { activity -> _uiState.update { it.copy(activityId = activity.rawId()) } } + } + } + + private fun observeOriginalCompletion(original: OnchainSendAttempt) { + // Shop completion has its own proof resolution route. + if (original.requestId != null) return + if (original.isTransfer && (original.orderId == null || original.transferContext == null)) return + viewModelScope.launch { + lightningRepo.onchainSendAttemptUpdates.collect { + runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.onSuccess { current -> + if (current == null || !current.hasPositiveEvidence || !current.localFollowupComplete) { + return@onSuccess + } + if (current.matchesOriginalRoute(original)) { + _uiState.update { + it.copy( + recoveredTxid = current.txid.takeUnless { current.isTransfer }, + recoveredTransfer = current.takeIf { current.isTransfer }, + currentTxid = current.txid, + recoveryAttempt = null, + recoveryError = null, + ) + } + } + } + } + } + } + + private fun OnchainSendAttempt.matchesOriginalRoute(original: OnchainSendAttempt): Boolean { + val sameOperation = attemptId == original.attemptId && amountSats == original.amountSats && + address == original.address && originalInputs == original.originalInputs + val sameWallet = walletId == original.walletId && walletIndex == original.walletIndex + val sameCandidateFamily = candidateTxids.any { it.equals(original.txid, true) } && + candidateTxids.any { it.equals(txid, true) } + val samePurpose = requestId == null && isTransfer == original.isTransfer && + orderId == original.orderId && transferContext == original.transferContext + return sameOperation && sameWallet && sameCandidateFamily && samePurpose + } + + fun retryOriginal( + feeRateSatsPerVByte: ULong, + retry: suspend (OnchainSendAttempt, ULong) -> Result, + ) { + val original = _uiState.value.recoveryAttempt ?: return + if (_uiState.value.isRecovering) return + if (!OnchainRecoveryFeeRate.isValid(feeRateSatsPerVByte)) { + _uiState.update { it.copy(invalidFeeRate = true, recoveryError = "invalid fee rate") } + return + } + _uiState.update { it.copy(isRecovering = true, recoveryError = null, invalidFeeRate = false) } + viewModelScope.launch { + try { + val result = runSuspendCatching { retry(original, feeRateSatsPerVByte).getOrThrow() } + result.onSuccess { outcome -> + val completed = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.getOrNull() + ?.takeIf { it.hasPositiveEvidence && it.localFollowupComplete && it.matchesOriginalRoute(original) } + _uiState.update { + if (it.recoveredTxid != null || it.recoveredTransfer != null) return@update it + it.copy( + recoveredTxid = completed?.txid.takeUnless { original.isTransfer }, + recoveredTransfer = completed?.takeIf { original.isTransfer }, + recoveryAttempt = if (completed != null) null else it.recoveryAttempt, + recoveryError = (outcome as? OnchainSendOutcome.Rejected)?.reason, + currentTxid = completed?.txid ?: outcome.txid, + ) + } + }.onFailure { error -> + _uiState.update { + if (it.recoveredTxid == null && it.recoveredTransfer == null) { + it.copy(recoveryError = error.message) + } else { + it + } + } + } + } finally { + _uiState.update { it.copy(isRecovering = false) } + } + } + } + + suspend fun approveRecoveryFee( + receipt: OnchainPreparedReceipt, + warnings: List, + requiresPin: Boolean, + useBiometrics: Boolean, + ) = withContext(Dispatchers.Main.immediate) { + requireNotNull(receipt.miningFeeSats) + requestRecoveryApproval(RecoveryApproval.Fee(receipt)) + warnings.forEach { requestRecoveryApproval(RecoveryApproval.Warning(it)) } + if (requiresPin) { + requestRecoveryApproval(if (useBiometrics) RecoveryApproval.Biometrics else RecoveryApproval.Pin) + } + } + + private suspend fun requestRecoveryApproval(step: RecoveryApproval) { + val answer = CompletableDeferred() + recoveryApproval = answer + recoveryPinOpened = false + _uiState.update { it.copy(recoveryApproval = step) } + try { + if (!answer.await()) throw CancellationException("Original payment retry cancelled") + } finally { + if (recoveryApproval === answer) { + recoveryApproval = null + _uiState.update { it.copy(recoveryApproval = null) } + } + } + } + + fun answerRecoveryApproval(approved: Boolean) { + recoveryApproval?.complete(approved) + } + + fun answerRecoveryPin(approved: Boolean) { + if (_uiState.value.recoveryApproval == RecoveryApproval.Pin) answerRecoveryApproval(approved) + } + + fun useRecoveryPin() { + recoveryPinOpened = false + _uiState.update { it.copy(recoveryApproval = RecoveryApproval.Pin) } + } + + fun openRecoveryPin(): Boolean { + if (_uiState.value.recoveryApproval != RecoveryApproval.Pin || recoveryPinOpened) return false + recoveryPinOpened = true + return true + } + override fun onCleared() { + recoveryApproval?.cancel() pendingPaymentRepo.setActiveHash(null) } @@ -79,8 +251,24 @@ class SendPendingViewModel @Inject constructor( } } +@Stable data class SendPendingUiState( val amount: Long = 0L, val activityId: String? = null, val resolution: PendingPaymentResolution? = null, + val recoveryAttempt: OnchainSendAttempt? = null, + val isRecovering: Boolean = false, + val recoveryApproval: RecoveryApproval? = null, + val invalidFeeRate: Boolean = false, + val recoveryError: String? = null, + val currentTxid: String? = null, + val recoveredTxid: String? = null, + val recoveredTransfer: OnchainSendAttempt? = null, ) + +sealed interface RecoveryApproval { + data class Fee(val receipt: OnchainPreparedReceipt) : RecoveryApproval + data class Warning(val warning: SanityWarning) : RecoveryApproval + data object Biometrics : RecoveryApproval + data object Pin : RecoveryApproval +} 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 87d1361153..99be199c81 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -47,6 +47,7 @@ import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.SendFailureDetails +import to.bitkit.models.WalletScope import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.ui.components.ConnectionIssuesView @@ -58,6 +59,7 @@ import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.screens.wallets.send.HwSendSignScreen import to.bitkit.ui.screens.wallets.send.HwSendViewModel import to.bitkit.ui.screens.wallets.send.PIN_CHECK_RESULT_KEY +import to.bitkit.ui.screens.wallets.send.RECOVERY_PIN_CHECK_RESULT_KEY import to.bitkit.ui.screens.wallets.send.SEND_CONFIRM_RESET_RESULT_KEY import to.bitkit.ui.screens.wallets.send.SendAddressScreen import to.bitkit.ui.screens.wallets.send.SendAmountScreen @@ -160,32 +162,66 @@ fun SendSheet( .testTag("SendSheet"), ) { val navController = rememberNavController() - LaunchedEffect(hwSendViewModel, sendUiState.resolvedHardwarePaymentTxId) { - val transactionId = sendUiState.resolvedHardwarePaymentTxId ?: return@LaunchedEffect - val walletId = sendUiState.hardwareWalletId ?: return@LaunchedEffect - val requestId = sendUiState.incomingPaymentRequestId ?: return@LaunchedEffect - if (!hwSendViewModel.resolveBroadcast(walletId, requestId, transactionId)) { - appViewModel.acknowledgeHardwarePaymentResolution(transactionId) + fun showHardwareSuccess(txId: String, amountSats: ULong, walletId: String, isShopPayment: Boolean) { + appViewModel.onSendSuccess( + details = NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txId, + activityWalletId = walletId, + sats = amountSats.toLong(), + ), + walletId = walletId, + navigate = false, + syncContact = !isShopPayment, + ) + appViewModel.clearClipboardForAutoRead() + navController.navigateTo(SendRoute.Success) { + popUpTo(navController.graph.id) { inclusive = true } + } + } + LaunchedEffect(appViewModel, hwSendViewModel) { + appViewModel.resolvedHardwarePayments.collect { resolutions -> + resolutions.values.forEach { resolution -> + if (appViewModel.resolvedHardwarePaymentFor(resolution.walletId, resolution.transactionId) == resolution && + hwSendViewModel.completeReconciledBroadcast(resolution.walletId, resolution.transactionId) { + showHardwareSuccess(it.txId, it.amountSats, it.walletId, it.paymentRequestId != null) + } + ) { + appViewModel.consumeResolvedHardwarePayment(resolution) + } + } } } LaunchedEffect(hwSendViewModel, navController) { hwSendViewModel.results.collect { result -> - appViewModel.completeHardwareContactPayment(result.txId) - appViewModel.onSendSuccess( - details = NewTransactionSheetDetails( - type = NewTransactionSheetType.ONCHAIN, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = result.txId, - activityWalletId = result.walletId, - sats = result.amountSats.toLong(), - ), - walletId = result.walletId, - navigate = false, + val resolved = appViewModel.resolvedHardwarePaymentFor(result.walletId, result.txId) + if (resolved != null && hwSendViewModel.completeReconciledBroadcast( + resolved.walletId, resolved.transactionId + ) + ) { + appViewModel.consumeResolvedHardwarePayment(resolved) + showHardwareSuccess(result.txId, result.amountSats, result.walletId, result.paymentRequestId != null) + return@collect + } + val proofComplete = appViewModel.completeHardwareContactPayment( + result.txId, result.walletId, result.paymentRequestId, result.paymentIdentity, ) - appViewModel.clearClipboardForAutoRead() - navController.navigateTo(SendRoute.Success) { - popUpTo(navController.graph.id) { inclusive = true } + if (!proofComplete) { + navController.navigateTo( + SendRoute.Pending( + paymentHash = result.txId, + amount = result.amountSats.toLong(), + walletId = result.walletId, + observeResolution = false, + isOnchain = true, + ) + ) { popUpTo(navController.graph.id) { inclusive = true } } + // Proof persistence may have failed. Keep the exact result replayable on reopening + // and retain the signed operation so another request cannot sign or broadcast. + return@collect } + showHardwareSuccess(result.txId, result.amountSats, result.walletId, result.paymentRequestId != null) hwSendViewModel.completeBroadcast() } } @@ -200,6 +236,8 @@ fun SendSheet( is SendEffect.NavigateToHardwareSign -> navController.navigateTo(SendRoute.HardwareSign) is SendEffect.PopBack -> navController.popBackStack(it.route, inclusive = false) is SendEffect.PaymentSuccess -> { + val details = appViewModel.successSendUiState.value + hwSendViewModel.completeReconciledBroadcast(details.activityWalletId, details.paymentHashOrTxId) appViewModel.clearClipboardForAutoRead() navController.navigateTo(SendRoute.Success) { popUpTo(navController.graph.id) { inclusive = true } @@ -216,7 +254,13 @@ fun SendSheet( is SendEffect.NavigateToComingSoon -> navController.navigateTo(SendRoute.ComingSoon) is SendEffect.NavigateToContacts -> navController.navigateTo(SendRoute.ContactSelect) is SendEffect.NavigateToPending -> navController.navigateTo( - SendRoute.Pending(it.paymentHash, it.amount, observeResolution = it.observeResolution) + SendRoute.Pending( + it.paymentHash, + it.amount, + observeResolution = it.observeResolution, + isOnchain = it.isOnchain, + refusalReason = it.refusalReason, + ) ) { popUpTo(startDestination) { inclusive = true } } is SendEffect.NavigateToError -> navController.navigateTo( SendRoute.errorFromFailure( @@ -370,16 +414,34 @@ fun SendSheet( ?.toULong() ?.takeIf { rate -> rate > 0uL } ?: HARDWARE_SEND_FALLBACK_SATS_PER_VBYTE + val paymentIdentity = appViewModel.hardwarePaymentIdentity() HwSendSignScreen( walletId = walletId, sendUiState = uiState, + paymentIdentity = paymentIdentity, satsPerVByte = satsPerVByte, viewModel = hwSendViewModel, - prepareContactPayment = appViewModel::prepareHardwareContactPayment, - authorizeContactPayment = appViewModel::authorizeHardwareContactPayment, - onPaymentDeadlineExpired = appViewModel::onHardwarePaymentDeadlineExpired, + prepareContactPayment = { signedTx -> + appViewModel.prepareHardwareContactPayment( + walletId, + uiState.address, + uiState.incomingPaymentRequestId, + paymentIdentity, + signedTx, + ) + }, + authorizeContactPayment = { hasAttemptedBroadcast -> + appViewModel.authorizeHardwareContactPayment( + hasAttemptedBroadcast, + uiState.incomingPaymentRequestId, + paymentIdentity, + walletId, + ) + }, onPaymentSubmissionChange = appViewModel::onHardwarePaymentSubmissionChanged, - onBroadcastAttemptChange = appViewModel::onHardwareBroadcastAttemptChanged, + onPaymentDeadlineExpired = { attempted -> + appViewModel.onHardwarePaymentDeadlineExpired(attempted, uiState.incomingPaymentRequestId, paymentIdentity, walletId) + }, paymentDeadlineAt = appViewModel.hardwarePaymentDeadlineAt, onBack = { navController.previousBackStackEntry @@ -444,6 +506,20 @@ fun SendSheet( addButtonTestTag = "SendTagsSubmit", ) } + composableWithDefaultTransitions { + SendPinCheckScreen( + onBack = { + navController.previousBackStackEntry?.savedStateHandle + ?.set(RECOVERY_PIN_CHECK_RESULT_KEY, false) + navController.popBackStack() + }, + onSuccess = { + navController.previousBackStackEntry?.savedStateHandle + ?.set(RECOVERY_PIN_CHECK_RESULT_KEY, true) + navController.popBackStack() + }, + ) + } composableWithDefaultTransitions { SendPinCheckScreen( onBack = { @@ -520,9 +596,27 @@ fun SendSheet( val route = it.toRoute() val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() SendPendingScreen( + savedStateHandle = it.savedStateHandle, + onNavigateToPin = { navController.navigateTo(SendRoute.RecoveryPinCheck) }, paymentHash = route.paymentHash, amount = route.amount, observeResolution = route.observeResolution, + isOnchain = route.isOnchain, + walletId = route.walletId, + refusalReason = route.refusalReason, + retryOriginal = appViewModel::retryOriginalOnchainSend, + onRecoveredTransfer = appViewModel::onRecoveredTransfer, + onRecovered = { txid, originalAmount -> + appViewModel.onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txid, + sats = originalAmount, + activityWalletId = route.walletId, + ) + ) + }, onPaymentSuccess = { paymentHash, amountWithFee -> appViewModel.onSendSuccess( NewTransactionSheetDetails( @@ -547,7 +641,7 @@ fun SendSheet( } }, onClose = { appViewModel.hideSheet() }, - onViewDetails = { rawId -> appViewModel.navigateToActivity(rawId) }, + onViewDetails = { rawId -> appViewModel.navigateToActivity(rawId, route.walletId) }, viewModel = hiltViewModel(), ) } @@ -689,6 +783,9 @@ sealed interface SendRoute { @Serializable data object PinCheck : InternalOnly + @Serializable + data object RecoveryPinCheck : InternalOnly + @Serializable data object CoinSelection : DeepLinkStart @@ -721,6 +818,9 @@ sealed interface SendRoute { val paymentHash: String, val amount: Long, val observeResolution: Boolean = true, + val isOnchain: Boolean = false, + val walletId: String = WalletScope.default, + val refusalReason: String? = null, val retryRoute: SendRetryRoute = SendRetryRoute.Confirm, val paymentRequest: String? = null, ) : InternalOnly diff --git a/app/src/main/java/to/bitkit/utils/SignedTransactionId.kt b/app/src/main/java/to/bitkit/utils/SignedTransactionId.kt new file mode 100644 index 0000000000..1201764b1a --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/SignedTransactionId.kt @@ -0,0 +1,54 @@ +package to.bitkit.utils + +import java.security.MessageDigest + +/** Canonical Bitcoin txid of a consensus-encoded signed transaction, excluding witness data. */ +internal object SignedTransactionId { + fun fromHex(hex: String): String { + require(hex.length in 20..2_000_000 && hex.length % 2 == 0) + val bytes = hex.hexToByteArray() + var offset = 4 + fun skip(length: Int) { + require(length >= 0 && length <= bytes.size - offset) + offset += length + } + fun compactSize(): Int { + require(offset < bytes.size) + val prefix = bytes[offset++].toInt() and 255 + if (prefix < 253) return prefix + val length = when (prefix) { 253 -> 2; 254 -> 4; else -> 8 } + require(length <= bytes.size - offset) + var value = 0uL + repeat(length) { value = value or ((bytes[offset++].toInt() and 255).toULong() shl (8 * it)) } + require(value <= Int.MAX_VALUE.toULong()) + return value.toInt() + } + val hasWitness = bytes[offset] == 0.toByte() + if (hasWitness) { + require(bytes[offset + 1] == 1.toByte()) + skip(2) + } + val baseStart = offset + val inputCount = compactSize() + require(inputCount > 0 && inputCount <= bytes.size / 41) + repeat(inputCount) { skip(36); skip(compactSize()); skip(4) } + val outputCount = compactSize() + require(outputCount > 0 && outputCount <= bytes.size / 9) + repeat(outputCount) { skip(8); skip(compactSize()) } + val baseEnd = offset + if (hasWitness) { + repeat(inputCount) { + val itemCount = compactSize() + require(itemCount <= bytes.size - offset) + repeat(itemCount) { skip(compactSize()) } + } + } + val lockTimeStart = offset + skip(4) + require(offset == bytes.size) + val canonical = bytes.copyOfRange(0, 4) + bytes.copyOfRange(baseStart, baseEnd) + + bytes.copyOfRange(lockTimeStart, offset) + val sha256 = MessageDigest.getInstance("SHA-256") + return sha256.digest(sha256.digest(canonical)).reversedArray().toHexString() + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 2edc7d8b8c..418b2bf8eb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -15,6 +15,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.LnurlAuthData @@ -39,6 +40,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -73,7 +75,6 @@ import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.Event -import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo @@ -143,6 +144,7 @@ import to.bitkit.models.sanitizedQrLogValue import to.bitkit.models.toActivityFilter import to.bitkit.models.toLdkNetwork import to.bitkit.models.toTxType +import to.bitkit.models.HwFundingSignedTx import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -157,6 +159,13 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainPreparedReceipt +import to.bitkit.repositories.OnchainSendAttemptUnreadableError +import to.bitkit.repositories.OnchainSendBlockedError +import to.bitkit.repositories.OnchainSendNotDispatchedError +import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.OnchainSendPendingError import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentProofRepo @@ -212,7 +221,6 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import to.bitkit.utils.NetworkValidationHelper -import to.bitkit.utils.ServiceError import to.bitkit.utils.jsonLogOf import to.bitkit.utils.timedsheets.TimedSheetManager import to.bitkit.utils.timedsheets.sheets.AppUpdateTimedSheet @@ -406,7 +414,6 @@ class AppViewModel @Inject constructor( private var requestedPaymentRequest: PaykitPaymentRequest? = null private var shouldRestorePaymentRequestSheet = false private var preparedContactPaymentContext: ContactPaymentContext? = null - private var attemptedHardwarePaymentContext: ContactPaymentContext? = null private var privatePaykitAppRegistrationIdentity: String? = null private var requestedPaymentRequestIdentity: String? = null private var deferredSubscriptionNotification: SubscriptionNotificationTarget? = null @@ -784,7 +791,6 @@ class AppViewModel @Inject constructor( val isStillCurrent = { isCurrentPaykitContactSync(state, signIn) } val identityChanged = !PubkyPublicKeyFormat.matches(paymentRequestIdentity, state.publicKey) if (identityChanged) { - paykitPaymentProofRepo.clearOnchainPaymentResolutions() resetPaykitPresentationState( dismissActiveRequest = paymentRequestIdentity != null, preserveRequestedPaymentRequest = paymentRequestIdentity == null, @@ -873,9 +879,28 @@ class AppViewModel @Inject constructor( } } + private val _resolvedHardwarePayments = MutableStateFlow< + ImmutableMap, PaykitOnchainPaymentProofResolution> + >(persistentMapOf()) + val resolvedHardwarePayments = _resolvedHardwarePayments.asStateFlow() + + fun resolvedHardwarePaymentFor(walletId: String, transactionId: String): PaykitOnchainPaymentProofResolution? = + _resolvedHardwarePayments.value[walletId to transactionId.lowercase()]?.takeIf { + PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, it.identity) + } + + fun consumeResolvedHardwarePayment(resolution: PaykitOnchainPaymentProofResolution) { + val key = resolution.walletId to resolution.transactionId.lowercase() + _resolvedHardwarePayments.update { current -> + if (current[key] == resolution) (current - key).toImmutableMap() else current + } + } + private fun observePaykitOnchainPaymentResolution() { viewModelScope.launch { - paykitPaymentProofRepo.onchainPaymentResolutions.collect { resolutions -> + combine(paykitPaymentProofRepo.onchainPaymentResolutions, pubkyRepo.publicKey) { resolutions, _ -> + resolutions + }.collect { resolutions -> resolutions.forEach(::handlePaykitOnchainPaymentResolution) } } @@ -883,17 +908,12 @@ class AppViewModel @Inject constructor( private fun handlePaykitOnchainPaymentResolution(resolution: PaykitOnchainPaymentProofResolution) { if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, resolution.identity)) return - paykitPaymentProofRepo.consumeOnchainPaymentResolution(resolution) - val sendState = _sendUiState.value - val matchesHardwareWallet = sendState.hardwareWalletId != null && - sendState.hardwareWalletId == resolution.walletId - if (_currentSheet.value is Sheet.Send && matchesHardwareWallet && - sendState.incomingPaymentRequestId == resolution.requestId - ) { - synchronizeResolvedPaykitOnchainPayment(resolution, updateSendDetails = false) - _sendUiState.update { it.copy(resolvedHardwarePaymentTxId = resolution.transactionId) } - return + if (resolution.walletId != WalletScope.default) { + _resolvedHardwarePayments.update { + (it + ((resolution.walletId to resolution.transactionId.lowercase()) to resolution)).toImmutableMap() + } } + paykitPaymentProofRepo.consumeOnchainPaymentResolution(resolution) val resolvesCurrentPayment = uncertainOnchainPaymentRequestId == resolution.requestId if (!resolvesCurrentPayment) { synchronizeResolvedPaykitOnchainPayment(resolution, updateSendDetails = false) @@ -912,7 +932,8 @@ class AppViewModel @Inject constructor( type = NewTransactionSheetType.ONCHAIN, direction = NewTransactionSheetDirection.SENT, paymentHashOrTxId = resolution.transactionId, - sats = _sendUiState.value.amount.toLong(), + sats = resolution.amountSats?.toLong() ?: _sendUiState.value.amount.toLong(), + activityWalletId = resolution.walletId, isLoadingDetails = true, ) ) @@ -1311,6 +1332,7 @@ class AppViewModel @Inject constructor( } if (!ownsPaymentRequestPreparation(preparation)) return true } + if (reopenRetainedHardwarePayment(request, generation, preparation)) return true val presentationResult = privatePaykitRepo.beginPaymentRequest(request) return finishIncomingPaymentRequestPreparation(request, generation, preparation, presentationResult) } finally { @@ -1322,6 +1344,66 @@ class AppViewModel @Inject constructor( } } + @Suppress("ReturnCount") // Keep presentation and identity guards as early exits. + private suspend fun reopenRetainedHardwarePayment( + request: PaykitPaymentRequest, + generation: Long, + preparation: PaymentRequestPreparation?, + ): Boolean { + val retained = runSuspendCatching { paykitPaymentProofRepo.retainedHardwareOnchainRequest(request) } + retained.exceptionOrNull()?.let { + toast(it) + return true + } + val proof = retained.getOrNull() ?: return false + val identity = proof.identity + if (!isCurrentPaymentRequestPresentation(request, generation, preparation) || + !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) + ) { + return true + } + paykitPaymentRequestRepo.ensurePaymentAllowed(request).exceptionOrNull()?.let { + toast(it) + return true + } + resetSendState( + isPaymentRequest = true, + paymentRequestNote = request.note, + hardwareWalletId = proof.onchainWalletId, + incomingPaymentRequestId = request.id, + selectedTags = requestedPaymentRequestTags, + ) + if (!isCurrentPaymentRequestPresentation(request, generation, preparation) || + !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) + ) { + return true + } + // The retained receipt authorizes only reopening the same signed payment, not preparing another spend. + setActiveContactPaymentContext( + ContactPaymentContext(publicKey = request.counterparty, incomingPaymentRequest = request), + ) + _sendUiState.update { + it.copy( + contactPaymentProfile = activeContactPaymentProfile(), + isRetainedHardwarePayment = true, + address = requireNotNull(proof.onchainAddress), + addressInput = requireNotNull(proof.onchainAddress), + isAddressInputValid = true, + amount = requireNotNull(proof.onchainAmountSats), + isAmountInputValid = true, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Custom(requireNotNull(proof.hardwareFeeRate).toUInt()), + onchainFeeUi = OnchainFeeUi( + rate = FeeRate.CUSTOM, + sats = requireNotNull(proof.hardwareMiningFeeSats).toLong(), + ), + ) + } + preparation?.let { paymentRequestPreparation.compareAndSet(it, null) } + showSheet(Sheet.Send(SendRoute.Confirm, hardwareWalletId = proof.onchainWalletId)) + return true + } + private fun finishIncomingPaymentRequestPreparation( request: PaykitPaymentRequest, generation: Long, @@ -2416,6 +2498,7 @@ class AppViewModel @Inject constructor( is SendEvent.CommentChange -> onCommentChange(it.value) SendEvent.SpeedAndFee -> { + if (_sendUiState.value.isRetainedHardwarePayment) return@collect if (_sendUiState.value.onchainFeeUi.estimates.isEmpty()) { viewModelScope.launch { refreshOnchainFeeUi() @@ -2990,6 +3073,7 @@ class AppViewModel @Inject constructor( } fun onSelectSpeed(speed: TransactionSpeed) { + if (_sendUiState.value.isRetainedHardwarePayment) return if (speed is TransactionSpeed.Custom && speed.satsPerVByte == 0u) { setSendEffect(SendEffect.NavigateToFeeCustom) } else { @@ -2998,6 +3082,7 @@ class AppViewModel @Inject constructor( } fun setTransactionSpeed(speed: TransactionSpeed) { + if (_sendUiState.value.isRetainedHardwarePayment) return onchainSendRefreshJob?.cancel() val previous = _sendUiState.value _sendUiState.update { @@ -3057,6 +3142,7 @@ class AppViewModel @Inject constructor( private suspend fun onPaymentMethodSwitch() { val current = _sendUiState.value + if (current.isRetainedHardwarePayment) return val sources = availableFundingSources(current) if (sources.size < 2) return sendStateGeneration++ @@ -3634,7 +3720,6 @@ class AppViewModel @Inject constructor( if (request != null && !retryIncomingRequest) completePaymentRequestPresentation(request) activeContactPaymentContext = null preparedContactPaymentContext = null - attemptedHardwarePaymentContext = null request } if (interruptedRequest == null) return @@ -3978,6 +4063,10 @@ class AppViewModel @Inject constructor( if (hardwareWalletId == null) maxOf(selectedMax, maximumHardwareFundingBalanceSats()) else selectedMax private fun availableFundingSources(state: SendUiState): List = buildList { + if (state.isRetainedHardwarePayment) { + state.hardwareWalletId?.let { add(SendFundingSource.Hardware(it)) } + return@buildList + } if (state.isUnified || state.decodedInvoice != null) add(SendFundingSource.Spending) if (state.address.isNotEmpty()) { add(SendFundingSource.Savings) @@ -4470,8 +4559,29 @@ class AppViewModel @Inject constructor( val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest val proofPreparation = preparePaymentProof(incomingPaymentRequest) - proofPreparation.exceptionOrNull()?.let { - handlePaymentPreparationFailure(it, contactPaymentContext) + if (proofPreparation.isFailure) { + if (incomingPaymentRequest != null && _sendUiState.value.payMethod == SendMethod.ONCHAIN) { + val previous = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.getOrNull() + ?.takeIf { it.requestId == incomingPaymentRequest.id } + if (previous != null) { + val txid = previous.txid + if (previous.hasPositiveEvidence && txid != null) { + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txid, + sats = previous.amountSats.toLong(), + isLoadingDetails = false, + ) + ) + } else { + showUnresolvedOnchainSend(txid, previous.amountSats, requestId = previous.requestId) + } + return + } + } + handlePaymentPreparationFailure(requireNotNull(proofPreparation.exceptionOrNull()), contactPaymentContext) return } val preparedPaymentProofRequest = proofPreparation.getOrNull() @@ -4555,6 +4665,14 @@ class AppViewModel @Inject constructor( contactPaymentContext: ContactPaymentContext?, amount: ULong, ) { + val originalPayer = preparedPaymentProofRequest?.let { + paykitPaymentProofRepo.captureOriginalOnchainPayer(it).getOrElse { error -> + releasePrivatePaymentListIfNeeded(contactPaymentContext) + cancelPaymentProofPreparation(preparedPaymentProofRequest) + handlePaymentPreparationFailure(error, contactPaymentContext) + return + } + } val address = _sendUiState.value.address val tags = _sendUiState.value.selectedTags val proofPreparation = incomingPaymentRequest?.let { paymentProofPreparation(contactPaymentContext) } @@ -4565,33 +4683,67 @@ class AppViewModel @Inject constructor( address = address, amount = amount, tags = tags, + requestId = incomingPaymentRequest?.id, + payerIdentity = originalPayer, + contactPublicKey = contactPaymentContext?.publicKey, paymentDeadlineAt = incomingPaymentRequest?.paymentDeadlineAt, beforeSendAttempt = { if (preparedPaymentProofRequest != null) { - markOnchainPaymentStarted(incomingPaymentRequest, address).getOrThrow() + markOnchainPaymentStarted( + incomingPaymentRequest, + address, + contactPaymentContext = contactPaymentContext, + ).getOrThrow() paymentProofStarted = true + paykitPaymentProofRepo.verifyOriginalOnchainPayer( + preparedPaymentProofRequest, + requireNotNull(originalPayer), + ).getOrThrow() } incomingPaymentRequest?.let { paykitPaymentRequestRepo.ensurePaymentAllowed(it).getOrThrow() } sendAttempted = true }, - onBroadcast = { txId -> - proofRequest = null - completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId, proofPreparation) - }, - ).onSuccess { txId -> - Logger.info("Onchain send result txid: $txId", context = TAG) - onSendSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.ONCHAIN, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = txId, - sats = amount.toLong(), - isLoadingDetails = true, - ) - ) - lightningRepo.sync() - activityRepo.syncActivities() - _successSendUiState.update { it.copy(isLoadingDetails = false) } + ).onSuccess { outcome -> + proofRequest = null + when (outcome) { + is OnchainSendOutcome.Accepted -> { + Logger.info("Accepted on-chain send '${outcome.txid}'", context = TAG) + completeOnchainPaymentProofInBackground(incomingPaymentRequest, outcome.txid, proofPreparation, outcome) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = outcome.txid, + sats = (outcome.amountSats ?: amount).toLong(), + isLoadingDetails = true, + ) + ) + if (incomingPaymentRequest == null) { + withContext(NonCancellable) { + lightningRepo.completeAcceptedOrdinaryFollowup(outcome.txid) + } + } + lightningRepo.sync() + activityRepo.syncActivities() + _successSendUiState.update { it.copy(isLoadingDetails = false) } + } + + is OnchainSendOutcome.Rejected -> { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__error_sending_title), + description = outcome.reason, + ) + showUnresolvedOnchainSend( + outcome.txid, + amount, + incomingPaymentRequest, + refusalReason = outcome.reason + ) + } + + is OnchainSendOutcome.Unknown -> showUnresolvedOnchainSend(outcome.txid, amount, incomingPaymentRequest) + } }.onFailure { error -> handleOnchainPaymentFailure( error = error, @@ -4613,23 +4765,81 @@ class AppViewModel @Inject constructor( contactPaymentContext: ContactPaymentContext?, ) { val amount = _sendUiState.value.amount - if ( - incomingPaymentRequest != null && - sendAttempted && - !error.isDefiniteOnchainPreBroadcastFailure() - ) { + val unresolved = error is OnchainSendPendingError || error is OnchainSendBlockedError || + error is OnchainSendAttemptUnreadableError || + (sendAttempted && error !is OnchainSendNotDispatchedError) + if (unresolved) { Logger.warn("On-chain payment outcome is uncertain after send started", error, context = TAG) - uncertainOnchainPaymentRequestId = incomingPaymentRequest.id - paykitPaymentProofRepo.onchainPaymentResolutions.value - .firstOrNull { it.requestId == incomingPaymentRequest.id } - ?.let(::handlePaykitOnchainPaymentResolution) - if (uncertainOnchainPaymentRequestId == null) return - setSendEffect( - SendEffect.NavigateToPending( - paymentHash = incomingPaymentRequest.paymentRequestId, - amount = amount.toLong(), - observeResolution = false, + if (!sendAttempted) releasePrivatePaymentListIfNeeded(contactPaymentContext) + if (!paymentProofStarted) cancelPaymentProofPreparation(preparedPaymentProofRequest) + val previous = (error as? OnchainSendBlockedError)?.attempt + if (previous != null && (incomingPaymentRequest == null || previous.requestId != incomingPaymentRequest.id)) { + clearCancelledContactContext(contactPaymentContext) + } + val priorAccepted = previous?.takeIf { + it.hasPositiveEvidence && it.txid != null && !it.isTransfer && + incomingPaymentRequest != null && it.requestId == incomingPaymentRequest.id + } + if (priorAccepted != null) { + val txid = requireNotNull(priorAccepted.txid) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txid, + sats = priorAccepted.amountSats.toLong(), + isLoadingDetails = false, + ) ) + return + } + if (previous != null && previous.hasPositiveEvidence && previous.txid != null && + !previous.isTransfer && previous.requestId == null + ) { + // Finish the earlier payment, but this blocked confirmation did not send the new payment. + runSuspendCatching { lightningRepo.completeAcceptedOrdinaryFollowup(previous.txid) } + .onFailure { Logger.warn("Failed to finish earlier ordinary send locally", it, context = TAG) } + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__error_sending_title), + description = context.getString(R.string.wallet__send_pending__blocked_new_send), + ) + hideSheet() + return + } + previous?.refusalReason?.let { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__error_sending_title), + description = it, + ) + } + val unresolvedRequestId = if (previous != null) previous.requestId else incomingPaymentRequest?.id + if (previous != null) { + _sendUiState.update { + it.copy( + incomingPaymentRequestId = unresolvedRequestId, + isPaymentRequest = unresolvedRequestId != null, + address = previous.address, + amount = previous.amountSats, + ) + } + } + uncertainOnchainPaymentRequestId = unresolvedRequestId + paykitPaymentProofRepo.onchainPaymentResolutions.value + .firstOrNull { + it.requestId == unresolvedRequestId && + PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, it.identity) + } + ?.let { + handlePaykitOnchainPaymentResolution(it) + return + } + showUnresolvedOnchainSend( + txid = (error as? OnchainSendPendingError)?.txid ?: previous?.txid, + amount = previous?.amountSats ?: amount, + requestId = unresolvedRequestId, + refusalReason = previous?.refusalReason, ) return } @@ -4638,6 +4848,7 @@ class AppViewModel @Inject constructor( } releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) + if (error is OnchainSendNotDispatchedError) paykitPaymentProofRepo.reconcile() Logger.error("Error sending onchain payment", error, context = TAG) if (contactPaymentContext?.isInitialSubscriptionPayment == true) { setSendEffect(SendEffect.NavigateToError(error.toSendFailureDetails(context, _sendUiState.value.address))) @@ -4651,6 +4862,124 @@ class AppViewModel @Inject constructor( } } + suspend fun retryOriginalOnchainSend( + original: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + approvePrepared: suspend (OnchainPreparedReceipt, List) -> Unit = { _, _ -> }, + ): Result { + val request = original.requestId?.let { id -> + (paykitPaymentRequestRepo.pendingRequests.value + paykitPaymentRequestRepo.paymentRequestHistory.value) + .firstOrNull { it.id == id } + } + if (original.requestId != null && request == null) { + return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + } + val result = lightningRepo.retryOriginalOnchainSend( + original.attemptId, + original.walletId, + feeRateSatsPerVByte, + paymentDeadlineAt = request?.paymentDeadlineAt, + approvePrepared = { receipt -> + val fee = requireNotNull(receipt.miningFeeSats) + check(fee <= Long.MAX_VALUE.toULong()) + val feeInUsd = currencyRepo.convertSatsToFiat(fee.toLong(), USD).getOrNull() + val warnings = buildList { + if (fee > original.amountSats / 2uL) add(SanityWarning.FEE_OVER_HALF_VALUE) + if (feeInUsd != null && feeInUsd.value > BigDecimal(TEN_USD)) add(SanityWarning.FEE_OVER_10_USD) + } + approvePrepared(receipt, warnings) + }, + ) { attempt -> + check( + attempt.attemptId == original.attemptId && attempt.requestId == original.requestId && + attempt.orderId == original.orderId && attempt.payerIdentity == original.payerIdentity + ) + attempt.orderId?.let { orderId -> + val order = blocktankRepo.fetchOrders(listOf(orderId)).getOrThrow().singleOrNull { it.id == orderId } + check(order != null && order.payment?.onchain?.address == attempt.address) + val originalContext = requireNotNull(attempt.transferContext) + check(order.state2 == BtOrderState2.CREATED) + check(order.clientBalanceSat == originalContext.originalOrderClientBalanceSats) + check(order.feeSat == originalContext.originalOrderFeeSats) + check(kotlin.time.Instant.parse(order.orderExpiresAt) > kotlin.time.Clock.System.now()) + } + if (request != null) { + val currentRequest = + ( + paykitPaymentRequestRepo.pendingRequests.value + + paykitPaymentRequestRepo.paymentRequestHistory.value + ) + .firstOrNull { it.id == attempt.requestId } + ?: throw PaykitPaymentRequestError.RequestUnavailable + check(currentRequest.paymentDeadlineAt == request.paymentDeadlineAt) + paykitPaymentRequestRepo.ensurePaymentAllowed(currentRequest).getOrThrow() + paykitPaymentProofRepo.authorizeOnchainRecovery(attempt).getOrThrow() + } + } + if (result.getOrNull() is OnchainSendOutcome.Accepted) { + runSuspendCatching { + val winner = lightningRepo.currentOnchainSendAttempt() ?: return@runSuspendCatching + if (request != null) { + paykitPaymentProofRepo.completeRecoveredOnchainPayment(request, winner) + } else if (winner.isTransfer) { + transferRepo.resumeAcceptedFunding().getOrThrow() + } else { + lightningRepo.completeAcceptedOrdinaryFollowup(requireNotNull(winner.txid)) + } + }.onFailure { Logger.warn("Failed to finish original recovered send locally", it, context = TAG) } + } + return result + } + + fun onRecoveredTransfer(original: OnchainSendAttempt) { + viewModelScope.launch { + runSuspendCatching { + val current = lightningRepo.currentOnchainSendAttempt() ?: return@runSuspendCatching + if (current != original || !current.isTransfer || current.requestId != null) return@runSuspendCatching + if (!current.hasPositiveEvidence || !current.localFollowupComplete) return@runSuspendCatching + if (current.txid == null || current.transferContext == null) return@runSuspendCatching + val orderId = current.orderId ?: return@runSuspendCatching + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.OrderDetail(orderId))) + }.onFailure { Logger.warn("Failed to open recovered transfer", it, context = TAG) } + } + } + + fun showPendingTransfer(attempt: OnchainSendAttempt) { + if (!attempt.isTransfer || attempt.orderId == null || !attempt.blocksNextSend) return + showSheet( + Sheet.Send( + SendRoute.Pending( + paymentHash = attempt.txid.orEmpty(), + amount = attempt.amountSats.toLong(), + observeResolution = false, + isOnchain = true, + walletId = attempt.walletId, + refusalReason = attempt.refusalReason, + ) + ) + ) + } + + private fun showUnresolvedOnchainSend( + txid: String?, + amount: ULong, + request: PaykitPaymentRequest? = null, + requestId: PaykitPaymentRequestId? = request?.id, + refusalReason: String? = null, + ) { + uncertainOnchainPaymentRequestId = requestId + setSendEffect( + SendEffect.NavigateToPending( + paymentHash = txid ?: requestId?.paymentRequestId.orEmpty(), + amount = amount.toLong(), + observeResolution = false, + isOnchain = true, + refusalReason = refusalReason, + ) + ) + } + @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun proceedWithLightningPayment( incomingPaymentRequest: PaykitPaymentRequest?, @@ -4665,8 +4994,8 @@ class AppViewModel @Inject constructor( var createdMetadataPaymentId: String? = null val paymentHash = decodedInvoice.paymentHash.toHex() associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { - cancelPaymentProofPreparation(proofRequest) releasePrivatePaymentListIfNeeded(contactPaymentContext) + cancelPaymentProofPreparation(proofRequest) handlePaymentPreparationFailure(it, contactPaymentContext) return } @@ -4765,7 +5094,10 @@ class AppViewModel @Inject constructor( } @Suppress("ReturnCount") - private suspend fun prepareContactPayment(contactPaymentContext: ContactPaymentContext?): Boolean { + private suspend fun prepareContactPayment( + contactPaymentContext: ContactPaymentContext?, + beforePrivateConsumption: suspend () -> Result = { Result.success(Unit) }, + ): Boolean { if (isPreparedContactPayment(contactPaymentContext)) return true if (!validateIncomingPaymentRequest(contactPaymentContext)) return false @@ -4775,6 +5107,11 @@ class AppViewModel @Inject constructor( return false } } + beforePrivateConsumption().onFailure { + releasePrivatePaymentListIfNeeded(contactPaymentContext) + handlePaymentPreparationFailure(it, contactPaymentContext) + return false + } consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { handlePaymentPreparationFailure(it, contactPaymentContext) return false @@ -4825,6 +5162,7 @@ class AppViewModel @Inject constructor( request: PaykitPaymentRequest?, txId: String, preparation: PaymentProofPreparation?, + acceptedOutcome: OnchainSendOutcome.Accepted? = null, ) { val paymentRequest = request ?: return if (preparation == null) return @@ -4834,6 +5172,7 @@ class AppViewModel @Inject constructor( txid = txId, paymentEndpointIdentifier = preparation.endpointIdentifier, paymentAppId = preparation.appId, + acceptedOutcome = acceptedOutcome, ) if (paymentRequest.billingPeriod != null) refreshIncomingPaykitPaymentRequests() } @@ -4843,8 +5182,15 @@ class AppViewModel @Inject constructor( request: PaykitPaymentRequest?, address: String, walletId: String = WalletScope.default, - ): Result = request?.let { paykitPaymentProofRepo.markOnchainPaymentStarted(it, address, walletId) } - ?: Result.success(Unit) + contactPaymentContext: ContactPaymentContext? = null, + ): Result = request?.let { + val privateContext = contactPaymentContext?.privatePaymentContext + paykitPaymentProofRepo.markOnchainPaymentStarted( + it, address, walletId, + privatePaymentListVersion = privateContext?.paymentListVersion, + previousPrivatePaymentListVersion = privateContext?.previousPaymentListVersion, + ) + } ?: Result.success(Unit) private suspend fun cancelPaymentProofPreparation(request: PaykitPaymentRequest?) { request?.let { paykitPaymentProofRepo.cancelPreparation(it) } @@ -5042,27 +5388,27 @@ class AppViewModel @Inject constructor( address: String, amount: ULong, tags: List = emptyList(), + requestId: PaykitPaymentRequestId? = null, + payerIdentity: String? = null, + contactPublicKey: String? = null, paymentDeadlineAt: Instant? = null, beforeSendAttempt: suspend () -> Unit = {}, onBroadcast: suspend (Txid) -> Unit = {}, - ): Result { - var broadcastTxId: Txid? = null - return lightningRepo.sendOnChain( - address = address, - sats = amount, - speed = _sendUiState.value.speed, - utxosToSpend = _sendUiState.value.selectedUtxos, - isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && - amount == walletRepo.balanceState.value.maxSendOnchainSats, - tags = tags, - beforeSendAttempt = beforeSendAttempt, - paymentDeadlineAt = paymentDeadlineAt, - onBroadcast = { - broadcastTxId = it - onBroadcast(it) - }, - ).recoverCatching { broadcastTxId ?: throw it } - } + ): Result = lightningRepo.sendOnChain( + address = address, + sats = amount, + speed = _sendUiState.value.speed, + utxosToSpend = _sendUiState.value.selectedUtxos, + isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && + amount == walletRepo.balanceState.value.maxSendOnchainSats, + tags = tags, + requestId = requestId, + payerIdentity = payerIdentity, + contactPublicKey = contactPublicKey, + paymentDeadlineAt = paymentDeadlineAt, + beforeSendAttempt = beforeSendAttempt, + onBroadcast = onBroadcast, + ) private suspend fun sendLightning( bolt11: String, @@ -5104,10 +5450,10 @@ class AppViewModel @Inject constructor( fun resetQuickPay() = _quickPayData.update { null } - fun navigateToActivity(activityRawId: String) { + fun navigateToActivity(activityRawId: String, walletId: String? = null) { viewModelScope.launch { hideSheet() - mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(activityRawId))) + mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(activityRawId, walletId))) } } @@ -5121,6 +5467,7 @@ class AppViewModel @Inject constructor( /** Reselect utxos for current amount & speed then refresh fees using updated utxos */ private fun refreshOnchainSendIfNeeded(): Job? { val currentState = _sendUiState.value + if (currentState.isRetainedHardwarePayment) return null if (currentState.payMethod != SendMethod.ONCHAIN || currentState.amount == 0uL || currentState.address.isEmpty() @@ -5160,6 +5507,7 @@ class AppViewModel @Inject constructor( private suspend fun refreshOnchainFeeUi() = withContext(bgDispatcher) { val currentState = _sendUiState.value + if (currentState.isRetainedHardwarePayment) return@withContext updateOnchainFeeUi { it.copy(isLoading = true) } val speeds = listOf( @@ -5195,7 +5543,7 @@ class AppViewModel @Inject constructor( private fun updateOnchainFeeUi(transform: (OnchainFeeUi) -> OnchainFeeUi) { _sendUiState.update { - it.copy(onchainFeeUi = transform(it.onchainFeeUi)) + if (it.isRetainedHardwarePayment) it else it.copy(onchainFeeUi = transform(it.onchainFeeUi)) } } @@ -5729,34 +6077,63 @@ class AppViewModel @Inject constructor( super.onCleared() } - suspend fun prepareHardwareContactPayment(): Boolean { - val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } - if (isPreparedContactPayment(contactPaymentContext)) return true + fun onHardwarePaymentSubmissionChanged(active: Boolean) { + paykitPaymentRequestRepo.setPaymentSubmissionActive(active && activeIncomingPaymentRequest() != null) + } + fun hardwarePaymentIdentity(): String? = pubkyRepo.publicKey.value?.let(PubkyPublicKeyFormat::normalized) + + suspend fun prepareHardwareContactPayment( + walletId: String? = _sendUiState.value.hardwareWalletId, + address: String = _sendUiState.value.address, + requestId: PaykitPaymentRequestId? = activeIncomingPaymentRequest()?.id, + identity: String? = hardwarePaymentIdentity(), + signedTx: HwFundingSignedTx? = null, + ): Boolean { + val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + if (contactPaymentContext?.incomingPaymentRequest?.id != requestId) return false val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest + if (incomingPaymentRequest != null && + (identity == null || !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value)) + ) return false + if (incomingPaymentRequest != null && signedTx == null) return false + // A retry of the original signed transaction skips this hook in HwSendViewModel. + // A new Shop payment must consult the durable paymentStarted guard again. + if (incomingPaymentRequest == null && isPreparedContactPayment(contactPaymentContext)) return true val proofPreparation = preparePaymentProof(incomingPaymentRequest) - proofPreparation.exceptionOrNull()?.let { + val preparedPaymentProofRequest = proofPreparation.getOrElse { handlePaymentPreparationFailure(it, contactPaymentContext) return false } - val preparedPaymentProofRequest = proofPreparation.getOrNull() - if (!prepareContactPayment(contactPaymentContext)) { - cancelPaymentProofPreparation(preparedPaymentProofRequest) - return false - } - if (preparedPaymentProofRequest != null) { - val walletId = _sendUiState.value.hardwareWalletId ?: WalletScope.default - markOnchainPaymentStarted(incomingPaymentRequest, _sendUiState.value.address, walletId).onFailure { - synchronized(contactPaymentContextLock) { - if (preparedContactPaymentContext == contactPaymentContext) preparedContactPaymentContext = null + var proofStarted = false + if (!prepareContactPayment(contactPaymentContext) { + if (preparedPaymentProofRequest == null) { + Result.success(Unit) + } else { + // Persist the signed original receipt and cleanup boundary before consuming its endpoint. + paykitPaymentProofRepo.markOnchainPaymentStarted( + requireNotNull(incomingPaymentRequest), + address, + walletId ?: WalletScope.default, + contactPaymentContext.privatePaymentContext?.paymentListVersion, + signedTx, + contactPaymentContext.privatePaymentContext?.previousPaymentListVersion, + ).onSuccess { proofStarted = true } } - releasePrivatePaymentListIfNeeded(contactPaymentContext) - cancelPaymentProofPreparation(preparedPaymentProofRequest) - handlePaymentPreparationFailure(it, contactPaymentContext) - return false } + ) { + if (proofStarted && incomingPaymentRequest != null && identity != null) { + paykitPaymentProofRepo.failHardwareOnchainPaymentBeforeDispatch( + incomingPaymentRequest, + walletId ?: WalletScope.default, + identity, + hasAttemptedBroadcast = false, + ) + } + cancelPaymentProofPreparation(preparedPaymentProofRequest) + return false } - return true + return incomingPaymentRequest == null || PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) } val hardwarePaymentDeadlineAt: Instant? @@ -5764,63 +6141,86 @@ class AppViewModel @Inject constructor( activeContactPaymentContext?.incomingPaymentRequest?.paymentDeadlineAt } - fun onHardwarePaymentSubmissionChanged(active: Boolean) { - paykitPaymentRequestRepo.setPaymentSubmissionActive(active && activeIncomingPaymentRequest() != null) - } - - fun onHardwareBroadcastAttemptChanged(attempted: Boolean) { - synchronized(contactPaymentContextLock) { - attemptedHardwarePaymentContext = if (attempted) preparedContactPaymentContext else null - } + suspend fun onHardwarePaymentDeadlineExpired( + hasAttemptedBroadcast: Boolean, + requestId: PaykitPaymentRequestId? = activeIncomingPaymentRequest()?.id, + identity: String? = hardwarePaymentIdentity(), + walletId: String? = _sendUiState.value.hardwareWalletId, + ) { + val context = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + val request = context?.incomingPaymentRequest ?: return + if (request.id != requestId || identity == null || walletId == null || + walletId != _sendUiState.value.hardwareWalletId || !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value)) return + handleHardwarePaymentFailure(PaykitPaymentRequestError.RequestExpired, context, request, walletId, identity, hasAttemptedBroadcast) } - suspend fun authorizeHardwareContactPayment(hasAttemptedBroadcast: Boolean): Boolean { + suspend fun authorizeHardwareContactPayment( + hasAttemptedBroadcast: Boolean, + requestId: PaykitPaymentRequestId? = activeIncomingPaymentRequest()?.id, + identity: String? = hardwarePaymentIdentity(), + walletId: String? = _sendUiState.value.hardwareWalletId, + ): Boolean { val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + if (contactPaymentContext?.incomingPaymentRequest?.id != requestId) return false val request = contactPaymentContext?.incomingPaymentRequest ?: return true - val error = paykitPaymentRequestRepo.ensurePaymentAllowed(request).exceptionOrNull() ?: return true - handleHardwarePaymentFailure(error, contactPaymentContext, hasAttemptedBroadcast) - return false - } - - suspend fun onHardwarePaymentDeadlineExpired(hasAttemptedBroadcast: Boolean) { - val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } - handleHardwarePaymentFailure( - PaykitPaymentRequestError.RequestExpired, - contactPaymentContext, - hasAttemptedBroadcast, - ) + if (identity == null || walletId == null) return false + if (walletId != _sendUiState.value.hardwareWalletId || + !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) + ) { + return false + } + val error = paykitPaymentRequestRepo.ensurePaymentAllowed(request).exceptionOrNull() + if (walletId != _sendUiState.value.hardwareWalletId || + !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) || + synchronized(contactPaymentContextLock) { activeContactPaymentContext } != contactPaymentContext + ) return false + if (error == null) return true + return handleHardwarePaymentFailure(error, contactPaymentContext, request, walletId, identity, hasAttemptedBroadcast) } private suspend fun handleHardwarePaymentFailure( error: Throwable, contactPaymentContext: ContactPaymentContext?, + request: PaykitPaymentRequest, + walletId: String, + identity: String, hasAttemptedBroadcast: Boolean, - ) { - val preservesUncertainPayment = synchronized(contactPaymentContextLock) { - contactPaymentContext != null && contactPaymentContext == attemptedHardwarePaymentContext - } - if (hasAttemptedBroadcast || preservesUncertainPayment) { + ): Boolean { + if (hasAttemptedBroadcast) { toast(error) - return + return false } - contactPaymentContext?.incomingPaymentRequest?.let { paykitPaymentProofRepo.failOnchainPayment(it) } - isSubmittingPaymentRequest = false + if (!paykitPaymentProofRepo.failHardwareOnchainPaymentBeforeDispatch( + request, + walletId, + identity, + hasAttemptedBroadcast, + ) + ) { + toast(error) + return false + } releasePrivatePaymentListIfNeeded(contactPaymentContext) synchronized(contactPaymentContextLock) { if (preparedContactPaymentContext == contactPaymentContext) preparedContactPaymentContext = null } + isSubmittingPaymentRequest = false handlePaymentPreparationFailure(error, contactPaymentContext) + return false } - fun completeHardwareContactPayment(txId: String) { - acknowledgeHardwarePaymentResolution(txId) - val context = synchronized(contactPaymentContextLock) { - preparedContactPaymentContext - } - val request = context?.incomingPaymentRequest - val preparation = request?.let { paymentProofPreparation(context) } - completeOnchainPaymentProofInBackground(request, txId, preparation) + suspend fun completeHardwareContactPayment( + txId: String, + walletId: String, + requestId: PaykitPaymentRequestId?, + identity: String?, + ): Boolean { + if (requestId == null) return true + val completed = paykitPaymentProofRepo.completeHardwareOnchainPayment(requestId, walletId, txId, identity) && + PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) + if (!completed) uncertainOnchainPaymentRequestId = requestId + return completed } fun acknowledgeHardwarePaymentResolution(txId: String) { @@ -5835,21 +6235,20 @@ class AppViewModel @Inject constructor( private fun cancelHardwarePaymentRequestIfNeeded() { val request = synchronized(contactPaymentContextLock) { - if (preparedContactPaymentContext == attemptedHardwarePaymentContext) { - preparedContactPaymentContext = null - } activeContactPaymentContext ?.takeIf { it == preparedContactPaymentContext && _sendUiState.value.hardwareWalletId != null } ?.incomingPaymentRequest } - if (request == null) { + // A returned Core result may still lack a durable txid when its candidate save failed. + // Dismissing that uncertain original payment must not reopen its started proof for a new send. + if (request == null || uncertainOnchainPaymentRequestId == request.id) { isSubmittingPaymentRequest = false return } viewModelScope.launch { try { - paykitPaymentProofRepo.failOnchainPayment(request) + paykitPaymentProofRepo.cancelPreparation(request) } finally { isSubmittingPaymentRequest = false } @@ -5861,13 +6260,14 @@ class AppViewModel @Inject constructor( allowDuplicateHash: Boolean = false, walletId: String = WalletScope.default, navigate: Boolean = true, + syncContact: Boolean = true, ) { details.paymentHashOrTxId?.let { val isNewPayment = synchronized(processedPaymentsLock) { processedPayments.add(it) } when { - isNewPayment -> syncContactForActivity(it, walletId) + isNewPayment -> if (syncContact) syncContactForActivity(it, walletId) !allowDuplicateHash -> { Logger.debug("Skipped duplicate processed payment '$it'", context = TAG) return @@ -6586,6 +6986,7 @@ data class SendUiState( val paymentRequestNote: String? = null, val hardwareWalletId: String? = null, val resolvedHardwarePaymentTxId: String? = null, + val isRetainedHardwarePayment: Boolean = false, val hardwareWalletName: String? = null, val hardwareAvailableSats: ULong = 0uL, val isSubscriptionPayment: Boolean = false, @@ -6671,6 +7072,8 @@ sealed class SendEffect { val paymentHash: String, val amount: Long, val observeResolution: Boolean = true, + val isOnchain: Boolean = false, + val refusalReason: String? = null, ) : SendEffect() } @@ -6722,29 +7125,6 @@ private class LightningPaymentFailedError( val paymentRequest: String?, ) : AppError(reason?.name) -private fun Throwable.isDefiniteOnchainPreBroadcastFailure(): Boolean = - generateSequence(this as Throwable?) { it.cause } - .any { - when (it) { - is ServiceError.NodeNotSetup, - is ServiceError.NodeNotStarted, - is ServiceError.PaymentDeadlineExpired, - is NodeException.NotRunning, - is NodeException.OnchainTxCreationFailed, - is NodeException.OnchainTxSigningFailed, - is NodeException.WalletOperationFailed, - is NodeException.PersistenceFailed, - is NodeException.InvalidAddress, - is NodeException.InvalidAmount, - is NodeException.InvalidNetwork, - is NodeException.InvalidFeeRate, - is NodeException.InsufficientFunds, - is NodeException.CoinSelectionFailed, - is NodeException.NoSpendableOutputs -> true - else -> false - } - } - sealed interface LnurlParams { data class LnurlPay(val data: LnurlPayData) : LnurlParams data class LnurlWithdraw(val data: LnurlWithdrawData) : LnurlParams diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index fc4b88deb5..fa340a8d12 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -73,6 +73,8 @@ import to.bitkit.repositories.HwPassphraseRequiredError import to.bitkit.repositories.HwWalletMismatchError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.OnchainTransferContext import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService @@ -379,6 +381,40 @@ class TransferViewModel @Inject constructor( walletRepo.getAddresses(count = 1).onFailure { ToastEventBus.send(it) }.getOrNull()?.firstOrNull()?.address private suspend fun paySpendingConfirmOrder(order: IBtOrder, shown: TransferToSpendingUiState): Boolean { + val previous = lightningRepo.currentOnchainSendAttempt() + if (previous?.orderId == order.id) { + val txid = previous.txid + if (previous.hasPositiveEvidence && txid != null) { + if (!previous.localFollowupComplete) { + val original = requireNotNull(previous.transferContext) { + "Original funding context is unavailable" + } + check(order.payment?.onchain?.address == previous.address) { + "Original funding order address changed" + } + check( + order.clientBalanceSat == original.originalOrderClientBalanceSats && + order.feeSat == original.originalOrderFeeSats + ) { "Original funding order terms changed or are unavailable" } + withContext(NonCancellable) { + fundPaidOrder( + order = order, + txId = txid, + txTotalSats = original.txTotalSats, + preTransferOnchainSats = original.preTransferOnchainSats, + originalContext = original, + requireTransferPersisted = true, + ) + runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(order.id, txid) } + .onFailure { Logger.warn("Failed to finish accepted transfer locally", it, context = TAG) } + } + } + return true + } + transferEffects.emit(TransferEffect.OnFundingPending(previous)) + return false + } + if (cacheStore.data.first().paidOrders.containsKey(order.id)) return true val plan = resolveSpendingConfirmPlan(order, shown) ?: return false Logger.debug( @@ -391,6 +427,16 @@ class TransferViewModel @Inject constructor( if (holdForFeesChange(order, shown, plan) || isSendAllBelowOrderFee(order, shown, plan)) return false val address = order.payment?.onchain?.address.orEmpty() + val transferContext = OnchainTransferContext( + txTotalSats = if (plan.shouldUseSendAll) { + plan.spendableBalance + } else { + order.feeSat.safe() + plan.miningFeeSats.safe() + }, + preTransferOnchainSats = plan.totalOnchainBalance, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ) return lightningRepo .sendOnChain( address = address, @@ -401,24 +447,42 @@ class TransferViewModel @Inject constructor( isTransfer = true, channelId = order.channel?.shortChannelId, isMaxAmount = plan.shouldUseSendAll, + orderId = order.id, + transferContext = transferContext, ) - .onSuccess { txId -> - // Survive ViewModel clearance between broadcast and paid-order cache write. - withContext(NonCancellable) { - fundPaidOrder( - order = order, - txId = txId, - txTotalSats = if (plan.shouldUseSendAll) { - plan.spendableBalance - } else { - order.feeSat.safe() + plan.miningFeeSats.safe() - }, - preTransferOnchainSats = plan.totalOnchainBalance, - ) + .fold( + onSuccess = { outcome -> + if (outcome !is OnchainSendOutcome.Accepted) { + showRetainedFundingPending() + return@fold false + } + // Survive ViewModel clearance between accepted broadcast and paid-order cache write. + withContext(NonCancellable) { + fundPaidOrder( + order = order, + txId = outcome.txid, + txTotalSats = transferContext.txTotalSats, + preTransferOnchainSats = transferContext.preTransferOnchainSats, + originalContext = transferContext, + requireTransferPersisted = true, + ) + runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(order.id, outcome.txid) } + .onFailure { Logger.warn("Failed to finish accepted transfer locally", it, context = TAG) } + } + true + }, + onFailure = { + if (!showRetainedFundingPending()) ToastEventBus.send(it) + false } - } - .onFailure { ToastEventBus.send(it) } - .isSuccess + ) + } + + private suspend fun showRetainedFundingPending(): Boolean { + val attempt = lightningRepo.currentOnchainSendAttempt() ?: return false + if (!attempt.isTransfer || !attempt.blocksNextSend) return false + transferEffects.emit(TransferEffect.OnFundingPending(attempt)) + return true } private suspend fun resolveSpendingConfirmPlan( @@ -640,16 +704,22 @@ class TransferViewModel @Inject constructor( txTotalSats: ULong? = null, preTransferOnchainSats: ULong? = null, activityWalletId: String = WalletScope.default, + originalContext: OnchainTransferContext? = null, + requireTransferPersisted: Boolean = false, ) { - cacheStore.addPaidOrder(orderId = order.id, txId = txId) - transferRepo.createTransfer( - type = TransferType.TO_SPENDING, - amountSats = order.clientBalanceSat.toLong(), - fundingTxId = txId, - lspOrderId = order.id, - txTotalSats = txTotalSats?.toLong(), - preTransferOnchainSats = preTransferOnchainSats?.toLong(), - ) + if (requireTransferPersisted) { + transferRepo.persistAcceptedFunding(order, txId, requireNotNull(originalContext)).getOrThrow() + } else { + cacheStore.addPaidOrder(orderId = order.id, txId = txId) + transferRepo.createTransfer( + type = TransferType.TO_SPENDING, + amountSats = order.clientBalanceSat.toLong(), + fundingTxId = txId, + lspOrderId = order.id, + txTotalSats = txTotalSats?.toLong(), + preTransferOnchainSats = preTransferOnchainSats?.toLong(), + ) + } if (createTransferActivity) { transferRepo.createPendingToSpendingActivity( order = order, @@ -2101,6 +2171,7 @@ data class TransferValues( sealed interface TransferEffect { data object OnQuoteReady : TransferEffect data object OnSpendingFundingPaid : TransferEffect + data class OnFundingPending(val attempt: to.bitkit.repositories.OnchainSendAttempt) : TransferEffect data object OnHwTxSigned : TransferEffect data class ToastException(val e: Throwable) : TransferEffect data class ToastError(val title: String, val description: String) : TransferEffect diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ad062d609..63748b1ade 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1476,8 +1476,18 @@ MAX The maximum spendable amount is a bit lower due to a required reserve balance. Reserve Balance + The new payment was not sent. Finish the previous payment before trying again. This payment is taking a bit longer than expected. You can continue using Bitkit. + Funding transaction is unresolved. Check its transaction before trying again. Payment Pending + The transaction outcome is unresolved. Check its status before trying again. Other payments remain blocked. If available, use Retry original payment to authorize a fee for the same payment. + Broadcast refused: %1$s + Authorize retry + Retry the original payment using the same amount and inputs. Enter the fee rate you authorize in sat/vB. A higher fee may be unavailable; Bitkit will never reduce the payment or add other inputs. + The original payment could not be retried. Its funds remain protected. There may be insufficient funds for the chosen fee. Try another fee or check the transaction status. + Enter a valid positive fee rate in sat/vB. + Retry original payment + Transaction ID: %1$s This invoice has already been paid. Currency conversion failed QuickPay diff --git a/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt index cba5b16adc..9ab2bf9118 100644 --- a/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt @@ -46,4 +46,137 @@ class BroadcastExceptionExtTest { ) assertFalse(AppError("unknown result").isDefiniteHardwarePreBroadcastFailure()) } + + @Test + fun `recognized backend refusals allow navigation without proving no dispatch`() { + val reasons = listOf( + "min relay fee not met", + "mempool min fee not met", + "bad-txns-inputs-missingorspent", + "txn-mempool-conflict", + "non-final", + ) + for (reason in reasons) { + val error = AppError(BroadcastException.ElectrumException("broadcast failed: $reason")) + assertTrue(error.isHardwareBroadcastRefusalForNavigation()) + assertFalse(error.isDefiniteHardwarePreBroadcastFailure()) + } + } + + @Test + fun `unknown and connectivity errors retain the navigation guard`() { + val errors = listOf( + "broadcast failed: disconnected", + "broadcast failed: unknown refusal", + "response lost after dispatch", + "min relay fee not met", + ) + for (details in errors) { + val error = AppError(BroadcastException.ElectrumException(details)) + assertFalse(error.isHardwareBroadcastRefusalForNavigation()) + } + assertFalse(AppError("unknown result").isHardwareBroadcastRefusalForNavigation()) + } + + @Test + fun `wrapped Electrum server refusals allow navigation`() { + val messages = listOf( + "the transaction was rejected by network rules.\n\nmin relay fee not met, 100 < 110", + "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"bad-txns-inputs-missingorspent\"}", + "mempool min fee not met, 110 < 300", + ) + for (message in messages) { + val payload = kotlinx.serialization.json.buildJsonObject { + put("code", kotlinx.serialization.json.JsonPrimitive(1)) + put("message", kotlinx.serialization.json.JsonPrimitive(message)) + } + val error = BroadcastException.ElectrumException("Broadcast failed: Electrum server error: $payload") + assertTrue(error.isHardwareBroadcastRefusalForNavigation()) + assertFalse(error.isDefiniteHardwarePreBroadcastFailure()) + } + } + + @Test + fun `malformed unknown and incidental Electrum messages stay guarded`() { + val payloads = listOf( + "Broadcast failed: Electrum server error: {invalid JSON min relay fee not met}", + "Broadcast failed: Electrum server error: {\"message\":\"disconnected\"}", + "Broadcast failed: Electrum server error: {\"message\":\"unknown refusal\"}", + "Broadcast failed: Electrum server error: {\"message\":{},\"reason\":\"min relay fee not met\"}", + "Broadcast failed: Electrum server error: {\"message\":\"non-finalized response lost\"}", + ) + for (payload in payloads) { + assertFalse(BroadcastException.ElectrumException(payload).isHardwareBroadcastRefusalForNavigation()) + } + } + + @Test + fun `reported missing inputs JSON string coded RPC refusal allows navigation only`() { + assertReportedRefusal( + """Broadcast failed: Electrum server error: "sendrawtransaction RPC error -25: """ + + """bad-txns-inputs-missingorspent"""", + ) + } + + @Test + fun `reported replacement fee JSON string coded RPC refusal allows navigation only`() { + assertReportedRefusal( + """Broadcast failed: Electrum server error: "sendrawtransaction RPC error -26: """ + + """insufficient fee, rejecting replacement """ + + """b3f63e62aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; new feerate """ + + """0.00001000 BTC/kvB <= old feerate 0.00001018 BTC/kvB"""", + ) + } + + private fun assertReportedRefusal(payload: String) { + val error = BroadcastException.ElectrumException(payload) + assertTrue(error.isHardwareBroadcastRefusalForNavigation(), payload) + assertFalse(error.isDefiniteHardwarePreBroadcastFailure()) + } + + @Test + fun `coded RPC unknown malformed and replacement lookalikes stay guarded`() { + val messages = listOf( + "sendrawtransaction RPC error -25: disconnected", + "sendrawtransaction RPC error unknown: bad-txns-inputs-missingorspent", + "sendrawtransaction RPC error -25 bad-txns-inputs-missingorspent", + "sendrawtransaction RPC error -25: bad-txns-inputs-missingorspentish", + "sendrawtransaction RPC error -26: insufficient fee information unavailable", + "sendrawtransaction RPC error -26: insufficient fee, rejecting replacementish", + "sendrawtransaction RPC error -26: unknown result containing insufficient fee, rejecting replacement tx", + ) + for (message in messages) { + val payload = kotlinx.serialization.json.JsonPrimitive(message) + val error = BroadcastException.ElectrumException("Broadcast failed: Electrum server error: $payload") + assertFalse(error.isHardwareBroadcastRefusalForNavigation(), message) + } + val malformed = "Broadcast failed: Electrum server error: \"sendrawtransaction RPC error -25: " + + "bad-txns-inputs-missingorspent" + assertFalse(BroadcastException.ElectrumException(malformed).isHardwareBroadcastRefusalForNavigation()) + } + + @Test + fun `replacement refusal requires complete txid and exact feerate grammar`() { + val txid = "b3f63e62" + "a".repeat(56) + val known = "insufficient fee, rejecting replacement $txid; " + + "new feerate 0.00001000 BTC/kvB <= old feerate 0.00001018 BTC/kvB" + val messages = listOf( + known.replace(txid, txid.dropLast(1)), + known.replace(txid, "g" + txid.drop(1)), + known.replace(txid, "b3f63e62…"), + known.replace("0.00001000", "0.0000100"), + known.replace("0.00001018", "0.000010180"), + known.replace("0.00001000", "-0.00001000"), + known.replace("BTC/kvB", "sats/vB"), + known.replace("<=", "<"), + "$known disconnected", + "$known\nunknown result", + "insufficient fee, rejecting replacement disconnected", + ) + for (message in messages) { + val payload = kotlinx.serialization.json.JsonPrimitive("sendrawtransaction RPC error -26: $message") + val error = BroadcastException.ElectrumException("Broadcast failed: Electrum server error: $payload") + assertFalse(error.isHardwareBroadcastRefusalForNavigation(), message) + } + } } diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt new file mode 100644 index 0000000000..f815f2a2e7 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -0,0 +1,349 @@ +package to.bitkit.models + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Test +import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.test.BaseUnitTest +import java.security.MessageDigest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class ActiveOnchainAttemptBackupTest : BaseUnitTest() { + private val json = Json { ignoreUnknownKeys = true } + private val binding = "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0" + + private fun golden(): WalletBackupV1 { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + assertEquals("8487f073ee55b9049aeed241819055138c82af8a596104bc2eada95b6ce96aa7", hash) + return json.decodeFromString(bytes.decodeToString()) + } + + @Test + fun `shared iOS candidate fee golden preserves exact map and older winner fee`() { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + assertEquals("8487f073ee55b9049aeed241819055138c82af8a596104bc2eada95b6ce96aa7", hash) + val backup = Json { ignoreUnknownKeys = true }.decodeFromString(bytes.decodeToString()) + val state = requireNotNull(backup.paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + wire.validateProofs(state.pendingProofs, "wallet0") + val attempt = wire.restored("regtest", wire.wallet.binding, "wallet0", 0) + assertEquals(4uL, attempt.winningFeeRateSatsPerVByte) + assertEquals(2uL, attempt.copy(txid = attempt.candidateTxids.first()).winningFeeRateSatsPerVByte) + assertEquals(wire, ActiveOnchainAttemptBackup.from(attempt, "regtest", wire.wallet.binding)) + } + + @Test + fun `candidate fee wire survives restore and rejects foreign candidates or invalid UInt32 rates`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val winner = requireNotNull(wire.txid) + val json = Json { ignoreUnknownKeys = true } + fun withRates(rates: Map): ActiveOnchainAttemptBackup { + val fields = json.parseToJsonElement(json.encodeToString(wire)) as kotlinx.serialization.json.JsonObject + val candidateFees = kotlinx.serialization.json.JsonObject( + rates.mapValues { kotlinx.serialization.json.JsonPrimitive(it.value) } + ) + return json.decodeFromString( + kotlinx.serialization.json.JsonObject(fields + ("candidateFeeRates" to candidateFees)).toString() + ) + } + val enhanced = withRates(mapOf(winner to "3")) + val restored = enhanced.restored("regtest", binding, "wallet0", 0) + assertEquals(3uL, restored.winningFeeRateSatsPerVByte) + val exported = ActiveOnchainAttemptBackup.from(restored, "regtest", binding) + assertEquals(enhanced, exported) + listOf( + mapOf("00".repeat(32) to "3"), + mapOf(winner to "0"), + mapOf(winner to "4294967296"), + mapOf(winner to "03"), + ).forEach { + assertFailsWith { withRates(it).restored("regtest", binding, "wallet0", 0) } + } + val original = wire.copy(txid = wire.candidateTxids.first()).restored("regtest", binding, "wallet0", 0) + assertEquals(wire.feeRateSatsPerVByte.toULong(), original.winningFeeRateSatsPerVByte) + val successor = wire.copy(txid = wire.candidateTxids.last()).restored("regtest", binding, "wallet0", 0) + assertEquals(4uL, successor.winningFeeRateSatsPerVByte) + } + + @Test + fun `original candidate rate must agree with the retained original rate`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val original = wire.candidateTxids.first() + val successor = wire.candidateTxids.last() + for (status in listOf("pending", "unknown", "rejected", "accepted")) { + val candidate = wire.copy(status = status, txid = original) + assertFailsWith { + candidate.copy(candidateFeeRates = mapOf(original to "3", successor to "4")) + .restored("regtest", binding, "wallet0", 0) + } + for (rates in listOf(mapOf(successor to "4"), mapOf(original to "2", successor to "4"))) { + val restored = candidate.copy(candidateFeeRates = rates).restored("regtest", binding, "wallet0", 0) + assertEquals(2uL, restored.winningFeeRateSatsPerVByte) + assertEquals(4uL, restored.copy(txid = successor).winningFeeRateSatsPerVByte) + } + } + } + + @Test + fun `restored transfer totals must fit the activity database range`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( + requestId = null, payerIdentity = null, orderId = "original-order", amountSats = "1000", + transfer = ActiveOnchainAttemptBackup.Transfer("2000", "3000", "900", "1000"), + ) + val overflow = (Long.MAX_VALUE.toULong() + 1uL).toString() + for (transfer in listOf( + requireNotNull(wire.transfer).copy(txTotalSats = overflow), + requireNotNull(wire.transfer).copy(preTransferOnchainSats = overflow), + )) { + assertFailsWith { + wire.copy(transfer = transfer).restored("regtest", binding, "wallet0", 0) + } + } + val maximum = Long.MAX_VALUE.toString() + val accepted = wire.copy(transfer = wire.transfer?.copy(txTotalSats = maximum, preTransferOnchainSats = maximum)) + .restored("regtest", binding, "wallet0", 0) + assertEquals(Long.MAX_VALUE.toULong(), accepted.transferContext?.txTotalSats) + assertEquals(Long.MAX_VALUE.toULong(), accepted.transferContext?.preTransferOnchainSats) + } + + @Test + fun `restored funding amount must equal its original order fee`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( + requestId = null, + payerIdentity = null, + orderId = "original-order", + transfer = ActiveOnchainAttemptBackup.Transfer("2000", "3000", "900", "1000"), + ) + for (amount in listOf("999", "1001", "1234")) { + assertFailsWith { + wire.copy(amountSats = amount).restored("regtest", binding, "wallet0", 0) + } + } + assertFailsWith { + wire.copy(amountSats = "1000", transfer = wire.transfer?.copy(txTotalSats = "1")) + .restored("regtest", binding, "wallet0", 0) + } + val restored = wire.copy(amountSats = "1000").restored("regtest", binding, "wallet0", 0) + assertEquals(1000uL, restored.amountSats) + assertEquals(restored.amountSats, restored.transferContext?.originalOrderFeeSats) + assertEquals(wire.copy(amountSats = "1000"), ActiveOnchainAttemptBackup.from(restored, "regtest", binding)) + } + + @Test + fun `send-all funding backup retains actual recipient amount and original order terms`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( + requestId = null, + payerIdentity = null, + orderId = "original-order", + isMaxAmount = true, + amountSats = "99500", + transfer = ActiveOnchainAttemptBackup.Transfer("100000", "100000", "97000", "99000"), + ) + for (status in listOf("pending", "unknown", "rejected", "accepted")) { + val candidate = wire.copy(status = status) + val restored = candidate.restored("regtest", binding, "wallet0", 0) + assertEquals(99500uL, restored.amountSats) + assertEquals(99000uL, restored.transferContext?.originalOrderFeeSats) + assertEquals(candidate, ActiveOnchainAttemptBackup.from(restored, "regtest", binding)) + } + for (status in listOf("pending", "unknown", "rejected", "accepted")) { + for (amount in listOf("98999", "100001")) { + assertFailsWith { + wire.copy(status = status, amountSats = amount).restored("regtest", binding, "wallet0", 0) + } + } + } + assertFailsWith { + wire.copy(isMaxAmount = false).restored("regtest", binding, "wallet0", 0) + } + } + + @Test + fun `every retained successor needs a fee rate regardless of current outcome`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val original = wire.candidateTxids.first() + val successor = wire.candidateTxids.last() + val later = "12".repeat(32) + for (status in listOf("pending", "unknown", "rejected", "accepted")) { + val candidate = wire.copy(status = status, txid = original, candidateTxids = listOf(original, successor, later)) + for (rates in listOf(null, emptyMap(), mapOf(successor to "4"), mapOf(later to "5"))) { + assertFailsWith { + candidate.copy(candidateFeeRates = rates).restored("regtest", binding, "wallet0", 0) + } + } + val restored = candidate.copy(candidateFeeRates = mapOf(successor to "4", later to "5")) + .restored("regtest", binding, "wallet0", 0) + assertEquals(wire.feeRateSatsPerVByte.toULong(), restored.winningFeeRateSatsPerVByte) + assertEquals(4uL, restored.copy(txid = successor).winningFeeRateSatsPerVByte) + assertEquals(5uL, restored.copy(txid = later).winningFeeRateSatsPerVByte) + } + } + + @Test + fun `incomplete historical fixture cannot install a restored guard`() { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val backup = json.decodeFromString(bytes.decodeToString()) + val wire = requireNotNull(backup.paykitPaymentState?.activeOnchainAttempt) + assertFailsWith { wire.restored("regtest", binding, "wallet0", 0) } + } + + @Test + fun `accepted successor restore requires its own valid fee rate`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val original = wire.candidateTxids.first() + val successor = wire.candidateTxids.last() + val accepted = wire.copy(status = "accepted", txid = successor) + for (rates in listOf(null, emptyMap(), mapOf(original to "2"))) { + assertFailsWith { + accepted.copy(candidateFeeRates = rates).restored("regtest", binding, "wallet0", 0) + } + } + val restored = accepted.copy(candidateFeeRates = mapOf(successor to "4")) + .restored("regtest", binding, "wallet0", 0) + assertEquals(4uL, restored.winningFeeRateSatsPerVByte) + val firstWinner = accepted.copy(txid = original, candidateFeeRates = mapOf(successor to "4")) + .restored("regtest", binding, "wallet0", 0) + assertEquals(wire.feeRateSatsPerVByte.toULong(), firstWinner.winningFeeRateSatsPerVByte) + } + + @Test + fun `restore rejects malformed original contact before installing a guard`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val followup = requireNotNull(wire.followup) + val invalidContacts = listOf( + kotlinx.serialization.json.JsonPrimitive(""), + kotlinx.serialization.json.JsonPrimitive(" "), + kotlinx.serialization.json.JsonPrimitive(123), + kotlinx.serialization.json.JsonPrimitive(true), + kotlinx.serialization.json.JsonNull, + kotlinx.serialization.json.JsonObject(emptyMap()), + kotlinx.serialization.json.JsonArray(emptyList()), + ) + for (contact in invalidContacts) { + assertFailsWith { + wire.copy(followup = followup.copy(contact = contact)).restored("regtest", binding, "wallet0", 0) + } + } + val missing = wire.copy(followup = followup.copy(contact = null)).restored("regtest", binding, "wallet0", 0) + assertNull(missing.backupFollowup?.contact) + val validContact = kotlinx.serialization.json.JsonPrimitive("original-contact") + val valid = wire.copy(followup = followup.copy(contact = validContact)).restored("regtest", binding, "wallet0", 0) + assertEquals(validContact, valid.backupFollowup?.contact) + } + + @Test + fun `restored active operations require original local followup context`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + for (status in listOf("pending", "accepted", "rejected", "unknown")) { + val candidate = wire.copy( + status = status, + txid = wire.candidateTxids.first(), + followup = null, + ) + assertFailsWith { + candidate.restored("regtest", binding, "wallet0", 0) + } + } + } + + @Test + fun `unsigned active preparation cannot restore a permanent wallet guard`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( + originalInputs = null, + candidateTxids = emptyList(), + candidateFeeRates = emptyMap(), + txid = null, + status = "pending", + ) + assertFailsWith { + wire.restored("regtest", binding, "wallet0", 0) + } + } + + @Test + fun `same golden bytes preserve original proof and remap only validated guard`() { + val state = requireNotNull(golden().paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + wire.validateProofs(state.pendingProofs, "wallet0") + val restored = wire.restored("regtest", binding, "destination-wallet", 2) + assertEquals(2, restored.walletIndex) + assertEquals("destination-wallet", restored.walletId) + assertEquals(wire.payerIdentity, restored.payerIdentity) + assertEquals(wire.requestId, restored.requestId) + assertEquals(wire.candidateTxids, restored.candidateTxids) + assertEquals(UInt.MAX_VALUE, restored.originalInputs?.single()?.vout) + assertEquals("123", restored.backupFollowup?.feeSats) + val precise = wire.copy(followup = wire.followup?.copy(createdAtMillis = "1791234000123")) + assertEquals( + "1791234000123", + precise.restored("regtest", binding, "wallet0", 0).backupFollowup?.createdAtMillis + ) + assertEquals(wire.followup?.contact, restored.backupFollowup?.contact) + assertFalse(restored.localFollowupComplete) + assertEquals(OnchainSendEvidence.Unknown, restored.evidence) + assertNull(state.pendingProofs.single().onchainWalletId) + WalletScope.pushTestOverride("destination-wallet").use { + assertEquals("destination-wallet", state.pendingProofs.single().restored().onchainWalletId) + val rebuilt = ActiveOnchainAttemptBackup.from(restored, "regtest", binding) + assertEquals(wire.copy(wallet = wire.wallet.copy(sourceIndex = "2")), rebuilt) + assertEquals(rebuilt, json.decodeFromString(json.encodeToString(rebuilt))) + } + } + + @Test + fun `wrong wallet network payer hardware proof or foreign candidate fail closed`() { + val state = requireNotNull(golden().paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + assertFailsWith { wire.restored("bitcoin", binding, "wallet0", 0) } + assertFailsWith { wire.restored("regtest", "00".repeat(32), "wallet0", 0) } + assertFailsWith { wire.copy(version = 2).restored("regtest", binding, "wallet0", 0) } + assertFailsWith { + wire.copy( + amountSats = "01234" + ).restored("regtest", binding, "wallet0", 0) + } + assertFailsWith { + wire.copy( + txid = "00".repeat(32) + ).restored("regtest", binding, "wallet0", 0) + } + val proof = state.pendingProofs.single() + val invalid = listOf( + proof.copy(identity = "foreign-payer"), + proof.copy(onchainWalletId = "hardware-wallet"), + proof.copy(paymentIdentifier = "00".repeat(32), onchainAcceptanceVerified = true), + proof.copy(onchainAmountSats = 1235uL), + proof.copy(proofData = "00".repeat(32)), + proof.copy(onchainAcceptanceVerified = true, proofData = proof.paymentIdentifier), + ) + invalid.forEach { assertFailsWith { wire.validateProofs(listOf(it), "wallet0") } } + } + + @Test + fun `unprepared zero fee guard is rejected and accepted restore resets local ack`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val pending = wire.copy( + status = "pending", + txid = null, + originalInputs = null, + candidateTxids = emptyList(), + feeRateSatsPerVByte = "0" + ) + assertFailsWith { + pending.restored("regtest", binding, "wallet0", 0) + } + assertFailsWith { + wire.copy( + feeRateSatsPerVByte = "0" + ).restored("regtest", binding, "wallet0", 0) + } + val accepted = wire.copy(status = "accepted", candidateFeeRates = mapOf(requireNotNull(wire.txid) to "4")).restored("regtest", binding, "wallet0", 0) + assertEquals(OnchainSendEvidence.Accepted, accepted.evidence) + assertFalse(accepted.localFollowupComplete) + } +} diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index 2b958d3061..b3da7c259e 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -10,11 +10,47 @@ import to.bitkit.repositories.PaykitPaymentRequestId import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.ExperimentalTime import kotlin.time.Instant class PaykitPaymentStateBackupTest { + @Test + fun `proofs without an app id remain readable without inventing provenance`() { + WalletScope.pushTestOverride("wallet0").use { + val local = Json.decodeFromString( + """{"identity":"alice","requestId":{"paymentRequestId":"request","counterparty":"bob"},"paymentEndpointIdentifier":"bitcoin-onchain","kind":"Onchain","paymentStarted":true}""", + ) + assertEquals("", local.paymentAppId) + assertTrue(local.paymentStarted) + assertFalse(local.onchainAcceptanceVerified) + val backup = Json.decodeFromString( + """{"identity":"alice","requestId":{"paymentRequestId":"request","counterparty":"bob"},"paymentEndpointIdentifier":"bitcoin-onchain","kind":"bitcoin-onchain-txid","paymentStarted":true,"onchainMatchingTransactionIdsBeforeAttempt":[]}""", + ) + assertEquals("", backup.restored().paymentAppId) + assertEquals(local.requestId, backup.restored().requestId) + assertFalse(backup.restored().onchainAcceptanceVerified) + } + } + @Test + fun `shared active operation survives payment backup reader writer roundtrip`() { + val wire = """ + {"subscriptions":{},"pendingProofs":[],"activeOnchainAttempt":{"version":1,"wallet":{"kind":"software","network":"regtest","binding":"${"ab".repeat( + 32 + )}","sourceIndex":"0"},"attemptId":"00000000-0000-4000-8000-000000000001","requestId":null,"orderId":null,"payerIdentity":null,"address":"bcrt1qrecipient","amountSats":"20000","isMaxAmount":false,"status":"unknown","txid":"${"cd".repeat( + 32 + )}","rejectionReason":null,"originalInputs":[{"txid":"${"ef".repeat( + 32 + )}","vout":"0"}],"candidateTxids":["${"cd".repeat( + 32 + )}"],"feeRateSatsPerVByte":"1","followup":null,"transfer":null}} + """.trimIndent() + val codec = Json { ignoreUnknownKeys = true } + val decoded = codec.decodeFromString(wire) + assertContains(codec.encodeToString(decoded), "\"activeOnchainAttempt\"") + } + @Test fun `payment backup accepts shared wire format and retains pending payment`() { WalletScope.pushTestOverride("wallet0").use { @@ -24,6 +60,7 @@ class PaykitPaymentStateBackupTest { val backup = Json.decodeFromString(fixture) val restored = backup.pendingProofs.single().restored() assertTrue(restored.paymentStarted) + assertFalse(restored.onchainAcceptanceVerified) assertEquals(PaykitPaymentProofKind.Onchain, restored.kind) assertEquals( Instant.parse(requireNotNull(restored.requestId.billingPeriodStartsAt)), @@ -44,6 +81,12 @@ class PaykitPaymentStateBackupTest { assertEquals(backup.subscriptions, decoded.subscriptions) assertEquals(rebuilt.acceptedOneTimeRequests, decoded.acceptedOneTimeRequests) + val verifiedProof = restored.copy(onchainAcceptanceVerified = true) + val verifiedBackup = PaykitPaymentStateBackup.Proof(verifiedProof) + val verifiedJson = Json.encodeToString(verifiedBackup) + assertContains(verifiedJson, "\"onchainAcceptanceVerified\":true") + assertTrue(Json.decodeFromString(verifiedJson).restored().onchainAcceptanceVerified) + val hardwareProof = restored.copy(onchainWalletId = "hardware-wallet") val hardwareBackup = PaykitPaymentStateBackup.Proof(hardwareProof) val hardwareJson = Json.encodeToString(hardwareBackup) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 7db4f233e2..df2f8edab2 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -24,6 +24,7 @@ import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking @@ -157,6 +158,33 @@ class ActivityRepoTest : BaseUnitTest() { ) } + @Test + fun `observed hardware payment completes only after original Sent activity is durable`() = test { + val txid = "ab".repeat(32) + val wallet = "original-hardware-wallet" + val original = baseOnchainActivity.copy(walletId = wallet, txId = txid) + whenever(coreService.activity.restoreSentOnchainTags(txid, wallet)).thenReturn(false) + whenever(coreService.activity.getOnchainActivityByTxId(txid, wallet)) + .thenReturn(null) + .thenReturn(original.copy(walletId = "different-wallet")) + .thenReturn(original) + val before = sut.activitiesChanged.value + + assertTrue(sut.completeObservedHardwarePayment(wallet, txid, original.address, original.value, + original.fee, original.feeRate).isFailure) + assertEquals(before, sut.activitiesChanged.value) + assertTrue(sut.completeObservedHardwarePayment(wallet, txid, original.address, original.value, + original.fee, original.feeRate).isFailure) + assertEquals(before, sut.activitiesChanged.value) + sut.completeObservedHardwarePayment(wallet, txid, original.address, original.value, + original.fee, original.feeRate).getOrThrow() + assertTrue(sut.activitiesChanged.value > before) + verify(coreService.activity).restoreSentOnchainTags(txid, wallet) + verify(coreService.activity, times(3)).createSentOnchainActivityFromSendResult( + txid, original.address, original.value, original.fee, original.feeRate, false, null, wallet, + ) + } + private fun setupSyncActivitiesMocks( cacheData: AppCacheData, ) { @@ -592,6 +620,44 @@ class ActivityRepoTest : BaseUnitTest() { assertEquals(cancellation.message, thrown.message) } + @Test + fun `failed contact assignment preserves manual detachment`() = test { + val activity = createOnchainActivity(id = "detached", txId = "detached-tx") + whenever(coreService.activity.getActivity("detached-tx", WalletScope.default)).thenReturn(activity) + whenever(coreService.activity.update(eq(activity.v1.id), any())) + .thenThrow(RuntimeException("Core write failed")) + val result = sut.setContact("new-contact", "detached-tx", syncLdkPayments = false) + assertTrue(result.isFailure) + verify(cacheStore, never()).setActivityContactDetached(any(), any(), eq(false)) + } + + @Test + fun `contact retry completes detachment cleanup and replacement after partial write`() = test { + val contact = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + var original = createOnchainActivity(id = "original", txId = "original-tx", doesExist = false) + val replacement = createOnchainActivity(id = "replacement", txId = "replacement-tx", boostTxIds = listOf("original-tx")) + whenever(coreService.activity.getActivity("original-tx", WalletScope.default)).thenAnswer { original } + whenever(coreService.activity.update(eq("original"), any())).doSuspendableAnswer { + original = it.getArgument(1) + Unit + } + whenever(coreService.activity.get(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenAnswer { listOf(original, replacement) } + var failCleanup = true + whenever(cacheStore.setActivityContactDetached("original", WalletScope.default, false)).doSuspendableAnswer { + if (failCleanup) { + failCleanup = false + error("detachment marker write failed") + } + Unit + } + assertTrue(sut.setContact(contact, "original-tx", syncLdkPayments = false).isFailure) + assertEquals(contact, original.v1.contact) + assertTrue(sut.setContact(contact, "original-tx", syncLdkPayments = false).isSuccess) + verify(cacheStore, times(2)).setActivityContactDetached("original", WalletScope.default, false) + verify(coreService.activity).update(eq("replacement"), argThat { this is Activity.Onchain && v1.contact == contact }) + } + @Test fun `setContact propagates contact to replacement transaction`() = test { val contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index adcb8760a8..685da84e68 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -5,13 +5,13 @@ import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.vssclient.VssItem import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.serialization.json.JsonObject @@ -89,6 +89,8 @@ class BackupRepoTest : BaseUnitTest() { private val hwWalletStore = mock() private val blocktankRepo = mock() private val activityRepo = mock() + private val coreService = mock() + private val lightningRepo = mock() private val pubkyRepo = mock() private val paykitSdkService = mock() private val privatePaykitRepo = mock() @@ -98,6 +100,9 @@ class BackupRepoTest : BaseUnitTest() { private val paykitPaymentRequestRepo = mock() private val paykitPresentationStore = mock() private val keychain = mock() + private val onchainSendAttemptStore = mock() + private val transferRepo = mock() + private val vssStoreIdProvider = mock() private val preActivityMetadataRepo = mock() private val lightningService = mock() private val clock = mock() @@ -107,15 +112,30 @@ class BackupRepoTest : BaseUnitTest() { private val settingsData = MutableStateFlow(SettingsData()) private val widgetsData = MutableStateFlow(WidgetsData()) private val hwWalletData = MutableStateFlow(HwWalletData()) + private val paymentSubmissionActive = MutableStateFlow(false) private lateinit var sut: BackupRepo @Before fun setUp() = test { - whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(1_000)) + whenever(coreService.validateBitcoinAddress(any())).thenAnswer { + com.synonym.bitkitcore.ValidationResult( + address = it.getArgument(0), network = com.synonym.bitkitcore.NetworkType.REGTEST, + addressType = com.synonym.bitkitcore.AddressType.P2WPKH, + ) + } whenever(paykitSdkService.isPaymentSubmissionActive).thenReturn(paymentSubmissionActive) + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(1_000)) whenever(db.transferDao()).thenReturn(transferDao) + whenever(db.configDao()).thenReturn(mock()) + whenever(db.configDao().getAll()).thenReturn(MutableStateFlow(listOf(to.bitkit.data.entities.ConfigEntity()))) + whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null)) + whenever(onchainSendAttemptStore.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(onchainSendAttemptStore.backupSnapshot(eq(0), any())).doSuspendableAnswer { + val capture = it.getArgument List>(1) + onchainSendAttemptStore.backupSnapshot(0) to capture() + } whenever { transferDao.upsert(any>()) }.thenReturn(Unit) whenever { cacheStore.updateBackupStatus(any(), any()) }.thenReturn(Unit) whenever { cacheStore.update(any()) }.thenReturn(Unit) @@ -139,6 +159,8 @@ class BackupRepoTest : BaseUnitTest() { whenever { transferDao.getAll() }.thenReturn(emptyList()) whenever { privatePaykitRepo.restoreBackup(anyOrNull()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.backupSnapshot() }.thenReturn(Result.success(null)) + whenever(privatePaykitRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(paykitSdkService.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever { paykitPaymentProofRepo.backupSnapshot() }.thenReturn(emptyList()) whenever(paykitPresentationStore.backupSnapshot()).thenReturn(emptyMap()) whenever { privatePaykitAddressReservationRepo.restoreBackup(any()) }.thenReturn(Result.success(Unit)) @@ -192,6 +214,49 @@ class BackupRepoTest : BaseUnitTest() { } } + @Test + fun `cancelled manual backup completes success and failure status writes`() = test { + for (uploadSucceeds in listOf(true, false)) { + val category = BackupCategory.SETTINGS + val statuses = MutableStateFlow(mapOf(category to BackupItemStatus(required = 1_000))) + val statusWriteStarted = CompletableDeferred() + val finishStatusWrite = CompletableDeferred() + whenever(cacheStore.backupStatuses).thenReturn(statuses) + whenever(cacheStore.updateBackupStatus(eq(category), any())).doSuspendableAnswer { + val transform = it.getArgument<(BackupItemStatus) -> BackupItemStatus>(1) + val updated = transform(statuses.value.getValue(category)) + if (!updated.running) { + statusWriteStarted.complete(Unit) + finishStatusWrite.await() + } + statuses.update { current -> current + (category to updated) } + } + val uploadResult = if (uploadSucceeds) { + Result.success(VssItem(key = category.name, value = byteArrayOf(), version = 1)) + } else { + Result.failure(BackupRepoTestError("upload failed")) + } + whenever(vssBackupClient.putObject(eq(category.name), any())).thenReturn(uploadResult) + + val backup = launch { sut.triggerBackup(category) } + runCurrent() + assertTrue(statusWriteStarted.isCompleted) + assertTrue(statuses.value.getValue(category).running) + + backup.cancel() + runCurrent() + assertFalse(backup.isCompleted) + finishStatusWrite.complete(Unit) + backup.join() + + val status = statuses.value.getValue(category) + assertTrue(backup.isCancelled) + assertFalse(status.running) + assertEquals(if (uploadSucceeds) status.required else 0L, status.synced) + assertEquals(!uploadSucceeds, status.isRequired) + } + } + @Test fun `cancelled manual wallet backup retains pending state and resumes automatically`() = test { val statuses = MutableStateFlow( @@ -238,46 +303,385 @@ class BackupRepoTest : BaseUnitTest() { } @Test - fun `cancelled manual backup completes success and failure status writes`() = test { - for (uploadSucceeds in listOf(true, false)) { - val category = BackupCategory.SETTINGS - val statuses = MutableStateFlow(mapOf(category to BackupItemStatus(required = 1_000))) - val statusWriteStarted = CompletableDeferred() - val finishStatusWrite = CompletableDeferred() - whenever(cacheStore.backupStatuses).thenReturn(statuses) - whenever(cacheStore.updateBackupStatus(eq(category), any())).doSuspendableAnswer { - val transform = it.getArgument<(BackupItemStatus) -> BackupItemStatus>(1) - val updated = transform(statuses.value.getValue(category)) - if (!updated.running) { - statusWriteStarted.complete(Unit) - finishStatusWrite.await() - } - statuses.update { current -> current + (category to updated) } - } - val uploadResult = if (uploadSucceeds) { - Result.success(VssItem(key = category.name, value = byteArrayOf(), version = 1)) - } else { - Result.failure(BackupRepoTestError("upload failed")) - } - whenever(vssBackupClient.putObject(eq(category.name), any())).thenReturn(uploadResult) + fun `restored accepted funding resumes after all original context is installed without a node transition`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val originalWire = requireNotNull(state.activeOnchainAttempt) + val original = originalWire.restored("regtest", originalWire.wallet.binding, WalletScope.default, 0) + val funding = original.copy( + requestId = null, + payerIdentity = null, + orderId = "original-order", + amountSats = 1_000uL, + isTransfer = true, + evidence = OnchainSendEvidence.Accepted, + candidateFeeRates = mapOf(requireNotNull(original.txid) to 4uL), + transferContext = OnchainTransferContext(2_000uL, 3_000uL, 900uL, 1_000uL), + backupFollowup = requireNotNull(original.backupFollowup).copy(contact = null), + ) + val wire = to.bitkit.models.ActiveOnchainAttemptBackup.from(funding, "regtest", originalWire.wallet.binding) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(originalWire.wallet.binding) + whenever(onchainSendAttemptStore.current()).thenReturn(funding) + whenever(transferRepo.resumeAcceptedFunding(any())).thenReturn(Result.success(Unit)) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire)) + sut.performFullRestoreFromLatestBackup().getOrThrow() + val ordered = org.mockito.kotlin.inOrder(onchainSendAttemptStore, transferDao, privatePaykitRepo, transferRepo) + ordered.verify(onchainSendAttemptStore).restoreActive( + wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + ) + ordered.verify(transferDao).upsert(any>()) + ordered.verify(privatePaykitRepo).restoreBackup(anyOrNull()) + ordered.verify(transferRepo).resumeAcceptedFunding( + wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + ) + // Local follow-up failure preserves the restored guard and does not turn restore into a payment failure. + whenever(transferRepo.resumeAcceptedFunding(any())).thenReturn(Result.failure(AppError("storage unavailable"))) + sut.performFullRestoreFromLatestBackup().getOrThrow() + verify(onchainSendAttemptStore, times(2)).restoreActive( + wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + ) + verify(transferRepo, times(2)).resumeAcceptedFunding(any()) + whenever(onchainSendAttemptStore.current()).thenReturn(funding.copy(evidence = OnchainSendEvidence.Unknown)) + stubWalletBackup( + paykitPaymentState = state.copy( + pendingProofs = emptyList(), + activeOnchainAttempt = wire.copy(status = "unknown") + ) + ) + sut.performFullRestoreFromLatestBackup().getOrThrow() + verify(transferRepo, times(2)).resumeAcceptedFunding(any()) + } - val backup = launch { sut.triggerBackup(category) } - runCurrent() - assertTrue(statusWriteStarted.isCompleted) - assertTrue(statuses.value.getValue(category).running) + @Test + fun `restored accepted ordinary payment resumes after original context without another node event`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val originalWire = requireNotNull(state.activeOnchainAttempt) + val original = originalWire.restored("regtest", originalWire.wallet.binding, WalletScope.default, 0) + val ordinary = original.copy( + requestId = null, payerIdentity = null, orderId = null, isTransfer = false, + evidence = OnchainSendEvidence.Accepted, + candidateFeeRates = mapOf(requireNotNull(original.txid) to 4uL), + ) + val wire = to.bitkit.models.ActiveOnchainAttemptBackup.from(ordinary, "regtest", originalWire.wallet.binding) + whenever(onchainSendAttemptStore.current()).thenReturn(ordinary) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(originalWire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire)) + sut.performFullRestoreFromLatestBackup().getOrThrow() + val ordered = org.mockito.kotlin.inOrder(onchainSendAttemptStore, privatePaykitRepo, lightningRepo) + ordered.verify(onchainSendAttemptStore).restoreActive(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0)) + ordered.verify(privatePaykitRepo).restoreBackup(anyOrNull()) + ordered.verify(lightningRepo).completeAcceptedOrdinaryFollowup(requireNotNull(ordinary.txid)) + whenever(onchainSendAttemptStore.current()).thenReturn(ordinary.copy(evidence = OnchainSendEvidence.Unknown)) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire.copy(status = "unknown"))) + sut.performFullRestoreFromLatestBackup().getOrThrow() + verify(lightningRepo, times(1)).completeAcceptedOrdinaryFollowup(any()) + } - backup.cancel() - runCurrent() - assertFalse(backup.isCompleted) - finishStatusWrite.complete(Unit) - backup.join() + @Test + fun `older unresolved backup resumes accepted merged ordinary guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val originalWire = requireNotNull(state.activeOnchainAttempt) + val wire = originalWire.copy(requestId = null, payerIdentity = null) + val downloaded = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + val merged = downloaded.copy( + evidence = OnchainSendEvidence.Accepted, + candidateFeeRates = mapOf(requireNotNull(downloaded.txid) to 4uL), + ) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(onchainSendAttemptStore.current()).thenReturn(merged) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire)) - val status = statuses.value.getValue(category) - assertTrue(backup.isCancelled) - assertFalse(status.running) - assertEquals(if (uploadSucceeds) 1_000L else 0L, status.synced) - assertEquals(!uploadSucceeds, status.isRequired) + sut.performFullRestoreFromLatestBackup().getOrThrow() + + val ordered = org.mockito.kotlin.inOrder(onchainSendAttemptStore, privatePaykitRepo, lightningRepo) + ordered.verify(onchainSendAttemptStore).restoreActive(downloaded) + ordered.verify(privatePaykitRepo).restoreBackup(anyOrNull()) + ordered.verify(lightningRepo).completeAcceptedOrdinaryFollowup(requireNotNull(merged.txid)) + } + + @Test + fun `wallet backup captures transfer completed before locked attempt snapshot`() = test { + val transfer = TransferEntity( + id = "original-transfer", type = to.bitkit.models.TransferType.TO_SPENDING, + amountSats = 1000, fundingTxId = "ab".repeat(32), createdAt = 1, + ) + var savedTransfers = emptyList() + whenever(transferDao.getAll()).thenAnswer { savedTransfers } + whenever(onchainSendAttemptStore.backupSnapshot(eq(0), any())).doSuspendableAnswer { + // Accepted follow-up commits its transfer and completes the guard before this lock is acquired. + savedTransfers = listOf(transfer) + val capture = it.getArgument List>(1) + null to capture() + } + sut.triggerBackup(BackupCategory.WALLET).getOrThrow() + val data = argumentCaptor() + verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.WALLET.name), data.capture()) } + val payload = json.decodeFromString(data.firstValue.decodeToString()) + assertEquals(listOf(transfer), payload.transfers) + } + + @Test + fun `wallet backup rejects private consumption changed during proof capture`() = test { + val version = MutableStateFlow(0L) + whenever(privatePaykitRepo.backupStateVersion).thenReturn(version) + whenever { onchainSendAttemptStore.backupSnapshot(any(), any()) }.doSuspendableAnswer { + version.value = 1L + null to emptyList() + } + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + + @Test + fun `restored accepted Shop reconciles after proof and private state`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val originalWire = requireNotNull(state.activeOnchainAttempt) + val wire = originalWire.copy(status = "accepted", + candidateFeeRates = mapOf(requireNotNull(originalWire.txid) to "4")) + whenever(onchainSendAttemptStore.current()).thenReturn(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0)) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire)) + sut.performFullRestoreFromLatestBackup().getOrThrow() + val ordered = org.mockito.kotlin.inOrder(paykitPaymentProofRepo, privatePaykitRepo) + ordered.verify(paykitPaymentProofRepo).restoreBackup(state.pendingProofs) + ordered.verify(privatePaykitRepo).restoreBackup(anyOrNull()) + ordered.verify(paykitPaymentProofRepo).reconcile() + } + + @Test + fun `wallet backup defers the entire never prepared Shop snapshot`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + val attempt = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0).copy( + evidence = OnchainSendEvidence.Pending, txid = null, originalInputs = null, + candidateTxids = emptyList(), candidateFeeRates = emptyMap(), + preparationPending = true, restoredFromBackup = false, + ) + val original = state.pendingProofs.single().copy(paymentIdentifier = null) + val unrelated = original.copy(paymentAppId = "unrelated-app", identity = "other-payer") + whenever(onchainSendAttemptStore.backupSnapshot(0)).thenReturn(attempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(paykitPaymentProofRepo.backupSnapshot()).thenReturn(listOf(original, unrelated)) + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + + @Test + fun `wallet backup defers unsigned ordinary and transfer snapshots`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + val attempt = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0).copy( + evidence = OnchainSendEvidence.Pending, + txid = null, + originalInputs = null, + candidateTxids = emptyList(), + candidateFeeRates = emptyMap(), + preparationPending = true, + restoredFromBackup = false, + ) + val unsigned = attempt.copy(requestId = null, preparationPending = false) + val original = state.pendingProofs.single().copy(paymentIdentifier = null) + val unrelated = original.copy(paymentAppId = "unrelated-app", identity = "other-payer") + whenever(onchainSendAttemptStore.backupSnapshot(0)).thenReturn(unsigned) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(paykitPaymentProofRepo.backupSnapshot()).thenReturn(listOf(original, unrelated)) + for (transfer in listOf(false, true)) { + whenever(onchainSendAttemptStore.backupSnapshot(0)).thenReturn(unsigned.copy(isTransfer = transfer)) + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) } + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + + @Test + fun `wallet backup exports and restores shared active guard before original proof`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val golden = json.decodeFromString(bytes.decodeToString()) + val state = requireNotNull(golden.paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + val attempt = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + whenever(onchainSendAttemptStore.backupSnapshot(0)).thenReturn(attempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(paykitPaymentProofRepo.backupSnapshot()).thenReturn(state.pendingProofs) + val data = argumentCaptor() + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isSuccess) + verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.WALLET.name), data.capture()) } + val exported = json.decodeFromString(data.firstValue.decodeToString()) + assertEquals(wire, exported.paykitPaymentState?.activeOnchainAttempt) + stubWalletBackup(paykitPaymentState = state) + sut.performFullRestoreFromLatestBackup().getOrThrow() + val ordered = org.mockito.kotlin.inOrder(onchainSendAttemptStore, paykitPaymentProofRepo) + ordered.verify(onchainSendAttemptStore).restoreActive(attempt) + ordered.verify(paykitPaymentProofRepo).restoreBackup(state.pendingProofs) + } + + @Test + fun `invalid unrelated proof fails restore before installing the guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + val invalid = state.pendingProofs.first().copy( + requestId = state.pendingProofs.first().requestId.copy(paymentRequestId = "550e8400-e29b-41d4-a716-446655440099"), + kind = "unsupported-proof", + ) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = state.pendingProofs + invalid)) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(any(), any()) + } + + @Test + fun `active guard uses captured backup namespace index instead of assuming index zero`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(db.configDao().getAll()).thenReturn(MutableStateFlow(listOf(to.bitkit.data.entities.ConfigEntity(2)))) + whenever(vssStoreIdProvider.getBackupWalletBinding(2)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state) + sut.performFullRestoreFromLatestBackup().getOrThrow() + verify( + onchainSendAttemptStore + ).restoreActive(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 2)) + verify( + onchainSendAttemptStore, + never() + ).restoreActive(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0)) + } + + @Test + fun `conflicting original fee fails restore before installing the guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + val conflicting = requireNotNull(wire.candidateFeeRates) + (wire.candidateTxids.first() to "3") + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire.copy(candidateFeeRates = conflicting))) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(any(), any()) + } + + @Test + fun `mismatched funding amount fails restore before installing the guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val original = requireNotNull(state.activeOnchainAttempt) + val wire = original.copy( + requestId = null, + payerIdentity = null, + orderId = "original-order", + transfer = to.bitkit.models.ActiveOnchainAttemptBackup.Transfer("2000", "3000", "900", "1000"), + ) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire)) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(transferRepo, never()).resumeAcceptedFunding(any()) + verify(vssBackupClient, never()).putObject(any(), any()) + } + + @Test + fun `missing successor fee blocks restore before installing the guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire.copy(candidateFeeRates = null))) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(any(), any()) + } + + @Test + fun `malformed active contact fails restore before installing the guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire.copy( + followup = requireNotNull(wire.followup).copy(contact = kotlinx.serialization.json.JsonPrimitive(123)), + ))) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(any(), any()) + } + + @Test + fun `wrong active binding or proof blocks restore without clearing guard or publishing empty backup`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn("00".repeat(32)) + stubWalletBackup(paykitPaymentState = state) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup( + paykitPaymentState = state.copy( + pendingProofs = state.pendingProofs.map { + it.copy(onchainWalletId = "foreign-hardware") + } + ) + ) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + + @Test + fun `invalid restored recipient cannot install a blocking wallet guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(coreService.validateBitcoinAddress(wire.address)).thenThrow(IllegalArgumentException("invalid address")) + stubWalletBackup(paykitPaymentState = state) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + } + + @Test + fun `wrong network restored recipient cannot install a blocking wallet guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(coreService.validateBitcoinAddress(wire.address)).thenReturn( + com.synonym.bitkitcore.ValidationResult( + wire.address, com.synonym.bitkitcore.NetworkType.BITCOIN, com.synonym.bitkitcore.AddressType.P2WPKH, + ) + ) + stubWalletBackup(paykitPaymentState = state) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + } + + @Test + fun `missing active followup blocks restore before installing a wallet guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire.copy(followup = null))) + + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) } @Test @@ -484,6 +888,56 @@ class BackupRepoTest : BaseUnitTest() { } } + @Test + fun `successful upload leaves a newer required snapshot queued`() = test { + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(3_000)) + val backupStatuses = MutableStateFlow( + mapOf( + BackupCategory.SETTINGS to BackupItemStatus( + synced = 1_000, + required = 2_000, + ), + ) + ) + var uploadAttempts = 0 + whenever { vssBackupClient.putObject(eq(BackupCategory.SETTINGS.name), any()) } + .doSuspendableAnswer { + uploadAttempts += 1 + if (uploadAttempts == 1) { + backupStatuses.update { statuses -> + val status = statuses.getValue(BackupCategory.SETTINGS) + statuses + (BackupCategory.SETTINGS to status.copy(required = 4_000)) + } + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(5_000)) + Result.success(VssItem(key = BackupCategory.SETTINGS.name, value = byteArrayOf(), version = 1)) + } else { + Result.success( + VssItem( + key = BackupCategory.SETTINGS.name, + value = byteArrayOf(), + version = 1, + ) + ) + } + } + val allowWalletClear = CompletableDeferred().apply { complete(Unit) } + stubBackupStatuses(backupStatuses, allowWalletClear) {} + stubBackupObservers() + + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(5_000) + runCurrent() + advanceTimeBy(5_000) + runCurrent() + + verify(vssBackupClient, times(2)).putObject(eq(BackupCategory.SETTINGS.name), any()) + } finally { + sut.stopObservingBackups() + } + } + @Test fun `failed automatic backup retries newer required timestamp`() = test { whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(3_000)) @@ -1262,6 +1716,7 @@ class BackupRepoTest : BaseUnitTest() { hwWalletStore = hwWalletStore, blocktankRepo = blocktankRepo, activityRepo = activityRepo, + coreService = coreService, pubkyRepo = pubkyRepo, paykitSdkService = paykitSdkService, privatePaykitRepo = Provider { privatePaykitRepo }, @@ -1271,6 +1726,10 @@ class BackupRepoTest : BaseUnitTest() { paykitPaymentRequestRepo = Provider { paykitPaymentRequestRepo }, paykitPresentationStore = paykitPresentationStore, keychain = keychain, + onchainSendAttemptStore = onchainSendAttemptStore, + transferRepo = Provider { transferRepo }, + lightningRepo = Provider { lightningRepo }, + vssStoreIdProvider = vssStoreIdProvider, preActivityMetadataRepo = preActivityMetadataRepo, lightningService = lightningService, clock = clock, diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index d48ccb4cfb..297313c44e 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -14,6 +14,7 @@ import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.TransactionDetails +import com.synonym.bitkitcore.TransactionDetail import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures @@ -21,6 +22,8 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -31,6 +34,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -155,6 +159,117 @@ class HwWalletRepoTest : BaseUnitTest() { whenever { hwWalletStore.setPendingName(any(), anyOrNull()) }.thenReturn(Unit) } + @Test + fun `fresh exact hardware observation keeps original account across wallet changes`() = test { + val txid = "ab".repeat(32) + val started = CompletableDeferred() + val finish = CompletableDeferred() + whenever(hwWalletStore.loadKnownDevices()).thenAnswer { storeData.value.knownDevices } + whenever(trezorRepo.getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT)) + .doSuspendableAnswer { + started.complete(Unit) + finish.await() + Result.success(mock { + on { this.txid }.thenReturn(txid) + on { sent }.thenReturn(1uL) + }) + } + val sut = createRepo() + val result = async { sut.observeExactTransaction(HARDWARE_WALLET_ID, txid) } + started.await() + storeData.value = HwWalletData(knownDevices = listOf(hiddenWallet)) + finish.complete(Unit) + assertTrue(result.await().getOrThrow()) + verify(trezorRepo).getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT) + verify(trezorRepo, never()).broadcastRawTx(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `hardware observation refuses outgoing transactions with other recipient terms`() = test { + val txid = "ab".repeat(32) + val address = "original-shop-address" + val amount = 25000uL + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val detail = mock { + on { this.txid }.thenReturn(txid) + on { sent }.thenReturn(26000uL) + on { fee }.thenReturn(1000uL) + on { feeRate }.thenReturn(2.0) + } + whenever(trezorRepo.getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT)) + .thenReturn(Result.success(detail)) + val sut = createRepo() + listOf( + emptyList(), + listOf(com.synonym.bitkitcore.TxDetailOutput(amount, "script", "different-recipient", false)), + listOf(com.synonym.bitkitcore.TxDetailOutput(amount - 1uL, "script", address, false)), + ).forEach { outputs -> + whenever(detail.outputs).thenReturn(outputs) + assertFalse(sut.observeExactTransaction(HARDWARE_WALLET_ID, txid, address, amount).getOrThrow()) + } + verify(activityRepo, never()).completeObservedHardwarePayment(any(), any(), any(), any(), any(), any()) + verify(trezorRepo, never()).broadcastRawTx(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `observed hardware Shop activity failure retries original transaction without broadcast`() = test { + val txid = "ab".repeat(32) + val address = "bcrt1-original-shop-address" + val amount = 25000uL + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val detail = mock { + on { this.txid }.thenReturn(txid) + on { sent }.thenReturn(26000uL) + on { fee }.thenReturn(1000uL) + on { feeRate }.thenReturn(2.0) + on { outputs }.thenReturn(listOf(com.synonym.bitkitcore.TxDetailOutput(amount, "script", address, false))) + } + whenever(trezorRepo.getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT)) + .thenReturn(Result.success(detail)) + whenever(activityRepo.completeObservedHardwarePayment(HARDWARE_WALLET_ID, txid, address, amount, 1000uL, 2uL)) + .thenReturn(Result.failure(AppError("local write failed"))) + .thenReturn(Result.success(Unit)) + + val sut = createRepo() + assertTrue(sut.observeExactTransaction(HARDWARE_WALLET_ID, txid, address, amount).isFailure) + assertTrue(sut.observeExactTransaction(HARDWARE_WALLET_ID, txid, address, amount).getOrThrow()) + verify(activityRepo, times(2)) + .completeObservedHardwarePayment(HARDWARE_WALLET_ID, txid, address, amount, 1000uL, 2uL) + verify(trezorRepo, times(2)) + .getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT) + verify(trezorRepo, never()).broadcastRawTx(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `fresh hardware observation rejects missing wallet mismatched txid and lookup errors`() = test { + val txid = "ab".repeat(32) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val mismatchedDetail = mock { on { this.txid }.thenReturn("cd".repeat(32)) } + whenever(trezorRepo.getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT)) + .thenReturn(Result.success(mismatchedDetail)) + .thenReturn(Result.failure(AppError("transaction not found"))) + val sut = createRepo() + assertFalse(sut.observeExactTransaction(HARDWARE_WALLET_ID, txid).getOrThrow()) + assertTrue(sut.observeExactTransaction(HARDWARE_WALLET_ID, txid).isFailure) + assertTrue(sut.observeExactTransaction(HIDDEN_WALLET_ID, txid).isFailure) + verify(trezorRepo, times(2)).getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT) + verify(trezorRepo, never()).broadcastRawTx(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `fresh hardware observation rejects exact inbound transaction`() = test { + val txid = "ab".repeat(32) + val incoming = mock { + on { this.txid }.thenReturn(txid) + on { sent }.thenReturn(0uL) + } + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + whenever(trezorRepo.getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT)) + .thenReturn(Result.success(incoming)) + assertFalse(createRepo().observeExactTransaction(HARDWARE_WALLET_ID, txid).getOrThrow()) + verify(trezorRepo, never()).broadcastRawTx(any(), org.mockito.kotlin.anyOrNull()) + } + private fun passphraseCapableFeatures(): TrezorFeatures = mock { on { passphraseProtection }.thenReturn(true) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index a83b903709..70d00bb7db 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -86,6 +86,7 @@ import to.bitkit.utils.LdkError import to.bitkit.utils.UrlValidator import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull @@ -95,6 +96,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @Suppress("LargeClass") +@OptIn(kotlin.time.ExperimentalTime::class) class LightningRepoTest : BaseUnitTest() { companion object { private const val NO_USABLE_CHANNELS_FEEDBACK_DELAY_MS = 2_500L @@ -121,6 +123,7 @@ class LightningRepoTest : BaseUnitTest() { private val keychain = mock() private val cacheStore = mock() private val preActivityMetadataRepo = mock() + private val onchainSendAttemptStore = mock() private val lnurlService = mock() private val connectivityRepo = mock() private val vssBackupClientLdk = mock() @@ -154,6 +157,7 @@ class LightningRepoTest : BaseUnitTest() { lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = onchainSendAttemptStore, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, @@ -1612,6 +1616,8 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `sendOnChain should cache activity meta data`() = test { + whenever(onchainSendAttemptStore.broadcastPreparedCandidate(any(), any(), any(), any())) + .doSuspendableAnswer { (it.getArgument Unit) -> OnchainSendOutcome>(3))({}) } val mockSettingsData = SettingsData( defaultTransactionSpeed = TransactionSpeed.Fast, coinSelectAuto = false // Disable auto coin selection to simplify the test @@ -1622,15 +1628,40 @@ class LightningRepoTest : BaseUnitTest() { whenever(coreService.activity).thenReturn(mock()) whenever( - lightningService.send( + lightningService.prepareOnchainSend( address = any(), sats = any(), satsPerVByte = any(), utxosToSpend = anyOrNull(), isMaxAmount = any(), + walletIndex = any(), paymentDeadlineAt = anyOrNull(), ) - ).thenReturn("testPaymentId") + ).thenReturn(preparedOutcome(OnchainSendOutcome.Accepted("testPaymentId"))) + + val attempt = OnchainSendAttempt( + walletId = "test-wallet", + attemptId = "attempt-1", + requestId = null, + orderId = null, + address = "test_address", + amountSats = 1000uL, + isMaxAmount = false, + feeRateSatsPerVByte = 10uL, + isTransfer = true, + channelId = "test_channel_id", + tags = emptyList(), + ) + whenever { + onchainSendAttemptStore.admit( + any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), + anyOrNull(), any(), anyOrNull(), any(), anyOrNull(), + contactPublicKey = anyOrNull(), + ) + } + .thenReturn(attempt) + whenever { onchainSendAttemptStore.recordOutcome(any(), any(), any()) } + .thenReturn(attempt.copy(evidence = OnchainSendEvidence.Accepted, txid = "testPaymentId")) startNodeForTesting() @@ -1650,12 +1681,704 @@ class LightningRepoTest : BaseUnitTest() { // Verify the result is successful assertTrue(result.isSuccess) - assertEquals("testPaymentId", result.getOrNull()) + assertEquals(OnchainSendOutcome.Accepted("testPaymentId"), result.getOrNull()) // Verify pre-activity metadata was saved verifyBlocking(preActivityMetadataRepo) { addPreActivityMetadata(any()) } + + whenever { onchainSendAttemptStore.recordOutcome(any(), any(), any()) } + .thenThrow(IllegalStateException("encrypted attempt write failed")) + val acceptedDespiteStorageFailure = spySut.sendOnChain( + address = "test_address", + sats = 1000uL, + speed = TransactionSpeed.Fast, + isTransfer = true, + channelId = "test_channel_id", + ) + assertIs(acceptedDespiteStorageFailure.exceptionOrNull()) + } + + @Test + fun `preparation errors release an admitted send without dispatch`() = test { + val attempt = pendingSendAttempt() + val repo = prepareGuardedSend(attempt) + var sendError: Throwable = NodeException.InvalidAddress("invalid address") + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + )) + .doSuspendableAnswer { throw sendError } + assertTrue(repo.sendOnChain("address", 1_000uL).exceptionOrNull() is OnchainSendNotDispatchedError) + verify(onchainSendAttemptStore).releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) + + sendError = LdkError(NodeException.PersistenceFailed("workflow storage failure")) + assertTrue(repo.sendOnChain("address", 1_000uL).exceptionOrNull() is OnchainSendNotDispatchedError) + verify(onchainSendAttemptStore, times(2)).releaseBeforeDispatch(any(), any()) + } + + @Test + fun `preparation cancellation releases empty admitted guard without dispatch`() = test { + val attempt = pendingSendAttempt() + val repo = prepareGuardedSend(attempt) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + )) + .thenThrow(CancellationException("screen closed")) + assertFailsWith { repo.sendOnChain("address", 1_000uL) } + verify(onchainSendAttemptStore).releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) + verify(onchainSendAttemptStore, never()).broadcastPreparedCandidate(any(), any(), any(), any()) + } + + @Test + fun `accepted callback workflow error cannot turn a paid send into retry`() = test { + val attempt = pendingSendAttempt() + val repo = prepareGuardedSend(attempt) + val txid = "ab".repeat(32) + val accepted = OnchainSendOutcome.Accepted(txid) + whenever( + lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + ) + ).thenReturn(preparedOutcome(accepted)) + whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) + .thenReturn(attempt.copy(evidence = OnchainSendEvidence.Accepted, txid = txid)) + val result = repo.sendOnChain("address", 1_000uL, onBroadcast = { + throw NodeException.PersistenceFailed("callback storage failure") + }) + assertEquals(accepted, result.getOrThrow()) + verify(onchainSendAttemptStore, never()).releaseBeforeDispatch(any(), any()) + } + + @Test + fun `direct send fallback cannot use a later attempt winner`() = test { + assertDirectSendFallbackIdentity(pendingSendAttempt().copy(attemptId = "later-attempt"), false) + } + + @Test + fun `direct send fallback cannot use another wallet winner`() = test { + assertDirectSendFallbackIdentity(pendingSendAttempt().copy(walletId = "another-wallet"), false) + } + + @Test + fun `direct send fallback retains its exact observed winner`() = test { + assertDirectSendFallbackIdentity(pendingSendAttempt(), true) + } + + private suspend fun assertDirectSendFallbackIdentity(winnerIdentity: OnchainSendAttempt, matches: Boolean) { + val original = pendingSendAttempt() + val repo = prepareGuardedSend(original) + val originalTxid = "ab".repeat(32) + val winnerTxid = "cd".repeat(32) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), anyOrNull())) + .thenReturn(preparedOutcome(OnchainSendOutcome.Unknown(originalTxid))) + whenever(onchainSendAttemptStore.current()).thenReturn(null).thenReturn( + winnerIdentity.copy(evidence = OnchainSendEvidence.Observed, txid = winnerTxid), + ) + whenever(onchainSendAttemptStore.currentDurable()).thenReturn( + winnerIdentity.copy(evidence = OnchainSendEvidence.Observed, txid = winnerTxid), + ) + whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) + .thenThrow(IllegalStateException("original outcome raced with durable observation")) + val broadcasts = mutableListOf() + val result = repo.sendOnChain("address", 1_000uL, isTransfer = true, onBroadcast = { broadcasts += it }) + if (matches) { + assertEquals(OnchainSendOutcome.Accepted(winnerTxid), result.getOrThrow()) + assertEquals(listOf(winnerTxid), broadcasts) + } else { + assertIs(result.exceptionOrNull()) + assertTrue(broadcasts.isEmpty()) + } + } + + @Test + fun `pre-marker failure is not dispatched after exact unsent guard cleanup`() = test { + val txid = "ab".repeat(32) + val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + var saved: String? = null + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { + saved = null + Unit + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) + sut = LightningRepo( + bgDispatcher = testDispatcher, + lightningService = lightningService, + settingsStore = settingsStore, + coreService = coreService, + lspNotificationsService = lspNotificationsService, + firebaseMessaging = firebaseMessaging, + keychain = keychain, + lnurlService = lnurlService, + cacheStore = cacheStore, + preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = store, + connectivityRepo = connectivityRepo, + vssBackupClientLdk = vssBackupClientLdk, + urlValidator = urlValidator, + electrumProbeService = electrumProbeService, + ) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) + whenever(coreService.activity).thenReturn(mock()) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + val prepared = PreparedOnchainSend( + OnchainPreparedReceipt(txid, listOf(OnchainSendInput("11".repeat(32), 0u)), "address", 1_000uL), + ) { throw to.bitkit.utils.ServiceError.NodeNotSetup() } + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), anyOrNull())) + .thenReturn(prepared) + startNodeForTesting() + val repo = spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } + val error = repo.sendOnChain("address", 1_000uL).exceptionOrNull() + assertNull(store.current()) + assertIs(error) + } + + @Test + fun `accepted Max result carries recorded prepared recipient amount`() = test { + val original = pendingSendAttempt().copy(isMaxAmount = true) + val repo = prepareGuardedSend(original) + val txid = "ab".repeat(32) + val prepared = PreparedOnchainSend( + OnchainPreparedReceipt(txid, listOf(OnchainSendInput("11".repeat(32), 0u)), "address", 900uL), + ) { beforeDispatch -> + beforeDispatch() + OnchainSendOutcome.Accepted(txid) + } + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), anyOrNull())) + .thenReturn(prepared) + whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) + .thenReturn(original.copy(amountSats = 900uL, evidence = OnchainSendEvidence.Accepted, txid = txid)) + val result = repo.sendOnChain("address", 1_000uL, isMaxAmount = true).getOrThrow() + assertEquals(OnchainSendOutcome.Accepted(txid, 900uL), result) + } + + private fun preparedOutcome(outcome: OnchainSendOutcome) = PreparedOnchainSend( + OnchainPreparedReceipt(outcome.txid, listOf(OnchainSendInput("11".repeat(32), 0u)), "address", 1_000uL), + ) { it(); outcome } + + @Test + fun `original retry delegates saved outpoints to native validation when fresh outputs are absent`() = test { + startNodeForTesting() + val originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u), OnchainSendInput("22".repeat(32), 1u)) + val original = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Unknown, + txid = "aa".repeat(32), + originalInputs = originalInputs, + candidateTxids = listOf("aa".repeat(32)), + ) + whenever(onchainSendAttemptStore.current()).thenReturn(original) + whenever(lightningService.listSpendableOutputs()).thenReturn(Result.success(emptyList())) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), anyOrNull())) + .thenThrow(IllegalStateException("fixture stops after exact preparation request")) + var authorizations = 0 + val result = sut.retryOriginalOnchainSend(original.attemptId, original.walletId, 4uL) { + authorizations++ + } + assertTrue(result.isFailure) + assertEquals(0, authorizations) + verify(lightningService).prepareOnchainSend( + eq(original.address), eq(original.amountSats), eq(4uL), + argThat { map { OnchainSendInput(it.outpoint.txid, it.outpoint.vout) } == originalInputs }, + eq(false), eq(original.walletIndex), isNull(), + ) + verify(onchainSendAttemptStore, never()).broadcastPreparedCandidate(any(), any(), any(), any()) + } + + @Test + fun `unresolved original opens recovery before an unhealthy sync blocks another send`() = test { + startNodeForTesting() + whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.DISCONNECTED)) + whenever(lightningService.sync()).thenThrow(RuntimeException("exact original observation unavailable")) + val original = pendingSendAttempt().copy(evidence = OnchainSendEvidence.Unknown) + whenever(onchainSendAttemptStore.current()).thenReturn(original) + + val result = sut.sendOnChain("different-address", 2_000uL) + + val blocked = assertIs(result.exceptionOrNull()) + assertEquals(original, blocked.attempt) + verify(onchainSendAttemptStore, never()).admit(any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), anyOrNull(), any(), anyOrNull(), + contactPublicKey = anyOrNull(), + ) + } + + private fun pendingSendAttempt() = OnchainSendAttempt( + walletId = "test-wallet", attemptId = "attempt-1", requestId = null, orderId = null, + address = "address", amountSats = 1_000uL, isMaxAmount = false, feeRateSatsPerVByte = 1uL, + isTransfer = false, channelId = null, tags = emptyList(), + ) + + private suspend fun prepareGuardedSend(attempt: OnchainSendAttempt): LightningRepo { + whenever(onchainSendAttemptStore.broadcastPreparedCandidate(any(), any(), any(), any())) + .doSuspendableAnswer { (it.getArgument Unit) -> OnchainSendOutcome>(3))({}) } + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) + whenever( + onchainSendAttemptStore.admit( + any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), + anyOrNull(), any(), anyOrNull(), any(), anyOrNull(), + contactPublicKey = anyOrNull(), + ) + ).thenReturn(attempt) + startNodeForTesting() + return spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } + } + + @Test + fun `restored accepted contact is applied before acknowledging and failures stay guarded`() = test { + val txid = "ab".repeat(32) + val contact = "pubky" + "y".repeat(52) + val attempt = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Accepted, txid = txid, restoredFromBackup = true, + backupFollowup = to.bitkit.models.ActiveOnchainAttemptBackup.Followup( + "143", emptyList(), kotlinx.serialization.json.JsonPrimitive(contact), "123", + ), + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + whenever(activityService.restoreSentOnchainContact(txid, attempt.walletId, contact)) + .thenThrow(IllegalStateException("contact write failed")) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + doReturn(Unit).whenever(activityService).restoreSentOnchainContact(txid, attempt.walletId, contact) + org.mockito.Mockito.clearInvocations(activityService) + sut.completeAcceptedOrdinaryFollowup(txid) + inOrder(activityService, onchainSendAttemptStore) { + verify(activityService).restoreSentOnchainContact(txid, attempt.walletId, contact) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + } + + @Test + fun `observed original activity waits for its exact fee instead of displaying zero`() = test { + val txid = "ab".repeat(32) + val attempt = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Accepted, + txid = txid, + candidateTxids = listOf(txid), + candidateFeeRates = mapOf(txid to 1uL), + backupFollowup = to.bitkit.models.ActiveOnchainAttemptBackup.Followup("0", emptyList(), createdAtMillis = "123"), + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(lightningService.observedOriginalSendFee(attempt)).thenReturn(null) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(activityService, never()).createSentOnchainActivityFromSendResult( + any(), any(), any(), any(), any(), any(), anyOrNull(), any(), + ) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + whenever(lightningService.observedOriginalSendFee(attempt)).thenReturn(141uL) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(activityService).createSentOnchainActivityFromSendResult( + txid, attempt.address, attempt.amountSats, 141uL, 1uL, false, null, attempt.walletId, + ) + verify(activityService).repairVerifiedSentOnchainFee(txid, attempt.walletId, 141uL, 1uL) + verify(onchainSendAttemptStore).retainWinningFee(attempt.attemptId, attempt.walletIndex, txid, 141uL) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + + + @Test + fun `successor activity fee never reuses original nonzero fee and missing exact fee stays guarded`() = test { + val txid = "cd".repeat(32) + val attempt = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Accepted, + txid = txid, + candidateTxids = listOf("ab".repeat(32), txid), + candidateFeeRates = mapOf("ab".repeat(32) to 1uL, txid to 3uL), + backupFollowup = to.bitkit.models.ActiveOnchainAttemptBackup.Followup("143", emptyList(), createdAtMillis = "123"), + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(lightningService.observedOriginalSendFee(attempt)).thenReturn(null) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(activityService, never()).createSentOnchainActivityFromSendResult( + any(), any(), any(), any(), any(), any(), anyOrNull(), any(), + ) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + whenever(lightningService.observedOriginalSendFee(attempt)).thenReturn(281uL) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(activityService).createSentOnchainActivityFromSendResult( + txid, attempt.address, attempt.amountSats, 281uL, 3uL, false, null, attempt.walletId, + ) + verify(activityService).repairVerifiedSentOnchainFee(txid, attempt.walletId, 281uL, 3uL) + verify(onchainSendAttemptStore).retainWinningFee(attempt.attemptId, attempt.walletIndex, txid, 281uL) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + + @Test + fun `successor followup reuses its retained exact fee when backend details disappear`() = test { + val txid = "cd".repeat(32) + val attempt = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Accepted, txid = txid, + candidateTxids = listOf("ab".repeat(32), txid), + candidateFeeRates = mapOf("ab".repeat(32) to 1uL, txid to 3uL), + backupFollowup = to.bitkit.models.ActiveOnchainAttemptBackup.Followup("281", emptyList(), createdAtMillis = "123"), + verifiedWinningFeeSats = 281uL, + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(lightningService.observedOriginalSendFee(any())).thenReturn(null) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(lightningService, never()).observedOriginalSendFee(any()) + verify(activityService).createSentOnchainActivityFromSendResult( + txid, attempt.address, attempt.amountSats, 281uL, 3uL, false, null, attempt.walletId, + ) + verify(activityService).repairVerifiedSentOnchainFee(txid, attempt.walletId, 281uL, 3uL) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + + @Test + fun `accepted successor metadata uses the winning candidate authorized fee`() = test { + val txid = "cd".repeat(32) + val attempt = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Accepted, + txid = txid, + candidateTxids = listOf("ab".repeat(32), txid), + candidateFeeRates = mapOf("ab".repeat(32) to 1uL, txid to 3uL), + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(lightningService.observedOriginalSendFee(attempt)).thenReturn(281uL) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedOrdinaryFollowup(txid) + val metadata = argumentCaptor() + verify(preActivityMetadataRepo).addPreActivityMetadata(metadata.capture()) + assertEquals(3uL, metadata.firstValue.feeRate) + } + + @Test + fun `accepted ordinary send stays guarded until its activity is durable`() = test { + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + walletId = "test-wallet", + attemptId = "attempt-1", + requestId = null, + orderId = null, + address = "bcrt1qrecipient", + amountSats = 1_000uL, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = false, + channelId = null, + tags = emptyList(), + evidence = OnchainSendEvidence.Accepted, + txid = txid, + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + + sut.completeAcceptedOrdinaryFollowup(txid) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + + whenever { activityService.getOnchainActivityByTxId(txid, attempt.walletId) }.thenReturn(mock()) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + + @Test + fun `transient accepted write failure repairs original guard without another native send`() = test { + val txid = "ab".repeat(32) + val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + var saved: String? = null + var writes = 0 + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + writes++ + if (writes == 4) error("transient accepted write failure") + saved = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) + sut = LightningRepo( + bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, + coreService = coreService, lspNotificationsService = lspNotificationsService, + firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, + vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + ) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(eq(txid), any())).thenReturn(mock()) + whenever(lightningService.observedOriginalSendFee(any())).thenReturn(141uL) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + )) + .thenReturn(preparedOutcome(OnchainSendOutcome.Accepted(txid))) + startNodeForTesting() + val repo = spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } + + assertEquals(OnchainSendOutcome.Accepted(txid), repo.sendOnChain("address", 1_000uL).getOrThrow()) + assertEquals(txid, repo.currentOnchainSendAttempt()?.txid) + val blocked = assertIs(repo.sendOnChain("different-address", 2_000uL).exceptionOrNull()) + assertEquals(txid, blocked.attempt?.txid) + repo.completeAcceptedOrdinaryFollowup(txid) + + val reopened = OnchainSendAttemptStore( + testDispatcher, + keychain, + lightningService, + kotlin.time.Clock.System + ).current() + assertEquals(txid, reopened?.txid) + assertEquals(OnchainSendEvidence.Accepted, reopened?.evidence) + assertTrue(reopened?.localFollowupComplete == true) + verify(lightningService, times(1)).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + ) + } + + @Test + fun `accepted write and fee repair failure stay Pending after restart`() = test { + val txid = "ab".repeat(32) + val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + var saved: String? = null + var writes = 0 + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + writes++ + if (writes >= 4) error("transient accepted write failure") + saved = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) + sut = LightningRepo( + bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, + coreService = coreService, lspNotificationsService = lspNotificationsService, + firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, + vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + ) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(eq(txid), any())).thenReturn(mock()) + whenever(lightningService.observedOriginalSendFee(any())).thenReturn(null) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + )) + .thenReturn(preparedOutcome(OnchainSendOutcome.Accepted(txid))) + startNodeForTesting() + val repo = spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } + + assertIs(repo.sendOnChain("address", 1_000uL).exceptionOrNull()) + val retained = requireNotNull(store.current()) + assertIs( + repo.retryOriginalOnchainSend(retained.attemptId, retained.walletId, 2uL) { + error("A retained accepted result must not authorize another payment") + }.exceptionOrNull() + ) + val reopened = OnchainSendAttemptStore( + testDispatcher, + keychain, + lightningService, + kotlin.time.Clock.System + ).current() + assertEquals(txid, reopened?.txid) + assertEquals(OnchainSendEvidence.Pending, reopened?.evidence) + assertTrue(reopened?.localFollowupComplete == false) + verify(lightningService, times(1)).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + ) + } + + @Test + fun `retained accepted fallback cannot report success before durable repair`() = test { + val txid = "ab".repeat(32) + val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + var saved: String? = null + var writes = 0 + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + writes++ + if (writes >= 4) error("transient accepted write failure") + saved = it.getArgument(1) + } + val durableStore = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) + val store = spy(durableStore) + doSuspendableAnswer { + durableStore.broadcastPreparedCandidate(it.getArgument(0), it.getArgument(1), it.getArgument(2), it.getArgument(3)) + store.recordOutcome(it.getArgument(0), OnchainSendOutcome.Accepted(txid), it.getArgument(1)) + error("broadcast result unavailable") + }.whenever(store).broadcastPreparedCandidate(any(), any(), any(), any()) + sut = LightningRepo( + bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, + coreService = coreService, lspNotificationsService = lspNotificationsService, + firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, + vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + ) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(eq(txid), any())).thenReturn(mock()) + whenever(lightningService.observedOriginalSendFee(any())).thenReturn(null) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + )) + .thenReturn(preparedOutcome(OnchainSendOutcome.Accepted(txid))) + startNodeForTesting() + val repo = spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } + + assertIs(repo.sendOnChain("address", 1_000uL).exceptionOrNull()) + val retained = requireNotNull(store.current()) + assertIs( + repo.retryOriginalOnchainSend(retained.attemptId, retained.walletId, 2uL) { + error("A retained accepted result must not authorize another payment") + }.exceptionOrNull() + ) + val reopened = OnchainSendAttemptStore( + testDispatcher, + keychain, + lightningService, + kotlin.time.Clock.System + ).current() + assertEquals(txid, reopened?.txid) + assertEquals(OnchainSendEvidence.Pending, reopened?.evidence) + assertTrue(reopened?.localFollowupComplete == false) + verify(lightningService, times(1)).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + ) + } + + @Test + fun `accepted Shop acknowledgement waits for original durable local activity`() = test { + val txid = "ab".repeat(32) + val requestId = PaykitPaymentRequestId("request-1", "counterparty", "receiver") + val attempt = pendingSendAttempt().copy( + requestId = requestId, + evidence = OnchainSendEvidence.Accepted, + txid = txid, + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + + sut.completeAcceptedShopFollowup(requestId.copy(paymentRequestId = "other-request"), txid) + sut.completeAcceptedShopFollowup(requestId, "cd".repeat(32)) + verify(preActivityMetadataRepo, never()).addPreActivityMetadata(any()) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + + assertTrue(runCatching { sut.completeAcceptedShopFollowup(requestId, txid) }.isFailure) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedShopFollowup(requestId, txid) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + verify(lightningService, never()).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + ) + } + + @Test + fun `accepted transfer acknowledgement waits for original durable local activity`() = test { + val txid = "ab".repeat(32) + val attempt = pendingSendAttempt().copy( + isTransfer = true, + orderId = "order-1", + evidence = OnchainSendEvidence.Accepted, + txid = txid + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + + assertTrue(runCatching { sut.completeAcceptedTransferFollowup("order-1", txid) }.isFailure) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedTransferFollowup("order-1", txid) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + verify(lightningService, never()).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + ) + } + + @Test + fun `transfer send admits original balance context before native dispatch`() = test { + val context = OnchainTransferContext(txTotalSats = 99_000uL, preTransferOnchainSats = 125_000uL) + val attempt = pendingSendAttempt().copy(isTransfer = true, orderId = "order-1", transferContext = context) + val repo = prepareGuardedSend(attempt) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + )) + .doSuspendableAnswer { + verify(onchainSendAttemptStore).admit( + any(), isNull(), eq("order-1"), any(), any(), any(), any(), eq(true), anyOrNull(), any(), + eq(context), any(), anyOrNull(), + contactPublicKey = anyOrNull(), + ) + preparedOutcome(OnchainSendOutcome.Unknown("ab".repeat(32))) + } + whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) + .thenReturn(attempt.copy(evidence = OnchainSendEvidence.Unknown, txid = "ab".repeat(32))) + + assertTrue(repo.sendOnChain( + "address", 98_000uL, isTransfer = true, orderId = "order-1", transferContext = context, + ).getOrThrow() is OnchainSendOutcome.Unknown) + } + + @Test + fun `observed ordinary send completes guard only after durable local activity`() = test { + val txid = "ab".repeat(32) + val attempt = pendingSendAttempt().copy(evidence = OnchainSendEvidence.Observed, txid = txid) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(onchainSendAttemptStore.observeExactTransaction(txid, isConfirmed = false)).thenReturn(attempt).thenReturn(null) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + val eventHandler = startNodeAndCaptureEvents() + val event = Event.OnchainTransactionReceived(txid = txid, details = mock()) + + eventHandler(event) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + eventHandler(event) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + + @Test + fun `observed ordinary send retries transient followup without another node event`() = test { + val txid = "ab".repeat(32) + var attempt = pendingSendAttempt().copy(evidence = OnchainSendEvidence.Observed, txid = txid) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(onchainSendAttemptStore.observeExactTransaction(txid, isConfirmed = false)).thenReturn(attempt) + whenever(onchainSendAttemptStore.current()).doSuspendableAnswer { attempt } + whenever(onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex)) + .doSuspendableAnswer { attempt = attempt.copy(localFollowupComplete = true) } + val handler = startNodeAndCaptureEvents() + handler(Event.OnchainTransactionReceived(txid = txid, details = mock())) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + testScheduler.advanceTimeBy(1_001) + runCurrent() + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + verify(lightningService, never()).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull()) } @Test @@ -1937,6 +2660,7 @@ class LightningRepoTest : BaseUnitTest() { lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = onchainSendAttemptStore, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = failingValidator, diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt new file mode 100644 index 0000000000..4205e9cbdc --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -0,0 +1,708 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.models.WalletScope +import to.bitkit.data.keychain.Keychain +import to.bitkit.services.LightningService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertFalse +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(kotlin.time.ExperimentalTime::class) +class OnchainSendAttemptStoreTest : BaseUnitTest() { + private val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + + @Test + fun `backup proof capture blocks receipt retention until the snapshot is complete`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val attempt = store.admitForTest() + val captureEntered = CompletableDeferred() + val finishCapture = CompletableDeferred() + val snapshot = async { + store.backupSnapshot(0) { + captureEntered.complete(Unit) + finishCapture.await() + emptyList() + } + } + captureEntered.await() + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, attempt.amountSats, + ) + val retain = async { store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) } + runCurrent() + assertEquals(false, retain.isCompleted) + finishCapture.complete(Unit) + assertNull(snapshot.await().first?.txid) + assertEquals(receipt.txid, retain.await().txid) + } + + @Test + fun `expired first Shop submission retains empty guard until original proof cleanup succeeds`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val service = mock() + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val attempt = store.admit( + walletId = "wallet0", requestId = PaykitPaymentRequestId("request", "payer"), + orderId = "order", address = "bcrt1qrecipient", amountSats = 1000uL, + isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = false, + channelId = null, tags = emptyList(), beforeSendAttempt = {}, payerIdentity = "payer", + ) + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, attempt.amountSats, + ) + store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) + assertFailsWith { + store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + throw to.bitkit.utils.ServiceError.PaymentDeadlineExpired() + } + } + store.releaseBeforeDispatch(attempt.attemptId, 0) + val pending = requireNotNull(store.current()) + assertTrue(pending.preparationPending) + assertEquals(attempt.requestId, pending.requestId) + assertEquals(attempt.payerIdentity, pending.payerIdentity) + assertTrue(pending.candidateTxids.isEmpty()) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + assertEquals(false, reopened.releaseInterruptedShopPreparation { false }) + assertEquals(pending, reopened.current()) + assertEquals(true, reopened.releaseInterruptedShopPreparation { + assertEquals(pending, it) + true + }) + assertNull(reopened.current()) + } + + @Test + fun `restart releases prepared candidate only when native dispatch was never entered`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val service = mock() + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val original = store.admitForTest(walletId = WalletScope.default) + val receipt = OnchainPreparedReceipt("ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), + original.address, original.amountSats) + store.retainPreparedReceipt(original.attemptId, 0, receipt, false) + assertEquals(receipt.txid, store.current()?.txid) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + assertNull(reopened.current()) + reopened.admitForTest(walletId = WalletScope.default) + } + + @Test + fun `failed durable native dispatch marker stops broadcast`() = test { + var saved: String? = null + var failMarker = false + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + if (failMarker) error("dispatch marker unavailable") + saved = it.getArgument(1) + } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val service = mock() + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val original = store.admitForTest(walletId = WalletScope.default) + val receipt = OnchainPreparedReceipt("ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), + original.address, original.amountSats) + store.retainPreparedReceipt(original.attemptId, 0, receipt, false) + failMarker = true + var broadcasts = 0 + assertFailsWith { + store.broadcastPreparedCandidate(original.attemptId, 0, receipt.txid) { beforeDispatch -> + beforeDispatch() + broadcasts++ + OnchainSendOutcome.Unknown(receipt.txid) + } + } + assertEquals(0, broadcasts) + failMarker = false + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + assertNull(reopened.current()) + } + + @Test + fun `first submission deadline failure clears only never dispatched receipt`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val attempt = store.admitForTest() + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, attempt.amountSats, + ) + store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) + assertFailsWith { + store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + throw to.bitkit.utils.ServiceError.PaymentDeadlineExpired() + } + } + store.releaseBeforeDispatch(attempt.attemptId, 0) + assertNull(store.current()) + store.admitForTest() + } + + @Test + fun `deadline on later submission or reopened guard never releases original candidate`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val service = mock() + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val attempt = store.admitForTest() + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, attempt.amountSats, + ) + val retained = store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) + store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + it() + OnchainSendOutcome.Unknown(receipt.txid) + } + // Even before outcome persistence, this process knows dispatch was attempted. + assertFailsWith { + store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + throw to.bitkit.utils.ServiceError.PaymentDeadlineExpired() + } + } + assertEquals(retained.copy(initialDispatchAttempted = true), store.current()) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + assertFailsWith { + reopened.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + throw to.bitkit.utils.ServiceError.PaymentDeadlineExpired() + } + } + assertEquals(retained.copy(initialDispatchAttempted = true), reopened.current()) + } + + @Test + fun `preparation cancellation cleanup releases only exact empty guard and never a receipt`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val empty = store.admitForTest() + store.releaseBeforeDispatch("foreign-attempt", 0) + assertEquals(empty, store.current()) + store.releaseBeforeDispatch(empty.attemptId, 0) + assertNull(store.current()) + val original = store.admitForTest() + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), + listOf(OnchainSendInput("11".repeat(32), 0u)), + original.address, + original.amountSats, + ) + val retained = store.retainPreparedReceipt(original.attemptId, 0, receipt, false) + store.releaseBeforeDispatch(original.attemptId, 0) + assertEquals(retained, store.current()) + assertFailsWith { store.admitForTest() } + } + + @Test + fun `restart releases unsigned ordinary sends and transfers but keeps live preparation`() = test { + for (isTransfer in listOf(false, true)) { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val original = store.admit( + walletId = WalletScope.default, + requestId = null, orderId = if (isTransfer) "original-order" else null, + address = "bcrt1qrecipient", amountSats = 1_000uL, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = isTransfer, channelId = null, + tags = emptyList(), beforeSendAttempt = {}, + ) + assertEquals(original, store.current(), "Current process must retain its live preparation") + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val snapshot = reopened.backupSnapshot(0) { emptyList() } + assertNull(snapshot.first, "An unsigned abandoned attempt must not stall wallet backup") + assertNull(reopened.current(), "An unsigned crash guard cannot represent a dispatched transaction") + assertNull(saved) + reopened.admitForTest() + } + } + + @Test + fun `restart never releases legacy empty guards or recorded candidates`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val original = store.admitForTest() + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + assertEquals(false, reopened.releaseInterruptedShopPreparation { error("legacy guard must remain") }) + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), + listOf(OnchainSendInput("11".repeat(32), 0u)), + original.address, + original.amountSats, + ) + val retained = store.retainPreparedReceipt(original.attemptId, 0, receipt, false) + assertEquals(false, reopened.releaseInterruptedShopPreparation { error("signed candidate must remain") }) + assertEquals(retained, reopened.current()) + } + + @Test + fun `admission is durable before a callback consumes request details`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + store.admitForTest { + assertTrue(saved != null, "guard must exist before proof mutation") + } + } + + @Test + fun `failed admission save does not consume request details`() = test { + val keychain = mock() + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { error("storage failure") } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + var callbacks = 0 + assertFailsWith { store.admitForTest { callbacks++ } } + assertEquals(0, callbacks) + } + + @Test + fun `original contact is durable before dispatch and survives reopening`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val contact = "original-contact" + val attempt = store.admitForTest(contactPublicKey = contact) { + assertTrue(requireNotNull(saved).contains(contact), "original contact must be saved before dispatch") + } + assertEquals(contact, (attempt.backupFollowup?.contact as? kotlinx.serialization.json.JsonPrimitive)?.content) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + assertEquals(attempt.backupFollowup?.contact, reopened.current()?.backupFollowup?.contact) + } + + @Test + fun `admission followup timestamp uses the injected clock`() = test { + val keychain = mock() + val clock = mock() + whenever(clock.now()).thenReturn(kotlin.time.Instant.fromEpochMilliseconds(123_456L)) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), clock) + assertEquals("123456", store.admitForTest().backupFollowup?.createdAtMillis) + } + + @Test + fun `unconfirmed original cannot win while a successor is retained`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + .decodeFromString(bytes.decodeToString()) + val wire = requireNotNull(requireNotNull(backup.paykitPaymentState).activeOnchainAttempt) + val original = wire.restored("regtest", wire.wallet.binding, "wallet0", 0) + val values = mutableMapOf() + val keychain = mock() + val service = mock() + whenever(service.currentWalletIndex).thenReturn(0) + whenever(keychain.loadString(eq(key), any())).thenAnswer { values[it.getArgument(1)] } + whenever(keychain.upsertString(eq(key), any(), any())).doSuspendableAnswer { + values[it.getArgument(2)] = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + store.restoreActive(original) + val first = original.candidateTxids.first() + val successor = original.candidateTxids.last() + assertNull(store.observeExactTransaction(first, isConfirmed = false)) + assertEquals(original, store.current()) + val accepted = store.recordOutcome(original.attemptId, OnchainSendOutcome.Accepted(successor), 0) + assertEquals(successor, accepted.txid) + assertNull(store.observeExactTransaction(first, isConfirmed = false)) + assertEquals(successor, store.current()?.txid) + } + + @Test + fun `restored exact candidate with contact observes without resend and acknowledges`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + .decodeFromString(bytes.decodeToString()) + val state = requireNotNull(backup.paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + wire.validateProofs(state.pendingProofs, "wallet0") + val original = wire.restored("regtest", wire.wallet.binding, "wallet0", 2) + val values = mutableMapOf() + val keychain = mock() + val service = mock() + whenever(service.currentWalletIndex).thenReturn(2) + whenever(keychain.loadString(eq(key), any())).thenAnswer { values[it.getArgument(1)] } + whenever(keychain.upsertString(eq(key), any(), any())).doSuspendableAnswer { + values[it.getArgument(2)] = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + store.restoreActive(original) + assertFailsWith { store.admitForTest() } + assertNull(store.observeExactTransaction("00".repeat(32))) + val observed = store.observeExactTransaction(requireNotNull(wire.txid)) + assertEquals(OnchainSendEvidence.Observed, observed?.evidence) + assertEquals(wire.candidateTxids, observed?.candidateTxids) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + assertEquals(observed, reopened.current()) + assertFailsWith { reopened.admitForTest() } + val other = original.copy(attemptId = "00000000-0000-4000-8000-000000000009") + assertFailsWith { reopened.restoreActive(other) } + store.markLocalFollowupComplete(original.attemptId, 2) + assertEquals(observed?.copy(localFollowupComplete = true), reopened.current()) + } + + @Test + fun `restoring same accepted operation preserves progressed fee and completion`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val wire = requireNotNull( + kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + .decodeFromString(bytes.decodeToString()) + .paykitPaymentState?.activeOnchainAttempt, + ) + val accepted = wire.copy(status = "accepted", candidateFeeRates = mapOf(requireNotNull(wire.txid) to "4"), + followup = wire.followup?.copy(contact = null)) + .restored("regtest", wire.wallet.binding, "wallet0", 0) + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + store.restoreActive(accepted) + store.retainWinningFee(accepted.attemptId, 0, requireNotNull(accepted.txid), 777u) + val progressed = store.current() + store.restoreActive(accepted) + assertEquals(progressed, store.current()) + assertFailsWith { store.restoreActive(accepted.copy(address = "different-recipient")) } + assertFailsWith { store.restoreActive(accepted.copy(payerIdentity = "different-payer")) } + store.markLocalFollowupComplete(accepted.attemptId, 0) + val completed = store.current() + store.restoreActive(accepted) + assertEquals(completed, store.current()) + store.restoreActive(accepted.copy(evidence = OnchainSendEvidence.Unknown)) + assertEquals(completed, store.current()) + assertFalse(requireNotNull(store.current()).isUnresolved) + } + + @Test + fun `restored supported accepted context resets acknowledgement and finishes idempotently`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + .decodeFromString(bytes.decodeToString()) + val wire = requireNotNull(backup.paykitPaymentState?.activeOnchainAttempt) + val accepted = wire.copy(status = "accepted", candidateFeeRates = mapOf(requireNotNull(wire.txid) to "4"), + followup = wire.followup?.copy(contact = null)) + .restored("regtest", wire.wallet.binding, "wallet0", 0) + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + store.restoreActive(accepted) + assertTrue(store.current()?.localFollowupComplete == false) + assertFailsWith { store.admitForTest() } + store.markLocalFollowupComplete(accepted.attemptId, 0) + store.markLocalFollowupComplete(accepted.attemptId, 0) + assertTrue(store.current()?.localFollowupComplete == true) + } + + @Test + fun `missing imported followup stays blocked while existing local positive context can acknowledge`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val admitted = store.admitForTest() + val local = admitted.copy(backupFollowup = null) + saved = kotlinx.serialization.json.Json.encodeToString(OnchainSendAttempt.serializer(), local) + store.recordOutcome(local.attemptId, OnchainSendOutcome.Accepted("ab".repeat(32)), 0) + store.markLocalFollowupComplete(local.attemptId, 0) + assertTrue(store.current()?.localFollowupComplete == true) + val imported = requireNotNull(store.current()).copy(restoredFromBackup = true, localFollowupComplete = false) + store.restoreActive(imported) + assertFailsWith { store.markLocalFollowupComplete(imported.attemptId, 0) } + assertFailsWith { store.admitForTest() } + } + + @Test + fun `reopen blocks unresolved send until exact transaction is observed and followup is complete`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + saved = it.getArgument(1) + } + val firstStore = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val attempt = firstStore.admitForTest() + firstStore.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown("ab".repeat(32)), attempt.walletIndex) + + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + assertFailsWith { reopened.admitForTest() } + assertNull(reopened.observeExactTransaction("cd".repeat(32))) + assertFailsWith { reopened.admitForTest() } + + val observed = reopened.observeExactTransaction("ab".repeat(32)) + assertEquals(OnchainSendEvidence.Observed, observed?.evidence) + assertFailsWith { reopened.admitForTest() } + reopened.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + assertTrue(reopened.admitForTest().attemptId != attempt.attemptId) + } + + @Test + fun `send-all funding validates prepared amount before candidate retention or dispatch`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val context = OnchainTransferContext(100_000uL, 100_000uL, 97_000uL, 99_000uL) + val attempt = store.admit( + walletId = "wallet-1", requestId = null, orderId = "order-1", address = "bcrt1qfunding", + amountSats = 99_000uL, isMaxAmount = true, feeRateSatsPerVByte = 1uL, isTransfer = true, + channelId = null, tags = emptyList(), transferContext = context, beforeSendAttempt = {}, + ) + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, 99_500uL, + miningFeeSats = 500uL, + ) + for (amount in listOf(98_999uL, 100_001uL)) { + assertFailsWith { + store.retainPreparedReceipt(attempt.attemptId, 0, receipt.copy(amountSats = amount), false) + } + assertNull(store.current()?.txid) + assertTrue(store.current()?.candidateTxids?.isEmpty() == true) + var dispatched = false + assertFailsWith { + store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + dispatched = true + OnchainSendOutcome.Accepted(receipt.txid) + } + } + assertFalse(dispatched) + } + for (fee in listOf(null, 501uL, ULong.MAX_VALUE)) { + assertFailsWith { + store.retainPreparedReceipt(attempt.attemptId, 0, receipt.copy(miningFeeSats = fee), false) + } + assertNull(store.current()?.txid) + assertTrue(store.current()?.candidateTxids?.isEmpty() == true) + } + val retained = store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) + assertEquals(99_500uL, retained.amountSats) + assertEquals(context, retained.transferContext) + } + + @Test + fun `transfer balance context is durable before dispatch and survives reopen`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val context = OnchainTransferContext(txTotalSats = 99_000uL, preTransferOnchainSats = 125_000uL) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val attempt = store.admit( + walletId = "wallet-1", requestId = null, orderId = "order-1", address = "bcrt1qfunding", + amountSats = 98_000uL, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = true, + channelId = null, tags = emptyList(), transferContext = context, beforeSendAttempt = {}, + ) + + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + assertEquals(context, reopened.current()?.transferContext) + assertEquals(OnchainSendEvidence.Pending, reopened.current()?.evidence) + assertFailsWith { reopened.admitForTest() } + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted("ab".repeat(32)), attempt.walletIndex) + assertEquals(context, reopened.current()?.transferContext) + } + + @Test + fun `corrupt attempt refuses a new send without replacing evidence`() = test { + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenReturn("not-json") + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + + assertFailsWith { store.admitForTest() } + verify(keychain).loadString(key, 0) + } + + @Test + fun `outcome remains in the admitted node wallet after config context changes`() = test { + val values = mutableMapOf() + val keychain = mock() + val service = mock() + var currentIndex = 7 + whenever(service.currentWalletIndex).thenAnswer { currentIndex } + whenever(keychain.loadString(eq(key), any())).thenAnswer { values[it.getArgument(1)] } + whenever(keychain.upsertString(eq(key), any(), any())).doSuspendableAnswer { + values[it.getArgument(2)] = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val attempt = store.admitForTest() + assertEquals(7, attempt.walletIndex) + currentIndex = 8 + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted("ab".repeat(32)), attempt.walletIndex) + assertNull(store.current()) + currentIndex = 7 + assertEquals(OnchainSendEvidence.Accepted, store.current()?.evidence) + assertFailsWith { store.admitForTest() } + } + + @Test + fun `known accepted survives write failures in memory while reopened pending stays blocked`() = test { + var saved: String? = null + var failWrite = false + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + if (failWrite) error("storage unavailable") + saved = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val attempt = store.admitForTest() + val txid = "ab".repeat(32) + failWrite = true + assertFailsWith { + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted(txid), attempt.walletIndex) + } + assertEquals(txid, store.current()?.txid) + assertEquals(OnchainSendEvidence.Accepted, store.current()?.evidence) + assertFailsWith { store.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) } + assertFailsWith { store.admitForTest() } + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + assertEquals(OnchainSendEvidence.Pending, reopened.current()?.evidence) + assertNull(reopened.current()?.txid) + assertFailsWith { reopened.admitForTest() } + } + + @Test + fun `parallel admissions dispatch only one before-send callback`() = test { + var saved: String? = null + var callbacks = 0 + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val entered = CompletableDeferred() + val finish = CompletableDeferred() + val first = async { store.admitForTest { callbacks++; entered.complete(Unit); finish.await() } } + entered.await() + val second = async { runCatching { store.admitForTest { callbacks++ } } } + finish.complete(Unit) + first.await() + assertTrue(second.await().exceptionOrNull() is OnchainSendBlockedError) + assertEquals(1, callbacks) + } + + @Test + fun `cancel after admission and failed result save preserve durable pending guard`() = test { + var saved: String? = null + var failWrite = false + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + if (failWrite) error("storage unavailable") + saved = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val admitted = CompletableDeferred() + val send = launch { admitted.complete(store.admitForTest()); kotlinx.coroutines.awaitCancellation() } + val attempt = admitted.await() + send.cancelAndJoin() + failWrite = true + assertFailsWith { + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted("ab".repeat(32)), attempt.walletIndex) + } + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + assertEquals(OnchainSendEvidence.Pending, reopened.current()?.evidence) + assertFailsWith { reopened.admitForTest() } + } + + @Test + fun `late unknown outcome cannot overwrite exact positive observation`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val attempt = store.admitForTest() + val txid = "ab".repeat(32) + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(txid), attempt.walletIndex) + store.observeExactTransaction(txid) + + val late = store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(txid), attempt.walletIndex) + + assertEquals(OnchainSendEvidence.Observed, late.evidence) + assertEquals(txid, store.current()?.txid) + assertEquals(OnchainSendEvidence.Observed, store.current()?.evidence) + assertFailsWith { store.admitForTest() } + } + + @Test + fun `verified winning fee remains durable after reopening the attempt store`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val service = mock() + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val attempt = store.admitForTest() + val txid = "ab".repeat(32) + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted(txid), 0) + store.retainWinningFee(attempt.attemptId, 0, txid, 281uL) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val durable = requireNotNull(reopened.current()) + assertEquals(281uL, durable.verifiedWinningFeeSats) + assertEquals("281", durable.backupFollowup?.feeSats) + assertEquals(txid, durable.txid) + } + + private suspend fun OnchainSendAttemptStore.admitForTest( + walletId: String = "wallet-1", + contactPublicKey: String? = null, + beforeSendAttempt: suspend () -> Unit = { + } + ): OnchainSendAttempt = admit( + walletId = walletId, + requestId = null, + orderId = null, + address = "bcrt1qrecipient", + amountSats = 1_000uL, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = false, + channelId = null, + tags = emptyList(), + beforeSendAttempt = beforeSendAttempt, + contactPublicKey = contactPublicKey, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt new file mode 100644 index 0000000000..0940058c4d --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -0,0 +1,707 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.services.LightningService +import to.bitkit.test.BaseUnitTest +import kotlin.time.Instant +import kotlin.time.Duration.Companion.milliseconds +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +@OptIn(kotlin.time.ExperimentalTime::class) +class OnchainSendCoordinatorTest : BaseUnitTest() { + private val input = OnchainSendInput("11".repeat(32), 0u) + private val firstTxid = "ab".repeat(32) + private val nextTxid = "cd".repeat(32) + + private inner class Fixture { + var saved: String? = null + var failWrite = false + var walletIndex = 0 + val keychain = mock() + val service = mock() + val store: OnchainSendAttemptStore + + init { + whenever(service.currentWalletIndex).thenAnswer { walletIndex } + whenever(keychain.loadString(eq(Keychain.Key.ONCHAIN_SEND_ATTEMPT.name), any())).thenAnswer { saved } + whenever { keychain.upsertString(eq(Keychain.Key.ONCHAIN_SEND_ATTEMPT.name), any(), any()) } + .doSuspendableAnswer { + if (failWrite) error("storage unavailable") + saved = it.getArgument(1) + } + store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + } + + suspend fun admit(isMax: Boolean = false): OnchainSendAttempt = store.admit( + walletId = "wallet-1", + requestId = if (isMax) { + null + } else { + PaykitPaymentRequestId( + "request", + "payer", + "receiver" + ) + }, + orderId = "order-1", address = "bcrt1qrecipient", amountSats = 1_000uL, + isMaxAmount = isMax, feeRateSatsPerVByte = 1uL, isTransfer = true, + channelId = "channel-1", tags = listOf("tag"), + transferContext = OnchainTransferContext(1_100uL, 2_000uL, 900uL, 1_000uL), beforeSendAttempt = {}, + payerIdentity = "original-payer", + ) + + fun receipt(txid: String = firstTxid, amount: ULong = 1_000uL) = + OnchainPreparedReceipt(txid, listOf(input), "bcrt1qrecipient", amount, miningFeeSats = 50uL) + + suspend fun unresolved(): OnchainSendAttempt { + val attempt = admit() + store.retainPreparedReceipt(attempt.attemptId, 0, receipt(), false) + return store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(firstTxid), 0) + } + } + + @Test + fun `retry fee approval uses the exact prepared object before retention and dispatch`() = test { + val f = Fixture() + val original = f.unresolved() + var preparations = 0 + var broadcasts = 0 + val receipt = f.receipt(nextTxid) + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant?): PreparedOnchainSend { + preparations++ + return PreparedOnchainSend(receipt) { it(); broadcasts++; OnchainSendOutcome.Unknown(nextTxid) } + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + var approved = false + coordinator.retryOriginal(original.attemptId, original.walletId, 4uL, + approvePrepared = { preview -> + assertEquals(receipt.copy(feeRateSatsPerVByte = 4uL), preview) + assertEquals(listOf(firstTxid), f.store.current()?.candidateTxids) + assertEquals(0, broadcasts) + approved = true + }) { assertTrue(approved) }.getOrThrow() + assertEquals(1, preparations) + assertEquals(1, broadcasts) + } + + @Test + fun `accepted retry without durable acceptance stays pending after restart`() = test { + val f = Fixture() + val original = f.unresolved() + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant?, + ) = PreparedOnchainSend(f.receipt(nextTxid)) { + it() + f.failWrite = true + OnchainSendOutcome.Accepted(nextTxid) + } + } + val result = OnchainSendCoordinator(f.store, sender, testDispatcher) + .retryOriginal(original.attemptId, original.walletId, 2uL) {} + assertTrue(result.exceptionOrNull() is OnchainSendPendingError) + val restarted = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) + assertTrue(restarted.current()?.blocksNextSend == true) + assertTrue(restarted.current()?.hasPositiveEvidence == false) + assertEquals(listOf(firstTxid, nextTxid), restarted.current()?.candidateTxids) + } + + @Test + fun `retry retains deadline through authorization and rejects expired queued broadcast`() = test { + for (expired in listOf(false, true)) { + val f = Fixture() + val original = f.unresolved() + val deadline = Instant.fromEpochSeconds(1_800_000_000) + var dispatchTime = deadline + var broadcasts = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant?, + ) = PreparedOnchainSend(f.receipt(nextTxid)) { + if (paymentDeadlineAt != null && dispatchTime > paymentDeadlineAt) { + throw PaykitPaymentRequestError.RequestExpired + } + it() + broadcasts++ + OnchainSendOutcome.Unknown(nextTxid) + } + } + val result = OnchainSendCoordinator(f.store, sender, testDispatcher).retryOriginal( + original.attemptId, original.walletId, 2uL, deadline, + ) { dispatchTime = if (expired) deadline + 1.milliseconds else deadline } + assertEquals(if (expired) 0 else 1, broadcasts, "Expired retry must not broadcast") + assertEquals(expired, result.isFailure) + val retained = requireNotNull(f.store.current()) + assertEquals(original.attemptId, retained.attemptId) + assertEquals(original.amountSats, retained.amountSats) + assertEquals(original.originalInputs, retained.originalInputs) + assertTrue(retained.blocksNextSend) + assertEquals(!expired, nextTxid in retained.candidateTxids) + if (expired) { + assertEquals(original.candidateTxids, retained.candidateTxids) + assertEquals(original.txid, retained.txid) + assertEquals(original.evidence, retained.evidence) + } + } + } + + @Test + fun `receipt is durable before first broadcast and restart retains candidate`() = test { + val f = Fixture() + val attempt = f.admit() + var sends = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt) = PreparedOnchainSend(f.receipt()) { + it() + assertTrue(requireNotNull(f.saved).contains(firstTxid)) + sends++ + OnchainSendOutcome.Unknown(firstTxid) + } + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, + ): PreparedOnchainSend = + error("not requested") + } + assertTrue(OnchainSendCoordinator(f.store, sender, testDispatcher).sendInitial(attempt).isSuccess) + val reopened = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) + assertEquals(listOf(firstTxid), reopened.current()?.candidateTxids) + assertEquals(listOf(input), reopened.current()?.originalInputs) + assertEquals(1, sends) + } + + @Test + fun `receipt storage failure prevents broadcast`() = test { + val f = Fixture() + val attempt = f.admit() + var sends = 0 + f.failWrite = true + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt) = PreparedOnchainSend(f.receipt()) { + it() + sends++ + OnchainSendOutcome.Accepted(firstTxid) + } + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, + ): PreparedOnchainSend = + error("not requested") + } + assertTrue(OnchainSendCoordinator(f.store, sender, testDispatcher).sendInitial(attempt).isFailure) + assertEquals(0, sends) + assertTrue(f.store.current()?.blocksNextSend == true) + } + + @Test + fun `initial Max actual amount becomes fixed recovery amount with original context`() = test { + val f = Fixture() + val attempt = f.admit(isMax = true) + var authorizations = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt) = PreparedOnchainSend( + f.receipt(amount = 1_050uL) + ) { + it() + OnchainSendOutcome.Unknown(firstTxid) + } + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, + ): PreparedOnchainSend { + assertEquals(1_050uL, attempt.amountSats) + assertEquals(listOf(input), attempt.originalInputs) + assertEquals("order-1", attempt.orderId) + assertEquals("wallet-1", attempt.walletId) + assertEquals(null, attempt.requestId) + assertEquals("channel-1", attempt.channelId) + assertEquals(OnchainTransferContext(1_100uL, 2_000uL, 900uL, 1_000uL), attempt.transferContext) + return PreparedOnchainSend(f.receipt(nextTxid, 1_050uL)) { it(); OnchainSendOutcome.Unknown(nextTxid) } + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + coordinator.sendInitial(attempt).getOrThrow() + coordinator.retryOriginal(attempt.attemptId, attempt.walletId, 2uL) { authorizations++ }.getOrThrow() + assertEquals(listOf(firstTxid, nextTxid), f.store.current()?.candidateTxids) + assertEquals(1, authorizations) + } + + @Test + fun `recovery rejects changed inputs amount or wallet before broadcasting`() = test { + for (mode in listOf("input", "amount", "wallet")) { + val f = Fixture() + val attempt = f.unresolved() + var sends = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error( + "not requested", + ) + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, + ): PreparedOnchainSend { + if (mode == "wallet") f.walletIndex = 1 + val receipt = when (mode) { + "input" -> f.receipt(nextTxid).copy(inputs = listOf(OnchainSendInput("22".repeat(32), 1u))) + "amount" -> f.receipt(nextTxid, 999uL) + else -> f.receipt(nextTxid) + } + return PreparedOnchainSend(receipt) { + it() + sends++ + OnchainSendOutcome.Accepted(nextTxid) + } + } + } + val result = OnchainSendCoordinator( + f.store, + sender, + testDispatcher + ).retryOriginal(attempt.attemptId, attempt.walletId, 2uL) {} + assertTrue(result.isFailure) + assertEquals(0, sends) + } + } + + @Test + fun `older candidate observation during successor broadcast wins over Unknown`() = test { + val f = Fixture() + val attempt = f.unresolved() + val entered = CompletableDeferred() + val finish = CompletableDeferred() + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error( + "not requested", + ) + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?) = + PreparedOnchainSend(f.receipt(nextTxid)) { + it() + entered.complete(Unit) + finish.await() + OnchainSendOutcome.Unknown(nextTxid) + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + val retry = async { coordinator.retryOriginal(attempt.attemptId, attempt.walletId, 2uL) {} } + entered.await() + val observed = async { f.store.observeExactTransaction(firstTxid) } + finish.complete(Unit) + retry.await().getOrThrow() + observed.await() + assertEquals(firstTxid, f.store.current()?.txid) + assertEquals(OnchainSendEvidence.Observed, f.store.current()?.evidence) + assertFailsWith { f.admit() } + } + + @Test + fun `positive observation during preparation prevents another broadcast`() = test { + val f = Fixture() + val attempt = f.unresolved() + var sends = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error( + "not requested", + ) + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, + ): PreparedOnchainSend { + f.store.observeExactTransaction(firstTxid) + return PreparedOnchainSend(f.receipt(nextTxid)) { + it() + sends++ + OnchainSendOutcome.Accepted(nextTxid) + } + } + } + assertTrue( + OnchainSendCoordinator( + f.store, + sender, + testDispatcher + ).retryOriginal(attempt.attemptId, attempt.walletId, 2uL) {}.getOrThrow() == + OnchainSendOutcome.Accepted(firstTxid) + ) + assertEquals(0, sends) + assertEquals(firstTxid, f.store.current()?.txid) + } + + @Test + fun `preexisting readable guard without receipt cannot retry`() = test { + val f = Fixture() + val attempt = f.admit() + f.store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(firstTxid), 0) + val sender = mock() + assertTrue( + OnchainSendCoordinator( + f.store, + sender, + testDispatcher + ).retryOriginal(attempt.attemptId, attempt.walletId, 2uL) {}.isFailure + ) + assertEquals(OnchainSendEvidence.Unknown, f.store.current()?.evidence) + } + + @Test + fun `adapter uses native Max only initially and exact fixed inputs for every recovery`() = test { + val f = Fixture() + val initial = f.admit(isMax = true) + val native = mock() + whenever(native.prepareMax(initial.address, true, 1uL, 0)).thenReturn( + PreparedOnchainSend(f.receipt(amount = 1_050uL)) { it(); OnchainSendOutcome.Unknown(firstTxid) }, + ) + whenever(native.prepareFixed(initial.address, 1_050uL, 2uL, listOf(input), 0)).thenReturn( + PreparedOnchainSend(f.receipt(nextTxid, 1_050uL)) { it(); OnchainSendOutcome.Unknown(nextTxid) }, + ) + val coordinator = OnchainSendCoordinator(f.store, OnchainPreparedSenderAdapter(native), testDispatcher) + coordinator.sendInitial(initial).getOrThrow() + coordinator.retryOriginal(initial.attemptId, initial.walletId, 2uL) {}.getOrThrow() + verify(native).prepareMax(initial.address, true, 1uL, 0) + verify(native).prepareFixed(initial.address, 1_050uL, 2uL, listOf(input), 0) + } + + @Test + fun `original authorization failure drops only unsent retry candidate`() = test { + val f = Fixture() + val unknown = f.unresolved() + val attempt = f.store.recordOutcome( + unknown.attemptId, OnchainSendOutcome.Rejected(firstTxid, "mempool min fee not met"), 0, + ) + var prepares = 0 + var broadcasts = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error( + "not requested", + ) + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, + ): PreparedOnchainSend { + prepares++ + return PreparedOnchainSend(f.receipt(nextTxid)) { + it() + broadcasts++ + OnchainSendOutcome.Accepted(nextTxid) + } + } + } + val result = OnchainSendCoordinator( + f.store, + sender, + testDispatcher + ).retryOriginal(attempt.attemptId, attempt.walletId, 2uL) { retained -> + assertEquals(f.store.current(), retained) + error("original order unavailable") + } + assertTrue(result.isFailure) + assertEquals(1, prepares) + assertEquals(0, broadcasts) + assertEquals(listOf(firstTxid), f.store.current()?.candidateTxids) + assertEquals(firstTxid, f.store.current()?.txid) + assertEquals(OnchainSendEvidence.Rejected, f.store.current()?.evidence) + assertEquals("mempool min fee not met", f.store.current()?.refusalReason) + } + + @Test + fun `payer switch during suspended preparation denies dispatch and retains candidate guard`() = test { + val f = Fixture() + val original = f.unresolved() + val entered = CompletableDeferred() + val finish = CompletableDeferred() + var currentPayer = original.payerIdentity + var broadcasts = 0 + var authorizations = 0 + var authorizedRecord: OnchainSendAttempt? = null + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, + ): PreparedOnchainSend { + entered.complete(Unit) + finish.await() + return PreparedOnchainSend(f.receipt(nextTxid)) { + it() + broadcasts++ + OnchainSendOutcome.Accepted(nextTxid) + } + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + val result = async { + coordinator.retryOriginal(original.attemptId, original.walletId, 2uL) { retained -> + authorizations++ + authorizedRecord = retained + check(currentPayer == original.payerIdentity) { "original payer changed" } + } + } + entered.await() + currentPayer = "different-payer" + finish.complete(Unit) + assertTrue(result.await().isFailure) + assertEquals(0, broadcasts) + assertEquals(1, authorizations) + val retained = f.store.current() + assertEquals(retained?.attemptId, authorizedRecord?.attemptId) + assertEquals(listOf(firstTxid, nextTxid), authorizedRecord?.candidateTxids) + assertEquals(listOf(firstTxid), retained?.candidateTxids) + assertEquals(original.payerIdentity, retained?.payerIdentity) + assertEquals(original.requestId, retained?.requestId) + assertEquals(original.orderId, retained?.orderId) + assertEquals(original.originalInputs, retained?.originalInputs) + assertEquals(original.evidence, retained?.evidence) + val restarted = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) + assertEquals(retained, restarted.current()) + assertFailsWith { f.admit() } + } + + @Test + fun `concurrent explicit retries never prepare or broadcast in parallel`() = test { + val f = Fixture() + val attempt = f.unresolved() + val entered = CompletableDeferred() + val finish = CompletableDeferred() + var prepares = 0 + var broadcasts = 0 + var active = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error( + "not requested", + ) + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, + ): PreparedOnchainSend { + assertEquals(0, active) + prepares++ + val txid = if (prepares == 1) nextTxid else "ef".repeat(32) + return PreparedOnchainSend(f.receipt(txid)) { + it() + active++ + assertEquals(1, active) + broadcasts++ + if (broadcasts == 1) { + entered.complete(Unit) + finish.await() + } + active-- + OnchainSendOutcome.Unknown(txid) + } + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + val first = async { coordinator.retryOriginal(attempt.attemptId, attempt.walletId, 2uL) {} } + entered.await() + val second = async { coordinator.retryOriginal(attempt.attemptId, attempt.walletId, 3uL) {} } + assertEquals(1, prepares) + finish.complete(Unit) + first.await().getOrThrow() + second.await().getOrThrow() + assertEquals(2, broadcasts) + assertEquals(listOf(firstTxid, nextTxid, "ef".repeat(32)), f.store.current()?.candidateTxids) + } + + @Test + fun `known older positive survives persistence failure and late successor outcome`() = test { + val f = Fixture() + val attempt = f.unresolved() + f.store.retainPreparedReceipt(attempt.attemptId, 0, f.receipt(nextTxid), true) + f.failWrite = true + assertFailsWith { f.store.observeExactTransaction(firstTxid) } + val retained = f.store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(nextTxid), 0) + assertEquals(firstTxid, retained.txid) + assertEquals(OnchainSendEvidence.Observed, retained.evidence) + f.failWrite = false + f.store.markLocalFollowupComplete(attempt.attemptId, 0) + val reopened = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) + assertEquals(firstTxid, reopened.current()?.txid) + assertTrue(reopened.current()?.localFollowupComplete == true) + } + + @Test + fun `old positive write failure during successor preparation returns original winner without sending`() = test { + val f = Fixture() + val original = f.unresolved() + var sends = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant? + ): PreparedOnchainSend { + f.failWrite = true + runCatching { f.store.observeExactTransaction(firstTxid) } + return PreparedOnchainSend(f.receipt(nextTxid)) { + it() + sends++ + error("must not broadcast after positive observation") + } + } + } + val result = OnchainSendCoordinator(f.store, sender, testDispatcher) + .retryOriginal(original.attemptId, original.walletId, 2uL) {} + assertEquals(OnchainSendOutcome.Accepted(firstTxid), result.getOrThrow()) + assertEquals(0, sends) + assertEquals(OnchainSendEvidence.Observed, f.store.current()?.evidence) + f.failWrite = false + f.store.markLocalFollowupComplete(original.attemptId, 0) + val restarted = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) + assertEquals(firstTxid, restarted.current()?.txid) + assertTrue(restarted.current()?.localFollowupComplete == true) + } + + @Test + fun `post dispatch fallback never returns another attempt or wallet winner`() = test { + for (failBroadcast in listOf(true, false)) { + for (changeWallet in listOf(true, false)) { + val f = Fixture() + val original = f.unresolved() + var current = original + val other = original.copy( + attemptId = if (changeWallet) original.attemptId else "different-attempt", + walletId = if (changeWallet) "different-wallet" else original.walletId, + txid = "ef".repeat(32), evidence = OnchainSendEvidence.Accepted, + localFollowupComplete = true, + ) + val store = mock() + whenever { store.current() }.doSuspendableAnswer { current } + whenever { store.retainPreparedReceipt(any(), any(), any(), any()) }.thenReturn(original) + whenever { store.broadcastPreparedCandidate(any(), any(), any(), any()) }.doSuspendableAnswer { + current = other + if (failBroadcast) error("native dispatch result unavailable") + OnchainSendOutcome.Unknown(nextTxid) + } + whenever { store.recordOutcome(any(), any(), any()) }.doSuspendableAnswer { + error("original operation is no longer current") + } + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?) = + PreparedOnchainSend(f.receipt(nextTxid)) { it(); OnchainSendOutcome.Unknown(nextTxid) } + } + val result = OnchainSendCoordinator(store, sender, testDispatcher) + .retryOriginal(original.attemptId, original.walletId, 2uL) {} + assertTrue(result.isFailure, "A later winner must not satisfy the original retry") + assertEquals(other, current) + } + } + } + + @Test + fun `candidate fees retain authorized successor rate and select the actual older winner`() = test { + val f = Fixture() + val original = f.unresolved() + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?) = + PreparedOnchainSend(f.receipt(nextTxid)) { + it() + val saved = kotlinx.serialization.json.Json.decodeFromString( + requireNotNull(f.saved) + ) + assertEquals(3uL, saved.candidateFeeRates[nextTxid]) + OnchainSendOutcome.Unknown(nextTxid) + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + coordinator.retryOriginal(original.attemptId, original.walletId, 3uL) {}.getOrThrow() + val reopened = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) + assertEquals(3uL, reopened.current()?.winningFeeRateSatsPerVByte) + reopened.observeExactTransaction(firstTxid) + assertEquals(1uL, reopened.current()?.winningFeeRateSatsPerVByte) + assertEquals(mapOf(firstTxid to 1uL, nextTxid to 3uL), reopened.current()?.candidateFeeRates) + } + + @Test + fun `initial retention rejects zero and rates beyond the confirmed UInt32 range`() = test { + val f = Fixture() + val original = f.admit() + for (feeRate in listOf(0uL, UInt.MAX_VALUE.toULong() + 1uL, ULong.MAX_VALUE)) { + assertFailsWith { + f.store.retainPreparedReceipt( + original.attemptId, + 0, + f.receipt().copy(feeRateSatsPerVByte = feeRate), + false, + ) + } + assertTrue(f.store.current()?.candidateTxids?.isEmpty() == true) + } + } + + @Test + fun `overflowing authorized recovery fee never reaches native preparation`() = test { + assertEquals(999uL, OnchainRecoveryFeeRate.maximum) + assertEquals(null, OnchainRecoveryFeeRate.parse("4294967296")) + assertEquals( + OnchainRecoveryFeeRate.maximum, + OnchainRecoveryFeeRate.parse(OnchainRecoveryFeeRate.maximum.toString()), + ) + assertEquals(null, OnchainRecoveryFeeRate.parse((OnchainRecoveryFeeRate.maximum + 1uL).toString())) + assertEquals(null, OnchainRecoveryFeeRate.parse("0")) + val f = Fixture() + val original = f.unresolved() + var preparations = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, + ): PreparedOnchainSend { + preparations++ + return PreparedOnchainSend(f.receipt(nextTxid)) { it(); OnchainSendOutcome.Unknown(nextTxid) } + } + } + for (invalidRate in listOf(0uL, 1_000uL, 4_294_967_296uL, ULong.MAX_VALUE)) { + val result = OnchainSendCoordinator(f.store, sender, testDispatcher) + .retryOriginal(original.attemptId, original.walletId, invalidRate) {} + assertTrue(result.isFailure) + } + assertEquals(0, preparations) + assertFailsWith { + f.store.retainPreparedReceipt( + original.attemptId, + 0, + f.receipt(nextTxid).copy(feeRateSatsPerVByte = 1_000uL), + true, + ) + } + assertEquals(listOf(firstTxid), f.store.current()?.candidateTxids) + } + + @Test + fun `prepared broadcast success or error is cached once`() = test { + for (fails in listOf(false, true)) { + var sends = 0 + val prepared = PreparedOnchainSend(Fixture().receipt()) { + it() + sends++ + if (fails) error("native failed") + OnchainSendOutcome.Unknown(firstTxid) + } + repeat(2) { runCatching { prepared.broadcast() } } + assertEquals(1, sends) + } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitOnchainPaymentProofLookupTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitOnchainPaymentProofLookupTest.kt deleted file mode 100644 index 560b7cfaf7..0000000000 --- a/app/src/test/java/to/bitkit/repositories/PaykitOnchainPaymentProofLookupTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package to.bitkit.repositories - -import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.ActivityFilter -import com.synonym.bitkitcore.OnchainActivity -import com.synonym.bitkitcore.PaymentType -import org.junit.Test -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import to.bitkit.ext.create -import to.bitkit.test.BaseUnitTest -import kotlin.test.assertEquals - -class PaykitOnchainPaymentProofLookupTest : BaseUnitTest() { - private val activityRepo = mock() - private val lookup = PaykitOnchainPaymentProofLookup(activityRepo) - - @Test - fun `payment lookup uses one scoped activity query`() = test { - val walletId = "hardware-wallet" - val activity = Activity.Onchain( - OnchainActivity.create( - walletId = walletId, - id = "activity-id", - txType = PaymentType.SENT, - txId = "transaction-id", - value = 1_000uL, - fee = 100uL, - address = "bcrt1qpaymentproof", - timestamp = 1uL, - ) - ) - whenever( - activityRepo.getActivities( - walletId = walletId, - filter = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT, - ) - ).thenReturn(Result.success(listOf(activity))) - - val transactionIds = lookup.existingTransactionIds( - address = activity.v1.address, - amountSats = activity.v1.value, - walletId = walletId, - ) - - assertEquals(setOf(activity.v1.txId), transactionIds) - verify(activityRepo).getActivities( - walletId = walletId, - filter = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT, - ) - } -} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 6d301477f1..05d3c032d2 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1,5 +1,7 @@ package to.bitkit.repositories +import android.content.Context +import com.synonym.bitkitcore.BroadcastException import com.synonym.paykit.BillingPeriod import com.synonym.paykit.IdentityStatus import com.synonym.paykit.PaykitException @@ -12,6 +14,11 @@ import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -19,9 +26,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.NodeException @@ -30,6 +38,7 @@ import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any +import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer @@ -40,19 +49,28 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.data.keychain.Keychain +import to.bitkit.models.HwConnectedDevice +import to.bitkit.models.HwFundingSignedTx +import to.bitkit.models.HwFundingTransaction import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.WalletScope +import to.bitkit.services.CoreService +import to.bitkit.data.keychain.Keychain import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.screens.wallets.send.HwSendRequest +import to.bitkit.ui.screens.wallets.send.HwSendViewModel import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.ServiceError +import to.bitkit.utils.SignedTransactionId import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) @@ -68,8 +86,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val paykitSdkService = mock() private val lightningRepo = mock() - private val onchainPaymentLookup = mock() private val store = mock() + private val hwWalletRepo = mock() + private val privatePaykitRepo = mock() private var storedProofs = emptyList() private var shouldFailNextLoad = false private var shouldFailNextSave = false @@ -87,12 +106,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailNextLoad = false shouldFailNextSave = false shouldFailProofRemoval = false + whenever(privatePaykitRepo.consumePrivatePaymentList(any(), any())).thenReturn(Result.success(Unit)) whenever(store.hasPendingProofs()).thenReturn(true) whenever( paykitSdkService.identityStatus() ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) - whenever(onchainPaymentLookup.existingTransactionIds(any(), any(), any())).thenReturn(emptySet()) + whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) whenever(store.load()).thenAnswer { if (shouldFailNextLoad) { shouldFailNextLoad = false @@ -121,6 +141,16 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(keychain.delete(any())).doSuspendableAnswer { storedProjectionJson = null } } + @Test + fun `proof without original app provenance stays retained and is not submitted`() = test { + storedProofs = listOf(readyLightningProof(PAYMENT_REQUEST_ID).copy(paymentAppId = "")) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + paymentProofRepo().reconcile() + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + assertEquals("", storedProofs.single().paymentAppId) + assertTrue(storedProofs.single().paymentStarted) + } + @Test fun `unstarted proof preparation notifies backup without refreshing requests`() = test { observeRequestStateChanges() @@ -198,6 +228,101 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(3, requestStateChanges) } + @Test + fun `restart clears only the original proof from interrupted predispatch admission`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + var saved: String? = null + val keychain = mock() + val key = to.bitkit.data.keychain.Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val attempts = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + var admitted = attempts.admit( + walletId = WalletScope.default, requestId = request.id, orderId = null, + address = ONCHAIN_ADDRESS, amountSats = request.amountSats, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + payerIdentity = LOCAL_IDENTITY, + beforeSendAttempt = { + repo.markOnchainPaymentStarted( + request, + ONCHAIN_ADDRESS, + privatePaymentListVersion = 7uL, + previousPrivatePaymentListVersion = 6uL, + ).getOrThrow() + }, + ) + assertTrue(admitted.preparationPending) + assertTrue(storedProofs.single().paymentStarted) + assertFalse(attempts.releaseInterruptedShopPreparation { error("live preparation must not be cleared") }) + admitted = attempts.retainPreparedReceipt(admitted.attemptId, 0, + OnchainPreparedReceipt("ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), + ONCHAIN_ADDRESS, request.amountSats), false) + assertFalse(attempts.releaseInterruptedShopPreparation { error("live prepared receipt must not be cleared") }) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + whenever(lightningRepo.currentOnchainSendAttempt()).doSuspendableAnswer { reopened.current() } + whenever(lightningRepo.releaseInterruptedShopPreparation(any())).doSuspendableAnswer { + reopened.releaseInterruptedShopPreparation(it.getArgument(0)) + } + val startedProof = storedProofs.single() + storedProofs = listOf(startedProof.copy(paymentIdentifier = "ab".repeat(32))) + repo.reconcile() + assertEquals(admitted, reopened.current()) + assertEquals("ab".repeat(32), storedProofs.single().paymentIdentifier) + storedProofs = listOf(startedProof) + whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL)) + .thenReturn(Result.failure(IllegalStateException("private boundary storage failed")), Result.success(Unit)) + repo.reconcile() + assertEquals(admitted, reopened.current()) + assertEquals(listOf(startedProof), storedProofs) + shouldFailProofRemoval = true + repo.reconcile() + assertEquals(admitted, reopened.current()) + assertTrue(storedProofs.single().paymentStarted) + repo.reconcile() + assertNull(reopened.current()) + assertTrue(storedProofs.isEmpty()) + verify(privatePaykitRepo, times(3)).releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL) + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + assertFalse(storedProofs.single().paymentStarted) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `lightning association cannot bypass a started onchain request`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue).copy( + acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue, MethodId.Bolt11.rawValue), + ) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + + val result = repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit") + + assertTrue(result.isFailure) + assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) + assertEquals(1, storedProofs.size) + assertEquals(PaykitPaymentProofKind.Onchain, storedProofs.single().kind) + } + + @Test + fun `onchain callback cannot bypass a started lightning request`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue).copy( + acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue, MethodId.Bolt11.rawValue), + ) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() + + val result = repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS) + + assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) + assertEquals(1, storedProofs.count { it.paymentStarted }) + assertEquals(PaykitPaymentProofKind.Lightning, storedProofs.single { it.paymentStarted }.kind) + } + @Test fun `reconcile avoids Paykit and proof loading without persisted proofs`() = test { whenever(store.hasPendingProofs()).thenReturn(false) @@ -273,9 +398,16 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, paymentIdentifier = "ab".repeat(32), proofData = "ab".repeat(32), + onchainAddress = ONCHAIN_ADDRESS, + onchainAmountSats = paymentRequest(MethodId.P2wpkh.rawValue).amountSats, ) listOf(lightning, onchain).forEach { proof -> + if (proof.kind == PaykitPaymentProofKind.Onchain) { + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( + acceptedAttempt(paymentRequest(MethodId.P2wpkh.rawValue), requireNotNull(proof.paymentIdentifier)), + ) + } storedProofs = listOf(proof) paymentProofRepo().reconcile() @@ -394,13 +526,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `existing proof suppresses duplicate submission`() = test { + fun `queued onchain proof blocks same Shop request switched to Lightning`() = test { val existingProofJson = mock { - on { exportText() } doReturn """{"type":"${PaykitPaymentProofKind.Lightning.type}","data":"$PREIMAGE"}""" + on { exportText() } doReturn """{"type":"${PaykitPaymentProofKind.Onchain.type}","data":"${"ab".repeat(32)}"}""" } val existingProof = mock { on { billingPeriod } doReturn null - on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { paymentEndpointIdentifier } doReturn MethodId.P2wpkh.rawValue on { paymentAppId } doReturn "bitkit" on { proof } doReturn existingProofJson } @@ -409,14 +541,25 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() - repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + val result = repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) + assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) assertTrue(storedProofs.isEmpty()) verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) } + @Test + fun `accepted onchain attempt blocks Lightning before its proof is queued`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + whenever(lightningRepo.currentOnchainSendAttempt()) + .thenReturn(acceptedAttempt(request, "ab".repeat(32))) + + val result = paymentProofRepo().prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) + + assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) + assertTrue(storedProofs.isEmpty()) + } + @Test fun `failed lightning payment clears persisted correlation`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) @@ -506,7 +649,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() assertTrue(storedProofs.single().paymentStarted) - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid)) val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() @@ -550,6 +693,25 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `definite software failure retains proof until private boundary release succeeds`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted( + request, ONCHAIN_ADDRESS, + privatePaymentListVersion = 7uL, previousPrivatePaymentListVersion = 6uL, + ).getOrThrow() + val original = storedProofs.single() + whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL)) + .thenReturn(Result.failure(IllegalStateException("storage")), Result.success(Unit)) + repo.failOnchainPayment(request) + assertEquals(listOf(original), storedProofs) + repo.failOnchainPayment(request) + assertTrue(storedProofs.isEmpty()) + verify(privatePaykitRepo, times(2)).releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL) + } + @Test fun `onchain failure clears started proof without a live identity`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) @@ -594,7 +756,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `broadcast transaction id is retained without a live identity`() = test { + fun `accepted callback without live payer identity keeps proof pending`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() val txid = "ab".repeat(32) @@ -602,10 +764,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() whenever(paykitSdkService.identityStatus()).thenReturn(null) - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") + assertFalse(repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid))) - assertEquals(txid, storedProofs.single().paymentIdentifier) - assertEquals(txid, storedProofs.single().proofData) + assertNull(storedProofs.single().paymentIdentifier) + assertNull(storedProofs.single().proofData) + assertTrue(storedProofs.single().paymentStarted) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) } @Test @@ -618,7 +782,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.completeOnchainPayment(request, txid, endpoint, "bitkit") + repo.completeOnchainPayment(request, txid, endpoint, "bitkit", OnchainSendOutcome.Accepted(txid)) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull()) assertTrue(storedProofs.isEmpty()) @@ -727,7 +891,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid)) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertTrue(storedProofs.isEmpty()) @@ -746,7 +910,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid)) assertEquals(txid, storedProofs.single().proofData) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) @@ -764,7 +928,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailProofRemoval = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid)) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertEquals(txid, storedProofs.single().proofData) @@ -783,7 +947,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, endpoint, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextLoad = true - repo.completeOnchainPayment(request, txid, endpoint, "bitkit") + repo.completeOnchainPayment(request, txid, endpoint, "bitkit", OnchainSendOutcome.Accepted(txid)) val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() @@ -804,24 +968,17 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `uncertain onchain payment is reconciled from its private destination`() = test { + fun `accepted onchain payment is reconciled from its exact recorded transaction`() = test { val txid = "ab".repeat(32) val record = paymentRequestRecord() val request = paymentRequest(MethodId.P2wpkh.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) - whenever( - onchainPaymentLookup.transactionId( - ONCHAIN_ADDRESS, - request.amountSats, - emptySet(), - WalletScope.default, - ) - ).thenReturn(txid) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, txid)) repo.reconcile() assertTrue(storedProofs.isEmpty()) @@ -838,7 +995,154 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `reconcile publishes every onchain resolution`() = test { + fun `typed accepted callback cannot invent proof when original guard cannot be read`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + val original = storedProofs.single() + whenever(lightningRepo.currentOnchainSendAttempt()).thenThrow(IllegalStateException("guard unavailable")) + assertFalse( + repo.completeOnchainPayment( + request, + "ab".repeat(32), + MethodId.P2wpkh.rawValue, "bitkit", + OnchainSendOutcome.Accepted("ab".repeat(32)), + ) + ) + assertEquals(listOf(original), storedProofs) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `shared golden restored software proof authorizes and completes exact observed successor`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + .decodeFromString(bytes.decodeToString()) + val state = requireNotNull(backup.paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + wire.validateProofs(state.pendingProofs, WalletScope.default) + val attempt = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 2) + val repo = paymentProofRepo() + repo.restoreBackup(state.pendingProofs) + assertEquals(WalletScope.default, storedProofs.single().onchainWalletId) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(requireNotNull(wire.payerIdentity), PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + repo.authorizeOnchainRecovery(attempt).getOrThrow() + assertEquals(state.pendingProofs.single().paymentIdentifier, storedProofs.single().paymentIdentifier) + val requestId = requireNotNull(wire.requestId) + val request = paymentRequest(state.pendingProofs.single().paymentEndpointIdentifier).copy( + paymentRequestId = requestId.paymentRequestId, + counterparty = requestId.counterparty, + amountSats = attempt.amountSats, + ) + val winner = attempt.copy(evidence = OnchainSendEvidence.Observed) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(winner) + assertTrue(repo.completeRecoveredOnchainPayment(request, winner)) + val completed = storedProofs.single() + assertTrue(completed.onchainAcceptanceVerified) + assertEquals(winner.txid, completed.paymentIdentifier) + assertEquals(winner.txid, completed.proofData) + storedProofs = listOf(completed.copy(onchainWalletId = "hardware-wallet", proofData = null)) + assertFalse(repo.completeRecoveredOnchainPayment(request, winner)) + storedProofs = listOf(completed.copy(identity = COUNTERPARTY, proofData = null)) + assertFalse(repo.completeRecoveredOnchainPayment(request, winner)) + } + + @Test + fun `successor winner updates the original payer proof without creating another proof`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val original = "cd".repeat(32) + val winner = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + storedProofs = storedProofs.map { it.copy(paymentIdentifier = original) } + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( + acceptedAttempt(request, winner).copy( + payerIdentity = LOCAL_IDENTITY, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf(original, winner), + ), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenThrow(IllegalStateException("delivery temporarily unavailable")) + + assertTrue( + repo.completeOnchainPayment( + request, + winner, + MethodId.P2wpkh.rawValue, "bitkit", + OnchainSendOutcome.Accepted(winner), + ) + ) + + val proof = storedProofs.single() + assertEquals(LOCAL_IDENTITY, proof.identity) + assertEquals(request.id, proof.requestId) + assertEquals(winner, proof.paymentIdentifier) + assertEquals(winner, proof.proofData) + assertTrue(proof.onchainAcceptanceVerified) + assertFalse(repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).isSuccess) + } + + @Test + fun `original retry authorization retains payer proof and rejects identity switch`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val original = "cd".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + storedProofs = storedProofs.map { it.copy(paymentIdentifier = original) } + val proof = storedProofs.single() + val attempt = acceptedAttempt(request, original).copy( + evidence = OnchainSendEvidence.Unknown, + payerIdentity = LOCAL_IDENTITY, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf(original), + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + + repo.authorizeOnchainRecovery(attempt).getOrThrow() + assertEquals(listOf(proof), storedProofs) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + storedProofs = listOf(proof, proof.copy(identity = COUNTERPARTY)) + assertTrue(repo.authorizeOnchainRecovery(attempt).isFailure) + assertEquals(listOf(proof, proof.copy(identity = COUNTERPARTY)), storedProofs) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt.copy(payerIdentity = null)) + assertTrue(repo.authorizeOnchainRecovery(attempt.copy(payerIdentity = null)).isFailure) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `candidate winner cannot replace a proof bound outside its original family`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val winner = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + storedProofs = storedProofs.map { it.copy(paymentIdentifier = "ef".repeat(32)) } + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( + acceptedAttempt(request, winner).copy( + payerIdentity = LOCAL_IDENTITY, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf("cd".repeat(32), winner), + ), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + + repo.reconcile() + + assertNull(storedProofs.single().proofData) + assertEquals("ef".repeat(32), storedProofs.single().paymentIdentifier) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(lightningRepo, never()).finishAcceptedShopActivity(any(), any()) + } + + @Test + fun `reconcile only resolves the request tied to exact transaction evidence`() = test { val secondPaymentRequestId = "550e8400-e29b-41d4-a716-446655440001" val firstRequest = paymentRequest(MethodId.P2wpkh.rawValue) val secondRequest = paymentRequest(MethodId.P2wpkh.rawValue, secondPaymentRequestId) @@ -850,50 +1154,225 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) .thenReturn(paymentRequestRecord()) - whenever(onchainPaymentLookup.transactionId(any(), any(), any(), any())) - .thenReturn("ab".repeat(32), "cd".repeat(32)) val repo = paymentProofRepo() repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(firstRequest, ONCHAIN_ADDRESS).getOrThrow() repo.prepare(secondRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS, "hardware-wallet").getOrThrow() + repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS).getOrThrow() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(firstRequest, "ab".repeat(32))) repo.reconcile() assertEquals( - listOf(PAYMENT_REQUEST_ID, secondPaymentRequestId), + listOf(PAYMENT_REQUEST_ID), repo.onchainPaymentResolutions.value.map { it.requestId.paymentRequestId }, ) - assertEquals( - listOf(WalletScope.default, "hardware-wallet"), - repo.onchainPaymentResolutions.value.map { it.walletId }, - ) + assertNull(storedProofs.single { it.requestId == secondRequest.id }.proofData) } @Test - fun `uncertain onchain payment ignores transaction from before attempt`() = test { - val oldTransactionId = "ab".repeat(32) + fun `uncertain onchain payment does not infer broadcast from destination or amount`() = test { val record = paymentRequestRecord() val request = paymentRequest(MethodId.P2wpkh.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(onchainPaymentLookup.existingTransactionIds(any(), any(), any())).thenReturn(setOf(oldTransactionId)) - whenever( - onchainPaymentLookup.transactionId( - ONCHAIN_ADDRESS, - request.amountSats, - setOf(oldTransactionId), - WalletScope.default, - ) - ).thenReturn(null) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.reconcile() - assertEquals(setOf(oldTransactionId), storedProofs.single().onchainMatchingTransactionIdsBeforeAttempt) + assertTrue(storedProofs.single().onchainMatchingTransactionIdsBeforeAttempt.isEmpty()) + assertNull(storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `Shop reconciliation never holds proof mutex while finishing attempt activity`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, txid)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + val finishing = CompletableDeferred() + val proofLockReleased = CompletableDeferred() + whenever(lightningRepo.finishAcceptedShopActivity(request.id, txid)).doSuspendableAnswer { + finishing.complete(Unit) + proofLockReleased.await() + } + val reconciliation = launch { repo.reconcile() } + finishing.await() + withTimeout(1_000) { + repo.backupSnapshot() + proofLockReleased.complete(Unit) + reconciliation.join() + } + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `accepted Shop activity failure retains original proof for reconciliation before delivery`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, txid)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(lightningRepo.finishAcceptedShopActivity(request.id, txid)) + .thenThrow(IllegalStateException("local activity unavailable")) + + assertFalse( + repo.completeOnchainPayment( + request, + txid, + MethodId.P2wpkh.rawValue, "bitkit", + OnchainSendOutcome.Accepted(txid), + ), + ) + assertTrue(storedProofs.single().paymentStarted) + assertNull(storedProofs.single().proofData) + verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + repo.reconcile() + assertTrue(storedProofs.single().paymentStarted) assertNull(storedProofs.single().proofData) + verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + + doReturn(Unit).whenever(lightningRepo).finishAcceptedShopActivity(request.id, txid) + repo.reconcile() + + assertTrue(storedProofs.isEmpty()) + verify(lightningRepo).completeAcceptedShopFollowup(request.id, txid) + verify(paykitSdkService).submitPaymentProof( + eq(request.counterparty), + eq(request.paymentRequestId), + eq("bitkit"), + eq(MethodId.P2wpkh.rawValue), + any(), + isNull(), + ) + } + + @Test + fun `completed onchain proof retries after a newer send replaces the bounded guard`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val record = paymentRequestRecord() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenThrow(IllegalStateException("delivery unavailable")) + .thenReturn(record) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + assertTrue(repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid))) + assertEquals(txid, storedProofs.single().proofData) + assertTrue(storedProofs.single().onchainAcceptanceVerified) + + val newerRequest = paymentRequest(MethodId.P2wpkh.rawValue, "550e8400-e29b-41d4-a716-446655440001") + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(newerRequest, "cd".repeat(32))) + paymentProofRepo().reconcile() + + assertTrue(storedProofs.isEmpty()) + val proofCaptor = argumentCaptor() + verify(paykitSdkService, times(2)).submitPaymentProof( + eq(request.counterparty), eq(request.paymentRequestId), eq("bitkit"), + eq(MethodId.P2wpkh.rawValue), proofCaptor.capture(), isNull(), + ) + assertTrue(proofCaptor.allValues.all { it.contains(txid) }) + verify(lightningRepo, never()).completeAcceptedShopFollowup(newerRequest.id, "cd".repeat(32)) + } + + @Test + fun `legacy completed onchain proof without acknowledgement stays blocked after guard replacement`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + storedProofs = listOf(PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, requestId = request.id, paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, paymentStarted = true, paymentIdentifier = txid, proofData = txid, + )) + val newerRequest = paymentRequest(MethodId.P2wpkh.rawValue, "550e8400-e29b-41d4-a716-446655440001") + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(newerRequest, "cd".repeat(32))) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + + paymentProofRepo().reconcile() + + assertEquals(1, storedProofs.size) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) + } + + @Test + fun `txid-only onchain completion cannot mint acknowledged evidence`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + + assertFalse(repo.completeOnchainPayment(request, "ab".repeat(32), MethodId.P2wpkh.rawValue, "bitkit")) + assertNull(storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `exact positive guard upgrades an unmarked completed proof durably`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + storedProofs = listOf(PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, requestId = request.id, paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, paymentStarted = true, paymentIdentifier = txid, proofData = txid, + )) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( + acceptedAttempt(request, txid).copy(evidence = OnchainSendEvidence.Observed), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenThrow(IllegalStateException("delivery unavailable")) + paymentProofRepo().reconcile() + + assertTrue(storedProofs.single().onchainAcceptanceVerified) + verify(lightningRepo).completeAcceptedShopFollowup(request.id, txid) + } + + @Test + fun `mismatched accepted outcome cannot verify a different proof txid`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + + assertFalse(repo.completeOnchainPayment( + request, "ab".repeat(32), MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted("cd".repeat(32)), + )) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + assertNull(storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `malformed completed onchain proof is neither delivered nor used to complete the guard`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + storedProofs = listOf(PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, requestId = request.id, paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, paymentStarted = true, paymentIdentifier = txid, + proofData = "cd".repeat(32), + )) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, txid)) + + paymentProofRepo().reconcile() + + assertEquals(1, storedProofs.size) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) } @Test @@ -954,6 +1433,607 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(listOf(request.id), storedProofs.map { it.requestId }) } + @Test + fun `definite hardware prebroadcast denial releases only the exact original proof`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + val original = storedProofs.single() + storedProofs = storedProofs + original.copy(onchainWalletId = "foreign-wallet") + assertTrue(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(listOf(original.copy(onchainWalletId = "foreign-wallet")), storedProofs) + } + + @Test + fun `hardware cleanup cannot release attempted candidate foreign context or failed durable removal`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + val original = storedProofs.single() + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, true)) + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, "foreign-wallet", LOCAL_IDENTITY, false)) + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, WalletScope.default, LOCAL_IDENTITY, false)) + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, COUNTERPARTY, false)) + assertFalse( + repo.failHardwareOnchainPaymentBeforeDispatch( + request.copy(paymentRequestId = "different-request"), + walletId, + LOCAL_IDENTITY, + false, + ) + ) + assertEquals(listOf(original), storedProofs) + shouldFailNextSave = true + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(listOf(original), storedProofs) + assertEquals( + PaykitPaymentRequestError.OperationInProgress, + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).exceptionOrNull() + ) + for (retained in listOf( + original.copy(paymentIdentifier = "ab".repeat(32)), + original.copy(onchainAcceptanceVerified = true), + )) { + storedProofs = listOf(retained) + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(listOf(retained), storedProofs) + } + } + + @Test + fun `denied hardware cleanup retries original private version before removing proof`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, 7uL).getOrThrow() + assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats)) + assertTrue(repo.clearHardwareOnchainCandidateBeforeDispatch(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats, false)) + whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL)) + .thenReturn(Result.failure(IllegalStateException("storage")), Result.success(Unit)) + assertTrue(runCatching { repo.backupSnapshot() }.isFailure) + paymentProofRepo().reconcile() + assertTrue(storedProofs.single().hardwareDispatchDenied) + assertEquals(txid, storedProofs.single().paymentIdentifier) + paymentProofRepo().reconcile() + assertTrue(storedProofs.isEmpty()) + verify(privatePaykitRepo, times(2)).releasePrivatePaymentListVersion(request.id.counterparty, 7uL) + } + + @Test + fun `queued hardware denial survives reopen and finishes cleanup before another preparation`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats)) + assertTrue(repo.clearHardwareOnchainCandidateBeforeDispatch(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats, false)) + // Kill after the durable denial, before the sheet's cleanup callback. + val reopened = paymentProofRepo() + reopened.reconcile() + assertTrue(storedProofs.isEmpty()) + assertTrue(reopened.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).isSuccess) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `first queued expiry clears only the exact unsubmitted hardware candidate`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats)) + val retained = storedProofs.single() + suspend fun clear(prior: Boolean = false, candidate: String = txid, address: String = ONCHAIN_ADDRESS) = + repo.clearHardwareOnchainCandidateBeforeDispatch(request.id, walletId, candidate, LOCAL_IDENTITY, + address, request.amountSats, prior) + assertFalse(clear(prior = true)) + assertFalse(clear(candidate = "cd".repeat(32))) + assertFalse(clear(address = "other-address")) + for (accepted in listOf(retained.copy(onchainAcceptanceVerified = true), retained.copy(proofData = txid))) { + storedProofs = listOf(accepted) + assertFalse(clear()) + assertEquals(listOf(accepted), storedProofs) + } + storedProofs = listOf(retained) + assertTrue(clear()) + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertTrue(storedProofs.single().hardwareDispatchDenied) + assertTrue(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertTrue(storedProofs.isEmpty()) + } + + @Test + @Suppress("LongMethod") + fun `signed hardware Shop receipt restores exact bytes before explicit retry`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val signed = + HwFundingSignedTx( + "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c1601" + + "00000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7b" + + "e80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a1" + + "5ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94" + + "716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf2911539" + + "4e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300", + 141uL, + 2uL, + request.amountSats + 141uL + ) + val txid = SignedTransactionId.fromHex(signed.serializedTx) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + // Snapshot at the authorization boundary, before any broadcast callback can save a receipt. + repo.markOnchainPaymentStarted( + request, + ONCHAIN_ADDRESS, + walletId, + 7uL, + signedTx = signed, + previousPrivatePaymentListVersion = 6uL, + ).getOrThrow() + assertEquals(false, storedProofs.single().hardwareDispatchAttempted) + val encoded = Json.encodeToString(repo.backupSnapshot()) + storedProofs = emptyList() + val reopened = paymentProofRepo() + reopened.restoreBackup(Json.decodeFromString>(encoded)) + whenever(privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any())).thenReturn(Result.success(Unit)) + assertEquals( + RetainedHardwareOnchainPayment(signed, false), + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats + ) + ) + verify(privatePaykitRepo).consumePrivatePaymentList(eq(request.counterparty), argThat { paymentListVersion == 7uL }) + val otherRequest = paymentRequest(MethodId.P2wpkh.rawValue, paymentRequestId = "another-order") + assertTrue(reopened.prepare(otherRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).isFailure) + whenever(privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any())) + .thenReturn(Result.failure(IllegalStateException("private state unavailable"))) + var blockedByStorage = false + try { + reopened.retainedHardwareOnchainPayment(request.id, walletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats) + } catch (_: IllegalStateException) { + blockedByStorage = true + } + assertTrue(blockedByStorage) + whenever(privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any())) + .thenReturn(Result.failure(PrivatePaykitError.PaymentListAlreadyConsumed)) + assertEquals(signed, reopened.retainedHardwareOnchainPayment(request.id, walletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats)?.signedTx) + whenever(privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any())).thenReturn(Result.success(Unit)) + assertNull( + reopened.retainedHardwareOnchainPayment( + request.id, + "other-wallet", + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats + ) + ) + assertNull( + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + COUNTERPARTY, + ONCHAIN_ADDRESS, + request.amountSats + ) + ) + assertNull( + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + "other-address", + request.amountSats + ) + ) + assertNull( + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats + 1uL + ) + ) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + assertEquals(7uL, storedProofs.single().privatePaymentListVersion) + assertEquals(6uL, storedProofs.single().previousPrivatePaymentListVersion) + whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL)) + .thenReturn( + Result.failure(IllegalStateException("private state storage")), + Result.success(Unit), + Result.success(Unit) + ) + val original = storedProofs.single() + assertFalse(reopened.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(listOf(original), storedProofs) + // A restart must retain the original version while cleanup is unfinished. + val retryCleanup = paymentProofRepo() + shouldFailProofRemoval = true + assertFalse(retryCleanup.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(listOf(original), storedProofs) + assertTrue( + paymentProofRepo().failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false) + ) + assertTrue(storedProofs.isEmpty()) + verify(privatePaykitRepo, times(3)).releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL) + reopened.restoreBackup(Json.decodeFromString>(encoded)) + assertTrue( + reopened.retainHardwareOnchainCandidate( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + signed + ) + ) + assertEquals(false, storedProofs.single().hardwareDispatchAttempted, + "Retaining the signed candidate is not native dispatch") + assertFalse(reopened.markHardwareOnchainDispatch(request.id, walletId, txid, COUNTERPARTY, + ONCHAIN_ADDRESS, request.amountSats)) + assertEquals(false, storedProofs.single().hardwareDispatchAttempted) + assertTrue(reopened.markHardwareOnchainDispatch(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats)) + val dispatched = Json.encodeToString(reopened.backupSnapshot()) + storedProofs = emptyList() + reopened.restoreBackup(Json.decodeFromString>(dispatched)) + assertEquals( + true, + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats + )?.hasAttemptedBroadcast + ) + assertFalse(reopened.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(1, storedProofs.size) + } + + @Test + @Suppress("LongMethod") // One lifecycle proves persisted refusal, reopen, guard retention and redispatch. + fun `hardware refusal navigation hint survives reopen and clears on dispatch`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val raw = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().readText().trim() + val signed = HwFundingSignedTx(raw, 141uL, 2uL, request.amountSats + 141uL) + val txid = SignedTransactionId.fromHex(raw) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, signedTx = signed).getOrThrow() + assertTrue( + repo.markHardwareOnchainDispatch( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + ) + ) + assertFalse( + repo.markHardwareRefusedForNavigation( + request.id, + walletId, + txid, + COUNTERPARTY, + ONCHAIN_ADDRESS, + request.amountSats, + ) + ) + assertTrue( + repo.markHardwareRefusedForNavigation( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + ) + ) + val reopened = paymentProofRepo() + val restored = reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + ) + assertEquals(RetainedHardwareOnchainPayment(signed, true, true), restored) + assertFalse(reopened.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, true)) + assertTrue( + reopened.markHardwareOnchainDispatch( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + ) + ) + assertFalse(storedProofs.single().hardwareRefusedForNavigation) + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + } + + @Test + fun `prebroadcast hardware candidate survives repository reopen without claiming acceptance`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + assertFalse(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, "other-address", request.amountSats)) + assertFalse(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats + 1uL)) + assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats)) + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertNull(storedProofs.single().proofData) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + assertFalse(repo.retainHardwareOnchainCandidate(request.id, walletId, "cd".repeat(32), LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats)) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(paymentRequestRecord()) + paymentProofRepo().reconcile() + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(MethodId.P2wpkh.rawValue), + eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull()) + assertTrue(storedProofs.isEmpty()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `hardware core txid remains pending until fresh exact observation then resumes original proof`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(false)) + + assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertNull(storedProofs.single().proofData) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + assertEquals(PaykitPaymentRequestError.OperationInProgress, + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).exceptionOrNull()) + repo.failOnchainPayment(request) + assertEquals(1, storedProofs.size) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenReturn(paymentRequestRecord()) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, "cd".repeat(32))) + paymentProofRepo().reconcile() + verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(MethodId.P2wpkh.rawValue), + eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull()) + assertTrue(storedProofs.isEmpty()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `restored verified hardware proof waits for original activity reconstruction`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + storedProofs = listOf(storedProofs.single().copy( + paymentIdentifier = txid, proofData = txid, onchainAcceptanceVerified = true, + )) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + .thenReturn(Result.success(false)) + paymentProofRepo().reconcile() + assertEquals(txid, storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + .thenReturn(Result.success(true)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenReturn(paymentRequestRecord()) + paymentProofRepo().reconcile() + assertTrue(storedProofs.isEmpty()) + verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `hardware fresh observation submits completed durable proof with original wallet`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "cd".repeat(32) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) + // No delivery session: preserve the verified proof durably for later delivery. + assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertEquals(txid, storedProofs.single().proofData) + assertTrue(storedProofs.single().onchainAcceptanceVerified) + assertEquals(walletId, storedProofs.single().onchainWalletId) + assertEquals(request.id, repo.onchainPaymentResolutions.value.single().requestId) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `hardware observation errors and changed transaction id retain only the original pending lookup`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.failure(AppError("lookup failed"))) + assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertFalse(repo.completeHardwareOnchainPayment(request.id, "different-wallet", txid, LOCAL_IDENTITY)) + assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, "cd".repeat(32), LOCAL_IDENTITY)) + repo.reconcile() + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertNull(storedProofs.single().proofData) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + } + + @Test + fun `hardware callback cannot verify another identity with the same request and wallet`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + val original = storedProofs.single() + val other = original.copy(identity = COUNTERPARTY) + storedProofs = listOf(other) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) + + assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertEquals(listOf(other), storedProofs) + verify(hwWalletRepo, never()).observeExactTransaction(any(), any(), any(), any()) + + storedProofs = listOf(original, other) + assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertTrue(storedProofs.first { it.identity == LOCAL_IDENTITY }.onchainAcceptanceVerified) + assertEquals(other, storedProofs.first { it.identity == COUNTERPARTY }) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + } + + @Test + fun `Core exception after hardware Shop start survives dismissal and reopen without a new payment`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.cancelPreparation(request) + assertTrue(storedProofs.isEmpty()) + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + val context = mock() + whenever(context.getString(any())).thenReturn("message") + val connected = mock() + val funding = HwFundingTransaction("psbt", 1000uL, 2.0f, 2000uL, 2uL) + val signed = HwFundingSignedTx(requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")).bufferedReader().readText().trim(), 1000uL, 2uL, 2000uL) + whenever(hwWalletRepo.needsPassphrase(walletId)).thenReturn(false) + whenever(hwWalletRepo.reconnectTimeout(walletId)).thenReturn(30.seconds) + whenever(hwWalletRepo.ensureConnected(walletId)).thenReturn(Result.success(connected)) + whenever(hwWalletRepo.composeFundingTransaction(walletId, ONCHAIN_ADDRESS, request.amountSats, 2uL)) + .thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signFunding(walletId, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)) + .thenReturn(Result.failure(BroadcastException.ElectrumException("response lost after dispatch"))) + whenever(hwWalletRepo.broadcastFundingAtBoundary(any(), org.mockito.kotlin.anyOrNull(), any())) + .doSuspendableAnswer { + it.getArgument Unit>(2)() + hwWalletRepo.broadcastFunding(it.getArgument(0), it.getArgument(1)) + } + val send = HwSendViewModel(context, hwWalletRepo, mock(), mock(), mock(), repo) + send.signAndBroadcast( + HwSendRequest( + walletId, + ONCHAIN_ADDRESS, + request.amountSats, + 2uL, + emptyList(), + request.id, + LOCAL_IDENTITY, + ), + prepareContactPayment = { receipt -> + assertEquals(signed, receipt) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, signedTx = receipt).getOrThrow() + true + }, + authorizeContactPayment = { + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + val backup = Json.encodeToString(repo.backupSnapshot()) + storedProofs = emptyList() + val reopened = paymentProofRepo() + reopened.restoreBackup(Json.decodeFromString>(backup)) + assertEquals( + RetainedHardwareOnchainPayment(signed, false), + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + ), + ) + true + }, + ) + advanceUntilIdle() + assertFalse(send.uiState.value.isSigning) + assertTrue(send.uiState.value.isBroadcastUnresolved) + assertTrue(storedProofs.single().paymentStarted) + assertEquals("605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e", storedProofs.single().paymentIdentifier) + assertEquals(true, storedProofs.single().hardwareDispatchAttempted) + + // Both generic failure and preparation cancellation must preserve an already dispatched Shop payment. + repo.failOnchainPayment(request) + repo.cancelPreparation(request) + send.cancel() + assertTrue(send.uiState.value.hasPendingBroadcast) + assertTrue(storedProofs.single().paymentStarted) + assertNull(storedProofs.single().proofData) + assertEquals(PaykitPaymentRequestError.OperationInProgress, + paymentProofRepo().prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + .exceptionOrNull()) + verify(hwWalletRepo, times(1)).broadcastFunding(signed) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + } + + @Test + fun `hardware candidate save failure leaves started proof blocking a fresh payment`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + val original = storedProofs.single() + shouldFailNextSave = true + + assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertEquals(listOf(original), storedProofs) + assertTrue(original.paymentStarted) + assertNull(original.paymentIdentifier) + assertNull(original.proofData) + assertTrue(repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + .exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) + verify(hwWalletRepo, never()).observeExactTransaction(any(), any(), any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + private fun TestScope.observeRequestStateChanges() { paymentProofRepo(projectionStore).paymentRequestStateChanges(projectionIdentity) .onEach { requestStateChanges++ } @@ -961,12 +2041,89 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { runCurrent() } + @Test + fun `retained refused hardware reopen preserves exact receipt and blocks new preparation`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val raw = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().use { it.readText().trim() } + val proof = PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, + requestId = request.id, + paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, + paymentStarted = true, + paymentIdentifier = SignedTransactionId.fromHex(raw), + onchainAddress = ONCHAIN_ADDRESS, + onchainAmountSats = request.amountSats, + onchainWalletId = "trezor:original", + hardwareDispatchAttempted = true, + hardwareRefusedForNavigation = true, + hardwareSignedTransaction = raw, + hardwareMiningFeeSats = 1_000uL, + hardwareFeeRate = 2uL, + hardwareTotalSpent = 2_000uL, + ) + storedProofs = listOf(proof) + val reopened = paymentProofRepo() + assertEquals(proof, reopened.retainedHardwareOnchainRequest(request)) + assertEquals( + raw, + reopened.retainedHardwareOnchainPayment( + request.id, + proof.onchainWalletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + )?.signedTx?.serializedTx, + ) + assertNull(reopened.retainedHardwareOnchainRequest(request.copy(amountSats = request.amountSats + 1uL))) + assertNull(reopened.retainedHardwareOnchainRequest(request.copy(paymentRequestId = "foreign-request"))) + assertTrue(reopened.hasRetainedHardwareOnchainPayment(proof.onchainWalletId)) + assertEquals( + PaykitPaymentRequestError.OperationInProgress, + reopened.prepare( + request, + MethodId.P2wpkh.rawValue, + "bitkit", + PaykitPaymentProofKind.Onchain, + ).exceptionOrNull(), + ) + storedProofs = listOf(proof.copy(hardwareRefusedForNavigation = false)) + assertNull(reopened.retainedHardwareOnchainRequest(request)) + assertTrue(reopened.hasRetainedHardwareOnchainPayment(proof.onchainWalletId)) + storedProofs = listOf(proof) + whenever(paykitSdkService.identityStatus()) + .thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + assertNull(reopened.retainedHardwareOnchainRequest(request)) + assertEquals(listOf(proof), storedProofs) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + } + private fun paymentProofRepo(store: PaykitPaymentProofStore = this.store) = PaykitPaymentProofRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, lightningRepo = lightningRepo, - onchainPaymentLookup = onchainPaymentLookup, store = store, + hwWalletRepo = hwWalletRepo, + privatePaykitRepo = dagger.Lazy { privatePaykitRepo }, + ) + + private fun acceptedAttempt(request: PaykitPaymentRequest, txid: String) = OnchainSendAttempt( + walletId = WalletScope.default, + attemptId = "attempt-1", + requestId = request.id, + orderId = null, + address = ONCHAIN_ADDRESS, + amountSats = request.amountSats, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = false, + channelId = null, + tags = emptyList(), + evidence = OnchainSendEvidence.Accepted, + txid = txid, ) private fun paymentRequest( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt index 13ba49107f..ca20fd69a9 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.SerializationException import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.eq +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -13,6 +14,8 @@ import to.bitkit.test.BaseUnitTest import kotlin.test.assertContains import kotlin.test.assertFailsWith import kotlin.test.assertIs +import kotlin.test.assertEquals +import kotlin.test.assertTrue class PaykitPaymentProofStoreTest : BaseUnitTest() { companion object { @@ -32,6 +35,29 @@ class PaykitPaymentProofStoreTest : BaseUnitTest() { verify(keychain, never()).delete(KEY) } + @Test + fun `unverified txid proof stays in flight without claiming local completion`() = test { + val identity = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val requestId = PaykitPaymentRequestId("request", "counterparty") + val proof = PendingPaykitPaymentProof( + identity = identity, requestId = requestId, paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, paymentStarted = true, + paymentIdentifier = "ab".repeat(32), proofData = "ab".repeat(32), + ) + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(KEY)).thenAnswer { saved } + whenever(keychain.upsertString(eq(KEY), any())).doSuspendableAnswer { saved = it.getArgument(1) } + val store = PaykitPaymentProofStore(keychain) + store.save(listOf(proof)) + assertTrue(store.completedRequestProofKindsAwaitingSubmission(identity).isEmpty()) + assertEquals(setOf(requestId), store.inFlightRequestIds(identity)) + + store.save(listOf(proof.copy(onchainAcceptanceVerified = true))) + assertContains(requireNotNull(saved), "\"onchainAcceptanceVerified\":true") + assertEquals(mapOf(requestId to PaykitPaymentProofKind.Onchain), store.completedRequestProofKindsAwaitingSubmission(identity)) + } + @Test fun `saving no proofs removes persisted state`() = test { val keychain = mock() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 2c348ba81e..412ec3f700 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -847,6 +847,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(requestId)) sut.refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) @@ -854,6 +855,8 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat verify(paykitSdkService, never()).processPendingPrivateMessages() verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() assertEquals(requestId, sut.paymentRequestHistory.value.single().id) + assertTrue(sut.paymentRequestHistory.value.single().isPaymentInFlight) + assertFalse(sut.paymentRequestHistory.value.single().canDismiss) assertEquals( PaymentRequestLifecycleState.PROOF_SUBMITTED, sut.paymentRequestHistory.value.single().lifecycleState, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index cb40b5cf41..e11987608a 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -292,6 +292,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(PaykitSubscriptionPresentationState()) whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptyMap()) whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofStore.load()).thenReturn(emptyList()) whenever(paymentProofStore.backupStateVersion).thenReturn(proofStateVersion) whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) .thenReturn(Result.success(emptySet())) @@ -2497,6 +2498,84 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { return PaykitPaymentRequestTarget(COUNTERPARTY) } + @Test + @Suppress("LongMethod") // Keep the reload, original receipt and guarded variants in one regression. + fun `refused retained hardware request remains reopenable after proof store reload`() = test { + restoreAcceptedRequest() + val record = paymentRequestRecord( + state = PaymentRequestLifecycleState.ACCEPTED, + amount = "0.00001000", + endpoints = listOf(MethodId.P2wpkh.rawValue), + ) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + val requestId = PaykitPaymentRequestId(record.paymentRequestId, record.counterparty) + val raw = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().use { it.readText().trim() } + val proof = PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, + requestId = requestId, + paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, + paymentStarted = true, + paymentIdentifier = to.bitkit.utils.SignedTransactionId.fromHex(raw), + onchainAddress = "original-address", + onchainAmountSats = 1_000uL, + onchainWalletId = "trezor:original", + hardwareDispatchAttempted = true, + hardwareRefusedForNavigation = true, + hardwareSignedTransaction = raw, + hardwareMiningFeeSats = 1_000uL, + hardwareFeeRate = 2uL, + hardwareTotalSpent = 2_000uL, + ) + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(any())).thenAnswer { saved } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { saved = it.getArgument(1) } + PaykitPaymentProofStore(keychain).save(listOf(proof)) + val reopened = PaykitPaymentProofStore(keychain) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)) + .thenAnswer { reopened.inFlightRequestIds(LOCAL_IDENTITY) } + whenever(paymentProofStore.load()).thenAnswer { reopened.load() } + + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + + assertEquals(requestId, sut.pendingRequests.value.single().id) + assertTrue(sut.pendingRequests.value.single().isPaymentInFlight) + assertFalse(sut.pendingRequests.value.single().canDismiss) + assertFalse(sut.paymentRequestHistory.value.single { it.id == requestId }.canDismiss) + assertTrue(sut.pendingRequests.value.single().copy(isPaymentInFlight = false).canDismiss) + sut.ensurePaymentAllowed(sut.pendingRequests.value.single()).getOrThrow() + assertEquals(listOf(proof), reopened.load()) + assertEquals(setOf(requestId), reopened.inFlightRequestIds(LOCAL_IDENTITY)) + assertTrue(reopened.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY).isEmpty()) + assertEquals( + PaykitPaymentRequestError.OperationInProgress, + sut.dismiss(sut.pendingRequests.value.single()).exceptionOrNull(), + ) + verify(paykitSdkService, never()).cancelPaymentRequest(any(), any(), anyOrNull()) + verify(paykitSdkService, never()).rejectPaymentRequest(any(), any(), anyOrNull()) + verify(paykitSdkService, never()).acceptPaymentRequest(any(), any()) + val guarded = listOf( + proof.copy(hardwareRefusedForNavigation = false), + proof.copy(hardwareDispatchDenied = true), + proof.copy(onchainAcceptanceVerified = true), + proof.copy(hardwareSignedTransaction = null), + proof.copy(paymentIdentifier = "00".repeat(32)), + proof.copy(onchainAmountSats = 1_001uL), + proof.copy(onchainWalletId = to.bitkit.models.WalletScope.default), + ) + for (candidate in guarded) { + reopened.save(listOf(candidate)) + proofStateVersion.value += 1 + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(listOf(candidate), reopened.load()) + assertEquals(setOf(requestId), reopened.inFlightRequestIds(LOCAL_IDENTITY)) + } + } + private suspend fun restoreAcceptedRequest() { whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn( setOf(PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY)), diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index d8c2e9f1b9..c484fbf6a3 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -333,6 +333,14 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { assertNull(sut.currentContactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) assertNull(sut.contactPublicKeyForReservedAddress(historicalAddress)) assertEquals(persisted, reservationData.value) + + org.mockito.kotlin.doAnswer { + val transform = it.getArgument<(PrivatePaykitReservationData) -> PrivatePaykitReservationData>(0) + reservationData.value = transform(reservationData.value) + }.whenever(reservationStore).update(any()) + sut.removeContactAssignments(listOf(CONTACT_KEY, historyOnlyKey)) + assertTrue(reservationData.value.contactAssignments.isEmpty()) + assertTrue(reservationData.value.contactAssignmentHistory.isEmpty()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index c51eab7d2c..d98bd79206 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -2886,6 +2886,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `unsent hardware release preserves previously consumed boundary`() = test { + sut.consumePrivatePaymentList(CONTACT_KEY, PrivatePaykitPaymentContext(emptyMap(), 6uL)).getOrThrow() + sut.consumePrivatePaymentList(CONTACT_KEY, PrivatePaykitPaymentContext(emptyMap(), 7uL)).getOrThrow() + sut.releasePrivatePaymentListVersion(CONTACT_KEY, 7uL, 6uL).getOrThrow() + assertEquals(6uL, cacheData.value.contacts.getValue(CONTACT_KEY).consumedPrivatePaymentListVersion) + } + @Test fun `releasePrivatePaymentList makes matching version reusable without clearing newer consumption`() = test { val releasedContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL) diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index aeab5f224c..d53f50af41 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -12,12 +12,16 @@ import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.OutPoint import org.lightningdevkit.ldknode.PendingSweepBalance @@ -31,11 +35,14 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.env.Env import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails +import to.bitkit.models.NodeLifecycleState import to.bitkit.models.TransferType import to.bitkit.models.WalletScope import to.bitkit.services.ActivityService @@ -64,6 +71,10 @@ class TransferRepoTest : BaseUnitTest() { on { activity } doReturn activityService } private val clock = mock() + private val cacheStore = mock() + private val nodeEvents = MutableSharedFlow() + private val online = MutableStateFlow(ConnectivityState.DISCONNECTED) + private val connectivityRepo = mock() companion object Fixtures { private const val ID_ORDER = "test-order-id" @@ -74,6 +85,10 @@ class TransferRepoTest : BaseUnitTest() { @Before fun setUp() { + whenever(connectivityRepo.isOnline).thenReturn(online) + whenever(cacheStore.data).thenReturn(MutableStateFlow(AppCacheData())) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(emptyList())) sut = TransferRepo( @@ -83,9 +98,372 @@ class TransferRepoTest : BaseUnitTest() { coreService = coreService, transferDao = transferDao, clock = clock, + cacheStore = cacheStore, + connectivityRepo = connectivityRepo, ) } + @Test + fun `accepted funding rejects changed original client balance and fee without acknowledgement or resend`() = test { + val original = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, "attempt", null, original.id, + requireNotNull(original.payment?.onchain?.address), original.feeSat, false, 1uL, true, null, emptyList(), + OnchainSendEvidence.Accepted, txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, original.clientBalanceSat, original.feeSat), + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + for (changed in listOf( + original.copy(clientBalanceSat = original.clientBalanceSat + 1uL), + original.copy(feeSat = original.feeSat + 1uL), + )) { + whenever(blocktankRepo.fetchOrders(listOf(original.id))).thenReturn(Result.success(listOf(changed))) + assertTrue(sut.resumeAcceptedFunding().isFailure) + } + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( + attempt.copy(transferContext = OnchainTransferContext(99_000uL, 125_000uL)) + ) + whenever(blocktankRepo.fetchOrders(listOf(original.id))).thenReturn(Result.success(listOf(original))) + assertTrue(sut.resumeAcceptedFunding().isFailure) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + assertTrue(sut.resumeAcceptedFunding(attempt.copy(walletIndex = 1)).isFailure) + verify(transferDao, never()).insert(any()) + verify(cacheStore, never()).addPaidOrder(any(), any()) + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + assertEquals(attempt, lightningRepo.currentOnchainSendAttempt()) + } + + @Test + fun `connectivity recovery completes accepted funding without another send`() = test { + val order = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Accepted, + txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + whenever(blocktankRepo.fetchOrders(listOf(order.id))) + .thenReturn(Result.failure(AppError("offline"))) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState( + nodeLifecycleState = NodeLifecycleState.Running, + ))) + runCurrent() + assertTrue(sut.resumeAcceptedFunding().isFailure) + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) + online.value = ConnectivityState.CONNECTED + runCurrent() + + verify(transferDao).insert( + org.mockito.kotlin.check { + assertEquals(order.id, it.lspOrderId) + assertEquals(txid, it.fundingTxId) + assertEquals(99_000L, it.txTotalSats) + assertEquals(125_000L, it.preTransferOnchainSats) + } + ) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + assertNotNull(sut) + } + + @Test + fun `reconnect during suspended funding recovery starts another attempt`() = test { + val order = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Accepted, + txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + val firstFetch = CompletableDeferred() + var fetches = 0 + whenever(blocktankRepo.fetchOrders(listOf(order.id))).doSuspendableAnswer { + fetches += 1 + if (fetches == 1) { + firstFetch.await() + Result.failure(AppError("offline")) + } else { + Result.success(listOf(order)) + } + } + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState( + nodeLifecycleState = NodeLifecycleState.Running, + ))) + runCurrent() + online.value = ConnectivityState.CONNECTED + runCurrent() + assertEquals(1, fetches) + online.value = ConnectivityState.DISCONNECTED + runCurrent() + online.value = ConnectivityState.CONNECTED + runCurrent() + firstFetch.complete(Unit) + runCurrent() + assertEquals(2, fetches) + + verify(transferDao).insert( + org.mockito.kotlin.check { + assertEquals(order.id, it.lspOrderId) + assertEquals(txid, it.fundingTxId) + assertEquals(99_000L, it.txTotalSats) + assertEquals(125_000L, it.preTransferOnchainSats) + } + ) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + assertNotNull(sut) + } + + @Test + fun `node restart during suspended funding recovery starts another attempt`() = test { + val order = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Accepted, + txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + val firstFetch = CompletableDeferred() + var fetches = 0 + whenever(blocktankRepo.fetchOrders(listOf(order.id))).doSuspendableAnswer { + fetches += 1 + if (fetches == 1) { + firstFetch.await() + Result.failure(AppError("offline")) + } else { + Result.success(listOf(order)) + } + } + val state = MutableStateFlow(LightningState()) + whenever(lightningRepo.lightningState).thenReturn(state) + val restarted = TransferRepo( + testDispatcher, lightningRepo, blocktankRepo, coreService, transferDao, clock, cacheStore, connectivityRepo, + ) + runCurrent() + state.value = state.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + runCurrent() + assertEquals(1, fetches) + state.value = state.value.copy(nodeLifecycleState = NodeLifecycleState.Stopped) + runCurrent() + state.value = state.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + runCurrent() + firstFetch.complete(Unit) + runCurrent() + assertEquals(2, fetches) + + verify(transferDao).insert( + org.mockito.kotlin.check { + assertEquals(order.id, it.lspOrderId) + assertEquals(txid, it.fundingTxId) + assertEquals(99_000L, it.txTotalSats) + assertEquals(125_000L, it.preTransferOnchainSats) + } + ) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + assertNotNull(restarted) + } + + @Test + fun `startup resumes accepted original funding without a confirmation or native resend`() = test { + val order = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Accepted, + txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) + // Construct after the saved result exists, as on process startup. + val restarted = + TransferRepo(testDispatcher, lightningRepo, blocktankRepo, coreService, transferDao, clock, cacheStore, connectivityRepo) + runCurrent() + lightningRepo.lightningState.value.let { + (lightningRepo.lightningState as MutableStateFlow).value = it.copy( + nodeLifecycleState = NodeLifecycleState.Running, + ) + } + runCurrent() + + verify(transferDao).insert( + org.mockito.kotlin.check { + assertEquals(order.id, it.lspOrderId) + assertEquals(txid, it.fundingTxId) + assertEquals(99_000L, it.txTotalSats) + assertEquals(125_000L, it.preTransferOnchainSats) + } + ) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + assertNotNull(restarted) + } + + @Test + fun `exact observed event retries partial original funding without duplicate transfer`() = test { + val order = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Observed, + txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) + var persisted: TransferEntity? = null + whenever(transferDao.getByFundingTxId(txid)).thenAnswer { persisted } + whenever(transferDao.insert(any())).doSuspendableAnswer { + persisted = it.getArgument(0) + Unit + } + var failCache = true + whenever(cacheStore.addPaidOrder(order.id, txid)).doSuspendableAnswer { + if (failCache) { + failCache = false + error("cache storage unavailable") + } + Unit + } + assertTrue(sut.resumeAcceptedFunding().isFailure) + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + + nodeEvents.emit(Event.OnchainTransactionReceived(txid, mock())) + runCurrent() + verify(transferDao, times(1)).insert(any()) + verify(cacheStore, times(2)).addPaidOrder(order.id, txid) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) + assertEquals(99_000L, persisted?.txTotalSats) + assertEquals(125_000L, persisted?.preTransferOnchainSats) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + } + + @Test + fun `unknown funding cannot resume from local records or another transaction event`() = test { + val order = previewBtOrder() + val attempt = OnchainSendAttempt( + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Unknown, + "ab".repeat(32), + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + nodeEvents.emit(Event.OnchainTransactionReceived("cd".repeat(32), mock())) + runCurrent() + verify(transferDao, never()).insert(any()) + verify(cacheStore, never()).addPaidOrder(any(), any()) + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + org.mockito.kotlin.verifyNoInteractions(blocktankRepo) + } + // MARK: - createTransfer @Test @@ -1575,6 +1953,8 @@ class TransferRepoTest : BaseUnitTest() { coreService = coreService, transferDao = transferDao, clock = clock, + cacheStore = cacheStore, + connectivityRepo = connectivityRepo, ) testSut.activeTransfers.test { diff --git a/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt b/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt index 0836287fd2..abe467b5e4 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.PaymentType import org.junit.Test import org.mockito.ArgumentMatchers.any @@ -9,6 +10,11 @@ import org.mockito.ArgumentMatchers.anyLong import org.mockito.ArgumentMatchers.anyString import org.mockito.Mockito.mockStatic import org.mockito.Mockito.never +import kotlinx.coroutines.flow.flowOf +import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.ext.scopedActivityId import org.mockito.kotlin.mock import to.bitkit.async.ServiceQueue import to.bitkit.ext.create @@ -26,10 +32,12 @@ class ActivityServiceTest : BaseUnitTest() { private val updateActivity = binding.getMethod("updateActivity", String::class.java, Activity::class.java) private val markActivityAsSeen = binding.methods.single { it.name.startsWith("markActivityAsSeen") } + private val cacheStore = mock() + private val sut by lazy { ActivityService( coreService = mock(), - cacheStore = mock(), + cacheStore = cacheStore, lightningService = mock(), settingsStore = mock(), privatePaykitContactResolver = mock(), @@ -63,6 +71,118 @@ class ActivityServiceTest : BaseUnitTest() { } } + @Test + fun `restored contact fills missing attribution and preserves a later edit`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) + ServiceQueue.CORE.background { + val getByTx = binding.getMethod("getActivityByTxId", String::class.java, String::class.java) + val upsert = binding.getMethod("upsertActivity", Activity::class.java) + for (existingContact in listOf(null, "later-contact")) { + var saved = activity().v1.copy(txType = PaymentType.SENT, contact = existingContact) + mockStatic(binding).use { native -> + native.`when` { getByTx.invoke(null, WALLET_ID, ACTIVITY_ID) }.thenAnswer { saved } + native.`when` { upsert.invoke(null, any(Activity::class.java)) }.thenAnswer { + saved = (it.getArgument(0) as Activity.Onchain).v1 + Unit + } + sut.restoreSentOnchainContact(ACTIVITY_ID, WALLET_ID, "original-contact") + kotlin.test.assertEquals(existingContact ?: "original-contact", saved.contact) + if (existingContact != null) { + native.verify({ upsert.invoke(null, any(Activity::class.java)) }, never()) + } + } + } + } + } + + @Test + fun `restored contact preserves durable manual detachment without blocking completion`() = test { + whenever(cacheStore.data).thenReturn( + flowOf(AppCacheData(detachedActivityContacts = setOf(scopedActivityId(WALLET_ID, ACTIVITY_ID)))) + ) + ServiceQueue.CORE.background { + val getByTx = binding.getMethod("getActivityByTxId", String::class.java, String::class.java) + val upsert = binding.getMethod("upsertActivity", Activity::class.java) + var saved = activity().v1.copy(txType = PaymentType.SENT, contact = null) + mockStatic(binding).use { native -> + native.`when` { getByTx.invoke(null, WALLET_ID, ACTIVITY_ID) }.thenAnswer { saved } + native.`when` { upsert.invoke(null, any(Activity::class.java)) }.thenAnswer { + saved = (it.getArgument(0) as Activity.Onchain).v1 + Unit + } + sut.restoreSentOnchainContact(ACTIVITY_ID, WALLET_ID, "original-contact") + kotlin.test.assertNull(saved.contact) + native.verify({ upsert.invoke(null, any(Activity::class.java)) }, never()) + } + } + } + + @Test + fun `observed hardware tags use original wallet once and preserve later edits`() = test { + ServiceQueue.CORE.background { + val getByTx = binding.getMethod("getActivityByTxId", String::class.java, String::class.java) + val getMetadata = binding.getMethod("getAllPreActivityMetadata") + val getTags = binding.getMethod("getTags", String::class.java, String::class.java) + val addTags = binding.getMethod("addTags", String::class.java, String::class.java, List::class.java) + val upsertMetadata = binding.getMethod("upsertPreActivityMetadata", List::class.java) + val original = tagMetadata(WALLET_ID) + val foreign = tagMetadata("another-wallet").copy(tags = listOf("foreign")) + var metadata = listOf(original, foreign) + var tags = listOf("existing") + mockStatic(binding).use { native -> + native.`when` { getByTx.invoke(null, WALLET_ID, ACTIVITY_ID) } + .thenReturn(activity().v1.copy(txType = PaymentType.SENT)) + native.`when` { getMetadata.invoke(null) }.thenAnswer { metadata } + native.`when` { getTags.invoke(null, WALLET_ID, ACTIVITY_ID) }.thenAnswer { tags } + native.`when` { + addTags.invoke(null, WALLET_ID, ACTIVITY_ID, listOf("original")) + }.thenAnswer { tags = tags + "original"; Unit } + native.`when` { upsertMetadata.invoke(null, any(List::class.java)) }.thenAnswer { + val updated = it.getArgument>(0) + metadata = metadata.map { row -> + updated.firstOrNull { it.walletId == row.walletId && it.paymentId == row.paymentId } ?: row + } + Unit + } + + kotlin.test.assertTrue(sut.restoreSentOnchainTags(ACTIVITY_ID, WALLET_ID)) + kotlin.test.assertEquals(listOf("existing", "original"), tags) + kotlin.test.assertEquals(original.copy(tags = emptyList()), metadata.first()) + kotlin.test.assertEquals(foreign, metadata.last()) + tags = listOf("later-edit") + kotlin.test.assertFalse(sut.restoreSentOnchainTags(ACTIVITY_ID, WALLET_ID)) + kotlin.test.assertEquals(listOf("later-edit"), tags) + } + } + } + + @Test + fun `failed hardware tag write retains saved tags for recovery`() = test { + ServiceQueue.CORE.background { + val getByTx = binding.getMethod("getActivityByTxId", String::class.java, String::class.java) + val getMetadata = binding.getMethod("getAllPreActivityMetadata") + val getTags = binding.getMethod("getTags", String::class.java, String::class.java) + val addTags = binding.getMethod("addTags", String::class.java, String::class.java, List::class.java) + val upsertMetadata = binding.getMethod("upsertPreActivityMetadata", List::class.java) + mockStatic(binding).use { native -> + native.`when` { getByTx.invoke(null, WALLET_ID, ACTIVITY_ID) } + .thenReturn(activity().v1.copy(txType = PaymentType.SENT)) + native.`when` { getMetadata.invoke(null) }.thenReturn(listOf(tagMetadata(WALLET_ID))) + native.`when` { getTags.invoke(null, WALLET_ID, ACTIVITY_ID) }.thenReturn(emptyList()) + native.`when` { addTags.invoke(null, WALLET_ID, ACTIVITY_ID, listOf("original")) } + .thenThrow(IllegalStateException("tag write failed")) + kotlin.test.assertFails { sut.restoreSentOnchainTags(ACTIVITY_ID, WALLET_ID) } + native.verify({ upsertMetadata.invoke(null, any(List::class.java)) }, never()) + } + } + } + + private fun tagMetadata(walletId: String) = PreActivityMetadata( + walletId = walletId, paymentId = ACTIVITY_ID, tags = listOf("original"), + paymentHash = null, txId = ACTIVITY_ID, address = "address", isReceive = false, + feeRate = 0uL, isTransfer = false, channelId = null, createdAt = 1uL, + ) + private fun activity() = Activity.Onchain( OnchainActivity.create( walletId = WALLET_ID, diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index b2f0137724..63934ce38c 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -18,14 +18,19 @@ import org.junit.rules.TemporaryFolder import org.lightningdevkit.ldknode.AddressInfo import org.lightningdevkit.ldknode.AddressType import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.OnchainPayment +import org.lightningdevkit.ldknode.OnchainSendResult import org.lightningdevkit.ldknode.OnchainWalletAccount +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.timeout @@ -43,12 +48,24 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendAttemptStore +import to.bitkit.repositories.OnchainSendCoordinator +import to.bitkit.repositories.OnchainPreparedSender +import to.bitkit.repositories.PreparedOnchainSend +import to.bitkit.repositories.OnchainTransferContext +import to.bitkit.models.ActiveOnchainAttemptBackup +import to.bitkit.models.WalletScope +import kotlinx.serialization.json.Json +import kotlin.time.Instant import to.bitkit.test.BaseUnitTest import to.bitkit.utils.LoggerLdk import to.bitkit.utils.ServiceError import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -67,6 +84,8 @@ class LightningServiceTest : BaseUnitTest() { private val watchOnlyAccountStore = mock() private val loggerLdk = mock() private val node = mock() + private val onchainPayment = mock() + private val sendFeeRate = mock() private val watchOnlyAccountLifecycleCoordinator = WatchOnlyAccountLifecycleCoordinator() @get:Rule @@ -86,6 +105,7 @@ class LightningServiceTest : BaseUnitTest() { loggerLdk = loggerLdk, watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, ldkQueue = testDispatcher, + onchainFeeRateFactory = { sendFeeRate }, ) sut.node = node } @@ -139,6 +159,134 @@ class LightningServiceTest : BaseUnitTest() { assertTrue(sut.canReceive()) } + private fun preparedNative(txid: String) = mock().also { + whenever(it.txid()).thenReturn(txid) + whenever(it.inputs()).thenReturn(listOf(org.lightningdevkit.ldknode.OutPoint("11".repeat(32), 0u))) + whenever(it.recipientAmountSats()).thenReturn(1_000uL) + } + + @Test + fun `fixed preparation captures actual receipt without dispatch and caches broadcast`() = test { + val txid = "ab".repeat(32) + val native = preparedNative(txid) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.prepareSendToAddress("address", 1_000uL, sendFeeRate, null)).thenReturn(native) + whenever(native.broadcast()).thenReturn(OnchainSendResult.Accepted(txid)) + + val prepared = sut.prepareOnchainSend("address", 1_000uL, 1uL) + assertEquals(txid, prepared.receipt.txid) + assertEquals(1_000uL, prepared.receipt.amountSats) + assertEquals(1, prepared.receipt.inputs.size) + verify(native, never()).broadcast() + repeat(2) { assertEquals(OnchainSendOutcome.Accepted(txid), prepared.broadcast()) } + verify(native).broadcast() + verify(onchainPayment, never()).sendToAddressWithBroadcastResult(any(), any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `initial Max uses native automatic reserve semantics then captures actual amount`() = test { + val txid = "ab".repeat(32) + val native = preparedNative(txid) + whenever(native.recipientAmountSats()).thenReturn(900uL) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.prepareSendAllToAddress("address", true, sendFeeRate)).thenReturn(native) + whenever(native.broadcast()).thenReturn(OnchainSendResult.Unknown(txid)) + + val prepared = sut.prepareOnchainSend("address", 1_000uL, 1uL, isMaxAmount = true) + assertEquals(900uL, prepared.receipt.amountSats) + verify(native, never()).broadcast() + assertEquals(OnchainSendOutcome.Unknown(txid), prepared.broadcast()) + verify(onchainPayment).prepareSendAllToAddress("address", true, sendFeeRate) + verify(onchainPayment, never()).sendAllToAddressWithBroadcastResult(any(), any(), anyOrNull()) + } + + @Test + fun `initial fixed preset above 999 dispatches through durable receipt retention`() = test { + initialPresetThroughRetention(isMax = false, isTransfer = false) + initialPresetThroughRetention(isMax = false, isTransfer = false, feeRate = UInt.MAX_VALUE.toULong()) + } + + @Test + fun `initial send-all preset above 999 dispatches through durable receipt retention`() = test { + initialPresetThroughRetention(isMax = true, isTransfer = false) + } + + @Test + fun `initial Fast transfer preset above 999 dispatches through durable receipt retention`() = test { + initialPresetThroughRetention(isMax = true, isTransfer = true) + } + + private suspend fun initialPresetThroughRetention(isMax: Boolean, isTransfer: Boolean, feeRate: ULong = 1_000uL) { + var saved: String? = null + val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, sut, kotlin.time.Clock.System) + val txid = "ab".repeat(32) + val native = preparedNative(txid) + val amount = if (isMax) 900uL else 1_000uL + whenever(native.recipientAmountSats()).thenReturn(amount) + whenever(native.miningFeeSats()).thenReturn(50uL) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.prepareSendToAddress("address", 1_000uL, sendFeeRate, null)).thenReturn(native) + whenever(onchainPayment.prepareSendAllToAddress("address", true, sendFeeRate)).thenReturn(native) + whenever(native.broadcast()).thenAnswer { + val retained = Json.decodeFromString(requireNotNull(saved)) + assertTrue(retained.initialDispatchAttempted) + assertEquals(feeRate, retained.candidateFeeRates[txid]) + assertEquals(amount, retained.amountSats) + OnchainSendResult.Accepted(txid) + } + val attempt = store.admit( + walletId = WalletScope.default, requestId = null, orderId = if (isTransfer) "order-1" else null, + address = "address", amountSats = 1_000uL, isMaxAmount = isMax, feeRateSatsPerVByte = feeRate, + isTransfer = isTransfer, channelId = null, tags = listOf("original-tag"), + transferContext = if (isTransfer) OnchainTransferContext(2_000uL, 2_000uL, 900uL, 900uL) else null, + beforeSendAttempt = {}, + ) + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt) = sut.prepareOnchainSend( + attempt.address, + attempt.amountSats, + attempt.feeRateSatsPerVByte, + isMaxAmount = attempt.isMaxAmount, + ) + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant?, + ): PreparedOnchainSend = error("unused") + } + val coordinator = OnchainSendCoordinator(store, sender, testDispatcher) + assertEquals(OnchainSendOutcome.Accepted(txid), coordinator.sendInitial(attempt).getOrThrow()) + verify(native).broadcast() + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, sut, kotlin.time.Clock.System) + val recorded = requireNotNull(reopened.current()) + val wire = ActiveOnchainAttemptBackup.from(recorded, "regtest", "12".repeat(32)) + val restoredWire = Json.decodeFromString(Json.encodeToString(wire)) + val restored = restoredWire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + assertEquals(feeRate, restored.feeRateSatsPerVByte) + assertEquals(mapOf(txid to feeRate), restored.candidateFeeRates) + assertEquals(recorded.originalInputs, restored.originalInputs) + assertEquals(amount, restored.amountSats) + assertEquals(recorded.transferContext, restored.transferContext) + } + + @Test + fun `onchain send refuses a different wallet before invoking native payment`() = test { + assertFailsWith { + sut.prepareOnchainSend("bcrt1qrecipient", 1_000uL, 1uL, walletIndex = 9) + } + verify(node, never()).onchainPayment() + } + + @Test + fun `generated node error stays direct across the service queue`() = test { + assertFailsWith { + sut.callOnchainSend { throw NodeException.InvalidAddress("invalid address") } + } + } + @Test fun `stop destroys the node handle and clears it`() = test { sut.stop() diff --git a/app/src/test/java/to/bitkit/services/OriginalSendFeeTest.kt b/app/src/test/java/to/bitkit/services/OriginalSendFeeTest.kt new file mode 100644 index 0000000000..260a8456bd --- /dev/null +++ b/app/src/test/java/to/bitkit/services/OriginalSendFeeTest.kt @@ -0,0 +1,58 @@ +package to.bitkit.services + +import org.junit.Test +import org.lightningdevkit.ldknode.TransactionDetails +import org.lightningdevkit.ldknode.TxInput +import org.lightningdevkit.ldknode.TxOutput +import to.bitkit.repositories.OnchainSendInput +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class OriginalSendFeeTest { + private val parentId = "ef".repeat(32) + private val input = TxInput(parentId, 1u, "", emptyList(), UInt.MAX_VALUE - 2u) + private fun output(value: Long, n: UInt = 0u) = TxOutput("", null, null, value, n) + private val inputs = listOf(OnchainSendInput(parentId, 1u)) + private val details = TransactionDetails(-1000L, listOf(input), listOf(output(1000), output(18719, 1u))) + private val parent = TransactionDetails(20000L, emptyList(), listOf(output(20000, 1u))) + + @Test + fun `winning fee uses exact previous output and never original fee hint`() { + assertEquals(281uL, exactOriginalSendFee(details, inputs) { parent }) + assertNull(exactOriginalSendFee(details, inputs) { null }) + } + + @Test + fun `invalid or substituted input and previous output cannot complete a fee`() { + assertFailsWith { exactOriginalSendFee(details, emptyList()) { parent } } + assertFailsWith { + exactOriginalSendFee(details.copy(inputs = listOf(input, input)), inputs) { parent } + } + assertFailsWith { + exactOriginalSendFee(details, inputs) { parent.copy(outputs = listOf(output(20000, 0u))) } + } + assertFailsWith { + exactOriginalSendFee(details, inputs) { parent.copy(outputs = listOf(output(-1, 1u))) } + } + assertFailsWith { + exactOriginalSendFee(details, inputs) { parent.copy(outputs = listOf(output(100, 1u))) } + } + } + + @Test + fun `negative and overflowing totals cannot complete a fee`() { + assertFailsWith { + exactOriginalSendFee(details.copy(outputs = listOf(output(-1))), inputs) { parent } + } + val other = input.copy(vout = 2u) + val three = input.copy(vout = 3u) + val many = details.copy(inputs = listOf(input, other, three)) + val original = many.inputs.map { OnchainSendInput(it.txid, it.vout) } + assertFailsWith { + exactOriginalSendFee(many, original) { + parent.copy(outputs = listOf(output(Long.MAX_VALUE, 1u), output(Long.MAX_VALUE, 2u), output(Long.MAX_VALUE, 3u))) + } + } + } +} diff --git a/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt b/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt index 5f70b0462d..48b8b3945b 100644 --- a/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt +++ b/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.withContext import org.junit.Test import org.lightningdevkit.ldknode.Node import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.mock import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @@ -44,12 +46,12 @@ class PaymentDeadlineSubmissionTest : BaseUnitTest() { for (isMax in listOf(false, true)) { now = deadline - 1.nanoseconds val result = async { - runCatching { service.send("address", 1uL, 1uL, isMaxAmount = isMax, paymentDeadlineAt = deadline) } + runCatching { service.prepareOnchainSend("address", 1uL, 1uL, isMaxAmount = isMax, paymentDeadlineAt = deadline) } } now = deadline + 1.nanoseconds runCurrent() - assertIs(result.await().exceptionOrNull()?.cause) + assertIs(result.await().exceptionOrNull()) } verifyNoInteractions(node) } @@ -68,10 +70,33 @@ class PaymentDeadlineSubmissionTest : BaseUnitTest() { ) val error = runCatching { - service.send("address", 1uL, 1uL, paymentDeadlineAt = deadline) + service.prepareOnchainSend("address", 1uL, 1uL, paymentDeadlineAt = deadline) }.exceptionOrNull() - assertSame(submitted, assertIs(error).cause) + assertSame(submitted, error) + } + + @Test + fun `expiry after preparation prevents native dispatch of the retained candidate`() = test { + var now = deadline + val node = mock() + val payment = mock() + val native = mock() + whenever(node.onchainPayment()).thenReturn(payment) + whenever(payment.prepareSendToAddress(org.mockito.kotlin.eq("address"), org.mockito.kotlin.eq(1uL), + org.mockito.kotlin.any(), org.mockito.kotlin.isNull())).thenReturn(native) + whenever(native.txid()).thenReturn("ab".repeat(32)) + whenever(native.inputs()).thenReturn(listOf(org.lightningdevkit.ldknode.OutPoint("11".repeat(32), 0u))) + whenever(native.recipientAmountSats()).thenReturn(1uL) + val service = lightningService(node, object : Clock { override fun now() = now }, testDispatcher) + val prepared = service.prepareOnchainSend("address", 1uL, 1uL, paymentDeadlineAt = deadline) + now = deadline + 1.nanoseconds + + val error = runCatching { prepared.broadcast() }.exceptionOrNull() + + assertIs(error) + assertEquals("ab".repeat(32), prepared.receipt.txid) + verify(native, never()).broadcast() } @Test @@ -116,5 +141,6 @@ class PaymentDeadlineSubmissionTest : BaseUnitTest() { watchOnlyAccountLifecycleCoordinator = WatchOnlyAccountLifecycleCoordinator(), ldkQueue = queue, clock = clock, + onchainFeeRateFactory = { mock() }, ).also { it.node = node } } diff --git a/app/src/test/java/to/bitkit/services/TrezorServiceDispatchTest.kt b/app/src/test/java/to/bitkit/services/TrezorServiceDispatchTest.kt new file mode 100644 index 0000000000..ddda975ce0 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/TrezorServiceDispatchTest.kt @@ -0,0 +1,48 @@ +package to.bitkit.services + +import org.junit.Test +import org.mockito.kotlin.mock +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.ServiceError +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Instant + +class TrezorServiceDispatchTest : BaseUnitTest() { + @Test + fun `expired native queue skips the durable dispatch callback`() = test { + val deadline = Instant.parse("2026-10-08T12:00:00Z") + val service = TrezorService(mock(), mock(), object : Clock { + override fun now() = Instant.parse("2026-10-08T12:00:01Z") + }) + var markers = 0 + val result = runCatching { + service.broadcastRawTxAtBoundary("not-a-transaction", "unused", deadline) { markers++ } + } + assertEquals(0, markers) + assertTrue( + generateSequence(result.exceptionOrNull()) { it.cause }.any { it is ServiceError.PaymentDeadlineExpired } + ) + } + + @Test + fun `expiry while the durable marker is saved skips native submission`() = test { + val deadline = Instant.parse("2026-10-08T12:00:00Z") + var now = Instant.parse("2026-10-08T11:59:59Z") + val service = TrezorService(mock(), mock(), object : Clock { + override fun now() = now + }) + var markers = 0 + val result = runCatching { + service.broadcastRawTxAtBoundary("not-a-transaction", "unused", deadline) { + markers++ + now = Instant.parse("2026-10-08T12:00:01Z") + } + } + assertEquals(1, markers) + assertTrue( + generateSequence(result.exceptionOrNull()) { it.cause }.any { it is ServiceError.PaymentDeadlineExpired } + ) + } +} 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 2df5004f01..c71d629d9e 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 @@ -3,19 +3,27 @@ package to.bitkit.ui.screens.wallets.send import android.content.Context import com.synonym.bitkitcore.BroadcastException import com.synonym.bitkitcore.TrezorException +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -32,21 +40,17 @@ import to.bitkit.models.Toast import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.HwWalletMismatchError import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.RetainedHardwareOnchainPayment import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import to.bitkit.utils.ServiceError -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.time.Clock -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant +import to.bitkit.utils.SignedTransactionId @OptIn(ExperimentalCoroutinesApi::class) class HwSendViewModelTest : BaseUnitTest() { @@ -57,6 +61,7 @@ class HwSendViewModelTest : BaseUnitTest() { private val coreService = mock() private val activityService = mock() private val activityRepo = mock() + private val proofRepo = mock() private lateinit var sut: HwSendViewModel private var now = Instant.parse("2026-10-06T11:59:59Z") @@ -64,6 +69,41 @@ class HwSendViewModelTest : BaseUnitTest() { @Before fun setUp() { whenever(coreService.activity).thenReturn(activityService) + whenever { proofRepo.hasRetainedHardwareOnchainPayment(any()) }.thenReturn(false) + + whenever { + proofRepo.retainHardwareOnchainCandidate( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + org.mockito.kotlin.anyOrNull() + ) + } + .thenReturn(true) + whenever { + proofRepo.clearHardwareOnchainCandidateBeforeDispatch( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + any() + ) + }.thenReturn(false) + whenever { + proofRepo.markHardwareOnchainDispatch(any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), any()) + } + .thenReturn(true) + whenever { hwWalletRepo.broadcastFundingAtBoundary(any(), org.mockito.kotlin.anyOrNull(), any()) } + .doSuspendableAnswer { + val beforeDispatch = it.getArgument Unit>(2) + beforeDispatch() + hwWalletRepo.broadcastFunding(it.getArgument(0), it.getArgument(1)) + } whenever { hwWalletRepo.reconnectTimeout(any()) }.thenReturn(30.seconds) sut = HwSendViewModel( context = context, @@ -71,6 +111,7 @@ class HwSendViewModelTest : BaseUnitTest() { preActivityMetadataRepo = preActivityMetadataRepo, coreService = coreService, activityRepo = activityRepo, + paykitPaymentProofRepo = proofRepo, clock = object : Clock { override fun now() = now }, @@ -79,12 +120,44 @@ class HwSendViewModelTest : BaseUnitTest() { @Test fun `signing reconnects and retries once after THP channel failure`() = test { - val (funding, signedTx, broadcast) = stubSuccessfulPayment() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = 1_000uL, + feeRate = 2.0f, + totalSpent = 26_000uL, + satsPerVByte = 2uL, + ) + val signedTx = HwFundingSignedTx( + serializedTx = signedFixtureHex(), + miningFeeSats = funding.miningFeeSats, + feeRate = 2uL, + totalSpent = funding.totalSpent, + ) + val broadcast = HwFundingBroadcastResult( + txId = "txid", + miningFeeSats = signedTx.miningFeeSats, + feeRate = signedTx.feeRate, + totalSpent = signedTx.totalSpent, + ) + whenever(hwWalletRepo.needsPassphrase(WALLET_ID)).thenReturn(false) + whenever(hwWalletRepo.ensureConnected(WALLET_ID)).thenReturn(Result.success(connectedDevice())) + whenever(hwWalletRepo.composeFundingTransaction(WALLET_ID, ADDRESS, AMOUNT_SATS, SATS_PER_VBYTE)) + .thenReturn(Result.success(funding)) whenever(hwWalletRepo.signFunding(WALLET_ID, funding)).thenReturn( Result.failure(TrezorException.ProtocolException("THP decryption error: aead::Error")), Result.success(signedTx), ) - sut.signAndBroadcast(request()) + whenever(hwWalletRepo.broadcastFunding(signedTx)).thenReturn(Result.success(broadcast)) + + sut.signAndBroadcast( + HwSendRequest( + walletId = WALLET_ID, + address = ADDRESS, + amountSats = AMOUNT_SATS, + satsPerVByte = SATS_PER_VBYTE, + tags = emptyList(), + ) + ) advanceUntilIdle() verify(hwWalletRepo, times(2)).ensureConnected(WALLET_ID) @@ -112,7 +185,7 @@ class HwSendViewModelTest : BaseUnitTest() { Result.success(fixture.broadcast), ) var preparationCalls = 0 - val prepareContactPayment: suspend () -> Boolean = { + val prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { verify(hwWalletRepo).signFunding(WALLET_ID, fixture.funding) verify(hwWalletRepo, never()).broadcastFunding(fixture.signedTx) preparationCalls += 1 @@ -142,54 +215,6 @@ class HwSendViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.hasPendingBroadcast) } - @Test - fun `invalid transaction releases the attempt only without an earlier uncertain broadcast`() = test { - whenever(context.getString(any())).thenReturn("message") - val fixture = stubSuccessfulPayment() - for (hadPriorAttempt in listOf(false, true)) { - val attempts = mutableListOf() - if (hadPriorAttempt) { - whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) - .thenReturn(Result.failure(BroadcastException.ElectrumException("offline"))) - sut.signAndBroadcast(request(), onBroadcastAttemptChanged = { attempts += it }) - advanceUntilIdle() - } - whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) - .thenReturn(Result.failure(AppError(BroadcastException.InvalidTransaction("invalid transaction")))) - - sut.signAndBroadcast(request(), onBroadcastAttemptChanged = { attempts += it }) - advanceUntilIdle() - - assertEquals(hadPriorAttempt, attempts.last()) - assertEquals(hadPriorAttempt, sut.uiState.value.hasPendingBroadcast) - assertTrue(sut.uiState.value.canLeave) - sut.cancel() - advanceUntilIdle() - } - } - - @Test - fun `unclassified broadcast failures retain the signed transaction for retry`() = test { - whenever(context.getString(any())).thenReturn("message") - val fixture = stubSuccessfulPayment() - whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( - Result.failure(AppError("broadcast outcome unknown")), - Result.success(fixture.broadcast), - ) - val attempts = mutableListOf() - sut.signAndBroadcast(request(), onBroadcastAttemptChanged = { attempts += it }) - advanceUntilIdle() - - assertEquals(listOf(true), attempts) - assertTrue(sut.uiState.value.hasPendingBroadcast) - - sut.signAndBroadcast(request(), onBroadcastAttemptChanged = { attempts += it }) - advanceUntilIdle() - - verify(hwWalletRepo).signFunding(WALLET_ID, fixture.funding) - verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) - } - @Test fun `denied retry keeps the signed transaction after a failed broadcast`() = test { whenever(context.getString(any())).thenReturn("message") @@ -243,7 +268,7 @@ class HwSendViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.reconnectWithPassphrase(WALLET_ID, "hidden wallet")) .thenReturn(Result.success(Unit)) var preparationCalls = 0 - val prepareContactPayment: suspend () -> Boolean = { + val prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { preparationCalls += 1 true } @@ -257,9 +282,7 @@ class HwSendViewModelTest : BaseUnitTest() { advanceUntilIdle() assertTrue(sut.uiState.value.isPassphraseRequired) - sut.submitPassphrase(WALLET_ID, "hidden wallet") { - sut.signAndBroadcast(request(), prepareContactPayment, authorizeContactPayment) - } + sut.submitPassphrase(request(), "hidden wallet", prepareContactPayment, authorizeContactPayment) advanceUntilIdle() assertEquals(1, preparationCalls) @@ -306,7 +329,7 @@ class HwSendViewModelTest : BaseUnitTest() { assertEquals("Payment timed out", toasts.single().description) assertFalse(sut.uiState.value.isSigning) verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any(), anyOrNull()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @Test @@ -324,6 +347,125 @@ class HwSendViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.isBroadcastUnresolved) } + @Test + fun `Shop broadcast connectivity failure retains original signed payment across cancel`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( + Result.failure(BroadcastException.ElectrumException("connection failed")), + Result.success(fixture.broadcast), + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.isBroadcastUnresolved) + sut.cancel() + advanceUntilIdle() + assertTrue(sut.uiState.value.hasPendingBroadcast) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + assertEquals(original.paymentRequestId, sut.results.first().paymentRequestId) + assertEquals(original.paymentIdentity, sut.results.first().paymentIdentity) + } + + @Test + fun `Shop invalid broadcast failure retains original signed payment across cancel`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( + Result.failure(BroadcastException.InvalidTransaction("invalid transaction")), + Result.success(fixture.broadcast), + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertTrue(sut.uiState.value.canLeave) + sut.cancel() + advanceUntilIdle() + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.canLeave) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + assertEquals(original.paymentRequestId, sut.results.first().paymentRequestId) + assertEquals(original.paymentIdentity, sut.results.first().paymentIdentity) + } + + @Test + fun `Shop backend refusal allows leaving while retaining original signed payment`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( + Result.failure( + BroadcastException.ElectrumException( + """Broadcast failed: Electrum server error: "sendrawtransaction RPC error -26: """ + + """insufficient fee, rejecting replacement """ + + """b3f63e62aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; new feerate """ + + """0.00001000 BTC/kvB <= old feerate 0.00001018 BTC/kvB"""", + ), + ), + Result.failure( + BroadcastException.ElectrumException( + """Broadcast failed: Electrum server error: "sendrawtransaction RPC error -25: """ + + """bad-txns-inputs-missingorspent"""", + ), + ), + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + sut.cancel() + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + verify(proofRepo, times(2)).markHardwareOnchainDispatch( + requireNotNull(original.paymentRequestId), + WALLET_ID, + SignedTransactionId.fromHex(fixture.signedTx.serializedTx), + original.paymentIdentity, + original.address, + original.amountSats, + ) + verify(proofRepo, never()).clearHardwareOnchainCandidateBeforeDispatch( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + any(), + ) + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + verify(proofRepo, times(2)).markHardwareRefusedForNavigation( + requireNotNull(original.paymentRequestId), + WALLET_ID, + SignedTransactionId.fromHex(fixture.signedTx.serializedTx), + original.paymentIdentity, + original.address, + original.amountSats, + ) + } + @Test fun `broadcast connectivity failure unblocks navigation`() = test { whenever(context.getString(any())).thenReturn("message") @@ -474,7 +616,7 @@ class HwSendViewModelTest : BaseUnitTest() { verify(hwWalletRepo).disconnectStaleSession(WALLET_ID) verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any(), anyOrNull()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) assertEquals(HwSendUiState(), sut.uiState.value) sut.signAndBroadcast(request()) @@ -490,7 +632,6 @@ class HwSendViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) .thenReturn(Result.failure(AppError(ServiceError.PaymentDeadlineExpired()))) val attempts = mutableListOf() - val broadcastAttempts = mutableListOf() sut.signAndBroadcast( request().copy(paymentDeadlineAt = deadline), @@ -499,12 +640,10 @@ class HwSendViewModelTest : BaseUnitTest() { true }, onPaymentDeadlineExpired = { attempts += it }, - onBroadcastAttemptChanged = { broadcastAttempts += it }, ) advanceUntilIdle() assertEquals(listOf(false, false), attempts) - assertEquals(listOf(true, false), broadcastAttempts) assertFalse(sut.uiState.value.isBroadcastUnresolved) assertFalse(sut.uiState.value.isSigning) sut.cancel() @@ -512,7 +651,63 @@ class HwSendViewModelTest : BaseUnitTest() { } @Test - fun `expiry during rebroadcast retains the earlier attempt without blocking dismissal`() = test { + fun `changed Shop receipt at native boundary prevents hardware submission`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + whenever(proofRepo.markHardwareOnchainDispatch( + any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), any() + )) + .thenReturn(false) + sut.signAndBroadcast(request().copy(paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity")) + advanceUntilIdle() + verify(proofRepo).markHardwareOnchainDispatch(any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(fixture.signedTx) + } + + @Test + fun `first queued Shop expiry clears exact retained candidate before releasing preparation`() = test { + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + val requestId = PaykitPaymentRequestId("request", "counterparty") + val original = request().copy(paymentRequestId = requestId, paymentIdentity = "original-identity", + paymentDeadlineAt = deadline) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) + .thenReturn(Result.failure(AppError(ServiceError.PaymentDeadlineExpired()))) + var cleared = false + whenever(proofRepo.clearHardwareOnchainCandidateBeforeDispatch(requestId, WALLET_ID, + "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e", + "original-identity", ADDRESS, AMOUNT_SATS, false)).doSuspendableAnswer { + cleared = true + true + } + var expired: Boolean? = null + sut.signAndBroadcast(original, onPaymentDeadlineExpired = { + assertTrue(cleared) + expired = it + }) + advanceUntilIdle() + assertEquals(false, expired) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + } + + @Test + fun `failed queued Shop candidate rollback preserves uncertainty`() = test { + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) + .thenReturn(Result.failure(AppError(ServiceError.PaymentDeadlineExpired()))) + var expired: Boolean? = null + sut.signAndBroadcast(request().copy(paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", paymentDeadlineAt = deadline), + onPaymentDeadlineExpired = { expired = it }) + advanceUntilIdle() + assertEquals(true, expired) + assertTrue(sut.uiState.value.isBroadcastUnresolved) + } + + @Test + fun `expiry during rebroadcast keeps the earlier uncertain attempt`() = test { whenever(context.getString(any())).thenReturn("message") val fixture = stubSuccessfulPayment() val deadline = Instant.parse("2026-10-06T12:00:00Z") @@ -521,7 +716,6 @@ class HwSendViewModelTest : BaseUnitTest() { Result.failure(AppError(ServiceError.PaymentDeadlineExpired())), ) val attempts = mutableListOf() - val broadcastAttempts = mutableListOf() val authorize: suspend (Boolean) -> Boolean = { attempts += it attempts.size < 3 @@ -531,39 +725,33 @@ class HwSendViewModelTest : BaseUnitTest() { sut.signAndBroadcast( request, authorizeContactPayment = authorize, - onPaymentDeadlineExpired = { attempts += it }, - onBroadcastAttemptChanged = { broadcastAttempts += it }, + onPaymentDeadlineExpired = { attempts += it } ) advanceUntilIdle() sut.signAndBroadcast( request, authorizeContactPayment = authorize, - onPaymentDeadlineExpired = { attempts += it }, - onBroadcastAttemptChanged = { broadcastAttempts += it }, + onPaymentDeadlineExpired = { attempts += it } ) advanceUntilIdle() assertEquals(listOf(false, true, true), attempts) - assertEquals(listOf(true, true, true), broadcastAttempts) - assertFalse(sut.uiState.value.isBroadcastUnresolved) - assertTrue(sut.uiState.value.hasPendingBroadcast) - assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.isBroadcastUnresolved) assertFalse(sut.uiState.value.isSigning) sut.cancel() - assertFalse(sut.uiState.value.hasPendingBroadcast) - assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.isBroadcastUnresolved) verify(hwWalletRepo).signFunding(WALLET_ID, fixture.funding) } @Test - fun `confirmed payment resolves an expired hardware retry without rebroadcasting`() = test { + fun `expiry before rebroadcast preserves uncertainty without repeating authorization`() = test { whenever(context.getString(any())).thenReturn("message") val fixture = stubSuccessfulPayment() val deadline = Instant.parse("2026-10-06T12:00:00Z") whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) .thenReturn(Result.failure(BroadcastException.ElectrumException("connection failed"))) - val requestId = PaykitPaymentRequestId("request", "counterparty") - val request = request().copy(paymentDeadlineAt = deadline, paymentRequestId = requestId) + val request = request().copy(paymentDeadlineAt = deadline) var authorizations = 0 var expiredPriorAttempt: Boolean? = null val authorize: suspend (Boolean) -> Boolean = { @@ -580,83 +768,18 @@ class HwSendViewModelTest : BaseUnitTest() { onPaymentDeadlineExpired = { expiredPriorAttempt = it }, ) advanceUntilIdle() + sut.cancel() assertEquals(1, authorizations) assertEquals(true, expiredPriorAttempt) - assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertTrue(sut.uiState.value.isBroadcastUnresolved) assertTrue(sut.uiState.value.hasPendingBroadcast) assertFalse(sut.uiState.value.isSigning) - assertTrue(sut.uiState.value.canLeave) - - sut.resolveBroadcast(WALLET_ID, requestId, fixture.broadcast.txId) - advanceUntilIdle() - - assertEquals(HwSendResult(WALLET_ID, fixture.broadcast.txId, AMOUNT_SATS), sut.results.first()) - verify(activityService).createSentOnchainActivityFromSendResult( - txid = fixture.broadcast.txId, - address = ADDRESS, - amount = AMOUNT_SATS, - fee = fixture.signedTx.miningFeeSats, - feeRate = fixture.signedTx.feeRate, - isTransfer = false, - channelId = null, - walletId = WALLET_ID, - ) - sut.completeBroadcast() - assertTrue(sut.uiState.value.canLeave) - assertFalse(sut.uiState.value.hasPendingBroadcast) verify(hwWalletRepo).broadcastFunding(fixture.signedTx, deadline) } @Test - fun `payment resolution ignores a different request or wallet and stops an active retry`() = test { - whenever(context.getString(any())).thenReturn("message") - val fixture = stubSuccessfulPayment() - whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) - .thenReturn(Result.failure(BroadcastException.ElectrumException("connection failed"))) - val requestId = PaykitPaymentRequestId("request", "counterparty") - val request = request().copy(paymentRequestId = requestId) - sut.signAndBroadcast(request) - advanceUntilIdle() - - val authorization = CompletableDeferred() - sut.signAndBroadcast(request, authorizeContactPayment = { authorization.await() }) - runCurrent() - sut.resolveBroadcast("other-wallet", requestId, fixture.broadcast.txId) - sut.resolveBroadcast(WALLET_ID, requestId.copy(paymentRequestId = "other-request"), fixture.broadcast.txId) - runCurrent() - verify(activityRepo, never()).notifyPaymentActivityChanged() - assertTrue(sut.uiState.value.isSigning) - - sut.resolveBroadcast(WALLET_ID, requestId, fixture.broadcast.txId) - advanceUntilIdle() - authorization.complete(true) - advanceUntilIdle() - - assertEquals(HwSendResult(WALLET_ID, fixture.broadcast.txId, AMOUNT_SATS), sut.results.first()) - verify(hwWalletRepo).broadcastFunding(fixture.signedTx) - sut.completeBroadcast() - assertTrue(sut.uiState.value.canLeave) - } - - @Test - fun `payment resolution cannot complete a hardware send before broadcast was attempted`() = test { - stubSuccessfulPayment() - val requestId = PaykitPaymentRequestId("request", "counterparty") - sut.signAndBroadcast(request().copy(paymentRequestId = requestId), authorizeContactPayment = { false }) - advanceUntilIdle() - - assertFalse(sut.resolveBroadcast(WALLET_ID, requestId, "txid")) - advanceUntilIdle() - - verify(hwWalletRepo, never()).broadcastFunding(any(), any()) - verify(activityRepo, never()).notifyPaymentActivityChanged() - assertTrue(sut.uiState.value.hasPendingBroadcast) - assertTrue(sut.uiState.value.canLeave) - } - - @Test - fun `expiry during retry authorization retains the earlier attempt without blocking dismissal`() = test { + fun `expiry during retry authorization preserves the earlier uncertain attempt`() = test { whenever(context.getString(any())).thenReturn("message") val fixture = stubSuccessfulPayment() val deadline = Instant.parse("2026-10-06T12:00:00Z") @@ -672,14 +795,11 @@ class HwSendViewModelTest : BaseUnitTest() { false }) advanceUntilIdle() + sut.cancel() - assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertTrue(sut.uiState.value.isBroadcastUnresolved) assertTrue(sut.uiState.value.hasPendingBroadcast) assertFalse(sut.uiState.value.isSigning) - assertTrue(sut.uiState.value.canLeave) - sut.cancel() - assertFalse(sut.uiState.value.hasPendingBroadcast) - assertTrue(sut.uiState.value.canLeave) verify(hwWalletRepo).broadcastFunding(fixture.signedTx, deadline) } @@ -692,7 +812,7 @@ class HwSendViewModelTest : BaseUnitTest() { satsPerVByte = SATS_PER_VBYTE, ) val signedTx = HwFundingSignedTx( - serializedTx = "rawtx", + serializedTx = signedFixtureHex(), miningFeeSats = funding.miningFeeSats, feeRate = SATS_PER_VBYTE, totalSpent = funding.totalSpent, @@ -712,6 +832,595 @@ class HwSendViewModelTest : BaseUnitTest() { return PaymentFixture(funding, signedTx, broadcast) } + @Test + fun `retained hardware receipt blocks ordinary and foreign request signing after restart`() = test { + stubSuccessfulPayment() + whenever(context.getString(org.mockito.kotlin.any())).thenReturn("payment blocked") + whenever { proofRepo.hasRetainedHardwareOnchainPayment(WALLET_ID) }.thenReturn(true) + for (next in listOf(request(), request().copy( + paymentRequestId = PaykitPaymentRequestId("foreign-request", "foreign-counterparty"), + paymentIdentity = "original-identity" + ))) { + sut = HwSendViewModel( + context = context, + hwWalletRepo = hwWalletRepo, + preActivityMetadataRepo = preActivityMetadataRepo, + coreService = coreService, + activityRepo = activityRepo, + paykitPaymentProofRepo = proofRepo, + clock = object : Clock { + override fun now() = now + }, + ) + sut.signAndBroadcast(next) + advanceUntilIdle() + } + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `restored hardware Shop receipt retries only after authorization without signing again`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity" + ) + whenever { + proofRepo.retainedHardwareOnchainPayment( + requireNotNull(original.paymentRequestId), + WALLET_ID, + original.paymentIdentity, + ADDRESS, + AMOUNT_SATS + ) + }.thenReturn(to.bitkit.repositories.RetainedHardwareOnchainPayment(fixture.signedTx, true)) + sut.signAndBroadcast( + original, + prepareContactPayment = { error("must retain original preparation") }, + authorizeContactPayment = { attempted -> + assertTrue(attempted) + false + } + ) + advanceUntilIdle() + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + sut.signAndBroadcast( + original, + prepareContactPayment = { error("must retain original preparation") }, + authorizeContactPayment = { attempted -> + assertTrue(attempted) + true + } + ) + advanceUntilIdle() + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `restored unattempted Shop receipt expires without claiming dispatch or signing again`() = test { + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + paymentDeadlineAt = deadline + ) + whenever { + proofRepo.retainedHardwareOnchainPayment( + requireNotNull(original.paymentRequestId), + WALLET_ID, + original.paymentIdentity, + ADDRESS, + AMOUNT_SATS + ) + }.thenReturn(to.bitkit.repositories.RetainedHardwareOnchainPayment(fixture.signedTx, false)) + now = deadline + 1.seconds + var attempted: Boolean? = null + sut.signAndBroadcast( + original, + prepareContactPayment = { error("must retain original preparation") }, + authorizeContactPayment = { error("expired before authorization") }, + onPaymentDeadlineExpired = { attempted = it } + ) + advanceUntilIdle() + assertEquals(false, attempted) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `hardware Shop never broadcasts when candidate persistence fails`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity" + ) + whenever { + proofRepo.retainHardwareOnchainCandidate( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + org.mockito.kotlin.anyOrNull() + ) + } + .thenReturn(false) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, never()).broadcastFunding(fixture.signedTx) + verify(proofRepo).retainHardwareOnchainCandidate( + requireNotNull(original.paymentRequestId), + WALLET_ID, + "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e", + original.paymentIdentity, + ADDRESS, + AMOUNT_SATS, + fixture.signedTx + ) + } + + @Test + fun `cancel cannot discard signed hardware receipt while candidate save is suspended`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + val resume = CompletableDeferred() + whenever { + proofRepo.retainHardwareOnchainCandidate( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + org.mockito.kotlin.anyOrNull() + ) + }.doSuspendableAnswer { resume.await() } + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.isBroadcastUnresolved) + sut.cancel() + 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) + } + + @Test + fun `hardware candidate save denial retains signed receipt across cancel and retries without signing`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever { + proofRepo.retainHardwareOnchainCandidate( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + org.mockito.kotlin.anyOrNull() + ) + } + .thenReturn(false, true) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.isBroadcastUnresolved) + sut.cancel() + assertTrue(sut.uiState.value.hasPendingBroadcast) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `hardware candidate save exception retains signed receipt across cancel and retries without signing`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever { + proofRepo.retainHardwareOnchainCandidate( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + org.mockito.kotlin.anyOrNull() + ) + } + .thenThrow(IllegalStateException("storage unavailable")).thenReturn(true) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.isBroadcastUnresolved) + sut.cancel() + assertTrue(sut.uiState.value.hasPendingBroadcast) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `observed Shop completion ignores a late suspended broadcast result`() = test { + val fixture = stubSuccessfulPayment() + val resume = CompletableDeferred() + val txid = "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e" + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).doSuspendableAnswer { + withContext(NonCancellable) { resume.await() } + Result.success(fixture.broadcast.copy(txId = txid)) + } + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("original", "counterparty"), + paymentIdentity = "original-identity", + ) + var emitted = 0 + backgroundScope.launch { sut.results.collect { emitted++ } } + sut.signAndBroadcast(original) + runCurrent() + assertTrue(sut.uiState.value.isBroadcastUnresolved) + var completed: HwSendResult? = null + assertTrue(sut.completeReconciledBroadcast(WALLET_ID, txid) { completed = it }) + assertEquals(original.paymentRequestId, completed?.paymentRequestId) + resume.complete(Unit) + advanceUntilIdle() + assertEquals(0, emitted) + assertFalse(sut.uiState.value.hasPendingBroadcast) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertFalse(sut.uiState.value.isSigning) + assertFalse(sut.completeReconciledBroadcast(WALLET_ID, txid)) + } + + @Test + fun `exact asynchronous completion clears a timed out Shop broadcast without a result`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val timeout = runCatching { withTimeout(Duration.ZERO) { Unit } } + .exceptionOrNull() as TimeoutCancellationException + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn(Result.failure(timeout)) + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("original", "counterparty"), + paymentIdentity = "original-identity", + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.isBroadcastUnresolved) + val txid = "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e" + assertFalse(sut.completeReconciledBroadcast("other-wallet", txid)) + assertFalse(sut.completeReconciledBroadcast(WALLET_ID, "other-tx")) + assertTrue(sut.uiState.value.hasPendingBroadcast) + var completed: HwSendResult? = null + assertTrue(sut.completeReconciledBroadcast(WALLET_ID, txid.uppercase()) { completed = it }) + assertEquals(HwSendResult(WALLET_ID, txid, original.amountSats, + original.paymentRequestId, original.paymentIdentity), completed) + assertFalse(sut.uiState.value.hasPendingBroadcast) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertFalse(sut.completeReconciledBroadcast(WALLET_ID, txid)) + + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn(Result.success(fixture.broadcast)) + sut.signAndBroadcast(request()) + advanceUntilIdle() + verify(hwWalletRepo, times(2)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `matching asynchronous completion consumes retained result and permits the next hardware send`() = test { + val fixture = stubSuccessfulPayment() + sut.signAndBroadcast(request().copy(paymentRequestId = PaykitPaymentRequestId("original", "counterparty"))) + advanceUntilIdle() + assertFalse(sut.completeReconciledBroadcast("other-wallet", fixture.broadcast.txId)) + assertFalse(sut.completeReconciledBroadcast(WALLET_ID, "other-tx")) + assertEquals(fixture.broadcast.txId, sut.results.first().txId) + assertTrue(sut.completeReconciledBroadcast(WALLET_ID, fixture.broadcast.txId)) + sut.signAndBroadcast(request()) + advanceUntilIdle() + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `core completed hardware result retains original request and never rebroadcasts while proof is pending`() = test { + val fixture = stubSuccessfulPayment() + val originalId = PaykitPaymentRequestId("original-request", "counterparty", "receiver") + val original = request().copy(paymentRequestId = originalId, paymentIdentity = "original-identity") + sut.signAndBroadcast(original) + advanceUntilIdle() + + assertEquals(originalId, sut.results.first().paymentRequestId) + assertEquals("original-identity", sut.results.first().paymentIdentity) + // A failed proof write keeps the result: closing/reopening must replay its exact identity. + sut.cancel() + assertEquals(fixture.broadcast.txId, sut.results.first().txId) + assertEquals(originalId, sut.results.first().paymentRequestId) + assertTrue(sut.uiState.value.isBroadcastUnresolved) + // Local proof work has not consumed the result yet: neither this request nor another may resend. + sut.signAndBroadcast(original) + sut.signAndBroadcast(original.copy(paymentRequestId = originalId.copy(paymentRequestId = "different-request"))) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `hardware Shop core result does not create Sent activity before exact observation`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy(paymentRequestId = PaykitPaymentRequestId("request", "counterparty", "receiver")) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertEquals(fixture.broadcast.txId, sut.results.first().txId) + verify(activityService, never()).createSentOnchainActivityFromSendResult( + any(), any(), any(), any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), + ) + verify(activityRepo, never()).notifyPaymentActivityChanged() + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `ordinary hardware Core result preserves existing Sent activity behavior`() = test { + val fixture = stubSuccessfulPayment() + sut.signAndBroadcast(request()) + advanceUntilIdle() + verify(activityService).createSentOnchainActivityFromSendResult( + fixture.broadcast.txId, ADDRESS, AMOUNT_SATS, fixture.broadcast.miningFeeSats, + fixture.broadcast.feeRate, false, null, WALLET_ID, + ) + } + + @Test + fun `refused Shop payment stays dismissable after its deadline`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + paymentDeadlineAt = deadline, + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) + .thenReturn(Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + now = deadline + 1.seconds + var expiredAttempted: Boolean? = null + sut.signAndBroadcast(original, onPaymentDeadlineExpired = { expiredAttempted = it }) + advanceUntilIdle() + assertEquals(true, expiredAttempted) + assertTrue(sut.uiState.value.canLeave) + sut.cancel() + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx, deadline) + } + + @Test + fun `retained Shop payment retries original bytes after display fee changes`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) + .thenReturn(Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + sut.cancel() + advanceUntilIdle() + sut.signAndBroadcast(original.copy(satsPerVByte = SATS_PER_VBYTE + 1uL)) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `retained Shop payment retries original bytes after tags change`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + tags = listOf("original-tag"), + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( + Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent")), + Result.failure(BroadcastException.ElectrumException("response lost after dispatch")), + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + sut.cancel() + advanceUntilIdle() + sut.signAndBroadcast(original.copy(tags = listOf("new-display-tag"))) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + verify(proofRepo, times(2)).retainHardwareOnchainCandidate( + requireNotNull(original.paymentRequestId), + original.walletId, + SignedTransactionId.fromHex(fixture.signedTx.serializedTx), + original.paymentIdentity, + original.address, + original.amountSats, + fixture.signedTx, + ) + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertFalse(sut.uiState.value.canLeave) + } + + @Test + fun `pre-dispatch retry marker denial after refusal keeps the sheet dismissable`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) + .thenReturn(Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + whenever( + proofRepo.markHardwareOnchainDispatch( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + ) + ).thenReturn(false) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + verify(proofRepo, never()).clearHardwareOnchainCandidateBeforeDispatch( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + any(), + ) + } + + @Test + fun `retry setup failure before boundary after refusal keeps the sheet dismissable`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) + .thenReturn(Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + whenever(hwWalletRepo.broadcastFundingAtBoundary(any(), org.mockito.kotlin.anyOrNull(), any())) + .thenReturn(Result.failure(IllegalStateException("device setup failed before callback"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(proofRepo, times(1)).markHardwareOnchainDispatch( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + ) + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + } + + @Test + fun `retained Shop retry still rejects changed payment identity or recipient`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) + .thenReturn(Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + for (changed in listOf( + original.copy(paymentIdentity = "different-payer"), + original.copy(paymentRequestId = PaykitPaymentRequestId("different-request", "counterparty")), + original.copy(address = "different-recipient"), + original.copy(amountSats = original.amountSats + 1uL), + )) { + sut.signAndBroadcast(changed) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + } + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `restored refused Shop receipt remains dismissable after expiry without sending`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + paymentDeadlineAt = now - 1.seconds, + ) + whenever( + proofRepo.retainedHardwareOnchainPayment( + requireNotNull(original.paymentRequestId), + WALLET_ID, + original.paymentIdentity, + original.address, + original.amountSats, + ) + ).thenReturn(RetainedHardwareOnchainPayment(fixture.signedTx, true, true)) + var attempted: Boolean? = null + sut.signAndBroadcast(original, onPaymentDeadlineExpired = { attempted = it }) + advanceUntilIdle() + assertEquals(true, attempted) + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `fresh unknown retry after refusal re-arms navigation protection`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + paymentDeadlineAt = now + 1.seconds, + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, original.paymentDeadlineAt)).thenReturn( + Result.failure(BroadcastException.InvalidTransaction("invalid transaction")), + Result.failure(BroadcastException.ElectrumException("response lost after dispatch")), + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertFalse(sut.uiState.value.canLeave) + now += 2.seconds + sut.signAndBroadcast(original) + advanceUntilIdle() + assertFalse(sut.uiState.value.canLeave) + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx, original.paymentDeadlineAt) + } + + private fun signedFixtureHex() = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().readText().trim() + private fun request() = HwSendRequest( walletId = WALLET_ID, address = ADDRESS, diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index 34e86502db..f557d4d0fb 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -1,8 +1,14 @@ package to.bitkit.ui.screens.wallets.send import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType +import kotlinx.coroutines.async +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test @@ -10,10 +16,18 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainPreparedReceipt +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.repositories.OnchainSendInput +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.test.BaseUnitTest +import to.bitkit.viewmodels.SanityWarning import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull @@ -23,6 +37,9 @@ class SendPendingViewModelTest : BaseUnitTest() { private val pendingPaymentRepo = PendingPaymentRepo() private val activityRepo: ActivityRepo = mock() + private val lightningRepo: LightningRepo = mock() + + private val attemptUpdates = MutableStateFlow(0L) private val hash = "test_payment_hash" private val amount = 5000L @@ -34,9 +51,267 @@ class SendPendingViewModelTest : BaseUnitTest() { whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) }.thenReturn( Result.failure(Exception("not found")) ) + whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any(), any()) } + .thenReturn(Result.failure(Exception("not found"))) + whenever(lightningRepo.onchainSendAttemptUpdates).thenReturn(attemptUpdates) sut = createViewModel() } + @Test + fun `exact prepared fee and warnings precede original retry payment authentication`() = test { + val receipt = OnchainPreparedReceipt("ab".repeat(32), emptyList(), "original", 1_000uL, + feeRateSatsPerVByte = 10uL, miningFeeSats = 600uL) + val authorization = async { + sut.approveRecoveryFee(receipt, listOf(SanityWarning.FEE_OVER_HALF_VALUE, SanityWarning.FEE_OVER_10_USD), + requiresPin = true, useBiometrics = true) + } + advanceUntilIdle() + assertEquals(RecoveryApproval.Fee(receipt), sut.uiState.value.recoveryApproval) + sut.answerRecoveryApproval(true) + advanceUntilIdle() + assertEquals(RecoveryApproval.Warning(SanityWarning.FEE_OVER_HALF_VALUE), sut.uiState.value.recoveryApproval) + sut.answerRecoveryApproval(true) + advanceUntilIdle() + assertEquals(RecoveryApproval.Warning(SanityWarning.FEE_OVER_10_USD), sut.uiState.value.recoveryApproval) + sut.answerRecoveryApproval(true) + advanceUntilIdle() + assertEquals(RecoveryApproval.Biometrics, sut.uiState.value.recoveryApproval) + sut.useRecoveryPin() + assertEquals(true, sut.openRecoveryPin()) + assertEquals(false, sut.openRecoveryPin(), "Returning from PIN must not navigate to PIN again") + sut.answerRecoveryPin(true) + advanceUntilIdle() + authorization.await() + assertNull(sut.uiState.value.recoveryApproval) + } + + @Test + fun `pending original retry is explicit serialized and retains original amount`() = test { + val txid = "ab".repeat(32) + val original = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "original", requestId = null, + orderId = null, address = "original-address", amountSats = 1_000uL, isMaxAmount = true, + feeRateSatsPerVByte = 2uL, isTransfer = false, channelId = null, tags = emptyList(), + txid = txid, evidence = OnchainSendEvidence.Unknown, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf(txid), + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(txid, 9_999L) + advanceUntilIdle() + assertEquals(original, sut.uiState.value.recoveryAttempt) + var sends = 0 + val release = CompletableDeferred() + val retry: suspend (OnchainSendAttempt, ULong) -> Result = + { retained, fee -> + sends++ + assertEquals(original, retained) + assertEquals(3uL, fee) + release.await() + Result.success(OnchainSendOutcome.Unknown("cd".repeat(32))) + } + assertEquals(0, sends) + sut.retryOriginal(ULong.MAX_VALUE, retry) + advanceUntilIdle() + assertEquals(0, sends) + assertEquals(true, sut.uiState.value.invalidFeeRate) + assertEquals(false, sut.uiState.value.isRecovering) + sut.retryOriginal(3uL, retry) + sut.retryOriginal(3uL, retry) + advanceUntilIdle() + assertEquals(1, sends) + assertEquals(true, sut.uiState.value.isRecovering) + release.complete(Unit) + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + assertEquals("cd".repeat(32), sut.uiState.value.currentTxid) + assertEquals(original.amountSats, sut.uiState.value.recoveryAttempt?.amountSats) + } + + @Test + fun `accepted retry waits for exact durable original completion`() = test { + val original = pendingOriginal() + val winner = "cd".repeat(32) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + val accepted = original.copy( + txid = winner, candidateTxids = original.candidateTxids + winner, + evidence = OnchainSendEvidence.Accepted, localFollowupComplete = false, + ) + sut.retryOriginal(2uL) { _, _ -> + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(accepted) + Result.success(OnchainSendOutcome.Accepted(winner)) + } + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + assertEquals(winner, sut.uiState.value.currentTxid) + assertEquals(original, sut.uiState.value.recoveryAttempt) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(accepted.copy(localFollowupComplete = true)) + attemptUpdates.value++ + advanceUntilIdle() + assertEquals(winner, sut.uiState.value.recoveredTxid) + assertNull(sut.uiState.value.recoveryAttempt) + } + + @Test + fun `exact ordinary observed successor resolves only after original local completion`() = test { + val original = pendingOriginal() + val winner = "cd".repeat(32) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, 9_999L) + advanceUntilIdle() + val lateResult = CompletableDeferred() + sut.retryOriginal(2uL) { _, _ -> Result.success(lateResult.await()) } + advanceUntilIdle() + val observed = original.copy( + txid = winner, + candidateTxids = original.candidateTxids + winner, + evidence = OnchainSendEvidence.Observed, + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(observed) + attemptUpdates.value++ + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(observed.copy(localFollowupComplete = true)) + attemptUpdates.value++ + advanceUntilIdle() + assertEquals(winner, sut.uiState.value.recoveredTxid) + assertEquals(1_000L, sut.uiState.value.amount) + assertNull(sut.uiState.value.recoveryAttempt) + lateResult.complete(OnchainSendOutcome.Unknown(original.txid!!)) + advanceUntilIdle() + assertEquals(winner, sut.uiState.value.recoveredTxid) + assertEquals(winner, sut.uiState.value.currentTxid) + org.mockito.kotlin.verify(activityRepo, org.mockito.kotlin.never()).syncActivities() + } + + @Test + fun `pending screen cannot resolve a foreign operation or wallet after update`() = test { + val original = pendingOriginal() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + val positive = original.copy(evidence = OnchainSendEvidence.Observed, localFollowupComplete = true) + listOf( + positive.copy(attemptId = "another-operation"), + positive.copy(walletId = "hardware"), + positive.copy(walletIndex = 1), + positive.copy(txid = "ef".repeat(32), candidateTxids = listOf("ef".repeat(32))), + positive.copy(requestId = to.bitkit.repositories.PaykitPaymentRequestId("foreign", "payer", "/pub/paykit")), + ).forEach { foreign -> + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(foreign) + attemptUpdates.value++ + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + assertEquals(1_000L, sut.uiState.value.amount) + } + } + + @Test + fun `ordinary queued activity and nonpositive outcomes never resolve pending`() = test { + val original = pendingOriginal() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + listOf(OnchainSendEvidence.Pending, OnchainSendEvidence.Rejected, OnchainSendEvidence.Unknown).forEach { + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original.copy(evidence = it)) + attemptUpdates.value++ + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + } + } + + @Test + fun `transfer pending follows exact funded order only after durable completion`() = test { + val original = pendingOriginal().copy( + isTransfer = true, + orderId = "original-order", + transferContext = to.bitkit.repositories.OnchainTransferContext(1_200uL, 20_000uL, 1_000uL, 200uL), + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + sut.retryOriginal(2uL) { _, _ -> Result.success(OnchainSendOutcome.Accepted(original.txid!!)) } + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + assertNull(sut.uiState.value.recoveredTransfer) + val winner = "cd".repeat(32) + val observed = original.copy( + txid = winner, + candidateTxids = original.candidateTxids + winner, + evidence = OnchainSendEvidence.Observed, + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(observed) + attemptUpdates.value++ + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTransfer) + val complete = observed.copy(localFollowupComplete = true) + listOf( + complete.copy(orderId = "different-order"), + complete.copy(transferContext = original.transferContext!!.copy(txTotalSats = 1_300uL)), + complete.copy(attemptId = "different-attempt"), + complete.copy(walletIndex = 1), + ).forEach { unrelated -> + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(unrelated) + attemptUpdates.value++ + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTransfer) + } + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(complete) + attemptUpdates.value++ + advanceUntilIdle() + assertEquals(winner, sut.uiState.value.currentTxid) + assertEquals(complete, sut.uiState.value.recoveredTransfer) + assertNull(sut.uiState.value.recoveredTxid) + assertNull(sut.uiState.value.recoveryAttempt) + org.mockito.kotlin.verify(activityRepo, org.mockito.kotlin.never()).syncActivities() + } + + private fun pendingOriginal() = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "original", requestId = null, + orderId = null, address = "original-address", amountSats = 1_000uL, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + txid = "ab".repeat(32), evidence = OnchainSendEvidence.Unknown, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf("ab".repeat(32)), + ) + + @Test + fun `onchain activity enables original wallet Details without resolving acceptance`() = test { + val txid = "ab".repeat(32) + val onchainActivity = mock { on { id } doReturn "queued-local-activity" } + val activity = mock { on { v1 } doReturn onchainActivity } + whenever( + activityRepo.findActivityByPaymentId( + txid, + ActivityFilter.ONCHAIN, + PaymentType.SENT, + true, + "original-wallet" + ) + ) + .thenReturn(Result.success(activity)) + pendingPaymentRepo.resolve(PendingPaymentResolution.Success(txid, amountWithFeeSats = 9_999L)) + + sut.initOnchain(txid, amount, "original-wallet") + advanceUntilIdle() + + assertEquals("queued-local-activity", sut.uiState.value.activityId) + assertEquals(amount, sut.uiState.value.amount) + assertNull(sut.uiState.value.resolution) + assertEquals(false, pendingPaymentRepo.isActive(txid)) + } + + @Test + fun `missing onchain txid never uses request identifier as Details or acceptance`() = test { + sut.initOnchain("request-id", amount) + advanceUntilIdle() + assertNull(sut.uiState.value.activityId) + assertNull(sut.uiState.value.resolution) + org.mockito.kotlin.verifyNoInteractions(activityRepo) + } + @Test fun `init sets amount in uiState`() = test { sut.init(hash, amount) @@ -154,5 +429,6 @@ class SendPendingViewModelTest : BaseUnitTest() { private fun createViewModel() = SendPendingViewModel( pendingPaymentRepo = pendingPaymentRepo, activityRepo = activityRepo, + lightningRepo = lightningRepo, ) } diff --git a/app/src/test/java/to/bitkit/utils/SignedTransactionIdTest.kt b/app/src/test/java/to/bitkit/utils/SignedTransactionIdTest.kt new file mode 100644 index 0000000000..ea02f8ff9f --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/SignedTransactionIdTest.kt @@ -0,0 +1,29 @@ +package to.bitkit.utils + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SignedTransactionIdTest { + private fun fixture() = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().readText().trim() + + @Test + fun `matches independently observed backend transaction and ignores witness bytes`() { + val original = fixture() + val expected = "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e" + assertEquals(expected, SignedTransactionId.fromHex(original)) + val withoutWitness = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction-legacy.hex")) + .bufferedReader().readText().trim() + assertEquals(expected, SignedTransactionId.fromHex(withoutWitness)) + val witnessChanged = original.hexToByteArray().also { it[it.size - 5] = (it[it.size - 5].toInt() xor 1).toByte() } + assertEquals(expected, SignedTransactionId.fromHex(witnessChanged.toHexString())) + } + + @Test + fun `rejects truncated or trailing transaction data before persistence`() { + assertFailsWith { SignedTransactionId.fromHex(fixture().dropLast(2)) } + assertFailsWith { SignedTransactionId.fromHex(fixture() + "00") } + assertFailsWith { SignedTransactionId.fromHex("not-a-transaction") } + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 89eceed606..ce0a4a79d7 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -100,6 +100,7 @@ import to.bitkit.ext.toSendFailureDetails import to.bitkit.models.BalanceState import to.bitkit.models.ConvertedAmount import to.bitkit.models.FeeRate +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails @@ -130,6 +131,12 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendAttemptUnreadableError +import to.bitkit.repositories.OnchainSendBlockedError +import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.repositories.OnchainSendNotDispatchedError +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind @@ -204,7 +211,6 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock -import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -246,6 +252,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyRepo = mock() private val publicPaykitRepo = mock() private val privatePaykitRepo = mock() + private val paymentSubmissionActive = MutableStateFlow(false) private val paykitPaymentRequestRepo = mock() private val paykitPaymentProofRepo = mock() private val paykitPaymentRequestDiagnostics = mock() @@ -281,7 +288,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val paykitSubscriptions = MutableStateFlow>(emptyList()) private val onchainPaymentResolutions = MutableStateFlow>(emptyList()) private val proofStateVersion = MutableStateFlow(0L) - private val paymentSubmissionActive = MutableStateFlow(false) private val surfacedPaykitPaymentRequestIds = mutableSetOf() private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" private val nonCanonicalTestPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -297,6 +303,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Before fun setUp() { + whenever(paykitPaymentRequestRepo.isPaymentSubmissionActive).thenReturn(paymentSubmissionActive) + whenever(paykitPaymentRequestRepo.setPaymentSubmissionActive(any())).thenAnswer { + paymentSubmissionActive.value = it.getArgument(0) + Unit + } timedSheetType.value = null paykitPaymentRequestHistory.value = emptyList() surfacedPaykitPaymentRequestIds.clear() @@ -441,11 +452,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.automaticSubscriptionProposals()).thenReturn(emptyList()) whenever(paykitPaymentRequestRepo.eligibleTargets).thenReturn(MutableStateFlow(emptyList())) whenever(paykitPaymentRequestRepo.isCreatingRequest).thenReturn(MutableStateFlow(false)) - whenever(paykitPaymentRequestRepo.isPaymentSubmissionActive).thenReturn(paymentSubmissionActive) - whenever(paykitPaymentRequestRepo.setPaymentSubmissionActive(any())).thenAnswer { - paymentSubmissionActive.value = it.getArgument(0) - Unit - } whenever { paykitPaymentRequestRepo.refreshEligibleTargets(any(), any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentRequestRepo.refreshAfterStateChange(any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentRequestRepo.isSubscriptionNotificationHandled(any(), any()) }.thenReturn(false) @@ -476,13 +482,23 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isExpired(any())).thenReturn(false) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) - whenever(paykitPaymentProofRepo.paymentRequestStateChanges(any())).thenReturn(proofStateVersion.drop(1).map { }) + whenever { paykitPaymentProofRepo.captureOriginalOnchainPayer(any()) } + .thenReturn(Result.success("pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg")) + whenever { paykitPaymentProofRepo.verifyOriginalOnchainPayer(any(), any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.prepare(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentProofRepo.paymentRequestStateChanges(any())).thenReturn(proofStateVersion.drop(1).map { }) whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) }.thenReturn(Result.success(Unit)) whenever { - paykitPaymentProofRepo.markOnchainPaymentStarted(any(), any(), any()) + paykitPaymentProofRepo.markOnchainPaymentStarted( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) }.thenReturn(Result.success(Unit)) whenever { activityRepo.setContact(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.scheduleSavedContactPreparation(any>(), any()) } @@ -925,54 +941,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } - @Test - fun `deferred session recovery runs once while foreground online and enabled`() = test { - enablePaykitUi() - connectivityState.value = ConnectivityState.DISCONNECTED - var activeRetries = 0 - var attempts = 0 - whenever(pubkyRepo.retryDeferredSessionRestoration()).doSuspendableAnswer { - attempts++ - activeRetries++ - try { - awaitCancellation() - } finally { - activeRetries-- - } - } - - sut.startPaykitPaymentRequestPolling() - assertEquals(0, attempts) - connectivityState.value = ConnectivityState.CONNECTED - runCurrent() - assertEquals(1, attempts) - assertEquals(1, activeRetries) - sut.startPaykitPaymentRequestPolling() - assertEquals(1, attempts) - - connectivityState.value = ConnectivityState.DISCONNECTED - runCurrent() - assertEquals(0, activeRetries) - connectivityState.value = ConnectivityState.CONNECTED - runCurrent() - assertEquals(2, attempts) - isPaykitEnabled.value = false - runCurrent() - assertEquals(0, activeRetries) - - isPaykitEnabled.value = true - runCurrent() - assertEquals(3, attempts) - sut.stopPaykitPaymentRequestPolling() - runCurrent() - assertEquals(0, activeRetries) - clearInvocations(pubkyRepo) - connectivityState.value = ConnectivityState.DISCONNECTED - connectivityState.value = ConnectivityState.CONNECTED - runCurrent() - verify(pubkyRepo, never()).retryDeferredSessionRestoration() - } - @Test fun `foreground maintenance retries a missing session until restored`() = test { enablePaykitUi() @@ -1506,6 +1474,62 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).beginPaymentRequest(nextRequest) } + @Test + fun `reopened refused hardware request uses original receipt without new payment preparation`() = test { + val (request, signed) = reopenRetainedHardwareRequest(balanceSats = 0uL) + assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) + assertEquals(REGTEST_ADDRESS, sut.sendUiState.value.address) + assertEquals(request.amountSats, sut.sendUiState.value.amount) + assertTrue(sut.sendUiState.value.isAmountInputValid) + assertFalse(sut.sendUiState.value.canSwitchFundingSource) + assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) + assertEquals(signed.miningFeeSats.toLong(), sut.sendUiState.value.onchainFeeUi.sats) + verify(privatePaykitRepo, never()).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any()) + verify(hwWalletRepo, never()).signFunding(any(), any()) + assertTrue(sut.sendUiState.value.isRetainedHardwarePayment) + sut.resetSendState(hardwareWalletId = null) + assertFalse(sut.sendUiState.value.isRetainedHardwarePayment) + sut.setTransactionSpeed(TransactionSpeed.Fast) + assertEquals(TransactionSpeed.Fast, sut.sendUiState.value.speed) + } + + @Test + fun `reopened refused hardware request locks terms and reaches hardware sign on swipe`() = test { + reopenRetainedHardwareRequest(balanceSats = 1_000_000uL, amountSats = 30_000uL) + val sheet = sut.currentSheet.value + assertEquals(Sheet.Send(SendRoute.Confirm, hardwareWalletId = HARDWARE_WALLET_ID), sheet) + sut.onSheetVisible(sheet) + clearInvocations(toastManager) + val original = sut.sendUiState.value + assertTrue(original.isRetainedHardwarePayment) + sut.sendEffect.test { + sut.setSendEvent(SendEvent.SpeedAndFee) + sut.onSelectSpeed(TransactionSpeed.Custom(0u)) + sut.onSelectSpeed(TransactionSpeed.Fast) + sut.setTransactionSpeed(TransactionSpeed.Fast) + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + advanceUntilIdle() + assertEquals(original, sut.sendUiState.value, "Retained fee, wallet and exact payment terms stay fixed") + expectNoEvents() + sut.setSendEvent(SendEvent.SwipeToPay) + advanceUntilIdle() + assertNull(sut.sendUiState.value.showSanityWarningDialog) + assertTrue(sut.sendUiState.value.shouldConfirmPay) + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + val toasts = argumentCaptor() + verify(toastManager, atLeast(0)).enqueue(toasts.capture()) + assertEquals(emptyList(), toasts.allValues, "Reopened payment must preserve request context on swipe") + assertEquals(SendEffect.NavigateToHardwareSign, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + verify(privatePaykitRepo, never()).beginPaymentRequest(any()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any()) + verify(hwWalletRepo, never()).signFunding(any(), any()) + } + @Test fun `opened request passes its note to the confirm sheet`() = test { sut.setIsAuthenticated(true) @@ -7651,9 +7675,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - ) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") + contactPublicKey = anyOrNull(), + ) + verify( + paykitPaymentProofRepo + ).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted("txid")) } @Test @@ -7751,8 +7782,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) } + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted("txid")) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") } @Test @@ -7796,9 +7827,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), ) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } @Test @@ -7881,7 +7924,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) - setActiveContactPaymentContext(testPublicKey, privateContext, request) + setActiveContactPaymentContext(testPublicKey, privateContext, request, isInitialSubscriptionPayment = true) setSendState( SendUiState( address = bolt11, @@ -7891,8 +7934,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) - sut.setSendEvent(SendEvent.PayConfirmed) - advanceUntilIdle() + sut.sendEffect.test { + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + assertTrue(awaitItem() is SendEffect.NavigateToError) + } + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify( paykitPaymentProofRepo @@ -7954,13 +8002,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentRequestRepo, times(if (preparationSucceeds) 1 else 0)).accept(request) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) - verify(paykitPaymentProofRepo, times(if (preparationSucceeds) 1 else 0)).failLightningPayment(paymentHash) - verify( - privatePaykitRepo, - times(if (preparationSucceeds) 1 else 0) - ).releasePrivatePaymentList(testPublicKey, privateContext) + if (preparationSucceeds) { + verify(paykitPaymentRequestRepo).accept(request) + verify(paykitPaymentProofRepo).failLightningPayment(paymentHash) + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + } else { + verify(paykitPaymentRequestRepo, never()).accept(request) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(paykitPaymentProofRepo, never()).associateLightningPayment(any(), any(), any(), eq("bitkit")) + verify(paykitPaymentProofRepo, never()).failLightningPayment(any()) + } clearInvocations(lightningRepo, paykitPaymentProofRepo, paykitPaymentRequestRepo, privatePaykitRepo) } } @@ -8081,12 +8133,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test @Suppress("LongMethod") - fun `failed LNURL request callback reopens accepted request after proposal expiry`() = test { - val request = paymentRequest().copy( - lifecycleState = PaymentRequestLifecycleState.ACCEPTED, - expiresAt = Clock.System.now() - 1.seconds, - paymentDeadlineAt = Clock.System.now() + 1.hours, - ) + fun `failed LNURL request callback releases preparation and retry reopens request`() = test { + val request = paymentRequest() val privateContext = privatePaymentContext(7uL) val lnurl = LnurlPayData( uri = "lnurl1failedrequest", @@ -8239,6 +8287,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `in flight proof blocks switching to a hardware payment`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = privatePaymentContext(7uL) whenever( @@ -8250,15 +8299,23 @@ class AppViewModelSendFlowTest : BaseUnitTest() { SendUiState(address = "bcrt1qpaymentrequest", amount = request.amountSats, isPaymentRequest = true), ) - assertFalse(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) } @Test fun `hardware payment request releases private details when acceptance fails`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)) @@ -8277,15 +8334,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) - assertFalse(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) + verify(paykitPaymentProofRepo).markOnchainPaymentStarted( + request, + "bcrt1qpaymentrequest", + "hardware-wallet", + 7uL, + hardwareSignedReceipt(), + ) + verify(paykitPaymentProofRepo).failHardwareOnchainPaymentBeforeDispatch( + request, + "hardware-wallet", + testPublicKey, + hasAttemptedBroadcast = false, + ) } @Test fun `hardware payment request releases private details when proof start fails`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -8296,8 +8366,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { request, "bcrt1qpaymentrequest", "hardware-wallet", + 7uL, + hardwareSignedReceipt(), ) - ).thenReturn(Result.failure(IllegalStateException("proof start failed"))) + ).doSuspendableAnswer { + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + Result.failure(IllegalStateException("proof start failed")) + } setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -8310,14 +8385,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) - assertFalse(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) } @Test - fun `approved hardware payment request preparation is idempotent`() = test { + fun `started hardware payment request blocks another preparation instead of reusing approval`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -8335,39 +8411,38 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) - assertTrue(sut.prepareHardwareContactPayment()) - assertTrue(sut.prepareHardwareContactPayment()) + whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain)) + .thenReturn(Result.success(Unit), Result.failure(PaykitPaymentRequestError.OperationInProgress)) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) + assertFalse(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) - inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo).apply { - verify( - paykitPaymentProofRepo - ).prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo).accept(request) - verify(paykitPaymentProofRepo).markOnchainPaymentStarted( - request, - "bcrt1qpaymentrequest", - "hardware-wallet", - ) - } + verify(paykitPaymentProofRepo, times(2)).prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo).accept(request) + verify(paykitPaymentProofRepo).markOnchainPaymentStarted( + request, + "bcrt1qpaymentrequest", + "hardware-wallet", + 7uL, + hardwareSignedReceipt(), + ) verify(paykitPaymentRequestRepo, never()).ensurePaymentAllowed(request) - - whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) - .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) - assertFalse(sut.authorizeHardwareContactPayment(hasAttemptedBroadcast = false)) - verify(paykitPaymentProofRepo).failOnchainPayment(request) - verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) } @Test - fun `hardware queue expiry reports failure without repeating SDK authorization`() = test { + fun `hardware queue expiry only releases an original payment that never broadcast`() = test { + pubkyPublicKey.value = testPublicKey whenever(context.getString(R.string.common__error)).thenReturn("Error") val request = paymentRequest().copy(paymentDeadlineAt = Instant.parse("2026-10-06T12:00:00Z")) val privateContext = privatePaymentContext(7uL) val sheet = Sheet.Send(SendRoute.HardwareSign) + whenever(paykitPaymentProofRepo.failHardwareOnchainPaymentBeforeDispatch( + request, "hardware-wallet", testPublicKey, false, + )).thenReturn(true) for (priorAttempt in listOf(false, true)) { clearInvocations(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo, toastManager) setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState(SendUiState(hardwareWalletId = "hardware-wallet")) sut.showSheet(sheet) sut.onHardwarePaymentDeadlineExpired(priorAttempt) @@ -8375,20 +8450,93 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentRequestRepo, never()).ensurePaymentAllowed(any()) verify(toastManager).enqueue(any()) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) if (priorAttempt) { assertEquals(sheet, sut.currentSheet.value) - verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(paykitPaymentProofRepo, never()).failHardwareOnchainPaymentBeforeDispatch(any(), any(), any(), any()) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } else { assertNull(sut.currentSheet.value) - verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(paykitPaymentProofRepo).failHardwareOnchainPaymentBeforeDispatch(request, "hardware-wallet", testPublicKey, false) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) } } } @Test - fun `hardware retry denial keeps the started proof after cancellation`() = test { + fun `hardware authorization denial before first broadcast releases private payment details`() = test { + pubkyPublicKey.value = testPublicKey + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), 7uL) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1qpaymentrequest", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + isPaymentRequest = true, + hardwareWalletId = "hardware-wallet", + ), + ) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) + verify(paykitPaymentRequestRepo, never()).ensurePaymentAllowed(request) + whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) + + whenever( + paykitPaymentProofRepo.failHardwareOnchainPaymentBeforeDispatch( + request, + "hardware-wallet", + testPublicKey, + false, + ) + ).thenReturn(true) + assertFalse(sut.authorizeHardwareContactPayment(hasAttemptedBroadcast = false)) + + verify(paykitPaymentProofRepo).failHardwareOnchainPaymentBeforeDispatch( + request, + "hardware-wallet", + testPublicKey, + false, + ) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + } + + @Test + fun `hardware prebroadcast proof removal failure retains private preparation`() = test { + pubkyPublicKey.value = testPublicKey + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), 7uL) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState(SendUiState(hardwareWalletId = "hardware-wallet")) + whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) + whenever( + paykitPaymentProofRepo.failHardwareOnchainPaymentBeforeDispatch( + request, + "hardware-wallet", + testPublicKey, + false, + ) + ).thenReturn(false) + assertFalse(sut.authorizeHardwareContactPayment(false)) + verify(paykitPaymentProofRepo).failHardwareOnchainPaymentBeforeDispatch( + request, + "hardware-wallet", + testPublicKey, + false, + ) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + } + + @Test + fun `hardware retry denial and cancellation preserve the started proof`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = privatePaymentContext(7uL) whenever(context.getString(R.string.common__error)).thenReturn("Error") @@ -8417,9 +8565,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val sheet = Sheet.Send(SendRoute.HardwareSign) sut.showSheet(sheet) advanceUntilIdle() - assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) - sut.onHardwareBroadcastAttemptChanged(true) sut.sendEffect.test { assertFalse(sut.authorizeHardwareContactPayment(hasAttemptedBroadcast = true)) expectNoEvents() @@ -8428,24 +8575,58 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(sheet, sut.currentSheet.value) verify(toastManager).enqueue(any()) verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) + verify(paykitPaymentProofRepo, never()).failHardwareOnchainPaymentBeforeDispatch(any(), any(), any(), any()) sut.onHardwareSignCancelled() advanceUntilIdle() + verify(paykitPaymentProofRepo).cancelPreparation(request) verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) + } - whenever( - paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain), - ).thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) - assertFalse(sut.prepareHardwareContactPayment()) - sut.onHardwarePaymentDeadlineExpired(hasAttemptedBroadcast = false) - advanceUntilIdle() - verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) + @Test + fun `hardware authorization denies an identity switch while checking the original request`() = test { + pubkyPublicKey.value = testPublicKey + val request = paymentRequest() + setActiveContactPaymentContext(testPublicKey, PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), 7uL), request) + setSendState(SendUiState(hardwareWalletId = "hardware-wallet")) + val authorization = CompletableDeferred>() + whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)).doSuspendableAnswer { authorization.await() } + val completed = CompletableDeferred() + backgroundScope.launch { completed.complete(sut.authorizeHardwareContactPayment(hasAttemptedBroadcast = true)) } + runCurrent() + + pubkyPublicKey.value = "b".repeat(64) + authorization.complete(Result.success(Unit)) + runCurrent() + + assertFalse(completed.await()) + verify(paykitPaymentRequestRepo).ensurePaymentAllowed(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(paykitPaymentProofRepo, never()).failHardwareOnchainPaymentBeforeDispatch(any(), any(), any(), any()) + } + + @Test + fun `captured hardware authorization cannot approve another active request`() = test { + pubkyPublicKey.value = testPublicKey + val original = paymentRequest() + val replacement = original.copy(paymentRequestId = "replacement-request") + setActiveContactPaymentContext( + testPublicKey, PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), 7uL), replacement, + ) + + assertFalse(sut.authorizeHardwareContactPayment(true, original.id, testPublicKey)) + + verify(paykitPaymentRequestRepo, never()).ensurePaymentAllowed(any()) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) } @Test - fun `cancelling hardware signing fails the started payment proof`() = test { + fun `cancelling hardware signing only cancels unstarted preparation`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -8462,17 +8643,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { hardwareWalletId = "hardware-wallet", ) ) - assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) sut.onHardwareSignCancelled() advanceUntilIdle() - verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test - fun `dismissing hardware signing preserves only unresolved attempted payment proofs`() = test { + fun `dismissing hardware signing only cancels unstarted preparation`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -8489,54 +8672,115 @@ class AppViewModelSendFlowTest : BaseUnitTest() { hardwareWalletId = "hardware-wallet", ) ) - for (attemptChanges in listOf(listOf(true), emptyList(), listOf(true, false))) { - setActiveContactPaymentContext(testPublicKey, privateContext, request) - sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) - advanceUntilIdle() - assertTrue(sut.prepareHardwareContactPayment()) - clearInvocations(paykitPaymentProofRepo) - attemptChanges.forEach(sut::onHardwareBroadcastAttemptChanged) + sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) + advanceUntilIdle() + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) - sut.hideSheet() - advanceUntilIdle() + sut.hideSheet() + advanceUntilIdle() - verify(paykitPaymentProofRepo, times(if (attemptChanges.lastOrNull() == true) 0 else 1)) - .failOnchainPayment(request) - } + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test - fun `proof preparation failure blocks hardware payment request`() = test { + fun `hardware result persistence failure then dismissal preserves original started guard`() = test { + pubkyPublicKey.value = testPublicKey + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privatePaymentContext(7uL))) + .thenReturn(Result.success(Unit)) val request = paymentRequest() - val privateContext = privatePaymentContext(7uL) - whenever( - paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) - ) - .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) + val txid = "ab".repeat(32) + val walletId = "hardware-wallet" whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) - whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) - .thenReturn(Result.success(Unit)) - setActiveContactPaymentContext(testPublicKey, privateContext, request) - setSendState( - SendUiState( - address = "bcrt1qpaymentrequest", - amount = request.amountSats, - payMethod = SendMethod.ONCHAIN, - speed = TransactionSpeed.Medium, - isPaymentRequest = true, - ), - ) - - assertFalse(sut.prepareHardwareContactPayment()) + whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain)) + .thenReturn(Result.success(Unit), Result.failure(PaykitPaymentRequestError.OperationInProgress)) + // Core returned, but retaining its candidate txid failed: durable proof is still started with no identifier. + whenever(paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey)) + .thenReturn(false) + setActiveContactPaymentContext(testPublicKey, privatePaymentContext(7uL), incomingPaymentRequest = request) + setSendState(SendUiState(address = "bcrt1qpaymentrequest", amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, hardwareWalletId = walletId, isPaymentRequest = true)) + sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) + advanceUntilIdle() + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) + assertFalse(sut.completeHardwareContactPayment(txid, walletId, request.id, testPublicKey)) - verify(privatePaykitRepo, never()).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo, never()).accept(request) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) + sut.onHardwareSignCancelled() + sut.hideSheet() + advanceUntilIdle() + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + + setActiveContactPaymentContext(testPublicKey, privatePaymentContext(7uL), incomingPaymentRequest = request) + assertFalse( + sut.prepareHardwareContactPayment( + walletId, + "bcrt1qpaymentrequest", + request.id, + testPublicKey, + hardwareSignedReceipt(), + ), + ) + verify(paykitPaymentRequestRepo, times(1)).accept(request) + verify(paykitPaymentProofRepo, times(1)).markOnchainPaymentStarted( + request, + "bcrt1qpaymentrequest", + walletId, + 7uL, + hardwareSignedReceipt(), + ) + verify( + lightningRepo, + never() + ).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) } @Test - fun `hardware payment request completes proof in background after broadcast`() = test { + fun `proof preparation failure blocks hardware payment request before broadcast`() = test { + pubkyPublicKey.value = testPublicKey + val request = paymentRequest() + val privateContext = privatePaymentContext(7uL) + whenever( + paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + ) + .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1qpaymentrequest", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + assertFalse(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) + + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `hardware payment request waits for pending proof retention without claiming typed acceptance`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = privatePaymentContext(7uL) val completionStarted = CompletableDeferred() @@ -8545,11 +8789,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit")) + whenever(paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, "hardware-wallet", "txid", testPublicKey)) .doSuspendableAnswer { completionStarted.complete(Unit) finishCompletion.await() proofCompleted = true + false } setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -8562,11 +8807,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) - assertTrue(sut.prepareHardwareContactPayment()) - sut.completeHardwareContactPayment("txid") + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) + val completion = backgroundScope.launch { sut.completeHardwareContactPayment("txid", "hardware-wallet", request.id, testPublicKey) } runCurrent() completionStarted.await() + assertFalse(completion.isCompleted) assertFalse(finishCompletion.isCompleted) assertFalse(proofCompleted) @@ -8574,10 +8820,116 @@ class AppViewModelSendFlowTest : BaseUnitTest() { finishCompletion.complete(Unit) advanceUntilIdle() assertTrue(proofCompleted) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") + verify(paykitPaymentProofRepo).completeHardwareOnchainPayment(request.id, "hardware-wallet", "txid", testPublicKey) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } + @Test + fun `hardware proof callback keeps original request and wallet after screen context changes`() = test { + pubkyPublicKey.value = testPublicKey + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privatePaymentContext(7uL))) + .thenReturn(Result.success(Unit)) + val request = paymentRequest() + val txid = "ab".repeat(32) + val walletId = "original-hardware-wallet" + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privatePaymentContext(7uL), incomingPaymentRequest = request) + setSendState(SendUiState(address = "bcrt1qpaymentrequest", amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, hardwareWalletId = walletId, isPaymentRequest = true)) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) + pubkyPublicKey.value = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request.copy(paymentRequestId = "another-request")) + setSendState(SendUiState(address = "bcrt1qother", amount = 9_000uL, + payMethod = SendMethod.ONCHAIN, hardwareWalletId = "another-wallet", isPaymentRequest = true)) + whenever(paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey)) + .thenReturn(true) + + assertFalse(sut.completeHardwareContactPayment(txid, walletId, request.id, testPublicKey)) + verify(paykitPaymentProofRepo).completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey) + verify( + lightningRepo, + never() + ).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + } + + @Test + fun `retained resolution replays when its original payer becomes active`() = test { + val request = paymentRequest() + val resolution = PaykitOnchainPaymentProofResolution( + testPublicKey, request.id, "ab".repeat(32), "hardware-original", request.amountSats, + ) + onchainPaymentResolutions.value = listOf(resolution) + runCurrent() + assertNull(sut.resolvedHardwarePaymentFor(resolution.walletId, resolution.transactionId)) + verify(paykitPaymentProofRepo, never()).consumeOnchainPaymentResolution(resolution) + pubkyPublicKey.value = testPublicKey + runCurrent() + assertEquals(resolution, sut.resolvedHardwarePaymentFor(resolution.walletId, resolution.transactionId)) + verify(paykitPaymentProofRepo).consumeOnchainPaymentResolution(resolution) + verify(paykitPaymentProofRepo, never()).clearOnchainPaymentResolutions() + } + + @Test + fun `closed hardware sheet retains earlier completion when multiple proofs resolve`() = test { + pubkyPublicKey.value = testPublicKey + runCurrent() + val request = paymentRequest() + val first = PaykitOnchainPaymentProofResolution( + testPublicKey, request.id, "ab".repeat(32), "hardware-first", request.amountSats, + ) + val second = first.copy(transactionId = "cd".repeat(32), walletId = "hardware-second") + onchainPaymentResolutions.value = listOf(first, second) + runCurrent() + assertEquals(first, sut.resolvedHardwarePaymentFor(first.walletId, first.transactionId)) + assertEquals(second, sut.resolvedHardwarePaymentFor(second.walletId, second.transactionId)) + sut.consumeResolvedHardwarePayment(first.copy(transactionId = "ef".repeat(32))) + assertEquals(first, sut.resolvedHardwarePaymentFor(first.walletId, first.transactionId)) + sut.consumeResolvedHardwarePayment(first) + assertNull(sut.resolvedHardwarePaymentFor(first.walletId, first.transactionId)) + assertEquals(second, sut.resolvedHardwarePaymentFor(second.walletId, second.transactionId)) + pubkyPublicKey.value = null + assertNull(sut.resolvedHardwarePaymentFor(second.walletId, second.transactionId)) + pubkyPublicKey.value = testPublicKey + assertEquals(second, sut.resolvedHardwarePaymentFor(second.walletId, second.transactionId)) + } + + @Test + fun `pending hardware proof resolution uses original wallet and amount without another payment`() = test { + pubkyPublicKey.value = testPublicKey + val request = paymentRequest() + val txid = "ab".repeat(32) + val walletId = "original-hardware-wallet" + pubkyPublicKey.value = testPublicKey + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setSendState(SendUiState(address = "bcrt1qpaymentrequest", amount = 9_000uL, + payMethod = SendMethod.ONCHAIN, hardwareWalletId = "different-selected-wallet", + incomingPaymentRequestId = request.id, isPaymentRequest = true)) + sut.showSheet(Sheet.Send(SendRoute.Pending(txid, request.amountSats.toLong(), false, isOnchain = true))) + whenever(paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey)).thenReturn(false) + assertFalse(sut.completeHardwareContactPayment(txid, walletId, request.id, testPublicKey)) + onchainPaymentResolutions.value = listOf(PaykitOnchainPaymentProofResolution( + testPublicKey, request.id, txid, walletId, request.amountSats, + )) + runCurrent() + assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + assertEquals(walletId, sut.successSendUiState.value.activityWalletId) + assertEquals(request.amountSats.toLong(), sut.successSendUiState.value.sats) + verify( + lightningRepo, + never() + ).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + } + @Test fun `private hardware contact preparation is idempotent`() = test { val privateContext = privatePaymentContext(7uL) @@ -8585,8 +8937,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(testPublicKey, privateContext) - assertTrue(sut.prepareHardwareContactPayment()) - assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) } @@ -8670,11 +9022,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) val completionStarted = CompletableDeferred() val finishCompletion = CompletableDeferred() - whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit")) + whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted("txid"))) .doSuspendableAnswer { proofStateVersion.value += 1 completionStarted.complete(Unit) finishCompletion.await() + true } whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -8698,7 +9051,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentRequestRepo, never()).refresh(PaykitPaymentRequestRefreshMode.FULL) finishCompletion.complete(Unit) advanceUntilIdle() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted("txid")) verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) } @@ -8897,12 +9250,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `uncertain ordinary onchain failure keeps existing error handling`() = test { + fun `proven predispatch ordinary onchain failure keeps existing error handling`() = test { balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) stubOnchainSend( address = "bcrt1quncertainordinarysend", sats = 1_000uL, - result = Result.failure(IllegalStateException("outcome unknown")), + result = Result.failure(OnchainSendNotDispatchedError(IllegalStateException("not dispatched"))), ) setSendState( SendUiState( @@ -8932,7 +9285,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubOnchainSend( address = "bcrt1qpreflightfailure", sats = request.amountSats, - result = Result.failure(IllegalStateException("preflight failed")), + result = Result.failure(OnchainSendNotDispatchedError(IllegalStateException("preflight failed"))), invokeBeforeSendAttempt = false, ) setActiveContactPaymentContext(testPublicKey, privateContext, request, isInitialSubscriptionPayment = true) @@ -8967,7 +9320,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) whenever( - paykitPaymentProofRepo.markOnchainPaymentStarted(request, address, WalletScope.default), + paykitPaymentProofRepo.markOnchainPaymentStarted(request, address, WalletScope.default, 7uL), ).doSuspendableAnswer { whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) @@ -8999,7 +9352,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).failOnchainPayment(request) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(paykitPaymentProofRepo, never()).completeOnchainPayment(any(), any(), any(), eq("bitkit")) + verify(paykitPaymentProofRepo, never()).completeOnchainPayment(any(), any(), any(), eq("bitkit"), anyOrNull()) } @Test @@ -9021,7 +9374,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubOnchainSend( address = "bcrt1qdefinitefailure", sats = request.amountSats, - result = Result.failure(error), + result = Result.failure(OnchainSendNotDispatchedError(error)), ) setActiveContactPaymentContext( testPublicKey, @@ -9085,9 +9438,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { request.paymentRequestId, request.amountSats.toLong(), observeResolution = false, + isOnchain = true, ) assertEquals( - SendEffect.NavigateToPending(pendingRoute.paymentHash, pendingRoute.amount, false), + SendEffect.NavigateToPending(pendingRoute.paymentHash, pendingRoute.amount, false, isOnchain = true), awaitItem(), ) sut.showSheet(Sheet.Send(pendingRoute)) @@ -9112,76 +9466,27 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `hardware resolution survives collector reattachment until completion`() = test { + fun `cold onchain proof resolution restores contact correlation without opening success`() = test { val request = paymentRequest() val transactionId = "ef".repeat(32) pubkyPublicKey.value = testPublicKey runCurrent() - setSendState( - SendUiState( - hardwareWalletId = "hardware-wallet", - amount = request.amountSats, - incomingPaymentRequestId = request.id, - ) - ) - sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) - - onchainPaymentResolutions.value = listOf( - PaykitOnchainPaymentProofResolution("other-identity", request.id, transactionId), - ) - runCurrent() - assertNull(sut.sendUiState.value.resolvedHardwarePaymentTxId) - verify(paykitPaymentProofRepo, never()).consumeOnchainPaymentResolution(any()) onchainPaymentResolutions.value = listOf( PaykitOnchainPaymentProofResolution( testPublicKey, - request.id.copy(paymentRequestId = "other-request"), + request.id, transactionId, ), ) runCurrent() - assertNull(sut.sendUiState.value.resolvedHardwarePaymentTxId) - onchainPaymentResolutions.value = listOf( - PaykitOnchainPaymentProofResolution(testPublicKey, request.id, transactionId, "hardware-wallet"), + verify(paykitPaymentProofRepo).consumeOnchainPaymentResolution(any()) + verify(activityRepo).setContact( + contactPublicKey = request.counterparty, + forPaymentId = transactionId, + syncLdkPayments = false, ) - runCurrent() - repeat(2) { - sut.sendUiState.test { - assertEquals(transactionId, awaitItem().resolvedHardwarePaymentTxId) - } - } - verify(activityRepo).setContact(testPublicKey, transactionId, false, "hardware-wallet") - sut.completeHardwareContactPayment("other-transaction") - assertEquals(transactionId, sut.sendUiState.value.resolvedHardwarePaymentTxId) - sut.completeHardwareContactPayment(transactionId) - assertNull(sut.sendUiState.value.resolvedHardwarePaymentTxId) - verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) - verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) - } - - @Test - fun `cold onchain proof resolution restores contact correlation without opening success`() = test { - val request = paymentRequest() - val transactionId = "ef".repeat(32) - pubkyPublicKey.value = testPublicKey - runCurrent() - - for (walletId in listOf(WalletScope.default, "hardware-wallet")) { - onchainPaymentResolutions.value = listOf( - PaykitOnchainPaymentProofResolution(testPublicKey, request.id, transactionId, walletId), - ) - runCurrent() - - verify(activityRepo).setContact( - contactPublicKey = request.counterparty, - forPaymentId = transactionId, - syncLdkPayments = false, - walletId = walletId, - ) - } - verify(paykitPaymentProofRepo, times(2)).consumeOnchainPaymentResolution(any()) assertNull(sut.successSendUiState.value.paymentHashOrTxId) } @@ -9244,7 +9549,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `post broadcast bookkeeping failure still completes payment proof`() = test { + fun `retained accepted outcome after bookkeeping failure completes verified payment proof`() = test { val request = paymentRequest() val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) @@ -9254,7 +9559,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubOnchainSend( address = "bcrt1qbookkeepingfailure", sats = request.amountSats, - result = Result.failure(IllegalStateException("activity persistence failed")), + result = Result.success(OnchainSendOutcome.Accepted("broadcast-txid")), broadcastTxId = "broadcast-txid", ) setActiveContactPaymentContext(testPublicKey, privateContext, request) @@ -9270,9 +9575,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify( - paykitPaymentProofRepo - ).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue, "bitkit") + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted("broadcast-txid")) verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @@ -9311,8 +9614,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) } @Test @@ -9352,8 +9660,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) } @Test @@ -9413,8 +9726,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) } @Test @@ -9519,8 +9837,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) } @Test @@ -9540,6 +9863,450 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() } + @Test + fun `rejected onchain send opens unresolved state without a success transaction`() = test { + val address = "bcrt1qrejected" + val txid = "ab".repeat(32) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + stubOnchainSend(address, 1_000u, Result.success(OnchainSendOutcome.Rejected(txid, "broadcast refused"))) + setSendState(SendUiState(address = address, amount = 1_000u, payMethod = SendMethod.ONCHAIN)) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals( + SendEffect.NavigateToPending(txid, 1_000, false, isOnchain = true, refusalReason = "broadcast refused"), + awaitItem() + ) + } + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `unknown onchain send opens unresolved state without a success transaction`() = test { + val address = "bcrt1qunknown" + val txid = "cd".repeat(32) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + stubOnchainSend(address, 1_000u, Result.success(OnchainSendOutcome.Unknown(txid))) + setSendState(SendUiState(address = address, amount = 1_000u, payMethod = SendMethod.ONCHAIN)) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.NavigateToPending(txid, 1_000, false, isOnchain = true), awaitItem()) + } + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `pre admission failure releases private context without navigating to pending`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), 7uL) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + stubOnchainSend( + "bcrt1qpreadmission", + request.amountSats, + Result.failure(IllegalArgumentException("fee estimation failed")), + invokeBeforeSendAttempt = false + ) + setActiveContactPaymentContext(testPublicKey, privateContext, request, isInitialSubscriptionPayment = true) + setSendState( + SendUiState( + address = "bcrt1qpreadmission", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + isPaymentRequest = true, + isInitialSubscriptionPayment = true + ) + ) + + sut.sendEffect.test { + confirmCurrentPayment() + assertTrue(awaitItem() is SendEffect.NavigateToError) + } + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + } + + @Test + fun `blocked admission releases consumed private context before dispatch only`() = test { + val cases = listOf( + false to OnchainSendBlockedError(), + true to OnchainSendBlockedError(), + false to OnchainSendAttemptUnreadableError(IllegalStateException("guard unreadable")), + true to OnchainSendAttemptUnreadableError(IllegalStateException("guard unreadable")), + ) + for ((index, case) in cases.withIndex()) { + val (sendAttempted, error) = case + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), 7uL + index.toULong()) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + stubOnchainSend( + "bcrt1qblocked", + request.amountSats, + Result.failure(error), + invokeBeforeSendAttempt = sendAttempted, + ) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1qblocked", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + isPaymentRequest = true, + ), + ) + + sut.sendEffect.test { + confirmCurrentPayment() + assertTrue(awaitItem() is SendEffect.NavigateToPending) + } + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + if (sendAttempted) { + verify(privatePaykitRepo, never()).releasePrivatePaymentList(testPublicKey, privateContext) + } else { + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + } + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + } + } + + @Test + fun `send-all Success uses accepted prepared recipient amount`() = test { + val address = "bcrt1qmaxrecipient" + val txid = "ab".repeat(32) + balanceState.value = BalanceState(maxSendOnchainSats = 1_000uL) + stubOnchainSend(address, 1_000uL, Result.success(OnchainSendOutcome.Accepted(txid, 900uL))) + setSendState(SendUiState(address = address, amount = 1_000uL, payMethod = SendMethod.ONCHAIN)) + sut.sendEffect.test { + confirmCurrentPayment() + awaitItem() + } + assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + assertEquals(900L, sut.successSendUiState.value.sats) + } + + @Test + fun `generic outer error after ordinary send remains unresolved`() = test { + val address = "bcrt1qoutererror" + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + stubOnchainSend(address, 1_000u, Result.failure(IllegalStateException("outcome unknown"))) + setSendState(SendUiState(address = address, amount = 1_000u, payMethod = SendMethod.ONCHAIN)) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.NavigateToPending("", 1_000, false, isOnchain = true), awaitItem()) + } + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `blocked ordinary payment never reports an earlier accepted send as its success`() = test { + val address = "bcrt1qreopened" + val txid = "ef".repeat(32) + val previous = OnchainSendAttempt( + walletId = WalletScope.default, + attemptId = "attempt-1", + requestId = null, + orderId = null, + address = address, + amountSats = 1_000uL, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = false, + channelId = null, + tags = emptyList(), + evidence = OnchainSendEvidence.Accepted, + txid = txid, + candidateTxids = listOf(txid), + originalInputs = listOf(to.bitkit.repositories.OnchainSendInput("11".repeat(32), 0u)), + ) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + setSendState(SendUiState(address = "bcrt1qdifferentrecipient", amount = 2_000u, payMethod = SendMethod.ONCHAIN)) + stubOnchainSend("bcrt1qdifferentrecipient", 2_000u, Result.failure(OnchainSendBlockedError(previous))) + whenever(lightningRepo.completeAcceptedOrdinaryFollowup(txid)) + .doSuspendableAnswer { Unit } + + sut.sendEffect.test { + confirmCurrentPayment() + expectNoEvents() + } + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + verify(lightningRepo).completeAcceptedOrdinaryFollowup(txid) + } + + @Test + fun `blocked Shop recovery follows original request through success`() = test { + val original = paymentRequest() + val incoming = original.copy(paymentRequestId = "new-request", amountSats = 3_000uL) + val privateContext = privatePaymentContext(7uL) + val txid = "ef".repeat(32) + val previous = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "older-shop-payment", requestId = original.id, orderId = null, + address = "bcrt1qoriginal", amountSats = original.amountSats, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, txid = txid, + ) + pubkyPublicKey.value = testPublicKey + runCurrent() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(incoming)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, incoming, isInitialSubscriptionPayment = true) + setSendState(SendUiState(address = "bcrt1qnewshop", amount = incoming.amountSats, + payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, + isPaymentRequest = true, incomingPaymentRequestId = incoming.id)) + stubOnchainSend("bcrt1qnewshop", incoming.amountSats, Result.failure(OnchainSendBlockedError(previous))) + val resolution = PaykitOnchainPaymentProofResolution(testPublicKey, original.id, txid, + amountSats = original.amountSats) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.NavigateToPending(txid, original.amountSats.toLong(), false, isOnchain = true), awaitItem()) + assertEquals(original.id, sut.sendUiState.value.incomingPaymentRequestId) + sut.showSheet(Sheet.Send(SendRoute.Pending(txid, original.amountSats.toLong(), false, isOnchain = true))) + onchainPaymentResolutions.value = listOf(resolution) + assertEquals(SendEffect.PaymentSuccess, awaitItem()) + assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + assertEquals(original.amountSats.toLong(), sut.successSendUiState.value.sats) + runCurrent() + expectNoEvents() + } + } + + @Test + fun `already resolved original Shop payment never returns to pending`() = test { + val original = paymentRequest() + val incoming = original.copy(paymentRequestId = "new-request", amountSats = 3_000uL) + val privateContext = privatePaymentContext(7uL) + val txid = "ef".repeat(32) + val previous = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "older-shop-payment", requestId = original.id, orderId = null, + address = "bcrt1qoriginal", amountSats = original.amountSats, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, txid = txid, + ) + pubkyPublicKey.value = testPublicKey + runCurrent() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(incoming)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, incoming, isInitialSubscriptionPayment = true) + setSendState(SendUiState(address = "bcrt1qnewshop", amount = incoming.amountSats, + payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, + isPaymentRequest = true, incomingPaymentRequestId = incoming.id)) + stubOnchainSend("bcrt1qnewshop", incoming.amountSats, Result.failure(OnchainSendBlockedError(previous))) + val resolution = PaykitOnchainPaymentProofResolution(testPublicKey, original.id, txid, + amountSats = original.amountSats) + sut.showSheet(Sheet.Send(SendRoute.Confirm)) + onchainPaymentResolutions.value = listOf(resolution) + runCurrent() + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.PaymentSuccess, awaitItem()) + assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + assertEquals(original.amountSats.toLong(), sut.successSendUiState.value.sats) + runCurrent() + expectNoEvents() + } + } + + @Test + fun `late hardware Shop success preserves a newer active contact`() = test { + val txid = "ef".repeat(32) + setActiveContactPaymentContext(testPublicKey) + sut.onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txid, + sats = 1_000, + ), + walletId = "hardware-original", + navigate = false, + syncContact = false, + ) + advanceUntilIdle() + assertEquals(testPublicKey, activeContactPaymentContext()?.publicKey) + verify(activityRepo, never()).setContact(any(), eq(txid), any(), any()) + assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `blocked new contact cannot be assigned to recovered older payment`() = test { + val txid = "ef".repeat(32) + val previous = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "older-payment", requestId = null, orderId = null, + address = "bcrt1qoriginal", amountSats = 1_000uL, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, txid = txid, + ) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + setActiveContactPaymentContext(testPublicKey) + setSendState(SendUiState(address = "bcrt1qnewcontact", amount = 2_000u, payMethod = SendMethod.ONCHAIN)) + stubOnchainSend("bcrt1qnewcontact", 2_000u, Result.failure(OnchainSendBlockedError(previous))) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.NavigateToPending(txid, 1_000, false, isOnchain = true), awaitItem()) + } + sut.onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txid, + sats = 1_000, + ) + ) + advanceUntilIdle() + verify(activityRepo, never()).setContact(any(), eq(txid), any(), any()) + } + + @Test + fun `completed original transfer opens its funded order without another send`() = test { + val txid = "ab".repeat(32) + val original = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "original-funding", requestId = null, + orderId = "original-order", address = "bcrt1qoriginalorder", amountSats = 1_000uL, + isMaxAmount = false, feeRateSatsPerVByte = 2uL, isTransfer = true, + channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Observed, + txid = txid, localFollowupComplete = true, + transferContext = to.bitkit.repositories.OnchainTransferContext(1_200uL, 20_000uL), + ) + sut.mainScreenEffect.test { + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original.copy(localFollowupComplete = false)) + sut.onRecoveredTransfer(original) + advanceUntilIdle() + expectNoEvents() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original.copy(orderId = "different-order")) + sut.onRecoveredTransfer(original) + advanceUntilIdle() + expectNoEvents() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.onRecoveredTransfer(original) + advanceUntilIdle() + assertEquals(MainScreenEffect.Navigate(Routes.OrderDetail("original-order")), awaitItem()) + } + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `retained transfer Pending opens original funding recovery without changing send inputs`() = test { + val original = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "original-funding", requestId = null, + orderId = "order-1", address = "bcrt1qoriginalorder", amountSats = 1_000uL, + isMaxAmount = false, feeRateSatsPerVByte = 2uL, isTransfer = true, + channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Rejected, + txid = "ab".repeat(32), refusalReason = "declined", + ) + setSendState(SendUiState(address = "bcrt1qother", amount = 9_000u)) + sut.showPendingTransfer(original) + advanceUntilIdle() + assertEquals( + Sheet.Send( + SendRoute.Pending( + paymentHash = requireNotNull(original.txid), + amount = 1_000, + observeResolution = false, + isOnchain = true, + walletId = original.walletId, + refusalReason = original.refusalReason, + ) + ), + sut.currentSheet.value, + ) + assertEquals("bcrt1qother", sut.sendUiState.value.address) + } + + @Test + fun `retry fee approval works without USD rate and keeps the Bitcoin fee warning`() = test { + val original = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "original", requestId = null, orderId = null, + address = "bcrt1qoriginal", amountSats = 1_000uL, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, + ) + val receipt = to.bitkit.repositories.OnchainPreparedReceipt( + txid = "ab".repeat(32), inputs = emptyList(), address = original.address, + amountSats = original.amountSats, feeRateSatsPerVByte = 2uL, miningFeeSats = 600uL, + ) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())) + .thenReturn(Result.failure(Exception("USD unavailable"))) + whenever(lightningRepo.retryOriginalOnchainSend(any(), any(), any(), anyOrNull(), any(), any())) + .doSuspendableAnswer { invocation -> + val approve = invocation.getArgument Unit>(4) + runCatching { + approve(receipt) + OnchainSendOutcome.Unknown(receipt.txid) + } + } + var approved = false + val result = sut.retryOriginalOnchainSend(original, 2uL) { prepared, warnings -> + assertEquals(receipt, prepared) + assertEquals(listOf(SanityWarning.FEE_OVER_HALF_VALUE), warnings) + approved = true + } + assertTrue(result.isSuccess) + assertTrue(approved) + } + + @Test + fun `recovery denies paid expired or changed original Blocktank order before broadcast`() = test { + val order = to.bitkit.ext.mockOrder().copy( + orderExpiresAt = (Clock.System.now() + 60.seconds).toString(), + ) + val original = OnchainSendAttempt( + walletId = WalletScope.default, + attemptId = "original-order-attempt", + transferContext = to.bitkit.repositories.OnchainTransferContext( + 1_200uL, + 100_000uL, + order.clientBalanceSat, + order.feeSat, + ), + requestId = null, + orderId = order.id, + address = requireNotNull(order.payment?.onchain?.address), + amountSats = order.feeSat, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = true, + channelId = null, + tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, + ) + var dispatched = 0 + whenever(lightningRepo.retryOriginalOnchainSend(any(), any(), any(), anyOrNull(), any(), any())).doSuspendableAnswer { invocation -> + val authorize = invocation.getArgument Unit>(5) + runCatching { + authorize(original) + dispatched += 1 + OnchainSendOutcome.Accepted("ef".repeat(32)) + } + } + val invalidOrders = listOf( + order.copy(state2 = com.synonym.bitkitcore.BtOrderState2.PAID), + order.copy(orderExpiresAt = (Clock.System.now() - 1.seconds).toString()), + order.copy(feeSat = order.feeSat + 1uL), + order.copy(clientBalanceSat = order.clientBalanceSat + 1uL), + ) + for (invalidOrder in invalidOrders) { + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(invalidOrder))) + assertTrue(sut.retryOriginalOnchainSend(original, 2uL).isFailure) + } + assertEquals(0, dispatched) + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) + assertTrue(sut.retryOriginalOnchainSend(original, 2uL).isSuccess) + assertEquals(1, dispatched) + } + @Test fun `private lightning contact payment consumes private list before send`() = test { val bolt11 = "lnbcrt1privatecontact" @@ -10382,13 +11149,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } private suspend fun stubSuccessfulOnchainSend(address: String, sats: ULong, txId: String = "txid") { - stubOnchainSend(address, sats, Result.success(txId), broadcastTxId = txId) + stubOnchainSend(address, sats, Result.success(OnchainSendOutcome.Accepted(txId)), broadcastTxId = txId) } private suspend fun stubOnchainSend( address: String, sats: ULong, - result: Result, + result: Result, invokeBeforeSendAttempt: Boolean = true, broadcastTxId: String? = null, ) { @@ -10405,7 +11172,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) }.doSuspendableAnswer { invocation -> kotlin.check(invocation.getArgument(0) == address) @@ -10771,6 +11543,60 @@ class AppViewModelSendFlowTest : BaseUnitTest() { creatorIdentity = testPublicKey, wasPublishedToActiveState = wasPublishedToActiveState, ) + + private suspend fun TestScope.reopenRetainedHardwareRequest( + balanceSats: ULong, + amountSats: ULong = 1_000uL, + ): Pair { + sut.setIsAuthenticated(true) + val request = paymentRequest().copy( + lifecycleState = PaymentRequestLifecycleState.ACCEPTED, + amountSats = amountSats, + amountValue = java.math.BigDecimal(amountSats.toString()).movePointLeft(8).toPlainString(), + ) + val signed = hardwareSignedReceipt() + val proof = to.bitkit.repositories.PendingPaykitPaymentProof( + identity = testPublicKey, + requestId = request.id, + paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, + paymentStarted = true, + paymentIdentifier = to.bitkit.utils.SignedTransactionId.fromHex(signed.serializedTx), + onchainAddress = REGTEST_ADDRESS, + onchainAmountSats = request.amountSats, + onchainWalletId = HARDWARE_WALLET_ID, + hardwareDispatchAttempted = true, + hardwareRefusedForNavigation = true, + hardwareSignedTransaction = signed.serializedTx, + hardwareMiningFeeSats = signed.miningFeeSats, + hardwareFeeRate = signed.feeRate, + hardwareTotalSpent = signed.totalSpent, + ) + whenever(paykitPaymentProofRepo.retainedHardwareOnchainRequest(request)).thenReturn(proof) + whenever(privatePaykitRepo.beginPaymentRequest(request)) + .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) + hwWallets.value = persistentListOf(hardwareWallet(fundingBalanceSats = balanceSats)) + pendingPaykitPaymentRequests.value = listOf(request) + surfacedPaykitPaymentRequestIds += request.id + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + return request to signed + } + + private fun hardwareSignedReceipt() = HwFundingSignedTx( + requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().use { it.readText().trim() }, + 1000uL, + 2uL, + 2000uL, + ) } private const val SAMROCK_SETUP_URL = diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 547a69035c..2a661ffe5f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -17,7 +17,6 @@ import com.synonym.bitkitcore.TrezorException import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException @@ -25,6 +24,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy @@ -55,6 +55,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R +import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -77,6 +78,7 @@ import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.TransportType +import to.bitkit.models.WalletScope import to.bitkit.models.formatToModernDisplay import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo @@ -89,6 +91,10 @@ import to.bitkit.repositories.HwWalletMismatchError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.OnchainTransferContext import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService @@ -142,6 +148,19 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(any())).thenReturn("") whenever(walletRepo.getOnchainAddress()).thenReturn(WALLET_ADDRESS) whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) + whenever(cacheStore.data).thenReturn(MutableStateFlow(AppCacheData())) + whenever { transferRepo.persistAcceptedFunding(any(), any(), anyOrNull()) }.doSuspendableAnswer { + val originalOrder = it.getArgument(0) + cacheStore.addPaidOrder(originalOrder.id, it.getArgument(1)) + Result.success(Unit) + } + whenever { transferRepo.findLspOrderIdByFundingTxId(any()) }.thenReturn(Result.success(null)) + whenever { + transferRepo.createTransfer( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) + } + .thenReturn(Result.success("transfer-id")) whenever { hwWalletRepo.needsPassphrase(any()) }.thenReturn(false) val nodeStatus = mock() whenever(nodeStatus.isRunning).thenReturn(true) @@ -1147,7 +1166,28 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = eq( + OnchainTransferContext( + txTotalSats = 100_000uL, + preTransferOnchainSats = 100_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ), + ), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + verify(transferRepo).persistAcceptedFunding( + order, TXID, + OnchainTransferContext( + txTotalSats = 100_000uL, + preTransferOnchainSats = 100_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) @@ -1186,7 +1226,19 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = eq( + OnchainTransferContext( + txTotalSats = 38_171uL, + preTransferOnchainSats = 41_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ), + ), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1200,7 +1252,12 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @@ -1218,7 +1275,27 @@ class TransferViewModelTest : BaseUnitTest() { // 100000 - 98000 - 1000 = 1000, above dust → fixed send; drain would still cover order. whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) - stubSendOnChain(Result.failure(AppError("Coin selection failed"))) + whenever( + lightningRepo.sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ), + ).thenReturn(Result.failure(AppError("Coin selection failed"))) quoteOrder(order) @@ -1239,7 +1316,12 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1253,11 +1335,295 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } + @Test + fun `accepted confirmation rejects changed original client balance`() = test { + assertChangedFundingTermsRejected("balance") + } + + @Test + fun `accepted confirmation rejects changed original service fee`() = test { + assertChangedFundingTermsRejected("fee") + } + + @Test + fun `accepted confirmation rejects changed original funding address`() = test { + assertChangedFundingTermsRejected("address") + } + + private suspend fun TestScope.assertChangedFundingTermsRejected(changedTerm: String) { + val originalOrder = spendingOrder(feeSat = 98_000uL) + val original = OnchainTransferContext( + 99_000uL, + 125_000uL, + originalOrderClientBalanceSats = originalOrder.clientBalanceSat, + originalOrderFeeSats = originalOrder.feeSat, + ) + val attempt = acceptedFundingAttempt(originalOrder, original).let { + if (changedTerm == "address") it.copy(address = "different-original-address") else it + } + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + val changed = when (changedTerm) { + "balance" -> originalOrder.copy(clientBalanceSat = originalOrder.clientBalanceSat + 1uL) + "fee" -> originalOrder.copy(feeSat = originalOrder.feeSat + 1uL) + else -> originalOrder + } + quoteOrder(changed) + prepareConfirm() + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + verify(transferRepo, never()).persistAcceptedFunding(any(), any(), anyOrNull()) + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + verifySendOnChain(sats = changed.feeSat, count = 0) + assertFalse(sut.spendingUiState.value.isConfirmPaying) + } + + @Test + fun `accepted transfer resumes after transfer storage failure without another send`() = test { + val order = spendingOrder(feeSat = 98_000uL) + val original = OnchainTransferContext( + 99_000uL, 125_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ) + val attempt = acceptedFundingAttempt(order, original) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + whenever(transferRepo.persistAcceptedFunding(order, TXID, original)) + .thenReturn(Result.failure(AppError("transfer storage unavailable")), Result.success(Unit)) + val effects = mutableListOf() + backgroundScope.launch { sut.transferEffects.collect { effects.add(it) } } + quoteOrder(order) + prepareConfirm() + effects.clear() + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + assertEquals(order, sut.spendingUiState.value.order) + assertFalse(sut.spendingUiState.value.isConfirmPaying) + assertTrue(effects.isEmpty()) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, TXID) + verifySendOnChain(sats = order.feeSat, count = 0) + verify(transferRepo, times(2)).persistAcceptedFunding(order, TXID, original) + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + assertEquals(listOf(TransferEffect.OnSpendingFundingPaid), effects) + } + + @Test + fun `durably paid accepted funding survives local followup failure without a second order`() = test { + assertPaidFundingSurvivesLocalFailure(resumedEvidence = null) + } + + @Test + fun `durably paid resumed accepted funding survives local followup failure without a second order`() = test { + assertPaidFundingSurvivesLocalFailure(resumedEvidence = OnchainSendEvidence.Accepted) + } + + @Test + fun `durably paid observed funding survives local followup failure without a second order`() = test { + assertPaidFundingSurvivesLocalFailure(resumedEvidence = OnchainSendEvidence.Observed) + } + + private suspend fun TestScope.assertPaidFundingSurvivesLocalFailure(resumedEvidence: OnchainSendEvidence?) { + val order = spendingOrder(feeSat = 98_000uL) + val original = OnchainTransferContext( + 99_000uL, 110_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ) + val attempt = acceptedFundingAttempt(order, original).copy( + evidence = resumedEvidence ?: OnchainSendEvidence.Accepted, + ) + var retainedAttempt = if (resumedEvidence == null) null else attempt + whenever(lightningRepo.currentOnchainSendAttempt()).doSuspendableAnswer { retainedAttempt } + whenever(lightningRepo.completeAcceptedTransferFollowup(order.id, TXID)).doSuspendableAnswer { + throw AppError("accepted activity readback unavailable") + } + stubSpendableBalances(spendable = 110_000uL) + stubSingleUtxoFunding(miningFee = 1_000uL) + stubSendOnChain() + val toasts = collectToasts() + val effects = mutableListOf() + backgroundScope.launch { sut.transferEffects.collect { effects.add(it) } } + quoteOrder(order) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn( + Result.success(order), Result.success(order.copy(id = "second-order")), + ) + prepareConfirm() + effects.clear() + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + // Simulate the background resumer repairing the original activity and acknowledging the guard. + // A second swipe on the still-mounted confirmation must not create or fund another order. + retainedAttempt = attempt.copy(localFollowupComplete = true) + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verifySendOnChain(sats = order.feeSat, count = if (resumedEvidence == null) 1 else 0) + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + verify(transferRepo).persistAcceptedFunding(order, TXID, original) + verify(cacheStore).addPaidOrder(order.id, TXID) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, TXID) + assertEquals(listOf(TransferEffect.OnSpendingFundingPaid), effects) + assertTrue(sut.spendingUiState.value.isConfirmPaying) + assertTrue(toasts.isEmpty()) + } + + @Test + fun `accepted transfer recovery preserves original balance totals`() = test { + val order = spendingOrder(feeSat = 98_000uL) + val original = OnchainTransferContext( + 99_000uL, 125_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedFundingAttempt(order, original)) + quoteOrder(order) + prepareConfirm() + stubSpendableBalances(7_000uL) + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verify(transferRepo).persistAcceptedFunding(order, TXID, original) + verifySendOnChain(sats = order.feeSat, count = 0) + } + + @Test + fun `accepted transfer without original context stays blocked instead of inventing balance totals`() = test { + val order = spendingOrder(feeSat = 98_000uL) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedFundingAttempt(order, null)) + whenever(transferRepo.persistAcceptedFunding(order, TXID, null)) + .thenReturn(Result.failure(AppError("missing original context"))) + quoteOrder(order) + prepareConfirm() + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + verifySendOnChain(sats = order.feeSat, count = 0) + assertFalse(sut.spendingUiState.value.isConfirmPaying) + } + + private fun acceptedFundingAttempt(order: IBtOrder, original: OnchainTransferContext?) = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "attempt", requestId = null, orderId = order.id, + address = order.payment?.onchain?.address.orEmpty(), amountSats = order.feeSat, + isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = true, + channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Accepted, txid = TXID, + transferContext = original?.copy( + originalOrderClientBalanceSats = original.originalOrderClientBalanceSats ?: order.clientBalanceSat, + originalOrderFeeSats = original.originalOrderFeeSats ?: order.feeSat, + ), + ) + + @Test + fun `unknown transfer funding does not mark order paid`() = test { + val order = spendingOrder(feeSat = 98_000uL) + stubSpendableBalances(spendable = 100_000u) + whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever { lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) } + .thenReturn(Result.success(listOf(stubUtxo(100_000u)))) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever( + lightningRepo.sendOnChain( + address = any(), + sats = any(), + speed = any(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ), + ).thenReturn(Result.success(OnchainSendOutcome.Unknown(TXID))) + quoteOrder(order) + + prepareConfirm() + val retained = acceptedFundingAttempt(order, null).copy(evidence = OnchainSendEvidence.Unknown) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(null, retained) + val effects = mutableListOf() + val collector = launch { sut.transferEffects.collect { effects += it } } + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + assertEquals(listOf(TransferEffect.OnFundingPending(retained)), effects) + collector.cancel() + + verify(cacheStore, never()).addPaidOrder(any(), any()) + verify(transferRepo, never()).createTransfer( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) + } + + @Test + fun `blocked transfer opens the retained previous order`() = test { + val order = spendingOrder(feeSat = 98_000uL) + stubSpendableBalances(spendable = 100_000u) + whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever { lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) } + .thenReturn(Result.success(listOf(stubUtxo(100_000u)))) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever( + lightningRepo.sendOnChain( + address = any(), + sats = any(), + speed = any(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ), + ).thenReturn(Result.success(OnchainSendOutcome.Unknown(TXID))) + quoteOrder(order) + + prepareConfirm() + val retained = acceptedFundingAttempt(order, null).copy(orderId = "previous-order", evidence = OnchainSendEvidence.Unknown) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(null, retained) + val effects = mutableListOf() + val collector = launch { sut.transferEffects.collect { effects += it } } + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + assertEquals(listOf(TransferEffect.OnFundingPending(retained)), effects) + collector.cancel() + + verify(cacheStore, never()).addPaidOrder(any(), any()) + verify(transferRepo, never()).createTransfer( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) + } + @Test fun `confirmation blocks replacing or clearing the transfer while creating its order`() = test { val order = spendingOrder(feeSat = 98_000uL) @@ -1297,7 +1663,30 @@ class TransferViewModelTest : BaseUnitTest() { }.thenReturn(Result.success(listOf(stubUtxo(110_000u)))) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) - stubSendOnChain(Result.failure(AppError("Coin selection failed")), Result.success(TXID)) + whenever( + lightningRepo.sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ), + ).thenReturn( + Result.failure(AppError("Coin selection failed")), + Result.success(OnchainSendOutcome.Accepted(TXID)), + ) quoteOrder(order) prepareConfirm() @@ -1553,7 +1942,12 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) } @@ -1736,7 +2130,12 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), anyOrNull(), - ) + anyOrNull(), + anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1749,7 +2148,27 @@ class TransferViewModelTest : BaseUnitTest() { }.thenReturn(Result.success(listOf(stubUtxo(110_000u)))) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) - stubSendOnChain(Result.failure(AppError("Coin selection failed"))) + whenever( + lightningRepo.sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ), + ).thenReturn(Result.failure(AppError("Coin selection failed"))) quoteOrder(order) prepareConfirm() sut.onTransferToSpendingConfirm() @@ -1895,7 +2314,12 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), anyOrNull(), - ) + anyOrNull(), + anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } @@ -3211,7 +3635,12 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) } @@ -3364,9 +3793,16 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), anyOrNull(), + anyOrNull(), + anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ), - ).thenReturn(result) - nextResult?.let { stubbing.thenReturn(it) } + ) + fun typed(value: Result) = value.map { OnchainSendOutcome.Accepted(it) } + stubbing.thenReturn(typed(result)) + nextResult?.let { stubbing.thenReturn(typed(it)) } } private companion object { diff --git a/app/src/test/resources/active-onchain-attempt-golden.json b/app/src/test/resources/active-onchain-attempt-golden.json new file mode 100644 index 0000000000..d805d55c43 --- /dev/null +++ b/app/src/test/resources/active-onchain-attempt-golden.json @@ -0,0 +1,74 @@ +{ + "version": 1, + "createdAt": 1791234000000, + "transfers": [], + "paykitPaymentState": { + "subscriptions": {}, + "pendingProofs": [ + { + "identity": "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "requestId": { + "paymentRequestId": "550e8400-e29b-41d4-a716-446655440000", + "counterparty": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "billingPeriodStartsAt": null + }, + "paymentEndpointIdentifier": "btc-regtest-p2wpkh", + "paymentAppId": "bitkit", + "kind": "bitcoin-onchain-txid", + "paymentStarted": true, + "paymentIdentifier": "abababababababababababababababababababababababababababababababab", + "proofData": null, + "billingPeriod": null, + "onchainAddress": "bcrt1qoriginal", + "onchainAmountSats": 1234, + "onchainWalletId": null, + "onchainMatchingTransactionIdsBeforeAttempt": [], + "onchainAcceptanceVerified": false + } + ], + "activeOnchainAttempt": { + "version": 1, + "wallet": { + "kind": "software", + "network": "regtest", + "binding": "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0", + "sourceIndex": "0" + }, + "attemptId": "550e8400-e29b-41d4-a716-446655440001", + "requestId": { + "paymentRequestId": "550e8400-e29b-41d4-a716-446655440000", + "counterparty": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "billingPeriodStartsAt": null + }, + "orderId": null, + "payerIdentity": "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "address": "bcrt1qoriginal", + "amountSats": "1234", + "isMaxAmount": false, + "status": "unknown", + "txid": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "rejectionReason": null, + "originalInputs": [ + { + "txid": "efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef", + "vout": "4294967295" + } + ], + "candidateTxids": [ + "abababababababababababababababababababababababababababababababab", + "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + ], + "feeRateSatsPerVByte": "2", + "followup": { + "feeSats": "123", + "tags": [ + "original tag" + ], + "contact": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "createdAtMillis": "1791234000000", + "channelId": null + }, + "transfer": null + } + } +} diff --git a/app/src/test/resources/candidate-fee-rates-golden.json b/app/src/test/resources/candidate-fee-rates-golden.json new file mode 100644 index 0000000000..b7e48d25b9 --- /dev/null +++ b/app/src/test/resources/candidate-fee-rates-golden.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "createdAt": 1791234000000, + "transfers": [], + "paykitPaymentState": { + "subscriptions": {}, + "pendingProofs": [ + { + "identity": "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "requestId": { + "paymentRequestId": "550e8400-e29b-41d4-a716-446655440000", + "counterparty": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "counterpartyReceiverPath": "bitkit/server", + "billingPeriodStartsAt": null + }, + "paymentEndpointIdentifier": "btc-regtest-p2wpkh", + "paymentAppId": "bitkit", + "kind": "bitcoin-onchain-txid", + "paymentStarted": true, + "paymentIdentifier": "abababababababababababababababababababababababababababababababab", + "proofData": null, + "billingPeriod": null, + "onchainAddress": "bcrt1qoriginal", + "onchainAmountSats": 1234, + "onchainWalletId": null, + "onchainMatchingTransactionIdsBeforeAttempt": [], + "onchainAcceptanceVerified": false + } + ], + "activeOnchainAttempt": { + "version": 1, + "wallet": { + "kind": "software", + "network": "regtest", + "binding": "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0", + "sourceIndex": "0" + }, + "attemptId": "550e8400-e29b-41d4-a716-446655440001", + "requestId": { + "paymentRequestId": "550e8400-e29b-41d4-a716-446655440000", + "counterparty": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "counterpartyReceiverPath": "bitkit/server", + "billingPeriodStartsAt": null + }, + "orderId": null, + "payerIdentity": "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "address": "bcrt1qoriginal", + "amountSats": "1234", + "isMaxAmount": false, + "status": "unknown", + "txid": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "rejectionReason": null, + "originalInputs": [ + { + "txid": "efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef", + "vout": "4294967295" + } + ], + "candidateTxids": [ + "abababababababababababababababababababababababababababababababab", + "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + ], + "feeRateSatsPerVByte": "2", + "followup": { + "feeSats": "123", + "tags": [ + "original tag" + ], + "contact": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "createdAtMillis": "1791234000000", + "channelId": null + }, + "transfer": null, + "candidateFeeRates": { + "abababababababababababababababababababababababababababababababab": "2", + "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd": "4" + } + } + } +} diff --git a/app/src/test/resources/hardware-signed-transaction-legacy.hex b/app/src/test/resources/hardware-signed-transaction-legacy.hex new file mode 100644 index 0000000000..d0e9dfd5f2 --- /dev/null +++ b/app/src/test/resources/hardware-signed-transaction-legacy.hex @@ -0,0 +1 @@ +0200000001f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa724c0300 diff --git a/app/src/test/resources/hardware-signed-transaction.hex b/app/src/test/resources/hardware-signed-transaction.hex new file mode 100644 index 0000000000..f705338546 --- /dev/null +++ b/app/src/test/resources/hardware-signed-transaction.hex @@ -0,0 +1 @@ +02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300 diff --git a/changelog.d/next/1384.fixed.md b/changelog.d/next/1384.fixed.md new file mode 100644 index 0000000000..e9962d4f40 --- /dev/null +++ b/changelog.d/next/1384.fixed.md @@ -0,0 +1 @@ +On-chain sends keep uncertain payments protected across restart and wallet restore, with an authenticated retry of the original amount and inputs. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b236d5cb1c..b47a6d06f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.66" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.71" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } diff --git a/journeys/hardware-wallet/shop-onchain-proof.xml b/journeys/hardware-wallet/shop-onchain-proof.xml new file mode 100644 index 0000000000..d54020dd61 --- /dev/null +++ b/journeys/hardware-wallet/shop-onchain-proof.xml @@ -0,0 +1,28 @@ + + + Pays a linked issuer's one-time on-chain request with the paired, funded Bridge Trezor emulator. + Requires the hardware-wallet Bridge setup and payment-requests issuer fixture; the hardware + native-segwit account must cover 100,000 sats plus fees. Observe the exact returned transaction + in the original hardware account and match the issuer's delivered proof to that transaction. + If Core fails after payment has started and returns no transaction ID, dismissing and reopening + must keep that original request pending; this journey cannot inject that response-loss condition. + A recognized backend refusal or invalid transaction permits leaving the sign sheet while retaining + the original signed payment and durable wallet guard; reopening may retry only that payment + without signing again. Connectivity and unrecognized errors keep navigation guarded. + This journey does not inject native broadcast faults or prove physical USB/BLE behavior. + + + Launch the E2E Bitkit app on regtest with Paykit UI enabled, a Pubky identity authenticated, the fixture issuer saved and linked on receiver path "bitkit/server", and the funded Bridge hardware wallet paired + Have the issuer publish a current regtest P2WPKH destination under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} + Have the issuer send a new proposed one-time Payment Request for amount "0.001", asset "btc", and accepted identifier "btc-regtest-p2wpkh"; record its exact request ID and payer identity for the backend proof check + Verify Payment Request confirmation (testTag "PaymentRequestConfirm") shows 100,000 sats and the saved issuer contact under Show details (testTag "SendConfirmToggleDetails") + Tap the funding-source button (testTag "SendConfirmAssetButton"), waiting for balance loading between taps, until FROM shows the paired hardware wallet + Swipe to confirm; verify hardware sign shows the original issuer destination (testTag "HardwareSendAddress") and 100,000 sats (testTag "HardwareSendAmount") + Tap Open Trezor Connect (testTag "HardwareSendOpenTrezorConnect") once and approve the Bridge emulator's transaction prompts + While signing or local proof observation is running, verify another tap cannot start another payment; wait for Success (testTag "SendSuccess") or the existing Payment Pending screen + If Pending is shown, verify there is no resend action or Sent activity from the returned Core transaction ID alone; close the sheet and reopen the same request from Payment Requests and verify it cannot start a fresh hardware payment + Once the original outgoing transaction is visible to Electrum, resume Bitkit to allow existing proof reconciliation; verify the same original payment creates Sent activity in its original hardware wallet and finishes or its request leaves the pending list, without another signing prompt + Open the original hardware wallet's activity and record the exact new transaction ID; independently read that transaction from the regtest backend and verify it spends that hardware account's inputs + Inspect the fixture issuer's proof for the original request and payer identity; verify type "bitcoin-onchain-txid" contains exactly the recorded original transaction ID, and the backend contains only the original payment + + diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index e5da87d9b9..00f77d4024 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -5,6 +5,13 @@ Paykit access and a watch-only account, a linked buyer receives the Payment Requ the request on regtest through confirmation. It does not cover marketplace browsing, Locks content delivery, fiat payment, or Hypercolor. +## Local-only fallback + +Remote Shop staging remains the default. After an actual staging Paykit pairing failure, see +[`local-paykit-fallback.md`](local-paykit-fallback.md) and `local-paykit-fallback.xml` for the +conditional standalone fixture. It is unrun and does not establish Shop order-paid acceptance. +The companion journey is shared with iOS; only platform build instructions differ. + ## Companion consent and reconnect - `paykit-only-approval.xml` checks Paykit-only consent and cancellation without account creation. diff --git a/journeys/pubky-marketplace/local-paykit-fallback.md b/journeys/pubky-marketplace/local-paykit-fallback.md new file mode 100644 index 0000000000..b931bb2382 --- /dev/null +++ b/journeys/pubky-marketplace/local-paykit-fallback.md @@ -0,0 +1,121 @@ +# Local-only Paykit fallback + +Status: **unrun**. The default is a purchase on `https://shop.staging.pubky.app` with its deployed +Paykit service. Run this companion only after an actual staging Paykit pairing attempt fails and +its URL, timestamp, HTTP outcome and wallet error have been recorded. A pending signup prerequisite, +unknown server version, invoice-delivery failure or unpaid Shop order does not trigger fallback. + +This exercises an operator-owned Paykit server with staging identities and staging regtest Bitcoin. +It does not replace the deployed Shop backend. Standalone pairing, request delivery, accepted payment +and server observation are separate results; none proves that the original Shop order is paid or +that Locks released its content. Follow `local-paykit-fallback.xml` after this setup. + +## Prerequisites + +- Confirm the homeserver fix PR is open and signup has been rerun successfully against + `ufibwbmed6jeq9k4p583go95wofakh9fwpp4k734trq79pd9u1uy` before starting this fixture. +- Use two isolated wallets, fresh seller/buyer staging invites and Bitkit-owned identities registered + on that homeserver. Preserve existing identities. Use the grant signup journey for a fresh + `pubkyauth://signup_grant` URL naming that homeserver; invites and authorization URLs are secrets. +- Record the app commit, APK hash and installed package. Android uses `devDebug`, `to.bitkit.dev`, + Paykit `0.1.0-rc72` and native LDK `0.7.0-rc.71`. Use a normal build (`E2E=false`) or explicitly + network-backed E2E build; the local E2E default selects a different chain endpoint. +- Both wallets and the server must use `ssl://electrs.bitkit.stag0.blocktank.to:9999`, the regtest + endpoint in `app/src/main/java/to/bitkit/env/Env.kt`. Verify this before funding or creating a request. +- Provide an owned disposable PostgreSQL database, an operator-controlled secret store, and a fixture + service signer with its exact public key allowlisted. Never point migrations at staging/shared + databases. The server automatically applies migrations at startup. +- A signed fixture driver must create and observe one request using the pinned server's documented + routes and signature contract. Record its source revision. Without that driver, stop at pairing; + do not invent unsigned invoice endpoints or borrow deployed service signing secrets. + +## Pinned server and configuration + +Use [`pubky/paykit-server` tag `v0.1.0-rc11`](https://github.com/pubky/paykit-server/tree/v0.1.0-rc11), +commit `662dca0619a9aa2962bcd677bd5ddd4563cd2784`. Its `Cargo.toml` pins Paykit to +`ad3c72248d18587bb5b6ef3c99b063fa9bf31551`, matching client rc72. A matching pin is not pairing evidence. +Verify `git rev-parse HEAD` in the server checkout before running it. + +Copy `config/paykit-server.example.toml` from that revision to an operator-controlled file and set: + +| Key | Value | +| --- | --- | +| `http.listen_addr` | `127.0.0.1:8080` on the fixture host | +| `http.trusted_proxy_hops` | `0`; do not trust forwarded headers without verifying the proxy chain | +| `signed_services.trusted_public_keys` | Nonempty list containing only the fixture service's canonical public key | +| `setup.allowed_origins` | Exact HTTPS origin of the fixture's requesting page; no wildcard | +| `setup.log_authorization_url` | `false` | +| `paykit.client_id` | `app.paykit.server` | +| `paykit.app_id` | `paykit-server` | +| `paykit.network` | `mainnet` | +| `bitcoin.network` | `regtest` | +| `electrum.endpoint` | `ssl://electrs.bitkit.stag0.blocktank.to:9999` | + +Retain the example's remaining supported keys. `paykit.network = "mainnet"` means normal Pkarr and +homeserver resolution, including the staging identities above; it does not select Bitcoin mainnet. +`"testnet"` instead selects fixed localhost Pubky services and is wrong for this fixture. The server +accepts no arbitrary homeserver/relay config keys: select the homeserver during identity signup. + +Supply `PAYKIT_CONFIG` (file path), `PAYKIT_DATABASE_URL` (owned database) and `PAYKIT_MASTER_KEY` +(exactly 32 bytes encoded as unpadded base64url) through the secret store, not command arguments, +source files or captured logs. From the pinned server checkout, the documented commands are: + +```sh +cargo run --locked -p paykit-server -- --check-config +cargo run --locked -p paykit-server +``` + +The first command must print `configuration valid`; it does not connect to the database or prove +runtime readiness. In a separate owned process, expose only this listener: + +```sh +cloudflared tunnel --url http://127.0.0.1:8080 +``` + +Record the assigned HTTPS tunnel origin and exact process identities. Require successful +`GET /health/live` and `GET /health/ready` through the tunnel before pairing. A changed tunnel URL +requires updating the fixture URL; do not restart the server or reset identities to retry a payment. + +Open the tunnel's `/setup?return_to=&state=` in the +fixture page. Upstream rc11 initial setup accepts only `return_to` and `state`, not `creator`. +The page supplies the real Pubky Auth QR/deep link and polls `POST /setup/{flow_id}/complete`. +Approve its Paykit and watch-only account claims in the seller wallet. Existing stored bindings use +`/setup/reconnect` with `creator`, `return_to` and `state`; do not replace or reset the binding. +Do not change the deployed Shop configuration to point at this tunnel. + +## Payment and evidence + +Enable contact payments and save both contacts as in `wallet-leg.xml`. Fund only the fresh buyer's +verified staging-regtest savings address. The repository's staging-default `lsp` helper supports: + +```sh +./lsp POST /regtest/chain/deposit '{"address":"","amountSat":100000}' +./lsp POST /regtest/chain/mine '{"count":1}' +``` + +Wait for the confirmed balance, then create one small request within it. Freeze the original buyer, +seller, request ID, fixture invoice/bundle ID, amount, network, derived recipient and deadline before +authentication. Keep the failed remote Shop order separately identified; a fixture invoice is not a +replacement order. Capture the original selected inputs, signed transaction ID and retained-receipt +hash where available, without logging raw secrets. On uncertainty, retain the original request and +payment guard; any authenticated retry must preserve the original amount, inputs and retained bytes. +Never substitute payer, endpoint, order, request or inputs, or use absence of evidence to unlock. + +Record wallet acceptance, the exact transaction/output on staging regtest, server delivery/payment +status and confirmation height separately. `connected` alone is not payment completion. Preserve +sanitized evidence of each boundary; omit invites, auth URLs, private keys, sessions and signatures. + +## Failure classification and teardown + +Report the first failed boundary: `signup prerequisite`, `config/startup`, `tunnel/transport`, +`pairing/claim`, `contact/link`, `request delivery`, `wallet pre-dispatch`, `broadcast rejected`, +`broadcast unknown`, `proof delivery`, `server observation`, or `Shop order completion`. +Capture the timestamp, original IDs, HTTP status/request ID and typed error where available. +Do not relabel a transport/storage error as revoked authority or a transaction ID as acceptance. +If no Shop order was exercised, report Shop acceptance **unrun**, not failed or passed. + +Save evidence before stopping owned server/tunnel processes and test devices. Verify their exact +process/device identities and shutdown; never stop a shared database, adb server or another fixture. +Retain the owned database and original recovery state while a payment outcome is unresolved, with +its location and owner recorded privately. Remove disposable binaries/browser profiles and temporary +files only after evidence and required recovery state are secured. Record every incomplete step. diff --git a/journeys/pubky-marketplace/local-paykit-fallback.xml b/journeys/pubky-marketplace/local-paykit-fallback.xml new file mode 100644 index 0000000000..ec49230a64 --- /dev/null +++ b/journeys/pubky-marketplace/local-paykit-fallback.xml @@ -0,0 +1,23 @@ + + + LOCAL-ONLY and unrun. Use the setup recipe in local-paykit-fallback.md only after actual + remote Shop staging Paykit pairing fails and the staging homeserver signup prerequisite passes. + This verifies a standalone Paykit fixture, not Shop order-paid or Locks content acceptance. + + + Record the failed remote staging pairing evidence and successful homeserver signup prerequisite + Verify the pinned server revision, app binary, staging homeserver identities and matching regtest Electrum endpoint + Validate the owned server configuration, start the server and Cloudflare tunnel, and verify both health endpoints + Open the fixture setup page in a browser, send its generated Pubky Auth URL to the seller wallet, and verify Paykit access and watch-only account consent + Authorize the claims and verify setup completes for the original seller without sharing root identity or spending keys + Enable contact payments in both wallets, save both contacts and verify their linked state + Fund the fresh buyer staging-regtest savings address and verify the confirmed balance + Create exactly one fixture request and record its original payer, seller, request, invoice, amount, recipient, network and deadline + Verify the received request and payment review match those original values before authenticating + Authenticate the payment once and record the retained transaction identity and backend outcome + If the outcome is uncertain, retain the original guard and request without creating a replacement payment + Verify the exact transaction output and server payment observation, then mine and record confirmation + Report standalone pairing, delivery and payment results separately from the original Shop order, marking unexercised Shop acceptance unrun + Save sanitized evidence, classify incomplete boundaries and verify teardown of only owned resources while preserving unresolved recovery state + + diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml new file mode 100644 index 0000000000..9d18c74341 --- /dev/null +++ b/journeys/send/onchain-accepted-result.xml @@ -0,0 +1,24 @@ + + + Verifies that fixed-amount and send-all sends show success only after the configured backend + acknowledges the transaction, and that an accepted send finishes its local activity before a new + send is admitted. Precondition: an onboarded dev wallet with confirmed savings funds sufficient + for both sends and fees, a running synced node, and a second regtest wallet's savings address. + The receiving wallet and backend must be test fixtures. + Rejected or missing-result cases need a controlled broadcast-refusal/response-loss fixture, + which the Capabilities table does not provide, and belong in Manual Tests. + + + Hand the receiving test wallet address to the app with adb shell am start -a android.intent.action.VIEW -d "bitcoin:<test recipient address>?amount=0.00001" to.bitkit.dev + Verify the Send Amount screen is visible with 1 000 sats and Savings selected + Tap Continue (tag "ContinueAmount"), expand Show Details (tag "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (tag "ReviewUri") + Swipe the send handle (tag "GRAB") fully to the right + Wait for the Bitcoin Sent success screen (tag "SendSuccess") and verify it shows the accepted payment amount + Tap Details (tag "Details"), then Explore (tag "ActivityTxDetails"), and verify the outbound transaction shows a transaction ID (tag "TXID"); record that exact ID without inferring a different transaction from address or amount + Close the transaction details and return to the wallet home screen + Hand the receiving test wallet address to the app again with adb shell am start -a android.intent.action.VIEW -d "bitcoin:<test recipient address>" to.bitkit.dev + Tap the available balance (tag "AvailableAmount") on Send Amount to choose Max, tap Continue (tag "ContinueAmount"), expand Show Details (tag "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (tag "ReviewUri") + Swipe the send handle (tag "GRAB") fully to the right, then tap Yes, Send (tag "DialogConfirm") if the over-50%-of-balance confirmation appears + Wait for the Bitcoin Sent success screen (tag "SendSuccess"), tap Details (tag "Details"), then Explore (tag "ActivityTxDetails"), and verify this send-all transaction shows its own exact transaction ID (tag "TXID") + + diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml new file mode 100644 index 0000000000..2983032f3b --- /dev/null +++ b/journeys/send/onchain-original-payment-retry.xml @@ -0,0 +1,27 @@ + + + Verifies explicit fee approval and normal spend authentication for retrying one original software + on-chain payment. Precondition: an onboarded funded dev wallet, PIN enabled, a synced node, and a + retained unresolved prepared-send receipt with its exact original recipient, amount, inputs and + candidate transaction IDs. Creating a controlled refusal or response-loss fixture is not provided + by the Capabilities table and remains in Manual Tests. A seeded Pending fixture must be labelled + as state injection, never as proof of a native fault. Manual Tests must verify the exact backend + winning transaction retains the complete original input set and recipient amount, and any original + Shop request or Blocktank order completes once without a new request or order. Local activity alone + cannot prove these checks. The funded native fault journey remains unrun. + + + Open the retained original payment's Pending screen and record its displayed transaction ID + Verify Pending describes the unresolved outcome and offers Retry original payment + Tap Retry original payment + Verify the dialog shows the original recipient and amount and prefills the original fee rate + Enter an invalid fee rate of 1000 sat/vB and verify valid positive fee feedback appears and Continue is disabled + Enter a valid authorized fee rate that the funded fixture can support and tap Continue + Verify the exact prepared mining fee in sats is shown beside the original recipient amount; tap Authorize retry, accept any normal fee-over-half or fee-over-10-USD warnings, and verify the normal payment PIN screen appears before dispatch + Go Back without entering the PIN and verify the original Pending payment remains retained + Tap Retry original payment again, choose the fixture-supported fee, tap Continue, approve the exact prepared fee and any normal fee warnings + Enter the fixture's normal payment PIN without recording entered digits + Wait for Bitcoin Sent only after exact positive evidence, or verify Pending remains with a readable error if fee headroom is insufficient + If successful, tap Details then Explore and record the exact winning transaction ID shown by TXID + +