Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
440f2b1
feat: add PendingSweepBalance extension functions
jvsena42 Feb 25, 2026
14db4c9
fix: improve syncTransferStates for batched force-close sweeps
jvsena42 Feb 25, 2026
28f1993
fix: add ConnectionClosed sheet variant and UI
jvsena42 Feb 25, 2026
03c9cb5
fix: update handleChannelClosed in AppViewModel
jvsena42 Feb 25, 2026
71aeef6
test: add force-close sync tests
jvsena42 Feb 25, 2026
a179821
chore: lint
jvsena42 Feb 25, 2026
33a77ec
chore: lint
jvsena42 Feb 26, 2026
356a1d7
fix: mark closing transaction as transfer activity
jvsena42 Feb 26, 2026
0fff4aa
Merge branch 'fix/reimport-channel-monitor' into fix/channel-close-ui
jvsena42 Feb 26, 2026
7e284ef
fix: sheet size medium
jvsena42 Feb 26, 2026
4030583
Merge remote-tracking branch 'origin/fix/channel-close-ui' into fix/c…
jvsena42 Feb 27, 2026
9c4f80d
fix: settle COOP_CLOSE immediately
jvsena42 Feb 27, 2026
298bdf5
fix: balance calculation includes COOP_CLOSE whole they are unsettled…
jvsena42 Feb 27, 2026
d3af0c3
test: update coop close tests to settle immediately
jvsena42 Feb 27, 2026
793e0b9
feat: add claimableAtHeight to TransferEntity
jvsena42 Feb 27, 2026
f4849a4
feat: pass claimableAtHeight when creating force close transfer
jvsena42 Feb 27, 2026
8777244
chore: create helpers to display the duration
jvsena42 Feb 27, 2026
0aa2049
feat: update banner for dynamic title
jvsena42 Feb 27, 2026
6af900c
fix: show dynamic duration for force-close
jvsena42 Feb 27, 2026
ae0f027
fix: suppress receive sheet for transfers
jvsena42 Feb 27, 2026
78efaf5
fix: claimableAtHeight type
jvsena42 Feb 27, 2026
5480492
chore: migration
jvsena42 Feb 27, 2026
37235c3
fix: claimableAtHeight type
jvsena42 Feb 27, 2026
dafdea3
test: BlockTimer helper tests
jvsena42 Feb 27, 2026
aad07e4
chore: lint
jvsena42 Feb 27, 2026
c1715b4
refactor: move force close duration calculation to TransferRepo.kt
jvsena42 Feb 27, 2026
94c86a3
fix: subtract coop closing amount from balanceInTransferToSavings
jvsena42 Feb 27, 2026
8302993
fix: transfer banner visibility
jvsena42 Feb 27, 2026
ed56df9
fix: check if this transaction is a pending sweep from a channel closure
jvsena42 Feb 27, 2026
e56d56a
Merge branch 'fix/reimport-channel-monitor' into fix/channel-close-ui
jvsena42 Feb 27, 2026
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
98 changes: 98 additions & 0 deletions app/schemas/to.bitkit.data.AppDb/6.json
Original file line number Diff line number Diff line change
@@ -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')"
]
}
}
10 changes: 9 additions & 1 deletion app/src/main/java/to/bitkit/data/AppDb.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,14 +31,20 @@ import to.bitkit.env.Env
ConfigEntity::class,
TransferEntity::class,
],
version = 5,
version = 6,
)
@TypeConverters(StringListConverter::class)
abstract class AppDb : RoomDatabase() {
abstract fun configDao(): ConfigDao
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
Expand Down Expand Up @@ -65,6 +72,7 @@ abstract class AppDb : RoomDatabase() {
}
}
})
.addMigrations(MIGRATION_5_6)
.apply {
if (Env.isDebug) fallbackToDestructiveMigration(dropAllTables = true)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ data class TransferEntity(
val isSettled: Boolean = false,
val createdAt: Long,
val settledAt: Long? = null,
val claimableAtHeight: Int? = null,
)
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/ext/LightningBalance.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
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
}
}
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/models/ActivityBannerType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@ enum class ActivityBannerType(
title = R.string.lightning__transfer_in_progress
)
}

data class BannerItem(
val type: ActivityBannerType,
val title: String,
)
102 changes: 100 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,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
Expand All @@ -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<List<TransferEntity>> = transferDao.getActiveTransfers()

val forceCloseRemainingDuration: Flow<String?> = 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<String> = withContext(bgDispatcher) {
runCatching {
val id = UUID.randomUUID().toString()
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -109,6 +141,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
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/repositories/WalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -204,6 +205,7 @@ class WalletRepo @Inject constructor(
delay(EVENT_SYNC_DEBOUNCE_MS)
syncBalances()
transferRepo.syncTransferStates()
activityRepo.syncActivities()
}
}

Expand Down
Loading
Loading