Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,10 @@ class LightningRepo @Inject constructor(
return isRunning && lightningService.canReceive()
}

fun separateTrustedChannels(
channels: List<ChannelDetails>,
): Pair<List<ChannelDetails>, List<ChannelDetails>> = lightningService.separateTrustedChannels(channels)

suspend fun registerForNotifications(token: String? = null) = executeWhenNodeRunning("registerForNotifications") {
return@executeWhenNodeRunning try {
val token = token ?: firebaseMessaging.token.await()
Expand Down
24 changes: 24 additions & 0 deletions app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,19 @@ class LightningService @Inject constructor(
return ourPeers.any { p -> p !in lspPeers }
}

fun getLspPeerNodeIds(): Set<String> = trustedPeers.map { it.nodeId }.toSet()

fun separateTrustedChannels(
channels: List<ChannelDetails>,
): Pair<List<ChannelDetails>, List<ChannelDetails>> {
val trustedPeerIds = getLspPeerNodeIds()
val trusted = channels.filter { it.counterpartyNodeId in trustedPeerIds }
val nonTrusted = channels.filter { it.counterpartyNodeId !in trustedPeerIds }
return trusted to nonTrusted
}

fun isTrustedPeer(nodeId: String): Boolean = nodeId in getLspPeerNodeIds()

// endregion

// region channels
Expand Down Expand Up @@ -418,6 +431,7 @@ class LightningService @Inject constructor(
}
}

@Suppress("ThrowsCount")
suspend fun closeChannel(
channel: ChannelDetails,
force: Boolean = false,
Expand All @@ -427,6 +441,12 @@ class LightningService @Inject constructor(
val channelId = channel.channelId
val userChannelId = channel.userChannelId
val counterpartyNodeId = channel.counterpartyNodeId

// Prevent force closing channels with trusted peers (LSP nodes)
if (force && isTrustedPeer(counterpartyNodeId)) {
throw TrustedPeerForceCloseException()
}

try {
ServiceQueue.LDK.background {
Logger.debug("Initiating channel close (force=$force): '$channelId'", context = TAG)
Expand Down Expand Up @@ -952,6 +972,10 @@ data class NetworkGraphInfo(
val latestRgsSyncTimestamp: ULong?,
)

class TrustedPeerForceCloseException : Exception(
"Cannot force close channel with trusted peer. Force close is disabled for Blocktank LSP channels."
)

// region helpers
/**
* TODO remove, replace all usages with [FeeRate.fromSatPerVbUnchecked]
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ private fun RootNavHost(
wallet = walletViewModel,
transfer = transferViewModel,
onContinueClick = { navController.popBackStack<Routes.TransferRoot>(inclusive = true) },
onTransferUnavailable = { navController.popBackStack<Routes.TransferRoot>(inclusive = true) },
)
}
composableWithDefaultTransitions<Routes.SpendingIntro> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
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 kotlinx.coroutines.delay
import to.bitkit.R
import to.bitkit.models.Toast
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.Display
import to.bitkit.ui.components.PrimaryButton
Expand All @@ -52,8 +54,10 @@ fun SavingsProgressScreen(
transfer: TransferViewModel,
wallet: WalletViewModel,
onContinueClick: () -> Unit = {},
onTransferUnavailable: () -> Unit = {},
) {
val window = LocalActivity.current?.window
val context = LocalContext.current
var progressState by remember { mutableStateOf(SavingsProgressState.PROGRESS) }

// Effect to close channels & update UI
Expand All @@ -68,11 +72,35 @@ fun SavingsProgressScreen(
delay(5000)
progressState = SavingsProgressState.SUCCESS
} else {
transfer.startCoopCloseRetries(channelsFailedToCoopClose) {
app.showSheet(Sheet.ForceTransfer)
// Check if any channels can be retried (filter out trusted peers)
val (_, nonTrustedChannels) = transfer.separateTrustedChannels(channelsFailedToCoopClose)

if (nonTrustedChannels.isEmpty()) {
// All channels are trusted peers - show error and navigate back immediately
window?.clearFlags(FLAG_KEEP_SCREEN_ON)
app.toast(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.lightning__close_error),
description = context.getString(R.string.lightning__close_error_msg),
)
onTransferUnavailable()
} else {
transfer.startCoopCloseRetries(
channels = nonTrustedChannels,
onGiveUp = { app.showSheet(Sheet.ForceTransfer) },
onTransferUnavailable = {
window?.clearFlags(FLAG_KEEP_SCREEN_ON)
app.toast(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.lightning__close_error),
description = context.getString(R.string.lightning__close_error_msg),
)
onTransferUnavailable()
},
)
delay(2500)
progressState = SavingsProgressState.INTERRUPTED
}
delay(2500)
progressState = SavingsProgressState.INTERRUPTED
}
}

Expand Down
59 changes: 53 additions & 6 deletions app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,10 @@ class TransferViewModel @Inject constructor(
/** Closes the channels selected earlier, pending closure */
suspend fun closeSelectedChannels() = closeChannels(channelsToClose)

fun separateTrustedChannels(
channels: List<ChannelDetails>,
): Pair<List<ChannelDetails>, List<ChannelDetails>> = lightningRepo.separateTrustedChannels(channels)

private suspend fun closeChannels(channels: List<ChannelDetails>): List<ChannelDetails> {
val channelsFailedToClose = coroutineScope {
channels.map { channel ->
Expand Down Expand Up @@ -404,6 +408,7 @@ class TransferViewModel @Inject constructor(
fun startCoopCloseRetries(
channels: List<ChannelDetails>,
onGiveUp: () -> Unit,
onTransferUnavailable: () -> Unit,
) {
val startTimeMs = clock.now().toEpochMilliseconds()
channelsToClose = channels
Expand All @@ -427,21 +432,63 @@ class TransferViewModel @Inject constructor(
delay(RETRY_INTERVAL_MS)
}

Logger.info("Giving up on coop close.")
onGiveUp()
Logger.info("Giving up on coop close. Checking if force close is possible.", context = TAG)

// Check if any channels can be force closed (filter out trusted peers)
val (_, nonTrustedChannels) = lightningRepo.separateTrustedChannels(channelsToClose)

if (nonTrustedChannels.isNotEmpty()) {
onGiveUp()
} else {
Logger.warn("All channels are with trusted peers. Cannot force close.", context = TAG)
channelsToClose = emptyList()
onTransferUnavailable()
}
}
}

fun forceTransfer(onComplete: () -> Unit) = viewModelScope.launch {
_isForceTransferLoading.value = true
runCatching {
val failedChannels = forceCloseChannels(channelsToClose)
// Filter out trusted peer channels (cannot force close LSP channels)
val (trustedChannels, nonTrustedChannels) = lightningRepo.separateTrustedChannels(channelsToClose)

if (trustedChannels.isNotEmpty()) {
Logger.warn("Skipping ${trustedChannels.size} trusted peer channel(s)", context = TAG)
}

if (nonTrustedChannels.isEmpty()) {
channelsToClose = emptyList()
Logger.error("Cannot force close channels with trusted peer", context = TAG)
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.lightning__force_failed_title),
description = context.getString(R.string.lightning__force_failed_msg)
)
return@runCatching
}

val failedChannels = forceCloseChannels(nonTrustedChannels)

// Remove successfully closed channels and trusted peer channels from the list
val successfulChannelIds = nonTrustedChannels
.filterNot { channel -> failedChannels.any { it.channelId == channel.channelId } }
.map { it.channelId }
.toSet()
val trustedChannelIds = trustedChannels.map { it.channelId }.toSet()
channelsToClose = channelsToClose.filterNot {
it.channelId in successfulChannelIds || it.channelId in trustedChannelIds
}

if (failedChannels.isEmpty()) {
Logger.info("Force close initiated successfully for all channels", context = TAG)
val initMsg = context.getString(R.string.lightning__force_init_msg)
val skippedMsg = context.getString(R.string.lightning__force_channels_skipped)
val description = if (trustedChannels.isNotEmpty()) "$initMsg $skippedMsg" else initMsg
ToastEventBus.send(
type = Toast.ToastType.LIGHTNING,
title = context.getString(R.string.lightning__force_init_title),
description = context.getString(R.string.lightning__force_init_msg)
description = description,
)
} else {
Logger.error("Force close failed for ${failedChannels.size} channels", context = TAG)
Expand All @@ -451,8 +498,8 @@ class TransferViewModel @Inject constructor(
description = context.getString(R.string.lightning__force_failed_msg)
)
}
}.onFailure { e ->
Logger.error("Force close failed", e = e, context = TAG)
}.onFailure {
Logger.error("Force close failed", e = it, context = TAG)
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.lightning__force_failed_title),
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@
<string name="lightning__force_button">Force Transfer</string>
<string name="lightning__force_init_title">Force Transfer Initiated</string>
<string name="lightning__force_init_msg">Your funds will be accessible in ±14 days.</string>
<string name="lightning__force_channels_skipped">Some connections could not be closed.</string>
<string name="lightning__force_failed_title">Force Transfer Failed</string>
<string name="lightning__force_failed_msg">Unable to transfer your funds back to savings. Please try again.</string>
<string name="lightning__channel_opened_title">Spending Balance Ready</string>
Expand Down
Loading