Skip to content

Commit e6dcb4b

Browse files
authored
Merge pull request #802 from synonymdev/fix/reimport-channel-monitor
fix: recover orphaned channel monitors from RN backup
2 parents 3801801 + a401035 commit e6dcb4b

28 files changed

Lines changed: 963 additions & 51 deletions
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
{
2+
"formatVersion": 1,
3+
"database": {
4+
"version": 6,
5+
"identityHash": "3be81070b5bbc85b549a246ad16af16d",
6+
"entities": [
7+
{
8+
"tableName": "config",
9+
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletIndex` INTEGER NOT NULL, PRIMARY KEY(`walletIndex`))",
10+
"fields": [
11+
{
12+
"fieldPath": "walletIndex",
13+
"columnName": "walletIndex",
14+
"affinity": "INTEGER",
15+
"notNull": true
16+
}
17+
],
18+
"primaryKey": {
19+
"autoGenerate": false,
20+
"columnNames": [
21+
"walletIndex"
22+
]
23+
}
24+
},
25+
{
26+
"tableName": "transfers",
27+
"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`))",
28+
"fields": [
29+
{
30+
"fieldPath": "id",
31+
"columnName": "id",
32+
"affinity": "TEXT",
33+
"notNull": true
34+
},
35+
{
36+
"fieldPath": "type",
37+
"columnName": "type",
38+
"affinity": "TEXT",
39+
"notNull": true
40+
},
41+
{
42+
"fieldPath": "amountSats",
43+
"columnName": "amountSats",
44+
"affinity": "INTEGER",
45+
"notNull": true
46+
},
47+
{
48+
"fieldPath": "channelId",
49+
"columnName": "channelId",
50+
"affinity": "TEXT"
51+
},
52+
{
53+
"fieldPath": "fundingTxId",
54+
"columnName": "fundingTxId",
55+
"affinity": "TEXT"
56+
},
57+
{
58+
"fieldPath": "lspOrderId",
59+
"columnName": "lspOrderId",
60+
"affinity": "TEXT"
61+
},
62+
{
63+
"fieldPath": "isSettled",
64+
"columnName": "isSettled",
65+
"affinity": "INTEGER",
66+
"notNull": true
67+
},
68+
{
69+
"fieldPath": "createdAt",
70+
"columnName": "createdAt",
71+
"affinity": "INTEGER",
72+
"notNull": true
73+
},
74+
{
75+
"fieldPath": "settledAt",
76+
"columnName": "settledAt",
77+
"affinity": "INTEGER"
78+
},
79+
{
80+
"fieldPath": "claimableAtHeight",
81+
"columnName": "claimableAtHeight",
82+
"affinity": "INTEGER"
83+
}
84+
],
85+
"primaryKey": {
86+
"autoGenerate": false,
87+
"columnNames": [
88+
"id"
89+
]
90+
}
91+
}
92+
],
93+
"setupQueries": [
94+
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
95+
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3be81070b5bbc85b549a246ad16af16d')"
96+
]
97+
}
98+
}

app/src/main/java/to/bitkit/data/AppDb.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import androidx.room.Room
99
import androidx.room.RoomDatabase
1010
import androidx.room.TypeConverters
1111
import androidx.room.Upsert
12+
import androidx.room.migration.Migration
1213
import androidx.sqlite.db.SupportSQLiteDatabase
1314
import androidx.work.CoroutineWorker
1415
import androidx.work.OneTimeWorkRequestBuilder
@@ -30,14 +31,20 @@ import to.bitkit.env.Env
3031
ConfigEntity::class,
3132
TransferEntity::class,
3233
],
33-
version = 5,
34+
version = 6,
3435
)
3536
@TypeConverters(StringListConverter::class)
3637
abstract class AppDb : RoomDatabase() {
3738
abstract fun configDao(): ConfigDao
3839
abstract fun transferDao(): TransferDao
3940

4041
companion object {
42+
private val MIGRATION_5_6 = object : Migration(5, 6) {
43+
override fun migrate(db: SupportSQLiteDatabase) {
44+
db.execSQL("ALTER TABLE transfers ADD COLUMN claimableAtHeight INTEGER DEFAULT NULL")
45+
}
46+
}
47+
4148
private const val DB_NAME = "${BuildConfig.APPLICATION_ID}.sqlite"
4249

4350
@Volatile
@@ -65,6 +72,7 @@ abstract class AppDb : RoomDatabase() {
6572
}
6673
}
6774
})
75+
.addMigrations(MIGRATION_5_6)
6876
.apply {
6977
if (Env.isDebug) fallbackToDestructiveMigration(dropAllTables = true)
7078
}

app/src/main/java/to/bitkit/data/entities/TransferEntity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ data class TransferEntity(
1717
val isSettled: Boolean = false,
1818
val createdAt: Long,
1919
val settledAt: Long? = null,
20+
val claimableAtHeight: Int? = null,
2021
)

app/src/main/java/to/bitkit/ext/LightningBalance.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ fun LightningBalance.channelId(): String {
2424
}
2525
}
2626

27+
fun LightningBalance.claimableAtHeight(): UInt? = when (this) {
28+
is LightningBalance.ClaimableAwaitingConfirmations -> this.confirmationHeight
29+
is LightningBalance.ContentiousClaimable -> this.timeoutHeight
30+
is LightningBalance.MaybeTimeoutClaimableHtlc -> this.claimableHeight
31+
else -> null
32+
}
33+
2734
fun LightningBalance.balanceUiText(): String {
2835
return when (this) {
2936
is LightningBalance.ClaimableOnChannelClose -> "Claimable on Channel Close"
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package to.bitkit.ext
2+
3+
import org.lightningdevkit.ldknode.PendingSweepBalance
4+
5+
fun PendingSweepBalance.channelId(): String? {
6+
return when (this) {
7+
is PendingSweepBalance.PendingBroadcast -> this.channelId
8+
is PendingSweepBalance.BroadcastAwaitingConfirmation -> this.channelId
9+
is PendingSweepBalance.AwaitingThresholdConfirmations -> this.channelId
10+
}
11+
}
12+
13+
fun PendingSweepBalance.latestSpendingTxid(): String? {
14+
return when (this) {
15+
is PendingSweepBalance.PendingBroadcast -> null
16+
is PendingSweepBalance.BroadcastAwaitingConfirmation -> this.latestSpendingTxid
17+
is PendingSweepBalance.AwaitingThresholdConfirmations -> this.latestSpendingTxid
18+
}
19+
}

app/src/main/java/to/bitkit/models/ActivityBannerType.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,8 @@ enum class ActivityBannerType(
2222
title = R.string.lightning__transfer_in_progress
2323
)
2424
}
25+
26+
data class BannerItem(
27+
val type: ActivityBannerType,
28+
val title: String,
29+
)

app/src/main/java/to/bitkit/repositories/TransferRepo.kt

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
package to.bitkit.repositories
22

3+
import com.synonym.bitkitcore.Activity
4+
import com.synonym.bitkitcore.ActivityFilter
5+
import com.synonym.bitkitcore.SortDirection
36
import kotlinx.coroutines.CoroutineDispatcher
47
import kotlinx.coroutines.flow.Flow
8+
import kotlinx.coroutines.flow.combine
59
import kotlinx.coroutines.flow.first
610
import kotlinx.coroutines.withContext
711
import org.lightningdevkit.ldknode.ChannelDetails
12+
import org.lightningdevkit.ldknode.PendingSweepBalance
813
import to.bitkit.data.dao.TransferDao
914
import to.bitkit.data.entities.TransferEntity
1015
import to.bitkit.di.BgDispatcher
1116
import to.bitkit.ext.channelId
17+
import to.bitkit.ext.latestSpendingTxid
1218
import to.bitkit.models.TransferType
19+
import to.bitkit.services.CoreService
20+
import to.bitkit.utils.BlockTimeHelpers
1321
import to.bitkit.utils.Logger
1422
import java.util.UUID
1523
import javax.inject.Inject
@@ -23,17 +31,33 @@ class TransferRepo @Inject constructor(
2331
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
2432
private val lightningRepo: LightningRepo,
2533
private val blocktankRepo: BlocktankRepo,
34+
private val coreService: CoreService,
2635
private val transferDao: TransferDao,
2736
private val clock: Clock,
2837
) {
2938
val activeTransfers: Flow<List<TransferEntity>> = transferDao.getActiveTransfers()
3039

40+
val forceCloseRemainingDuration: Flow<String?> = combine(
41+
activeTransfers,
42+
lightningRepo.lightningState,
43+
) { transfers, lightningState ->
44+
val forceClose = transfers.firstOrNull { it.type == TransferType.FORCE_CLOSE }
45+
?: return@combine null
46+
val targetHeight = forceClose.claimableAtHeight?.toUInt() ?: return@combine null
47+
val currentHeight = lightningState.block()?.height ?: return@combine null
48+
val remaining = BlockTimeHelpers.blocksRemaining(targetHeight, currentHeight)
49+
if (remaining <= 0) return@combine null
50+
BlockTimeHelpers.getDurationForBlocks(remaining)
51+
}
52+
53+
@Suppress("LongParameterList")
3154
suspend fun createTransfer(
3255
type: TransferType,
3356
amountSats: Long,
3457
channelId: String? = null,
3558
fundingTxId: String? = null,
3659
lspOrderId: String? = null,
60+
claimableAtHeight: UInt? = null,
3761
): Result<String> = withContext(bgDispatcher) {
3862
runCatching {
3963
val id = UUID.randomUUID().toString()
@@ -47,6 +71,7 @@ class TransferRepo @Inject constructor(
4771
lspOrderId = lspOrderId,
4872
isSettled = false,
4973
createdAt = clock.now().epochSeconds,
74+
claimableAtHeight = claimableAtHeight?.toInt(),
5075
)
5176
)
5277
Logger.info("Created transfer: id=$id type=$type channelId=$channelId", context = TAG)
@@ -98,8 +123,15 @@ class TransferRepo @Inject constructor(
98123
} ?: false
99124

100125
if (!hasBalance) {
101-
markSettled(transfer.id)
102-
Logger.debug("Channel $channelId balance swept, settled transfer: ${transfer.id}", context = TAG)
126+
if (transfer.type == TransferType.FORCE_CLOSE) {
127+
settleForceClose(transfer, channelId, balances?.pendingBalancesFromChannelClosures)
128+
} else {
129+
markSettled(transfer.id)
130+
Logger.debug(
131+
"Channel $channelId balance swept, settled transfer: ${transfer.id}",
132+
context = TAG
133+
)
134+
}
103135
}
104136
}
105137
}.onSuccess {
@@ -109,6 +141,72 @@ class TransferRepo @Inject constructor(
109141
}
110142
}
111143

144+
private suspend fun settleForceClose(
145+
transfer: TransferEntity,
146+
channelId: String?,
147+
pendingSweeps: List<PendingSweepBalance>?,
148+
) {
149+
if (channelId == null) return
150+
151+
if (coreService.activity.hasOnchainActivityForChannel(channelId)) {
152+
markActivityAsTransferByChannel(channelId)
153+
markSettled(transfer.id)
154+
Logger.debug("Force close sweep detected, settled transfer: ${transfer.id}", context = TAG)
155+
return
156+
}
157+
158+
// When LDK batches sweeps from multiple channels into one transaction,
159+
// the on-chain activity may only be linked to one channel. Fall back to
160+
// checking if there are no remaining pending sweep balances for this channel.
161+
val pendingSweep = pendingSweeps?.find { it.channelId() == channelId }
162+
163+
if (pendingSweep == null) {
164+
markSettled(transfer.id)
165+
Logger.debug(
166+
"Force close sweep completed (no pending sweeps), settled transfer: ${transfer.id}",
167+
context = TAG,
168+
)
169+
return
170+
}
171+
172+
val sweepTxid = pendingSweep.latestSpendingTxid()
173+
if (sweepTxid != null && coreService.activity.hasOnchainActivityForTxid(sweepTxid)) {
174+
// The sweep tx was already synced as an on-chain activity (linked to another
175+
// channel in the same batched sweep). Safe to settle this transfer.
176+
markActivityAsTransfer(sweepTxid, channelId)
177+
markSettled(transfer.id)
178+
Logger.debug(
179+
"Force close batched sweep detected via txid $sweepTxid, settled transfer: ${transfer.id}",
180+
context = TAG,
181+
)
182+
return
183+
}
184+
185+
Logger.debug("Force close awaiting sweep detection for transfer: ${transfer.id}", context = TAG)
186+
}
187+
188+
private suspend fun markActivityAsTransfer(txid: String, channelId: String) {
189+
val activity = coreService.activity.getOnchainActivityByTxId(txid) ?: return
190+
if (activity.isTransfer) return
191+
val updated = activity.copy(isTransfer = true, channelId = channelId)
192+
coreService.activity.update(activity.id, Activity.Onchain(updated))
193+
Logger.debug("Marked activity ${activity.id} as transfer for channel $channelId", context = TAG)
194+
}
195+
196+
private suspend fun markActivityAsTransferByChannel(channelId: String) {
197+
val activities = coreService.activity.get(
198+
filter = ActivityFilter.ONCHAIN,
199+
limit = 50u,
200+
sortDirection = SortDirection.DESC,
201+
)
202+
val activity = activities.firstOrNull { it is Activity.Onchain && it.v1.channelId == channelId }
203+
as? Activity.Onchain ?: return
204+
if (activity.v1.isTransfer) return
205+
val updated = activity.v1.copy(isTransfer = true, channelId = channelId)
206+
coreService.activity.update(activity.v1.id, Activity.Onchain(updated))
207+
Logger.debug("Marked activity ${activity.v1.id} as transfer for channel $channelId", context = TAG)
208+
}
209+
112210
/** Resolve channelId: for LSP orders: via order->fundingTx match, for manual: directly. */
113211
suspend fun resolveChannelIdForTransfer(
114212
transfer: TransferEntity,

app/src/main/java/to/bitkit/repositories/WalletRepo.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class WalletRepo @Inject constructor(
5252
private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase,
5353
private val wipeWalletUseCase: WipeWalletUseCase,
5454
private val transferRepo: TransferRepo,
55+
private val activityRepo: ActivityRepo,
5556
) {
5657
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())
5758

@@ -204,6 +205,7 @@ class WalletRepo @Inject constructor(
204205
delay(EVENT_SYNC_DEBOUNCE_MS)
205206
syncBalances()
206207
transferRepo.syncTransferStates()
208+
activityRepo.syncActivities()
207209
}
208210
}
209211

0 commit comments

Comments
 (0)