diff --git a/CHANGELOG.md b/CHANGELOG.md index 06dedb350d..038b3570f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.5.0] - 2026-09-10 + +### Added +- Added receiver-specific Paykit contact support for Bitkit wallet and server integrations. #1066 +- Bitkit now creates, names, backs up, and manages separate watch-only Bitcoin accounts and securely delivers signed setup claims to Paykit servers. #1084 +- Incoming Paykit payment requests can now be reviewed and approved through the existing payment flow. #1098 +- Developers running their own build can now open supported screens directly with `bitkit://screen/...` links while dev mode is enabled. #1119 +- Passphrase-protected (hidden) Trezor wallets can now be paired from the connect flow, each appearing as its own watch-only balance with its own label, activity and removal, and asking for its passphrase again when a transfer needs signing. #1142 +- Incoming Paykit payment requests now stay discoverable until handled, and users can send new private requests to connected contacts from the invoice flow. #1172 +- The name you give a hardware wallet is now included in your backup and comes back when you pair the device again, and removing a hardware wallet asks first whether to keep its name and tags in your backup. #1173 +- Payments made from incoming private payment requests now send a payment proof back to the requester. #1178 +- Bitkit can now review, manage, and pay recurring payment requests from Paykit contacts, with on-chain fees shown before confirming the first payment and unresolved payments protected from accidental retries. #1186 +- Added on-chain send support for paired Trezor wallets, including transaction approval on the device. #1187 +- Added a Trezor Receive tab with fast watch-only addresses and on-device verification. #1189 + +### Changed +- Added support for tags and transaction details to hardware wallet activity. #1044 +- Updated Pubky profiles, contacts, and Paykit settings to match the latest designs and simplified contact payments. #1097 +- Send now keeps the funding-source button, fee label, and swipe-to-confirm track stable while switching sources, continuing from amount, or refreshing fees. #1195 + +### Fixed +- Resetting the wallet no longer leaves backups broken after a failed setup, and no longer races an in-flight Lightning start. #1266 +- Receive requests now refresh reliably after a payment is completed. #1086 +- The Show Details button on the Receive screen is now available while the Lightning node is still starting up, so you can view and copy your on-chain address right away. #1090 +- Savings transfers to spending now use a faster on-chain fee rate, reserve mining fees when sizing max transfers, and only drain the wallet when leftover change would be dust or a retry can still fully fund the order. #1091 +- Fixed profile deletion being blocked when private Paykit contact cleanup could not complete. #1108 +- Activity timestamps now follow the device's 12/24-hour time setting instead of always using a 12-hour clock. #1111 +- App Status and Backup timestamps now follow the device language instead of always using the US date format. #1124 +- Fixed Paykit Server authorization to require both public and private payment capabilities. #1131 +- Restored the transfer label on hardware wallet transactions after removing and re-adding a device. #1133 +- Improved Lightning send failure recovery with clearer messages and a retry action that refreshes payment routing. #1140 +- Fixed delayed private payment requests after adding a Paykit contact or returning to the app. #1141 +- Tags you add to hardware wallet activity are now included in your backup, and older backups are upgraded automatically the first time you restore them. #1163 +- Error toasts are now visible on top of modal bottom sheets, including the hardware transfer passphrase sheet. #1165 +- The maximum Savings to Spending transfer amount now always leaves room for its own service fee, so transferring your full balance no longer fails with an insufficient funds error. #1179 +- The advanced transfer screen now offers a maximum receiving capacity your balance can actually pay for, instead of one that fails later on the confirmation screen. #1180 +- Hardware-wallet payments now send payment proofs for incoming Paykit requests. #1199 +- Receiving over Lightning now correctly falls back to Savings or additional liquidity setup when the requested amount exceeds available inbound capacity. #1222 +- The app no longer aborts when stopping the Lightning node, and 32-bit devices can open the wallet again. #1223 +- A hardware wallet payment whose broadcast fails no longer leaves the send screen and sheet stuck, and Bitkit now says the payment could not be confirmed instead of reporting a generic connection problem. #1230 +- LSP orders now include a wallet-owned address for on-chain refunds. #1235 + +### Security +- QuickPay stays PIN-free under a configurable daily spend limit; once that limit is reached, payments open Confirm instead. #1159 +- Connecting a custom Electrum server no longer crashes Bitkit if the server sends an oversized response. #1198 +- Added app-scoped Pubky authorization and secure sign-out, and fixed missing payment requests in history. #1200 + ## [2.4.1] - 2026-08-21 ### Changed @@ -167,7 +214,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - About screen (content merged into Support) #857 - Standalone General, Security, and Advanced settings screens (merged into tabs) #857 -[Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.4.1...HEAD +[Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.5.0...HEAD +[2.5.0]: https://github.com/synonymdev/bitkit-android/compare/v2.4.1...v2.5.0 [2.4.1]: https://github.com/synonymdev/bitkit-android/compare/v2.4.0...v2.4.1 [2.4.0]: https://github.com/synonymdev/bitkit-android/compare/v2.3.2...v2.4.0 [2.3.2]: https://github.com/synonymdev/bitkit-android/compare/v2.3.1...v2.3.2 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 275472ebdd..955d5e477d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -186,8 +186,8 @@ android { applicationId = "to.bitkit" minSdk = 28 targetSdk = 36 - versionCode = 188 - versionName = "2.4.1" + versionCode = 190 + versionName = "2.5.0" testInstrumentationRunner = "to.bitkit.test.HiltTestRunner" bitkitAndroidTestAnnotation?.let { testInstrumentationRunnerArguments["annotation"] = it diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index d404555329..eef9af0b15 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -31,15 +31,15 @@ class VssBackupClient @Inject constructor( private val vssStoreIdProvider: VssStoreIdProvider, private val keychain: Keychain, ) { + @Volatile private var isSetup = CompletableDeferred() private val setupMutex = Mutex() suspend fun setup(walletIndex: Int = 0): Result = withContext(ioDispatcher) { setupMutex.withLock { + val gate = isSetup runCatching { - if (isSetup.isCompleted && !isSetup.isCancelled) { - runCatching { isSetup.await() }.onSuccess { return@runCatching } - } + if (gate.isCompleted && !gate.isCancelled) return@runCatching val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw MnemonicNotAvailableException() @@ -63,11 +63,12 @@ class VssBackupClient @Inject constructor( passphrase = passphrase, lnurlAuthServerUrl = lnurlAuthServerUrl, ) - isSetup.complete(Unit) + gate.complete(Unit) Logger.info("VSS client setup with server: '$vssUrl'", context = TAG) } }.onFailure { - isSetup.completeExceptionally(it) + gate.completeExceptionally(it) + if (isSetup === gate) isSetup = CompletableDeferred() Logger.error("VSS client setup error", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt index 061723bea4..1fa5296ac2 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt @@ -39,15 +39,15 @@ class VssBackupClientLdk @Inject constructor( ) } + @Volatile private var isSetup = CompletableDeferred() private val setupMutex = Mutex() suspend fun setup(walletIndex: Int = 0): Result = withContext(ioDispatcher) { setupMutex.withLock { + val gate = isSetup runCatching { - if (isSetup.isCompleted && !isSetup.isCancelled) { - runCatching { isSetup.await() }.onSuccess { return@runCatching } - } + if (gate.isCompleted && !gate.isCancelled) return@runCatching val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw MnemonicNotAvailableException() @@ -64,11 +64,12 @@ class VssBackupClientLdk @Inject constructor( passphrase = passphrase, lnurlAuthServerUrl = Env.lnurlAuthServerUrl, ) - isSetup.complete(Unit) + gate.complete(Unit) Logger.info("VSS LDK client setup", context = TAG) } }.onFailure { - isSetup.completeExceptionally(it) + gate.completeExceptionally(it) + if (isSetup === gate) isSetup = CompletableDeferred() Logger.error("VSS LDK client setup error", 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 7c2c5b699d..f55770be20 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -122,6 +122,7 @@ class BackupRepo @Inject constructor( val isRestoring: StateFlow = _isRestoring.asStateFlow() private val _isWiping = MutableStateFlow(false) + val isWiping: StateFlow = _isWiping.asStateFlow() fun reset() { stopObservingBackups() @@ -140,6 +141,10 @@ class BackupRepo @Inject constructor( fun startObservingBackups() { if (isObserving) return + if (_isWiping.value) { + Logger.debug("Skipped observing backups while wiping", context = TAG) + return + } isObserving = true Logger.debug("Start observing backup statuses and data store changes", context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index c3d80e3143..e8005cdafa 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -144,6 +144,9 @@ class LightningRepo @Inject constructor( private val _isRecoveryMode = MutableStateFlow(false) val isRecoveryMode = _isRecoveryMode.asStateFlow() + @Volatile + private var isWiping = false + private val channelCache = ConcurrentHashMap() private val probeOutcomeCache = ConcurrentHashMap() private val probeOutcomeSignal = MutableSharedFlow(extraBufferCapacity = 64) @@ -341,6 +344,7 @@ class LightningRepo @Inject constructor( var initialLifecycleState: NodeLifecycleState val result = lifecycleMutex.withLock { + if (isWiping) return@withLock Result.failure(WipeInProgressError()) initialLifecycleState = _lightningState.value.nodeLifecycleState if (initialLifecycleState.isRunningOrStarting()) { return@withLock skipStartForRunningNode( @@ -574,6 +578,8 @@ class LightningRepo @Inject constructor( fun setRecoveryMode(enabled: Boolean) = _isRecoveryMode.update { enabled } + fun setWiping(enabled: Boolean) = run { isWiping = enabled } + suspend fun updateGeoBlockState() = withContext(bgDispatcher) { _lightningState.update { it.copy(isGeoBlocked = coreService.isGeoBlocked()) @@ -614,30 +620,32 @@ class LightningRepo @Inject constructor( fun cancelPendingStop() = synchronized(pendingStopLock) { pendingStopJob.getAndSet(null)?.cancel() } suspend fun stop(): Result = withContext(bgDispatcher) { - lifecycleMutex.withLock { - if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) { + lifecycleMutex.withLock { stopLocked() } + } + + private suspend fun stopLocked(): Result { + if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping() && lightningService.node == null) { + clearProbeOutcomes() + return Result.success(Unit) + } + + return runCatching { + withContext(NonCancellable) { + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } + lightningService.stop() clearProbeOutcomes() - return@withLock Result.success(Unit) + _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } } - - runCatching { - withContext(NonCancellable) { - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } - lightningService.stop() - clearProbeOutcomes() - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } - } - }.onFailure { - Logger.error("Node stop error", it, context = TAG) - // On failure, check actual node state and update accordingly - // If node is still running, revert to Running state to allow retry - if (lightningService.node != null && lightningService.status?.isRunning == true) { - Logger.warn("Stop failed but node is still running, reverting to Running state", context = TAG) - _lightningState.update { s -> s.copy(nodeLifecycleState = NodeLifecycleState.Running) } - } else { - // Node appears stopped, update state - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } - } + }.onFailure { + Logger.error("Node stop error", it, context = TAG) + // On failure, check actual node state and update accordingly + // If node is still running, revert to Running state to allow retry + if (lightningService.node != null && lightningService.status?.isRunning == true) { + Logger.warn("Stop failed but node is still running, reverting to Running state", context = TAG) + _lightningState.update { s -> s.copy(nodeLifecycleState = NodeLifecycleState.Running) } + } else { + // Node appears stopped, update state + _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } } } } @@ -810,17 +818,19 @@ class LightningRepo @Inject constructor( suspend fun wipeStorage(walletIndex: Int): Result = withContext(bgDispatcher) { Logger.debug("wipeStorage called, stopping node first", context = TAG) - stop().mapCatching { - Logger.debug("node stopped, calling wipeStorage", context = TAG) - lightningService.wipeStorage(walletIndex) - clearProbeOutcomes() - _lightningState.update { - LightningState( - nodeStatus = it.nodeStatus, - nodeLifecycleState = it.nodeLifecycleState, - ) + lifecycleMutex.withLock { + stopLocked().mapCatching { + Logger.debug("node stopped, calling wipeStorage", context = TAG) + lightningService.wipeStorage(walletIndex) + clearProbeOutcomes() + _lightningState.update { + LightningState( + nodeStatus = it.nodeStatus, + nodeLifecycleState = it.nodeLifecycleState, + ) + } + setRecoveryMode(false) } - setRecoveryMode(false) }.onFailure { Logger.error("wipeStorage error", it, context = TAG) } @@ -2111,6 +2121,8 @@ private data class PaymentRoutingRefreshStatus( } class RecoveryModeError : AppError("App in recovery mode, skipping node start") + +class WipeInProgressError : AppError("Wallet wipe in progress, refusing node start") class NodeSetupError : AppError("Unknown node setup error") class NodeStopTimeoutError : AppError("Timeout waiting for node to stop") class NodeConfigNotAppliedError : AppError("Node already running, requested config was not applied") diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 587f91aa07..2558834169 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -45,6 +45,7 @@ import to.bitkit.models.toDerivationPath import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase +import to.bitkit.usecases.WipeIncomplete import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger @@ -477,7 +478,9 @@ class WalletRepo @Inject constructor( walletIndex = walletIndex, resetWalletState = ::resetState, onSuccess = ::setWalletExistsState, - ) + ).onFailure { + if (it is WipeIncomplete) setWalletExistsState() + } } fun resetState() { diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt index fe901c1076..e23ffe5817 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.settings.backups +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -23,6 +24,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import to.bitkit.R import to.bitkit.ui.appViewModel @@ -45,12 +47,17 @@ fun ResetAndRestoreScreen( val app = appViewModel ?: return val wallet = walletViewModel ?: return var showDialog by remember { mutableStateOf(false) } + val isWiping by wallet.isWiping.collectAsStateWithLifecycle() Content( showConfirmDialog = showDialog, + isWiping = isWiping, onClickBackup = { app.showSheet(Sheet.Backup()) }, onClickReset = { showDialog = true }, - onResetConfirm = { wallet.wipeWallet() }, + onResetConfirm = { + showDialog = false + wallet.wipeWallet() + }, onResetDismiss = { showDialog = false }, onBack = { navController.popBackStack() }, ) @@ -59,16 +66,19 @@ fun ResetAndRestoreScreen( @Composable private fun Content( showConfirmDialog: Boolean, + isWiping: Boolean, onClickBackup: () -> Unit, onClickReset: () -> Unit, onResetConfirm: () -> Unit, onResetDismiss: () -> Unit, onBack: () -> Unit, ) { + BackHandler(enabled = isWiping) {} + ScreenColumn { AppTopBar( titleText = stringResource(R.string.security__reset_title), - onBackClick = onBack, + onBackClick = if (isWiping) null else onBack, actions = { DrawerNavIcon() }, ) Spacer(Modifier.height(32.dp)) @@ -101,6 +111,7 @@ private fun Content( SecondaryButton( text = stringResource(R.string.security__reset_button_backup), onClick = onClickBackup, + enabled = !isWiping, modifier = Modifier .weight(1f) .testTag(ResetAndRestoreTestTags.BACKUP_BUTTON) @@ -108,6 +119,7 @@ private fun Content( PrimaryButton( text = stringResource(R.string.security__reset_button_reset), onClick = onClickReset, + isLoading = isWiping, modifier = Modifier .weight(1f) .testTag(ResetAndRestoreTestTags.RESET_BUTTON) @@ -143,6 +155,7 @@ private fun Preview() { AppThemeSurface { Content( showConfirmDialog = false, + isWiping = false, onClickBackup = {}, onClickReset = {}, onResetConfirm = {}, @@ -158,6 +171,7 @@ private fun PreviewDialog() { AppThemeSurface { Content( showConfirmDialog = true, + isWiping = false, onClickBackup = {}, onClickReset = {}, onResetConfirm = {}, diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index ce9e32170e..215fc565d7 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -1,6 +1,7 @@ package to.bitkit.usecases import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.sync.Mutex import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore @@ -18,6 +19,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.services.CoreService import to.bitkit.services.MigrationService +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Provider @@ -44,52 +46,78 @@ class WipeWalletUseCase @Inject constructor( private val firebaseMessaging: FirebaseMessaging, private val migrationService: MigrationService, ) { + private val wipeMutex = Mutex() + suspend operator fun invoke( walletIndex: Int = 0, resetWalletState: () -> Unit, onSuccess: () -> Unit, ): Result { + if (!wipeMutex.tryLock()) return Result.failure(WipeAlreadyInProgress()) backupRepo.setWiping(true) - return try { + lightningRepo.setWiping(true) + val result = try { runSuspendCatching { - backupRepo.reset() - - privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG) - pubkyRepo.removeBitkitPaymentEndpoints() - .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } - privatePaykitRepo.get().closeAndClear() - privatePaykitAddressReservationRepo.clear() - pubkyRepo.wipeLocalState() - keychain.wipe() - firebaseMessaging.deleteToken() - - coreService.wipeData() - db.clearAllTables() - - settingsStore.reset() - cacheStore.reset() - watchOnlyAccountRepo.clear() - widgetsStore.reset() - - blocktankRepo.resetState() - activityRepo.resetState() - hwWalletRepo.resetState() - resetWalletState() - - migrationService.markMigrationChecked() - - lightningRepo.wipeStorage(walletIndex) - .onSuccess { onSuccess() } - .getOrThrow() - }.onFailure { - Logger.error("Failed to wipe wallet", it, context = TAG) + stopNode().getOrThrow() + cleanupRemote() + wipeLocal(walletIndex, resetWalletState).getOrThrow() + onSuccess() } } finally { + lightningRepo.setWiping(false) backupRepo.setWiping(false) + wipeMutex.unlock() + } + return result.onFailure { + Logger.error("Failed to wipe wallet", it, context = TAG) + if (lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) { + backupRepo.startObservingBackups() + } } } + private suspend fun stopNode(): Result { + backupRepo.reset() + return lightningRepo.stop() + } + + private suspend fun cleanupRemote() { + step("remove Paykit published endpoints") { privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG) } + step("remove Bitkit payment endpoints") { pubkyRepo.removeBitkitPaymentEndpoints() } + step("close Paykit SDK") { privatePaykitRepo.get().closeAndClear() } + } + + private suspend fun wipeLocal(walletIndex: Int, resetWalletState: () -> Unit): Result { + lightningRepo.wipeStorage(walletIndex).onFailure { return Result.failure(it) } + step("clear Paykit address reservations") { privatePaykitAddressReservationRepo.clear() } + step("wipe Pubky local state") { pubkyRepo.wipeLocalState() } + val keychainWiped = step("wipe keychain") { keychain.wipe() } + step("delete FCM token") { firebaseMessaging.deleteToken() } + step("wipe core data") { coreService.wipeData() } + step("clear database") { db.clearAllTables() } + step("reset settings") { settingsStore.reset() } + step("reset cache") { cacheStore.reset() } + step("clear watch-only accounts") { watchOnlyAccountRepo.clear() } + step("reset widgets") { widgetsStore.reset() } + blocktankRepo.resetState() + activityRepo.resetState() + hwWalletRepo.resetState() + resetWalletState() + step("mark migration checked") { migrationService.markMigrationChecked() } + return if (keychainWiped) Result.success(Unit) else Result.failure(WipeIncomplete()) + } + + private suspend fun step(name: String, block: suspend () -> Any?): Boolean = + runSuspendCatching { block() } + .mapCatching { if (it is Result<*>) it.getOrThrow() } + .onFailure { Logger.warn("Failed wipe step '$name'", it, context = TAG) } + .isSuccess + companion object { private const val TAG = "WipeWalletUseCase" } } + +class WipeAlreadyInProgress : AppError("Wallet wipe already in progress") + +class WipeIncomplete : AppError("Wallet wipe did not complete, please reset again") diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index aa4a52d69b..1b3620319b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -41,6 +41,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.RecoveryModeError import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo +import to.bitkit.repositories.WipeInProgressError import to.bitkit.services.BoltzService import to.bitkit.services.MigrationService import to.bitkit.ui.onboarding.LOADING_MS @@ -99,6 +100,7 @@ class WalletViewModel @Inject constructor( val isShowingMigrationLoading: StateFlow = migrationService.isShowingMigrationLoading val isRestoringFromRNRemoteBackup: StateFlow = migrationService.isRestoringFromRNRemoteBackup + val isWiping: StateFlow = backupRepo.isWiping private val _restoreState = MutableStateFlow(RestoreState.Initial) val restoreState: StateFlow = _restoreState.asStateFlow() @@ -340,10 +342,12 @@ class WalletViewModel @Inject constructor( // checkForOrphanedChannelMonitorRecovery() } .onFailure { - Logger.error("Node startup error", it, context = TAG) - if (it !is RecoveryModeError) { - ToastEventBus.send(it) + if (it is RecoveryModeError || it is WipeInProgressError) { + Logger.debug("Skipped node start: '${it.message}'", context = TAG) + return@onFailure } + Logger.error("Node startup error", it, context = TAG) + ToastEventBus.send(it) } } diff --git a/app/src/test/java/to/bitkit/data/backup/VssBackupClientLdkTest.kt b/app/src/test/java/to/bitkit/data/backup/VssBackupClientLdkTest.kt new file mode 100644 index 0000000000..f80e27d0b0 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/backup/VssBackupClientLdkTest.kt @@ -0,0 +1,55 @@ +package to.bitkit.data.backup + +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class VssBackupClientLdkTest : BaseUnitTest() { + + private lateinit var sut: VssBackupClientLdk + + private val vssStoreIdProvider = mock() + private val keychain = mock() + + @Before + fun setUp() = runBlocking { + sut = VssBackupClientLdk( + ioDispatcher = testDispatcher, + vssStoreIdProvider = vssStoreIdProvider, + keychain = keychain, + ) + } + + @Test + fun `setup succeeding after a failure leaves the client usable`() = test { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) + assertIs(sut.setup().exceptionOrNull()) + + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(TEST_MNEMONIC) + whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") + + mockStatic(Class.forName(VSS_FFI_CLASS)).use { + assertTrue(sut.setup().isSuccess) + + val result = sut.getObject("key") + + assertTrue(result.isSuccess) + assertNull(result.getOrNull()) + } + } + + companion object { + private const val VSS_FFI_CLASS = "com.synonym.vssclient.Vss_rust_client_ffiKt" + private const val TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon about" + } +} diff --git a/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt b/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt index 2c4d00216f..1ab0912034 100644 --- a/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt +++ b/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt @@ -1,8 +1,11 @@ package to.bitkit.data.backup +import com.synonym.vssclient.VssItem +import com.synonym.vssclient.vssStore import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -10,7 +13,9 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue class VssBackupClientTest : BaseUnitTest() { @@ -50,9 +55,7 @@ class VssBackupClientTest : BaseUnitTest() { @Test fun `setup checks mnemonic before proceeding with vss initialization`() = test { - val testMnemonic = "abandon abandon abandon abandon abandon abandon " + - "abandon abandon abandon abandon abandon about" - whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(testMnemonic) + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(TEST_MNEMONIC) whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") // Setup will fail on native VSS calls, but we verify we passed the mnemonic check @@ -70,4 +73,45 @@ class VssBackupClientTest : BaseUnitTest() { assertIs(sut.setup().exceptionOrNull()) assertIs(sut.setup().exceptionOrNull()) } + + @Test + fun `setup succeeding after a failure leaves the client usable`() = test { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) + assertIs(sut.setup().exceptionOrNull()) + + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(TEST_MNEMONIC) + whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") + + mockStatic(Class.forName(VSS_FFI_CLASS)).use { + assertTrue(sut.setup().isSuccess) + + val result = sut.getObject("METADATA") + + assertTrue(result.isSuccess) + assertNull(result.getOrNull()) + } + } + + @Test + fun `setupWithRetry succeeding after a failure leaves the client usable`() = test { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)) + .thenReturn(null) + .thenReturn(TEST_MNEMONIC) + whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") + + mockStatic(Class.forName(VSS_FFI_CLASS)).use { + assertTrue(sut.setupWithRetry(baseDelayMs = 0L) {}.isSuccess) + + val item = VssItem("METADATA", byteArrayOf(), 1L) + whenever(vssStore(any(), any())).thenReturn(item) + + assertEquals(item, sut.putObject("METADATA", byteArrayOf()).getOrNull()) + } + } + + companion object { + private const val VSS_FFI_CLASS = "com.synonym.vssclient.Vss_rust_client_ffiKt" + private const val TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon about" + } } diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 51ffa91440..7aa8fcf911 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -134,6 +134,16 @@ class BackupRepoTest : BaseUnitTest() { sut = createSut() } + @Test + fun `start observing is skipped while wiping`() = test { + sut.setWiping(true) + + sut.startObservingBackups() + runCurrent() + + verify(vssBackupClient, never()).setupWithRetry(any(), any(), any()) + } + @Test fun `full restore should fail when private Paykit reservations fail to restore`() = test { stubWalletBackup() diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index b694f62fd0..53d5756cfa 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.AddressTypeBalance @@ -416,6 +417,29 @@ class LightningRepoTest : BaseUnitTest() { } } + @Test + fun `stop tears down a node object left alive by a failed start`() = test { + whenever(lightningService.node).thenReturn(mock()) + whenever(lightningService.stop()).thenReturn(Unit) + assertEquals(NodeLifecycleState.Stopped, sut.lightningState.value.nodeLifecycleState) + + val result = sut.stop() + + assertTrue(result.isSuccess) + verify(lightningService).stop() + assertEquals(NodeLifecycleState.Stopped, sut.lightningState.value.nodeLifecycleState) + } + + @Test + fun `stop does not touch the service when nothing is running`() = test { + whenever(lightningService.node).thenReturn(null) + + val result = sut.stop() + + assertTrue(result.isSuccess) + verify(lightningService, never()).stop() + } + @Test fun `stopDebounced does not stop the node before the delay elapses`() = test { startNodeForTesting() @@ -848,6 +872,38 @@ class LightningRepoTest : BaseUnitTest() { verify(lightningService).wipeStorage(0) } + @Test + fun `wipeStorage holds the lifecycle lock so a start cannot rebuild the node mid-wipe`() = test { + startNodeForTesting() + whenever(lightningService.stop()).thenReturn(Unit) + val release = CompletableDeferred() + whenever(lightningService.wipeStorage(0)).doSuspendableAnswer { release.await() } + + val wipe = launch { sut.wipeStorage(0) } + runCurrent() + whenever(lightningService.node).thenReturn(null) + val start = launch { sut.start() } + runCurrent() + + verifyBlocking(lightningService, times(1)) { start(anyOrNull(), any()) } + release.complete(Unit) + wipe.join() + start.join() + verify(lightningService).wipeStorage(0) + verifyBlocking(lightningService, times(2)) { start(anyOrNull(), any()) } + } + + @Test + fun `start is refused while a wipe is in progress`() = test { + sut.setWiping(true) + + val result = sut.start() + + assertIs(result.exceptionOrNull()) + verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) } + assertEquals(NodeLifecycleState.Stopped, sut.lightningState.value.nodeLifecycleState) + } + @Test fun `connectToTrustedPeers should fail when node is not running`() = test { val result = sut.connectToTrustedPeers() diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 4cbf4f91b3..f8a10668e1 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -34,6 +34,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest import to.bitkit.usecases.DeriveBalanceStateUseCase +import to.bitkit.usecases.WipeIncomplete import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.ServiceError import kotlin.test.assertEquals @@ -846,6 +847,17 @@ class WalletRepoTest : BaseUnitTest() { verify(wipeWalletUseCase).invoke(any(), any(), any()) } + @Test + fun `wipeWallet re-reads wallet existence when the wipe is incomplete`() = test { + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) + whenever(wipeWalletUseCase.invoke(any(), any(), any())).thenReturn(Result.failure(WipeIncomplete())) + + val result = sut.wipeWallet() + + assertTrue(result.isFailure) + assertTrue(sut.walletState.value.walletExists) + } + @Test fun `wipeWallet should return failure when use case fails`() = test { whenever(wipeWalletUseCase.invoke(any(), any(), any())).thenReturn(Result.failure(error)) diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 54ab73af9b..10385b9708 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -2,12 +2,18 @@ package to.bitkit.usecases import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.AppDb @@ -15,11 +21,13 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.LightningState import to.bitkit.repositories.PrivatePaykitAddressReservationRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo @@ -29,6 +37,8 @@ import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest import javax.inject.Provider import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue class WipeWalletUseCaseTest : BaseUnitTest() { @@ -59,7 +69,9 @@ class WipeWalletUseCaseTest : BaseUnitTest() { @Before fun setUp() { + whenever { lightningRepo.stop() }.thenReturn(Result.success(Unit)) whenever { lightningRepo.wipeStorage(0) }.thenReturn(Result.success(Unit)) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever { pubkyRepo.removeBitkitPaymentEndpoints() }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) @@ -114,10 +126,13 @@ class WipeWalletUseCaseTest : BaseUnitTest() { privatePaykitAddressReservationRepo, ) inOrder.verify(backupRepo).setWiping(true) + inOrder.verify(lightningRepo).setWiping(true) inOrder.verify(backupRepo).reset() + inOrder.verify(lightningRepo).stop() inOrder.verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) inOrder.verify(pubkyRepo).removeBitkitPaymentEndpoints() inOrder.verify(privatePaykitRepo).closeAndClear() + inOrder.verify(lightningRepo).wipeStorage(0) inOrder.verify(privatePaykitAddressReservationRepo).clear() inOrder.verify(pubkyRepo).wipeLocalState() inOrder.verify(keychain).wipe() @@ -131,11 +146,84 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(activityRepo).resetState() inOrder.verify(hwWalletRepo).resetState() assertTrue(onWipeCalled) - inOrder.verify(lightningRepo).wipeStorage(0) assertTrue(onSetWalletExistsStateCalled) + inOrder.verify(lightningRepo).setWiping(false) inOrder.verify(backupRepo).setWiping(false) } + @Test + fun `invoke should reject a second wipe while one is in flight`() = runTest { + val release = CompletableDeferred() + whenever { lightningRepo.stop() }.doSuspendableAnswer { + release.await() + Result.success(Unit) + } + var first: Result? = null + val job = launch { first = sut.invoke(resetWalletState = {}, onSuccess = {}) } + runCurrent() + + val second = sut.invoke(resetWalletState = {}, onSuccess = {}) + + assertIs(second.exceptionOrNull()) + release.complete(Unit) + job.join() + assertTrue(requireNotNull(first).isSuccess) + verify(lightningRepo).stop() + } + + @Test + fun `invoke should fail without wiping and restart observers when node stop fails while running`() = runTest { + whenever(lightningRepo.lightningState) + .thenReturn(MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running))) + whenever { lightningRepo.stop() }.thenReturn(Result.failure(RuntimeException("stop failed"))) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isFailure) + val inOrder = inOrder(backupRepo) + inOrder.verify(backupRepo).setWiping(false) + inOrder.verify(backupRepo).startObservingBackups() + verify(lightningRepo, never()).wipeStorage(any()) + verify(keychain, never()).wipe() + verify(db, never()).clearAllTables() + assertFalse(onWipeCalled) + assertFalse(onSetWalletExistsStateCalled) + } + + @Test + fun `invoke should continue when address reservation clear fails`() = runTest { + whenever { privatePaykitAddressReservationRepo.clear() }.thenThrow(RuntimeException("clear failed")) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isSuccess) + verify(keychain).wipe() + assertTrue(onSetWalletExistsStateCalled) + } + + @Test + fun `invoke should fail after wiping the rest when keychain wipe fails`() = runTest { + whenever(keychain.wipe()).thenThrow(RuntimeException("keystore write failed")) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertIs(result.exceptionOrNull()) + verify(lightningRepo).wipeStorage(0) + verify(db).clearAllTables() + verify(settingsStore).reset() + assertTrue(onWipeCalled) + assertFalse(onSetWalletExistsStateCalled) + } + @Test fun `invoke should pass walletIndex to lightningRepo wipeStorage`() = runTest { val walletIndex = 5 @@ -153,7 +241,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { @Test fun `invoke should set wiping to false even on failure`() = runTest { - whenever(keychain.wipe()).thenThrow(RuntimeException("Test error")) + whenever { lightningRepo.stop() }.thenReturn(Result.failure(RuntimeException("Test error"))) val result = sut.invoke( resetWalletState = { onWipeCalled = true }, @@ -216,7 +304,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { } @Test - fun `invoke should return failure when lightningRepo wipeStorage fails`() = runTest { + fun `invoke should fail before wiping local state when LDK storage wipe fails`() = runTest { val error = RuntimeException("Lightning wipe failed") whenever { lightningRepo.wipeStorage(0) }.thenReturn(Result.failure(error)) @@ -226,11 +314,15 @@ class WipeWalletUseCaseTest : BaseUnitTest() { ) assertTrue(result.isFailure) - verify(backupRepo).setWiping(false) + verify(privatePaykitRepo).closeAndClear() + verify(keychain, never()).wipe() + verify(db, never()).clearAllTables() + assertFalse(onWipeCalled) + assertFalse(onSetWalletExistsStateCalled) } @Test - fun `invoke should return failure when database clear fails`() = runTest { + fun `invoke should complete the wipe when database clear fails`() = runTest { whenever(db.clearAllTables()).thenThrow(RuntimeException("DB clear failed")) val result = sut.invoke( @@ -238,7 +330,10 @@ class WipeWalletUseCaseTest : BaseUnitTest() { onSuccess = { onSetWalletExistsStateCalled = true }, ) - assertTrue(result.isFailure) - verify(backupRepo).setWiping(false) + assertTrue(result.isSuccess) + verify(settingsStore).reset() + verify(keychain).wipe() + assertTrue(onWipeCalled) + assertTrue(onSetWalletExistsStateCalled) } } diff --git a/changelog.d/next/1044.changed.md b/changelog.d/next/1044.changed.md deleted file mode 100644 index 7c42c552ac..0000000000 --- a/changelog.d/next/1044.changed.md +++ /dev/null @@ -1 +0,0 @@ -Added support for tags and transaction details to hardware wallet activity. diff --git a/changelog.d/next/1066.added.md b/changelog.d/next/1066.added.md deleted file mode 100644 index ccbf4efab4..0000000000 --- a/changelog.d/next/1066.added.md +++ /dev/null @@ -1 +0,0 @@ -Added receiver-specific Paykit contact support for Bitkit wallet and server integrations. diff --git a/changelog.d/next/1084.added.md b/changelog.d/next/1084.added.md deleted file mode 100644 index 6f68318587..0000000000 --- a/changelog.d/next/1084.added.md +++ /dev/null @@ -1 +0,0 @@ -Bitkit now creates, names, backs up, and manages separate watch-only Bitcoin accounts and securely delivers signed setup claims to Paykit servers. diff --git a/changelog.d/next/1086.fixed.md b/changelog.d/next/1086.fixed.md deleted file mode 100644 index 972cc48b82..0000000000 --- a/changelog.d/next/1086.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Receive requests now refresh reliably after a payment is completed. diff --git a/changelog.d/next/1090.fixed.md b/changelog.d/next/1090.fixed.md deleted file mode 100644 index 37f2133c38..0000000000 --- a/changelog.d/next/1090.fixed.md +++ /dev/null @@ -1 +0,0 @@ -The Show Details button on the Receive screen is now available while the Lightning node is still starting up, so you can view and copy your on-chain address right away. diff --git a/changelog.d/next/1091.fixed.md b/changelog.d/next/1091.fixed.md deleted file mode 100644 index cf3245d661..0000000000 --- a/changelog.d/next/1091.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Savings transfers to spending now use a faster on-chain fee rate, reserve mining fees when sizing max transfers, and only drain the wallet when leftover change would be dust or a retry can still fully fund the order. diff --git a/changelog.d/next/1097.changed.md b/changelog.d/next/1097.changed.md deleted file mode 100644 index 4b493b63a8..0000000000 --- a/changelog.d/next/1097.changed.md +++ /dev/null @@ -1 +0,0 @@ -Updated Pubky profiles, contacts, and Paykit settings to match the latest designs and simplified contact payments. diff --git a/changelog.d/next/1098.added.md b/changelog.d/next/1098.added.md deleted file mode 100644 index 94f414e756..0000000000 --- a/changelog.d/next/1098.added.md +++ /dev/null @@ -1 +0,0 @@ -Incoming Paykit payment requests can now be reviewed and approved through the existing payment flow. diff --git a/changelog.d/next/1108.fixed.md b/changelog.d/next/1108.fixed.md deleted file mode 100644 index 13b1beb5d6..0000000000 --- a/changelog.d/next/1108.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed profile deletion being blocked when private Paykit contact cleanup could not complete. diff --git a/changelog.d/next/1111.fixed.md b/changelog.d/next/1111.fixed.md deleted file mode 100644 index d9173d0e19..0000000000 --- a/changelog.d/next/1111.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Activity timestamps now follow the device's 12/24-hour time setting instead of always using a 12-hour clock. diff --git a/changelog.d/next/1119.added.md b/changelog.d/next/1119.added.md deleted file mode 100644 index e878a811e2..0000000000 --- a/changelog.d/next/1119.added.md +++ /dev/null @@ -1 +0,0 @@ -Developers running their own build can now open supported screens directly with `bitkit://screen/...` links while dev mode is enabled. diff --git a/changelog.d/next/1124.fixed.md b/changelog.d/next/1124.fixed.md deleted file mode 100644 index d5b925dd73..0000000000 --- a/changelog.d/next/1124.fixed.md +++ /dev/null @@ -1 +0,0 @@ -App Status and Backup timestamps now follow the device language instead of always using the US date format. diff --git a/changelog.d/next/1131.fixed.md b/changelog.d/next/1131.fixed.md deleted file mode 100644 index 5e47caa71c..0000000000 --- a/changelog.d/next/1131.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed Paykit Server authorization to require both public and private payment capabilities. diff --git a/changelog.d/next/1133.fixed.md b/changelog.d/next/1133.fixed.md deleted file mode 100644 index 127b84707b..0000000000 --- a/changelog.d/next/1133.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Restored the transfer label on hardware wallet transactions after removing and re-adding a device. diff --git a/changelog.d/next/1140.fixed.md b/changelog.d/next/1140.fixed.md deleted file mode 100644 index 08e36e9883..0000000000 --- a/changelog.d/next/1140.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Improved Lightning send failure recovery with clearer messages and a retry action that refreshes payment routing. diff --git a/changelog.d/next/1141.fixed.md b/changelog.d/next/1141.fixed.md deleted file mode 100644 index c76c161b9b..0000000000 --- a/changelog.d/next/1141.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed delayed private payment requests after adding a Paykit contact or returning to the app. diff --git a/changelog.d/next/1142.added.md b/changelog.d/next/1142.added.md deleted file mode 100644 index 54ed99bef6..0000000000 --- a/changelog.d/next/1142.added.md +++ /dev/null @@ -1 +0,0 @@ -Passphrase-protected (hidden) Trezor wallets can now be paired from the connect flow, each appearing as its own watch-only balance with its own label, activity and removal, and asking for its passphrase again when a transfer needs signing. diff --git a/changelog.d/next/1159.security.md b/changelog.d/next/1159.security.md deleted file mode 100644 index 3a5339f9fa..0000000000 --- a/changelog.d/next/1159.security.md +++ /dev/null @@ -1 +0,0 @@ -QuickPay stays PIN-free under a configurable daily spend limit; once that limit is reached, payments open Confirm instead. diff --git a/changelog.d/next/1163.fixed.md b/changelog.d/next/1163.fixed.md deleted file mode 100644 index 6af14dc8e2..0000000000 --- a/changelog.d/next/1163.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Tags you add to hardware wallet activity are now included in your backup, and older backups are upgraded automatically the first time you restore them. diff --git a/changelog.d/next/1165.fixed.md b/changelog.d/next/1165.fixed.md deleted file mode 100644 index d225d884d3..0000000000 --- a/changelog.d/next/1165.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Error toasts are now visible on top of modal bottom sheets, including the hardware transfer passphrase sheet. diff --git a/changelog.d/next/1172.added.md b/changelog.d/next/1172.added.md deleted file mode 100644 index bffb6f1b47..0000000000 --- a/changelog.d/next/1172.added.md +++ /dev/null @@ -1 +0,0 @@ -Incoming Paykit payment requests now stay discoverable until handled, and users can send new private requests to connected contacts from the invoice flow. diff --git a/changelog.d/next/1173.added.md b/changelog.d/next/1173.added.md deleted file mode 100644 index 451e942706..0000000000 --- a/changelog.d/next/1173.added.md +++ /dev/null @@ -1 +0,0 @@ -The name you give a hardware wallet is now included in your backup and comes back when you pair the device again, and removing a hardware wallet asks first whether to keep its name and tags in your backup. diff --git a/changelog.d/next/1178.added.md b/changelog.d/next/1178.added.md deleted file mode 100644 index 2a91491eea..0000000000 --- a/changelog.d/next/1178.added.md +++ /dev/null @@ -1 +0,0 @@ -Payments made from incoming private payment requests now send a payment proof back to the requester. diff --git a/changelog.d/next/1179.fixed.md b/changelog.d/next/1179.fixed.md deleted file mode 100644 index 06c14fef3d..0000000000 --- a/changelog.d/next/1179.fixed.md +++ /dev/null @@ -1 +0,0 @@ -The maximum Savings to Spending transfer amount now always leaves room for its own service fee, so transferring your full balance no longer fails with an insufficient funds error. diff --git a/changelog.d/next/1180.fixed.md b/changelog.d/next/1180.fixed.md deleted file mode 100644 index 080d2dbced..0000000000 --- a/changelog.d/next/1180.fixed.md +++ /dev/null @@ -1 +0,0 @@ -The advanced transfer screen now offers a maximum receiving capacity your balance can actually pay for, instead of one that fails later on the confirmation screen. diff --git a/changelog.d/next/1186.added.md b/changelog.d/next/1186.added.md deleted file mode 100644 index 92fef450fb..0000000000 --- a/changelog.d/next/1186.added.md +++ /dev/null @@ -1 +0,0 @@ -Bitkit can now review, manage, and pay recurring payment requests from Paykit contacts, with on-chain fees shown before confirming the first payment and unresolved payments protected from accidental retries. diff --git a/changelog.d/next/1187.added.md b/changelog.d/next/1187.added.md deleted file mode 100644 index 07e0e0dc6a..0000000000 --- a/changelog.d/next/1187.added.md +++ /dev/null @@ -1 +0,0 @@ -Added on-chain send support for paired Trezor wallets, including transaction approval on the device. diff --git a/changelog.d/next/1189.added.md b/changelog.d/next/1189.added.md deleted file mode 100644 index f242f0c0c9..0000000000 --- a/changelog.d/next/1189.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a Trezor Receive tab with fast watch-only addresses and on-device verification. diff --git a/changelog.d/next/1195.changed.md b/changelog.d/next/1195.changed.md deleted file mode 100644 index 92a0a1cb60..0000000000 --- a/changelog.d/next/1195.changed.md +++ /dev/null @@ -1 +0,0 @@ -Send now keeps the funding-source button, fee label, and swipe-to-confirm track stable while switching sources, continuing from amount, or refreshing fees. diff --git a/changelog.d/next/1198.security.md b/changelog.d/next/1198.security.md deleted file mode 100644 index 89ada688a6..0000000000 --- a/changelog.d/next/1198.security.md +++ /dev/null @@ -1 +0,0 @@ -Connecting a custom Electrum server no longer crashes Bitkit if the server sends an oversized response. diff --git a/changelog.d/next/1199.fixed.md b/changelog.d/next/1199.fixed.md deleted file mode 100644 index 185d90931e..0000000000 --- a/changelog.d/next/1199.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Hardware-wallet payments now send payment proofs for incoming Paykit requests. diff --git a/changelog.d/next/1200.security.md b/changelog.d/next/1200.security.md deleted file mode 100644 index 1088411f6d..0000000000 --- a/changelog.d/next/1200.security.md +++ /dev/null @@ -1 +0,0 @@ -Added app-scoped Pubky authorization and secure sign-out, and fixed missing payment requests in history. diff --git a/changelog.d/next/1222.fixed.md b/changelog.d/next/1222.fixed.md deleted file mode 100644 index d4bdaa40b6..0000000000 --- a/changelog.d/next/1222.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Receiving over Lightning now correctly falls back to Savings or additional liquidity setup when the requested amount exceeds available inbound capacity. diff --git a/changelog.d/next/1223.fixed.md b/changelog.d/next/1223.fixed.md deleted file mode 100644 index 4833ea3b32..0000000000 --- a/changelog.d/next/1223.fixed.md +++ /dev/null @@ -1 +0,0 @@ -The app no longer aborts when stopping the Lightning node, and 32-bit devices can open the wallet again. diff --git a/changelog.d/next/1230.fixed.md b/changelog.d/next/1230.fixed.md deleted file mode 100644 index 3c01738f62..0000000000 --- a/changelog.d/next/1230.fixed.md +++ /dev/null @@ -1 +0,0 @@ -A hardware wallet payment whose broadcast fails no longer leaves the send screen and sheet stuck, and Bitkit now says the payment could not be confirmed instead of reporting a generic connection problem. diff --git a/changelog.d/next/1235.fixed.md b/changelog.d/next/1235.fixed.md deleted file mode 100644 index 3684d6e4a2..0000000000 --- a/changelog.d/next/1235.fixed.md +++ /dev/null @@ -1 +0,0 @@ -LSP orders now include a wallet-owned address for on-chain refunds.