Skip to content
Draft
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
19 changes: 19 additions & 0 deletions app/src/main/java/to/bitkit/ext/PendingSweepBalance.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
84 changes: 82 additions & 2 deletions app/src/main/java/to/bitkit/repositories/TransferRepo.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
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.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.Logger
import java.util.UUID
import javax.inject.Inject
Expand All @@ -23,6 +29,7 @@ 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,
) {
Expand Down Expand Up @@ -98,8 +105,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 {
Expand All @@ -109,6 +123,72 @@ class TransferRepo @Inject constructor(
}
}

private suspend fun settleForceClose(
transfer: TransferEntity,
channelId: String?,
pendingSweeps: List<PendingSweepBalance>?,
) {
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,
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,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,
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/ui/components/SheetHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
92 changes: 92 additions & 0 deletions app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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.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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be size .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()
}
}
}
53 changes: 50 additions & 3 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -65,6 +67,8 @@ 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.getClipboardText
import to.bitkit.ext.getSatsPerVByteFor
import to.bitkit.ext.maxSendableSat
Expand All @@ -85,6 +89,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
Expand Down Expand Up @@ -319,7 +324,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)
Expand Down Expand Up @@ -357,11 +362,54 @@ 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,
)
}
}

private fun classifyClosureReason(reason: ClosureReason): Pair<Boolean, Boolean> {
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
Expand Down Expand Up @@ -2230,7 +2278,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), "")
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@
<string name="lightning__conn_open">Open connections</string>
<string name="lightning__conn_pending">Pending connections</string>
<string name="lightning__connection">Connection</string>
<string name="lightning__connection_closed__description">The funds on your spending balance have been transferred to your savings.</string>
<string name="lightning__connection_closed__title">Connection Closed</string>
<string name="lightning__connections">Lightning Connections</string>
<string name="lightning__created_on">Created on</string>
<string name="lightning__error_add">Bitkit could not add the Lightning peer.</string>
Expand Down
Loading
Loading