diff --git a/app/schemas/to.bitkit.data.AppDb/6.json b/app/schemas/to.bitkit.data.AppDb/6.json new file mode 100644 index 000000000..b27e9384b --- /dev/null +++ b/app/schemas/to.bitkit.data.AppDb/6.json @@ -0,0 +1,98 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "3be81070b5bbc85b549a246ad16af16d", + "entities": [ + { + "tableName": "config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletIndex` INTEGER NOT NULL, PRIMARY KEY(`walletIndex`))", + "fields": [ + { + "fieldPath": "walletIndex", + "columnName": "walletIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletIndex" + ] + } + }, + { + "tableName": "transfers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `amountSats` INTEGER NOT NULL, `channelId` TEXT, `fundingTxId` TEXT, `lspOrderId` TEXT, `isSettled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `settledAt` INTEGER, `claimableAtHeight` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountSats", + "columnName": "amountSats", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT" + }, + { + "fieldPath": "fundingTxId", + "columnName": "fundingTxId", + "affinity": "TEXT" + }, + { + "fieldPath": "lspOrderId", + "columnName": "lspOrderId", + "affinity": "TEXT" + }, + { + "fieldPath": "isSettled", + "columnName": "isSettled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "settledAt", + "columnName": "settledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "claimableAtHeight", + "columnName": "claimableAtHeight", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3be81070b5bbc85b549a246ad16af16d')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/data/AppDb.kt b/app/src/main/java/to/bitkit/data/AppDb.kt index d67f22094..81038037a 100644 --- a/app/src/main/java/to/bitkit/data/AppDb.kt +++ b/app/src/main/java/to/bitkit/data/AppDb.kt @@ -9,6 +9,7 @@ import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.Upsert +import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import androidx.work.CoroutineWorker import androidx.work.OneTimeWorkRequestBuilder @@ -30,7 +31,7 @@ import to.bitkit.env.Env ConfigEntity::class, TransferEntity::class, ], - version = 5, + version = 6, ) @TypeConverters(StringListConverter::class) abstract class AppDb : RoomDatabase() { @@ -38,6 +39,12 @@ abstract class AppDb : RoomDatabase() { abstract fun transferDao(): TransferDao companion object { + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE transfers ADD COLUMN claimableAtHeight INTEGER DEFAULT NULL") + } + } + private const val DB_NAME = "${BuildConfig.APPLICATION_ID}.sqlite" @Volatile @@ -65,6 +72,7 @@ abstract class AppDb : RoomDatabase() { } } }) + .addMigrations(MIGRATION_5_6) .apply { if (Env.isDebug) fallbackToDestructiveMigration(dropAllTables = true) } diff --git a/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt b/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt index 12ae0deac..6203f32ab 100644 --- a/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt +++ b/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt @@ -17,4 +17,5 @@ data class TransferEntity( val isSettled: Boolean = false, val createdAt: Long, val settledAt: Long? = null, + val claimableAtHeight: Int? = null, ) diff --git a/app/src/main/java/to/bitkit/ext/LightningBalance.kt b/app/src/main/java/to/bitkit/ext/LightningBalance.kt index 5a910fe0e..542f3248e 100644 --- a/app/src/main/java/to/bitkit/ext/LightningBalance.kt +++ b/app/src/main/java/to/bitkit/ext/LightningBalance.kt @@ -24,6 +24,13 @@ fun LightningBalance.channelId(): String { } } +fun LightningBalance.claimableAtHeight(): UInt? = when (this) { + is LightningBalance.ClaimableAwaitingConfirmations -> this.confirmationHeight + is LightningBalance.ContentiousClaimable -> this.timeoutHeight + is LightningBalance.MaybeTimeoutClaimableHtlc -> this.claimableHeight + else -> null +} + fun LightningBalance.balanceUiText(): String { return when (this) { is LightningBalance.ClaimableOnChannelClose -> "Claimable on Channel Close" diff --git a/app/src/main/java/to/bitkit/ext/PendingSweepBalance.kt b/app/src/main/java/to/bitkit/ext/PendingSweepBalance.kt new file mode 100644 index 000000000..21dc46236 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/PendingSweepBalance.kt @@ -0,0 +1,19 @@ +package to.bitkit.ext + +import org.lightningdevkit.ldknode.PendingSweepBalance + +fun PendingSweepBalance.channelId(): String? { + return when (this) { + is PendingSweepBalance.PendingBroadcast -> this.channelId + is PendingSweepBalance.BroadcastAwaitingConfirmation -> this.channelId + is PendingSweepBalance.AwaitingThresholdConfirmations -> this.channelId + } +} + +fun PendingSweepBalance.latestSpendingTxid(): String? { + return when (this) { + is PendingSweepBalance.PendingBroadcast -> null + is PendingSweepBalance.BroadcastAwaitingConfirmation -> this.latestSpendingTxid + is PendingSweepBalance.AwaitingThresholdConfirmations -> this.latestSpendingTxid + } +} diff --git a/app/src/main/java/to/bitkit/models/ActivityBannerType.kt b/app/src/main/java/to/bitkit/models/ActivityBannerType.kt index cd366c294..7a6cf8a6c 100644 --- a/app/src/main/java/to/bitkit/models/ActivityBannerType.kt +++ b/app/src/main/java/to/bitkit/models/ActivityBannerType.kt @@ -22,3 +22,8 @@ enum class ActivityBannerType( title = R.string.lightning__transfer_in_progress ) } + +data class BannerItem( + val type: ActivityBannerType, + val title: String, +) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 6aa2e8708..48a64f400 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -1,15 +1,23 @@ package to.bitkit.repositories +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.PendingSweepBalance import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher import to.bitkit.ext.channelId +import to.bitkit.ext.latestSpendingTxid import to.bitkit.models.TransferType +import to.bitkit.services.CoreService +import to.bitkit.utils.BlockTimeHelpers import to.bitkit.utils.Logger import java.util.UUID import javax.inject.Inject @@ -23,17 +31,33 @@ class TransferRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, private val blocktankRepo: BlocktankRepo, + private val coreService: CoreService, private val transferDao: TransferDao, private val clock: Clock, ) { val activeTransfers: Flow> = transferDao.getActiveTransfers() + val forceCloseRemainingDuration: Flow = combine( + activeTransfers, + lightningRepo.lightningState, + ) { transfers, lightningState -> + val forceClose = transfers.firstOrNull { it.type == TransferType.FORCE_CLOSE } + ?: return@combine null + val targetHeight = forceClose.claimableAtHeight?.toUInt() ?: return@combine null + val currentHeight = lightningState.block()?.height ?: return@combine null + val remaining = BlockTimeHelpers.blocksRemaining(targetHeight, currentHeight) + if (remaining <= 0) return@combine null + BlockTimeHelpers.getDurationForBlocks(remaining) + } + + @Suppress("LongParameterList") suspend fun createTransfer( type: TransferType, amountSats: Long, channelId: String? = null, fundingTxId: String? = null, lspOrderId: String? = null, + claimableAtHeight: UInt? = null, ): Result = withContext(bgDispatcher) { runCatching { val id = UUID.randomUUID().toString() @@ -47,6 +71,7 @@ class TransferRepo @Inject constructor( lspOrderId = lspOrderId, isSettled = false, createdAt = clock.now().epochSeconds, + claimableAtHeight = claimableAtHeight?.toInt(), ) ) Logger.info("Created transfer: id=$id type=$type channelId=$channelId", context = TAG) @@ -98,8 +123,15 @@ class TransferRepo @Inject constructor( } ?: false if (!hasBalance) { - markSettled(transfer.id) - Logger.debug("Channel $channelId balance swept, settled transfer: ${transfer.id}", context = TAG) + if (transfer.type == TransferType.FORCE_CLOSE) { + settleForceClose(transfer, channelId, balances?.pendingBalancesFromChannelClosures) + } else { + markSettled(transfer.id) + Logger.debug( + "Channel $channelId balance swept, settled transfer: ${transfer.id}", + context = TAG + ) + } } } }.onSuccess { @@ -109,6 +141,72 @@ class TransferRepo @Inject constructor( } } + private suspend fun settleForceClose( + transfer: TransferEntity, + channelId: String?, + pendingSweeps: List?, + ) { + if (channelId == null) return + + if (coreService.activity.hasOnchainActivityForChannel(channelId)) { + markActivityAsTransferByChannel(channelId) + markSettled(transfer.id) + Logger.debug("Force close sweep detected, settled transfer: ${transfer.id}", context = TAG) + return + } + + // When LDK batches sweeps from multiple channels into one transaction, + // the on-chain activity may only be linked to one channel. Fall back to + // checking if there are no remaining pending sweep balances for this channel. + val pendingSweep = pendingSweeps?.find { it.channelId() == channelId } + + if (pendingSweep == null) { + markSettled(transfer.id) + Logger.debug( + "Force close sweep completed (no pending sweeps), settled transfer: ${transfer.id}", + context = TAG, + ) + return + } + + val sweepTxid = pendingSweep.latestSpendingTxid() + if (sweepTxid != null && coreService.activity.hasOnchainActivityForTxid(sweepTxid)) { + // The sweep tx was already synced as an on-chain activity (linked to another + // channel in the same batched sweep). Safe to settle this transfer. + markActivityAsTransfer(sweepTxid, channelId) + markSettled(transfer.id) + Logger.debug( + "Force close batched sweep detected via txid $sweepTxid, settled transfer: ${transfer.id}", + context = TAG, + ) + return + } + + Logger.debug("Force close awaiting sweep detection for transfer: ${transfer.id}", context = TAG) + } + + private suspend fun markActivityAsTransfer(txid: String, channelId: String) { + val activity = coreService.activity.getOnchainActivityByTxId(txid) ?: return + if (activity.isTransfer) return + val updated = activity.copy(isTransfer = true, channelId = channelId) + coreService.activity.update(activity.id, Activity.Onchain(updated)) + Logger.debug("Marked activity ${activity.id} as transfer for channel $channelId", context = TAG) + } + + private suspend fun markActivityAsTransferByChannel(channelId: String) { + val activities = coreService.activity.get( + filter = ActivityFilter.ONCHAIN, + limit = 50u, + sortDirection = SortDirection.DESC, + ) + val activity = activities.firstOrNull { it is Activity.Onchain && it.v1.channelId == channelId } + as? Activity.Onchain ?: return + if (activity.v1.isTransfer) return + val updated = activity.v1.copy(isTransfer = true, channelId = channelId) + coreService.activity.update(activity.v1.id, Activity.Onchain(updated)) + Logger.debug("Marked activity ${activity.v1.id} as transfer for channel $channelId", context = TAG) + } + /** Resolve channelId: for LSP orders: via order->fundingTx match, for manual: directly. */ suspend fun resolveChannelIdForTransfer( transfer: TransferEntity, diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 3b5782db4..176dcee2d 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -52,6 +52,7 @@ class WalletRepo @Inject constructor( private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase, private val wipeWalletUseCase: WipeWalletUseCase, private val transferRepo: TransferRepo, + private val activityRepo: ActivityRepo, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -204,6 +205,7 @@ class WalletRepo @Inject constructor( delay(EVENT_SYNC_DEBOUNCE_MS) syncBalances() transferRepo.syncTransferStates() + activityRepo.syncActivities() } } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 4f82a9ece..7c097e158 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -71,7 +71,9 @@ import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore import to.bitkit.env.Env import to.bitkit.ext.amountSats +import to.bitkit.ext.channelId import to.bitkit.ext.create +import to.bitkit.ext.latestSpendingTxid import to.bitkit.models.toCoreNetwork import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -283,6 +285,14 @@ class ActivityService( getActivityByTxId(txId = txId) } + suspend fun hasOnchainActivityForChannel(channelId: String): Boolean { + val activities = get(filter = ActivityFilter.ONCHAIN, limit = 50u, sortDirection = SortDirection.DESC) + return activities.any { it is Activity.Onchain && it.v1.channelId == channelId } + } + + suspend fun hasOnchainActivityForTxid(txid: String): Boolean = + getOnchainActivityByTxId(txid) != null + @Suppress("LongParameterList") suspend fun get( filter: ActivityFilter? = null, @@ -1038,6 +1048,16 @@ class ActivityService( runCatching { val onchain = getOnchainActivityByTxId(txid) ?: return@background true + if (onchain.isTransfer) { + Logger.info("Skipping received sheet for transfer transaction $txid", context = TAG) + return@background false + } + + if (onchain.channelId != null) { + Logger.info("Skipping received sheet for channel transaction $txid", context = TAG) + return@background false + } + // Check if activity has already been seen if (onchain.seenAt != null) { Logger.info( @@ -1239,10 +1259,13 @@ class ActivityService( transactionDetails: BitkitCoreTransactionDetails? = null, ): String? { return runCatching { + // Check if this transaction is a pending sweep from a channel closure + val pendingSweeps = lightningService.balances?.pendingBalancesFromChannelClosures + val matchingSweep = pendingSweeps?.firstOrNull { it.latestSpendingTxid() == txid } + if (matchingSweep != null) return matchingSweep.channelId() + val closedChannelsList = closedChannels(SortDirection.DESC) - if (closedChannelsList.isEmpty()) { - return null - } + if (closedChannelsList.isEmpty()) return null // Use provided transaction details if available, otherwise fetch from bitkitcore val details = transactionDetails ?: fetchTransactionDetails(txid) ?: run { @@ -1250,24 +1273,39 @@ class ActivityService( return null } - for (input in details.inputs) { - val inputTxid = input.txid - val inputVout = input.vout.toInt() - - val matchingChannel = closedChannelsList.firstOrNull { channel -> - channel.fundingTxoTxid == inputTxid && channel.fundingTxoIndex == inputVout.toUInt() - } - - if (matchingChannel != null) { - return matchingChannel.channelId - } - } - null + // Check if any input spends a closed channel's funding UTXO (commitment tx) + findChannelByFundingUtxo(details, closedChannelsList) + // Check if any input's parent transaction is a channel-related activity + // (e.g., sweep tx spending from commitment tx) + ?: findChannelByParentActivity(details) }.onFailure { e -> Logger.warn("Failed to check if transaction $txid spends closed channel funding UTXO", e, context = TAG) }.getOrNull() } + private fun findChannelByFundingUtxo( + details: BitkitCoreTransactionDetails, + closedChannels: List, + ): String? { + for (input in details.inputs) { + val matchingChannel = closedChannels.firstOrNull { channel -> + channel.fundingTxoTxid == input.txid && channel.fundingTxoIndex == input.vout + } + if (matchingChannel != null) return matchingChannel.channelId + } + return null + } + + private suspend fun findChannelByParentActivity( + details: BitkitCoreTransactionDetails, + ): String? { + for (input in details.inputs) { + val parentActivity = getOnchainActivityByTxId(input.txid) + if (parentActivity?.channelId != null) return parentActivity.channelId + } + return null + } + companion object { private const val TAG = "ActivityService" } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 45ae8f1d5..830bcd9f4 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -171,6 +171,7 @@ import to.bitkit.ui.settings.transactionSpeed.TransactionSpeedSettingsScreen import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.BackupSheet +import to.bitkit.ui.sheets.ConnectionClosedSheet import to.bitkit.ui.sheets.ForceTransferSheet import to.bitkit.ui.sheets.GiftSheet import to.bitkit.ui.sheets.HighBalanceWarningSheet @@ -413,6 +414,10 @@ fun ContentView( onCancel = { appViewModel.hideSheet() }, ) + Sheet.ConnectionClosed -> ConnectionClosedSheet( + onDismiss = { appViewModel.hideSheet() }, + ) + is Sheet.Gift -> GiftSheet(sheet, appViewModel) is Sheet.TimedSheet -> { when (sheet.type) { @@ -804,6 +809,7 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() val onchainActivities by activityListViewModel.onchainActivities.collectAsStateWithLifecycle() + val forceCloseRemainingDuration by appViewModel.forceCloseRemainingDuration.collectAsStateWithLifecycle() SavingsWalletScreen( isGeoBlocked = isGeoBlocked, @@ -819,6 +825,7 @@ private fun NavGraphBuilder.home( } }, onBackClick = { navController.popBackStack() }, + forceCloseRemainingDuration = forceCloseRemainingDuration, ) } composable( diff --git a/app/src/main/java/to/bitkit/ui/components/IncomingTransfer.kt b/app/src/main/java/to/bitkit/ui/components/IncomingTransfer.kt index 489f312c4..92687e699 100644 --- a/app/src/main/java/to/bitkit/ui/components/IncomingTransfer.kt +++ b/app/src/main/java/to/bitkit/ui/components/IncomingTransfer.kt @@ -24,9 +24,16 @@ import to.bitkit.ui.utils.withAccent @Composable fun IncomingTransfer( amount: ULong, + remainingDuration: String? = null, modifier: Modifier = Modifier, currencies: CurrencyState = LocalCurrencies.current, ) { + val subtitle = if (remainingDuration != null) { + stringResource(R.string.wallet__activity_transfer_savings_pending) + .replace("{duration}", "±$remainingDuration") + } else { + stringResource(R.string.wallet__details_transfer_subtitle) + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -39,7 +46,7 @@ fun IncomingTransfer( tint = Colors.White64, ) CaptionB( - text = stringResource(R.string.wallet__details_transfer_subtitle), + text = subtitle, color = Colors.White64, ) CaptionB( diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 599569b21..6d5fb4d8a 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -46,6 +46,7 @@ sealed interface Sheet { data object ForceTransfer : Sheet data class Gift(val code: String, val amount: ULong) : Sheet data object SweepPrompt : Sheet + data object ConnectionClosed : Sheet data class TimedSheet(val type: TimedSheetType) : Sheet } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 5367f742f..1543b821d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -469,11 +469,11 @@ private fun Content( ) { homeUiState.banners.forEach { banner -> ActivityBanner( - gradientColor = banner.color, - title = stringResource(banner.title), - icon = banner.icon, + gradientColor = banner.type.color, + title = banner.title, + icon = banner.type.icon, onClick = { - when (banner) { + when (banner.type) { ActivityBannerType.SPENDING -> rootNavController.navigate(Routes.SettingUp) ActivityBannerType.SAVINGS -> Unit } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt index 7b086e64e..bebb01e6e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt @@ -2,7 +2,7 @@ package to.bitkit.ui.screens.wallets import androidx.compose.runtime.Stable import to.bitkit.data.dto.price.PriceDTO -import to.bitkit.models.ActivityBannerType +import to.bitkit.models.BannerItem import to.bitkit.models.Suggestion import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition @@ -18,7 +18,7 @@ import to.bitkit.ui.screens.widgets.blocks.WeatherModel @Stable data class HomeUiState( val suggestions: List = listOf(), - val banners: List = listOf(), + val banners: List = listOf(), val showWidgets: Boolean = false, val showWidgetTitles: Boolean = false, val widgetsWithPosition: List = emptyList(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 5410ad6e3..a2e6e563d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -1,8 +1,10 @@ package to.bitkit.ui.screens.wallets +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -11,8 +13,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.models.ActivityBannerType +import to.bitkit.models.BannerItem import to.bitkit.models.Suggestion import to.bitkit.models.TransferType import to.bitkit.models.WidgetType @@ -29,6 +33,7 @@ import kotlin.time.Duration.Companion.seconds @HiltViewModel class HomeViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val walletRepo: WalletRepo, private val widgetsRepo: WidgetsRepo, private val settingsStore: SettingsStore, @@ -215,18 +220,28 @@ class HomeViewModel @Inject constructor( } private suspend fun createBannersFlow() { - transferRepo.activeTransfers - .collect { transfers -> - val banners = listOfNotNull( - ActivityBannerType.SPENDING.takeIf { - transfers.any { it.type.isToSpending() } - }, - ActivityBannerType.SAVINGS.takeIf { - transfers.any { it.type.isToSavings() } - }, - ) - _uiState.update { it.copy(banners = banners) } - } + combine( + walletRepo.balanceState, + transferRepo.forceCloseRemainingDuration, + ) { balanceState, remainingDuration -> + val defaultTitle = context.getString(R.string.lightning__transfer_in_progress) + val savingsTitle = remainingDuration?.let { + context.getString(R.string.lightning__transfer_ready_in, it) + } ?: defaultTitle + + listOfNotNull( + BannerItem( + type = ActivityBannerType.SPENDING, + title = defaultTitle, + ).takeIf { balanceState.balanceInTransferToSpending > 0uL }, + BannerItem( + type = ActivityBannerType.SAVINGS, + title = savingsTitle, + ).takeIf { balanceState.balanceInTransferToSavings > 0uL }, + ) + }.collect { banners -> + _uiState.update { it.copy(banners = banners) } + } } private fun createSuggestionsFlow() = combine( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index 7b2b48671..7b7e32433 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -52,6 +52,7 @@ fun SavingsWalletScreen( onActivityItemClick: (String) -> Unit, onTransferToSpendingClick: () -> Unit, onBackClick: () -> Unit, + forceCloseRemainingDuration: String? = null, balances: BalanceState = LocalBalances.current, ) { val showEmptyState by remember(balances.totalOnchainSats, onchainActivities.size) { @@ -102,7 +103,8 @@ fun SavingsWalletScreen( if (balances.balanceInTransferToSavings > 0u) { IncomingTransfer( amount = balances.balanceInTransferToSavings, - modifier = Modifier.padding(vertical = 8.dp) + remainingDuration = forceCloseRemainingDuration, + modifier = Modifier.padding(vertical = 8.dp), ) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt new file mode 100644 index 000000000..c9a1a3e79 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt @@ -0,0 +1,95 @@ +package to.bitkit.ui.sheets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +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 to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SheetSize +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +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 + +@Composable +fun ConnectionClosedSheet( + onDismiss: () -> Unit, +) { + Content(onDismiss = onDismiss) +} + +@Composable +private fun Content( + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {}, +) { + Column( + modifier = modifier + .sheetHeight( + size = SheetSize.MEDIUM + ) + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("ConnectionClosedSheet") + ) { + SheetTopBar(titleText = stringResource(R.string.lightning__connection_closed__title)) + + BodyM( + text = stringResource(R.string.lightning__connection_closed__description), + color = Colors.White64, + modifier = Modifier.fillMaxWidth() + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Image( + painter = painterResource(R.drawable.switch_box), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(256.dp) + ) + } + + PrimaryButton( + text = stringResource(R.string.common__ok), + onClick = onDismiss, + modifier = Modifier + .fillMaxWidth() + .testTag("ConnectionClosedButton") + ) + + VerticalSpacer(16.dp) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Content() + } + } +} diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index fd2cd74dd..34165fed7 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -9,6 +9,7 @@ import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.BalanceState +import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo @@ -32,6 +33,7 @@ class DeriveBalanceStateUseCase @Inject constructor( val pendingChannelsSats = getPendingChannelsSats(activeTransfers, channels, balanceDetails) val toSavingsAmount = getTransferToSavingsSats(activeTransfers, channels, balanceDetails) + val coopCloseSavingsSats = getCoopCloseTransferSats(activeTransfers, channels, balanceDetails) val toSpendingAmount = paidOrdersSats.safe() + pendingChannelsSats.safe() val totalOnchainSats = balanceDetails.totalOnchainBalanceSats @@ -43,7 +45,7 @@ class DeriveBalanceStateUseCase @Inject constructor( totalLightningSats = totalLightningSats, maxSendLightningSats = lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), maxSendOnchainSats = getMaxSendAmount(balanceDetails), - balanceInTransferToSavings = toSavingsAmount, + balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), balanceInTransferToSpending = toSpendingAmount, ) @@ -97,6 +99,20 @@ class DeriveBalanceStateUseCase @Inject constructor( return toSavingsAmount } + private suspend fun getCoopCloseTransferSats( + transfers: List, + channels: List, + balanceDetails: BalanceDetails, + ): ULong { + var amount = 0uL + for (transfer in transfers.filter { it.type == TransferType.COOP_CLOSE }) { + val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) + val channelBalance = balanceDetails.lightningBalances.find { it.channelId() == channelId } + amount += channelBalance?.amountSats() ?: 0u + } + return amount + } + private suspend fun getMaxSendAmount(balanceDetails: BalanceDetails): ULong { val spendableOnchainSats = balanceDetails.spendableOnchainBalanceSats if (spendableOnchainSats == 0uL) return 0u diff --git a/app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt b/app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt new file mode 100644 index 000000000..46fd32b4f --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt @@ -0,0 +1,21 @@ +package to.bitkit.utils + +import kotlin.math.roundToInt + +@Suppress("MagicNumber") +object BlockTimeHelpers { + private const val BLOCK_TIME_MINUTES = 10 + private const val MINUTES_PER_HOUR = 60.0 + private const val HOURS_PER_DAY = 24.0 + private const val BLOCKS_PER_DAY = 143 + private const val REORG_PROTECTION_BLOCKS = 6 + + fun getDurationForBlocks(blocks: Int): String = when { + blocks > BLOCKS_PER_DAY -> "${(blocks * BLOCK_TIME_MINUTES / MINUTES_PER_HOUR / HOURS_PER_DAY).roundToInt()}d" + blocks > REORG_PROTECTION_BLOCKS -> "${(blocks * BLOCK_TIME_MINUTES / MINUTES_PER_HOUR).roundToInt()}h" + else -> "${blocks * BLOCK_TIME_MINUTES}m" + } + + fun blocksRemaining(targetHeight: UInt, currentHeight: UInt): Int = + maxOf(0, (targetHeight.toInt() - currentHeight.toInt())) +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f0d371c8c..00ece95a3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -22,6 +22,7 @@ import com.synonym.bitkitcore.LnurlWithdrawData import com.synonym.bitkitcore.OnChainInvoice import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.Scanner +import com.synonym.bitkitcore.SortDirection import com.synonym.bitkitcore.validateBitcoinAddress import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -47,6 +48,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.lightningdevkit.ldknode.ChannelDataMigration +import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId @@ -65,6 +67,9 @@ import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.WatchResult import to.bitkit.ext.amountOnClose +import to.bitkit.ext.amountSats +import to.bitkit.ext.channelId +import to.bitkit.ext.claimableAtHeight import to.bitkit.ext.getClipboardText import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.maxSendableSat @@ -85,6 +90,7 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.Suggestion import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed +import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.models.toActivityFilter import to.bitkit.models.toLdkNetwork @@ -170,6 +176,9 @@ class AppViewModel @Inject constructor( val isGeoBlocked = lightningRepo.lightningState.map { it.isGeoBlocked } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val forceCloseRemainingDuration = transferRepo.forceCloseRemainingDuration + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + private val _sendUiState = MutableStateFlow(SendUiState()) val sendUiState = _sendUiState.asStateFlow() @@ -319,7 +328,7 @@ class AppViewModel @Inject constructor( runCatching { when (event) { is Event.BalanceChanged -> handleBalanceChanged() - is Event.ChannelClosed -> handleChannelClosed() + is Event.ChannelClosed -> handleChannelClosed(event) is Event.ChannelPending -> handleChannelPending() is Event.ChannelReady -> handleChannelReady(event) is Event.OnchainTransactionConfirmed -> handleOnchainTransactionConfirmed(event) @@ -357,11 +366,55 @@ class AppViewModel @Inject constructor( private suspend fun handleChannelPending() = transferRepo.syncTransferStates() - private suspend fun handleChannelClosed() { + private suspend fun handleChannelClosed(event: Event.ChannelClosed) { + val reason = event.reason + if (reason != null) { + val (isCounterpartyClose, isForceClose) = classifyClosureReason(reason) + if (isCounterpartyClose) { + createTransferForCounterpartyClose(event.channelId, isForceClose) + showSheet(Sheet.ConnectionClosed) + } + } transferRepo.syncTransferStates() walletRepo.syncBalances() } + private suspend fun createTransferForCounterpartyClose(channelId: String, isForceClose: Boolean) { + val transferType = if (isForceClose) TransferType.FORCE_CLOSE else TransferType.COOP_CLOSE + + val balances = lightningRepo.getBalancesAsync().getOrNull() + val lightningBalance = balances?.lightningBalances?.find { it.channelId() == channelId } + var channelBalance = lightningBalance?.amountSats() ?: 0uL + + if (channelBalance == 0uL) { + val closedChannels = runCatching { + coreService.activity.closedChannels(SortDirection.DESC) + }.getOrNull() + channelBalance = closedChannels + ?.firstOrNull { it.channelId == channelId } + ?.channelValueSats ?: 0uL + } + + if (channelBalance > 0uL) { + transferRepo.createTransfer( + type = transferType, + amountSats = channelBalance.toLong(), + channelId = channelId, + claimableAtHeight = lightningBalance?.claimableAtHeight(), + ) + } + } + + private fun classifyClosureReason(reason: ClosureReason): Pair { + return when (reason) { + is ClosureReason.CounterpartyForceClosed -> true to true + is ClosureReason.CommitmentTxConfirmed -> true to true + is ClosureReason.CounterpartyInitiatedCooperativeClosure -> true to false + is ClosureReason.CounterpartyCoopClosedUnfundedChannel -> true to false + else -> false to false + } + } + private suspend fun handleSyncCompleted() { val isShowingLoading = migrationService.isShowingMigrationLoading.value val isRestoringRemote = migrationService.isRestoringFromRNRemoteBackup.value @@ -2230,7 +2283,6 @@ class AppViewModel @Inject constructor( } // TODO Temporary fix while these schemes can't be decoded https://github.com/synonymdev/bitkit-core/issues/70 - @Suppress("SpellCheckingInspection") private fun String.removeLightningSchemes(): String { return this .replace(Regex("^lightning:", RegexOption.IGNORE_CASE), "") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d080f226..5f1e7e035 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,8 @@ Open connections Pending connections Connection + The funds on your spending balance have been transferred to your savings. + Connection Closed Lightning Connections Created on Bitkit could not add the Lightning peer. @@ -244,6 +246,7 @@ Transfer Funds Swipe To Transfer TRANSFER IN PROGRESS + TRANSFER READY IN %s Get Started Fund your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. Spending\n<accent>Balance</accent> diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 59ac8dae1..68f699344 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -1,9 +1,12 @@ package to.bitkit.repositories import app.cash.turbine.test +import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.FundingTx import com.synonym.bitkitcore.IBtChannel import com.synonym.bitkitcore.IBtOrder +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before @@ -12,7 +15,9 @@ import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.OutPoint +import org.lightningdevkit.ldknode.PendingSweepBalance import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -21,8 +26,11 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity +import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails import to.bitkit.models.TransferType +import to.bitkit.services.ActivityService +import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -31,6 +39,7 @@ import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime +@Suppress("LargeClass") @OptIn(ExperimentalTime::class) class TransferRepoTest : BaseUnitTest() { private lateinit var sut: TransferRepo @@ -38,6 +47,10 @@ class TransferRepoTest : BaseUnitTest() { private val transferDao = mock() private val lightningRepo = mock() private val blocktankRepo = mock() + private val activityService = mock() + private val coreService = mock { + on { activity } doReturn activityService + } private val clock = mock() companion object Fixtures { @@ -55,6 +68,7 @@ class TransferRepoTest : BaseUnitTest() { bgDispatcher = testDispatcher, lightningRepo = lightningRepo, blocktankRepo = blocktankRepo, + coreService = coreService, transferDao = transferDao, clock = clock, ) @@ -446,6 +460,267 @@ class TransferRepoTest : BaseUnitTest() { assertEquals(exception, result.exceptionOrNull()) } + @Test + fun `syncTransferStates does not settle COOP_CLOSE while LDK balance exists`() = test { + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.COOP_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val lightningBalance = LightningBalance.ClaimableAwaitingConfirmations( + channelId = ID_CHANNEL, + counterpartyNodeId = "node123", + amountSatoshis = 75000u, + confirmationHeight = 344u, + source = org.lightningdevkit.ldknode.BalanceSource.COOP_CLOSE, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 0u, + spendableOnchainBalanceSats = 0u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 75000u, + lightningBalances = listOf(lightningBalance), + pendingBalancesFromChannelClosures = emptyList(), + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao, never()).markSettled(any(), any()) + } + + @Test + fun `syncTransferStates settles COOP_CLOSE when LDK balance is gone`() = test { + val settledAt = setupClockNowMock() + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.COOP_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 75000u, + spendableOnchainBalanceSats = 75000u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) + } + + // MARK: - syncTransferStates (force close sweep handling) + + @Test + fun `syncTransferStates settles FORCE_CLOSE when on-chain activity exists for channel`() = test { + val settledAt = setupClockNowMock() + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.FORCE_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val sweepActivity = OnchainActivity.create( + id = "sweep-activity-id", + txType = PaymentType.RECEIVED, + txId = "sweep-txid", + value = 75000u, + fee = 0u, + address = "bc1test", + timestamp = 1000u, + isTransfer = false, + channelId = ID_CHANNEL, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 0u, + spendableOnchainBalanceSats = 0u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(true) + whenever( + activityService.get( + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ) + .thenReturn(listOf(Activity.Onchain(sweepActivity))) + whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) + verify(activityService).update( + eq(sweepActivity.id), + eq(Activity.Onchain(sweepActivity.copy(isTransfer = true, channelId = ID_CHANNEL))), + ) + } + + @Test + fun `syncTransferStates settles FORCE_CLOSE when no pending sweeps remain`() = test { + val settledAt = setupClockNowMock() + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.FORCE_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 0u, + spendableOnchainBalanceSats = 0u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(false) + whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) + } + + @Test + fun `syncTransferStates does not settle FORCE_CLOSE when pending sweep still exists`() = test { + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.FORCE_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val pendingSweep = PendingSweepBalance.PendingBroadcast( + channelId = ID_CHANNEL, + amountSatoshis = 75000u, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 0u, + spendableOnchainBalanceSats = 0u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = listOf(pendingSweep), + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(false) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao, never()).markSettled(any(), any()) + } + + @Test + fun `syncTransferStates settles FORCE_CLOSE via batched sweep txid`() = test { + val settledAt = setupClockNowMock() + val sweepTxid = "batched-sweep-txid" + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.FORCE_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val sweepActivity = OnchainActivity.create( + id = "sweep-activity-id", + txType = PaymentType.RECEIVED, + txId = sweepTxid, + value = 75000u, + fee = 0u, + address = "bc1test", + timestamp = 1000u, + isTransfer = false, + ) + + val pendingSweep = PendingSweepBalance.BroadcastAwaitingConfirmation( + channelId = ID_CHANNEL, + latestBroadcastHeight = 800000u, + latestSpendingTxid = sweepTxid, + amountSatoshis = 75000u, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 0u, + spendableOnchainBalanceSats = 0u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = listOf(pendingSweep), + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(false) + whenever(activityService.hasOnchainActivityForTxid(sweepTxid)).thenReturn(true) + whenever(activityService.getOnchainActivityByTxId(sweepTxid)).thenReturn(sweepActivity) + whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) + verify(activityService).update( + eq(sweepActivity.id), + eq(Activity.Onchain(sweepActivity.copy(isTransfer = true, channelId = ID_CHANNEL))), + ) + } + // MARK: - resolveChannelIdForTransfer @Test @@ -629,6 +904,7 @@ class TransferRepoTest : BaseUnitTest() { bgDispatcher = testDispatcher, lightningRepo = lightningRepo, blocktankRepo = blocktankRepo, + coreService = coreService, transferDao = transferDao, clock = clock, ) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index c0b4e19d4..5ab02c682 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -45,6 +45,7 @@ class WalletRepoTest : BaseUnitTest() { private val deriveBalanceStateUseCase = mock() private val wipeWalletUseCase = mock() private val transferRepo = mock() + private val activityRepo = mock() companion object Fixtures { const val ACTIVITY_TAG = "testTag" @@ -109,6 +110,7 @@ class WalletRepoTest : BaseUnitTest() { deriveBalanceStateUseCase = deriveBalanceStateUseCase, wipeWalletUseCase = wipeWalletUseCase, transferRepo = transferRepo, + activityRepo = activityRepo, ) @Test diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index b9a7e48c3..bb00ff383 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -164,7 +164,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { val transfers = listOf( newTransferEntity( - type = TransferType.COOP_CLOSE, + type = TransferType.FORCE_CLOSE, amountSats = amountSats.toLong(), channelId = channelId, lspOrderId = null @@ -192,6 +192,43 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { ) } + @Test + fun `should subtract coop close balance from lightning without showing transfer in progress`() = test { + val channelId = "closing-channel-id" + val amountSats = 40_000uL + val closingChannelBalance = newClosingChannelBalance(channelId, amountSats) + + val balance = newBalanceDetails().copy( + lightningBalances = listOf(closingChannelBalance), + totalLightningBalanceSats = amountSats, + ) + wheneverBlocking { lightningRepo.getBalancesAsync() }.thenReturn(Result.success(balance)) + + val transfers = listOf( + newTransferEntity( + type = TransferType.COOP_CLOSE, + amountSats = amountSats.toLong(), + channelId = channelId, + lspOrderId = null + ) + ) + + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(transferRepo.activeTransfers).thenReturn(flowOf(transfers)) + wheneverBlocking { transferRepo.resolveChannelIdForTransfer(any(), any()) }.thenReturn(channelId) + + val result = sut() + + assertTrue(result.isSuccess) + val balanceState = result.getOrThrow() + assertEquals(0uL, balanceState.balanceInTransferToSavings, "No transfer in progress for coop close") + assertEquals( + 0uL, + balanceState.totalLightningSats, + "Lightning balance reduced - coop close balance subtracted" + ) + } + @Test fun `should calculate zero max send onchain when spendable balance is zero`() = test { val balance = newBalanceDetails().copy(totalOnchainBalanceSats = 50_000u) @@ -292,7 +329,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { lspOrderId = null ), newTransferEntity( - type = TransferType.COOP_CLOSE, + type = TransferType.FORCE_CLOSE, amountSats = toSavings.toLong(), channelId = savingsChannelId, lspOrderId = null diff --git a/app/src/test/java/to/bitkit/utils/BlockTimeHelpersTest.kt b/app/src/test/java/to/bitkit/utils/BlockTimeHelpersTest.kt new file mode 100644 index 000000000..f2ecc5285 --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/BlockTimeHelpersTest.kt @@ -0,0 +1,42 @@ +package to.bitkit.utils + +import org.junit.Test +import kotlin.test.assertEquals + +class BlockTimeHelpersTest { + + @Test + fun `blocksRemaining returns positive difference`() { + assertEquals(6, BlockTimeHelpers.blocksRemaining(106u, 100u)) + } + + @Test + fun `blocksRemaining returns zero when target equals current`() { + assertEquals(0, BlockTimeHelpers.blocksRemaining(100u, 100u)) + } + + @Test + fun `blocksRemaining returns zero when target is below current`() { + assertEquals(0, BlockTimeHelpers.blocksRemaining(95u, 100u)) + } + + @Test + fun `getDurationForBlocks returns minutes for 6 or fewer blocks`() { + assertEquals("60m", BlockTimeHelpers.getDurationForBlocks(6)) + assertEquals("10m", BlockTimeHelpers.getDurationForBlocks(1)) + assertEquals("30m", BlockTimeHelpers.getDurationForBlocks(3)) + } + + @Test + fun `getDurationForBlocks returns hours for 7 to 143 blocks`() { + assertEquals("1h", BlockTimeHelpers.getDurationForBlocks(7)) + assertEquals("12h", BlockTimeHelpers.getDurationForBlocks(72)) + assertEquals("24h", BlockTimeHelpers.getDurationForBlocks(143)) + } + + @Test + fun `getDurationForBlocks returns days for more than 143 blocks`() { + assertEquals("1d", BlockTimeHelpers.getDurationForBlocks(144)) + assertEquals("7d", BlockTimeHelpers.getDurationForBlocks(1008)) + } +}