Skip to content

Commit 306ee77

Browse files
authored
Merge pull request #561 from synonymdev/feat/rn-migration
feat: support RN app migration
2 parents 3444b07 + 6b81ff3 commit 306ee77

24 files changed

Lines changed: 2811 additions & 181 deletions

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ android {
4646
applicationId = "to.bitkit"
4747
minSdk = 28
4848
targetSdk = 36
49-
versionCode = 17
49+
versionCode = 162
5050
versionName = "0.0.17"
5151
testInstrumentationRunner = "to.bitkit.test.HiltTestRunner"
5252
vectorDrawables {

app/src/main/java/to/bitkit/env/Env.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,18 @@ internal object Env {
188188
const val BITREFILL_APP = "Bitkit"
189189
const val BITREFILL_REF = "AL6dyZYt"
190190

191+
val rnBackupServerHost: String
192+
get() = when (network) {
193+
Network.BITCOIN -> "https://blocktank.synonym.to/backups-ldk"
194+
else -> "https://bitkit.stag0.blocktank.to/backups-ldk"
195+
}
196+
197+
val rnBackupServerPubKey: String
198+
get() = when (network) {
199+
Network.BITCOIN -> "0236efd76e37f96cf2dced9d52ff84c97e5b3d4a75e7d494807291971783f38377"
200+
else -> "02c03b8b8c1b5500b622646867d99bf91676fac0f38e2182c91a9ff0d053a21d6d"
201+
}
202+
191203
// endregion
192204
}
193205

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,14 @@ class ActivityRepo @Inject constructor(
110110
/**
111111
* Syncs `ldk-node` [PaymentDetails] list to `bitkit-core` [Activity] items.
112112
*/
113-
private suspend fun syncLdkNodePayments(payments: List<PaymentDetails>): Result<Unit> = runCatching {
114-
val channelIdsByTxId = findChannelsForPayments(payments)
115-
coreService.activity.syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = channelIdsByTxId)
116-
}.onFailure { e ->
117-
Logger.error("Error syncing LDK payments:", e, context = TAG)
113+
suspend fun syncLdkNodePayments(payments: List<PaymentDetails>): Result<Unit> = withContext(bgDispatcher) {
114+
return@withContext runCatching {
115+
val channelIdsByTxId = findChannelsForPayments(payments)
116+
coreService.activity.syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = channelIdsByTxId)
117+
notifyActivitiesChanged()
118+
}.onFailure { e ->
119+
Logger.error("Error syncing LDK payments:", e, context = TAG)
120+
}
118121
}
119122

120123
private suspend fun findChannelsForPayments(
@@ -666,6 +669,15 @@ class ActivityRepo @Inject constructor(
666669
}
667670
}
668671

672+
suspend fun markAllUnseenActivitiesAsSeen(): Result<Unit> = withContext(bgDispatcher) {
673+
return@withContext runCatching {
674+
coreService.activity.markAllUnseenActivitiesAsSeen()
675+
notifyActivitiesChanged()
676+
}.onFailure { e ->
677+
Logger.error("Failed to mark all activities as seen: $e", e, context = TAG)
678+
}
679+
}
680+
669681
// MARK: - Development/Testing Methods
670682

671683
/**

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import kotlinx.coroutines.CoroutineScope
77
import kotlinx.coroutines.FlowPreview
88
import kotlinx.coroutines.Job
99
import kotlinx.coroutines.SupervisorJob
10+
import kotlinx.coroutines.async
11+
import kotlinx.coroutines.coroutineScope
1012
import kotlinx.coroutines.currentCoroutineContext
1113
import kotlinx.coroutines.delay
1214
import kotlinx.coroutines.flow.MutableStateFlow
@@ -21,6 +23,8 @@ import kotlinx.coroutines.flow.update
2123
import kotlinx.coroutines.isActive
2224
import kotlinx.coroutines.launch
2325
import kotlinx.coroutines.withContext
26+
import kotlinx.coroutines.withTimeout
27+
import kotlinx.serialization.Serializable
2428
import to.bitkit.R
2529
import to.bitkit.data.AppDb
2630
import to.bitkit.data.CacheStore
@@ -49,6 +53,7 @@ import java.util.concurrent.ConcurrentHashMap
4953
import javax.inject.Inject
5054
import javax.inject.Singleton
5155
import kotlin.time.Clock
56+
import kotlin.time.Duration.Companion.seconds
5257
import kotlin.time.ExperimentalTime
5358

5459
/**
@@ -536,6 +541,37 @@ class BackupRepo @Inject constructor(
536541
}
537542
}
538543

544+
suspend fun getLatestBackupTime(): ULong? = withContext(ioDispatcher) {
545+
runCatching {
546+
withTimeout(VSS_TIMESTAMP_TIMEOUT) {
547+
vssBackupClient.setup()
548+
coroutineScope {
549+
BackupCategory.entries
550+
.filter { it != BackupCategory.LIGHTNING_CONNECTIONS }
551+
.map { category -> async { getRemoteBackupTimestamp(category) } }
552+
.mapNotNull { it.await() }
553+
.filter { it > 0uL }
554+
.maxOrNull()
555+
}
556+
}
557+
}.onFailure { e ->
558+
Logger.warn("Failed to get VSS backup timestamp: $e", context = TAG)
559+
}.getOrNull()
560+
}
561+
562+
private suspend fun getRemoteBackupTimestamp(category: BackupCategory): ULong? {
563+
val item = vssBackupClient.getObject(category.name).getOrNull() ?: return null
564+
val data = item.value ?: return null
565+
566+
@Serializable
567+
data class BackupWithCreatedAt(val createdAt: Long? = null)
568+
569+
return runCatching {
570+
val millis = json.decodeFromString<BackupWithCreatedAt>(String(data)).createdAt ?: return@runCatching null
571+
(millis / 1000).toULong()
572+
}.getOrNull()
573+
}
574+
539575
fun scheduleFullBackup() {
540576
Logger.debug("Scheduling backups for all categories", context = TAG)
541577
BackupCategory.entries
@@ -578,5 +614,6 @@ class BackupRepo @Inject constructor(
578614
private const val FAILED_BACKUP_CHECK_TIME = 30 * 60 * 1000L // 30 minutes
579615
private const val FAILED_BACKUP_NOTIFICATION_INTERVAL = 10 * 60 * 1000L // 10 minutes
580616
private const val SYNC_STATUS_DEBOUNCE = 500L // 500ms debounce for sync status updates
617+
private val VSS_TIMESTAMP_TIMEOUT = 60.seconds
581618
}
582619
}

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import org.lightningdevkit.ldknode.Address
2929
import org.lightningdevkit.ldknode.BalanceDetails
3030
import org.lightningdevkit.ldknode.BestBlock
3131
import org.lightningdevkit.ldknode.ChannelConfig
32+
import org.lightningdevkit.ldknode.ChannelDataMigration
3233
import org.lightningdevkit.ldknode.ChannelDetails
3334
import org.lightningdevkit.ldknode.ClosureReason
3435
import org.lightningdevkit.ldknode.Event
@@ -167,10 +168,11 @@ class LightningRepo @Inject constructor(
167168
walletIndex: Int,
168169
customServerUrl: String? = null,
169170
customRgsServerUrl: String? = null,
171+
channelMigration: ChannelDataMigration? = null,
170172
) = withContext(bgDispatcher) {
171173
return@withContext try {
172174
val trustedPeers = getTrustedPeersFromBlocktank()
173-
lightningService.setup(walletIndex, customServerUrl, customRgsServerUrl, trustedPeers)
175+
lightningService.setup(walletIndex, customServerUrl, customRgsServerUrl, trustedPeers, channelMigration)
174176
Result.success(Unit)
175177
} catch (e: Throwable) {
176178
Logger.error("Node setup error", e, context = TAG)
@@ -196,6 +198,7 @@ class LightningRepo @Inject constructor(
196198
customServerUrl: String? = null,
197199
customRgsServerUrl: String? = null,
198200
eventHandler: NodeEventHandler? = null,
201+
channelMigration: ChannelDataMigration? = null,
199202
): Result<Unit> = withContext(bgDispatcher) {
200203
if (_isRecoveryMode.value) {
201204
return@withContext Result.failure(RecoveryModeException())
@@ -214,7 +217,7 @@ class LightningRepo @Inject constructor(
214217

215218
// Setup if needed
216219
if (lightningService.node == null) {
217-
val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl)
220+
val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration)
218221
if (setupResult.isFailure) {
219222
_lightningState.update {
220223
it.copy(
@@ -264,6 +267,7 @@ class LightningRepo @Inject constructor(
264267
shouldRetry = false,
265268
customServerUrl = customServerUrl,
266269
customRgsServerUrl = customRgsServerUrl,
270+
channelMigration = channelMigration,
267271
)
268272
} else {
269273
Logger.error("Node start error", e, context = TAG)
@@ -311,6 +315,19 @@ class LightningRepo @Inject constructor(
311315
}
312316
}
313317

318+
suspend fun restart(): Result<Unit> = withContext(bgDispatcher) {
319+
stop().onFailure {
320+
Logger.error("Failed to stop node during restart", it, context = TAG)
321+
return@withContext Result.failure(it)
322+
}
323+
delay(500)
324+
start(shouldRetry = false).onFailure {
325+
Logger.error("Failed to start node during restart", it, context = TAG)
326+
return@withContext Result.failure(it)
327+
}
328+
Result.success(Unit)
329+
}
330+
314331
suspend fun sync(): Result<Unit> = executeWhenNodeRunning("sync") {
315332
// If sync is in progress, mark pending and skip
316333
if (!syncMutex.tryLock()) {

app/src/main/java/to/bitkit/services/CoreService.kt

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -586,19 +586,15 @@ class ActivityService(
586586
timestamp: ULong,
587587
): ConfirmationData {
588588
var isConfirmed = false
589-
var confirmedTimestamp: ULong? = null
589+
var blockTimestamp: ULong? = null
590590

591591
val status = kind.status
592592
if (status is ConfirmationStatus.Confirmed) {
593593
isConfirmed = true
594-
confirmedTimestamp = status.timestamp
594+
blockTimestamp = status.timestamp
595595
}
596596

597-
if (isConfirmed && confirmedTimestamp != null && confirmedTimestamp < timestamp) {
598-
confirmedTimestamp = timestamp
599-
}
600-
601-
return ConfirmationData(isConfirmed, confirmedTimestamp, timestamp)
597+
return ConfirmationData(isConfirmed, blockTimestamp, timestamp)
602598
}
603599

604600
private fun buildUpdatedOnchainActivity(
@@ -636,6 +632,14 @@ class ActivityService(
636632
channelId: String? = null,
637633
): OnchainActivity {
638634
val isTransfer = channelId != null
635+
val paymentTimestamp = confirmationData.timestamp
636+
val blockTimestamp = confirmationData.confirmedTimestamp
637+
638+
val activityTimestamp = if (blockTimestamp != null && blockTimestamp < paymentTimestamp) {
639+
blockTimestamp
640+
} else {
641+
paymentTimestamp
642+
}
639643

640644
return OnchainActivity.create(
641645
id = payment.id,
@@ -644,10 +648,10 @@ class ActivityService(
644648
value = payment.amountSats ?: 0u,
645649
fee = (payment.feePaidMsat ?: 0u) / 1000u,
646650
address = resolvedAddress ?: "Loading...",
647-
timestamp = confirmationData.timestamp,
651+
timestamp = activityTimestamp,
648652
confirmed = confirmationData.isConfirmed,
649653
isTransfer = isTransfer,
650-
confirmTimestamp = confirmationData.confirmedTimestamp,
654+
confirmTimestamp = blockTimestamp,
651655
channelId = channelId,
652656
seenAt = null,
653657
)
@@ -1112,13 +1116,42 @@ class ActivityService(
11121116
markActivityAsSeen(activity.id, seenAt)
11131117
}
11141118

1119+
suspend fun markAllUnseenActivitiesAsSeen() = ServiceQueue.CORE.background {
1120+
val timestamp = (System.currentTimeMillis() / 1000).toULong()
1121+
val activities = getActivities(
1122+
filter = ActivityFilter.ALL,
1123+
txType = null,
1124+
tags = null,
1125+
search = null,
1126+
minDate = null,
1127+
maxDate = null,
1128+
limit = null,
1129+
sortDirection = null,
1130+
)
1131+
1132+
for (activity in activities) {
1133+
val isSeen = when (activity) {
1134+
is Activity.Onchain -> activity.v1.seenAt != null
1135+
is Activity.Lightning -> activity.v1.seenAt != null
1136+
}
1137+
1138+
if (!isSeen) {
1139+
val activityId = when (activity) {
1140+
is Activity.Onchain -> activity.v1.id
1141+
is Activity.Lightning -> activity.v1.id
1142+
}
1143+
markActivityAsSeen(activityId, timestamp)
1144+
}
1145+
}
1146+
}
1147+
11151148
suspend fun getBoostTxDoesExist(boostTxIds: List<String>): Map<String, Boolean> {
11161149
return ServiceQueue.CORE.background {
11171150
val doesExistMap = mutableMapOf<String, Boolean>()
11181151
for (boostTxId in boostTxIds) {
11191152
val boostActivity = getOnchainActivityByTxId(boostTxId)
11201153
if (boostActivity != null) {
1121-
doesExistMap[boostTxId] = boostActivity.doesExist
1154+
doesExistMap[boostTxId] = boostActivity.doesExist && !boostActivity.isBoosted
11221155
}
11231156
}
11241157
return@background doesExistMap

app/src/main/java/to/bitkit/services/LightningService.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import org.lightningdevkit.ldknode.Bolt11InvoiceDescription
1818
import org.lightningdevkit.ldknode.BuildException
1919
import org.lightningdevkit.ldknode.Builder
2020
import org.lightningdevkit.ldknode.ChannelConfig
21+
import org.lightningdevkit.ldknode.ChannelDataMigration
2122
import org.lightningdevkit.ldknode.ChannelDetails
2223
import org.lightningdevkit.ldknode.CoinSelectionAlgorithm
2324
import org.lightningdevkit.ldknode.Config
@@ -79,6 +80,7 @@ class LightningService @Inject constructor(
7980
customServerUrl: String? = null,
8081
customRgsServerUrl: String? = null,
8182
trustedPeers: List<PeerDetails>? = null,
83+
channelMigration: ChannelDataMigration? = null,
8284
) {
8385
Logger.debug("Building node…")
8486

@@ -88,6 +90,7 @@ class LightningService @Inject constructor(
8890
customServerUrl,
8991
customRgsServerUrl,
9092
config,
93+
channelMigration,
9194
)
9295

9396
Logger.info("LDK node setup")
@@ -123,11 +126,21 @@ class LightningService @Inject constructor(
123126
customServerUrl: String?,
124127
customRgsServerUrl: String?,
125128
config: Config,
129+
channelMigration: ChannelDataMigration? = null,
126130
): Node = ServiceQueue.LDK.background {
127131
val builder = Builder.fromConfig(config).apply {
128132
setCustomLogger(LdkLogWriter())
129133
configureChainSource(customServerUrl)
130134
configureGossipSource(customRgsServerUrl)
135+
136+
if (channelMigration != null) {
137+
setChannelDataMigration(channelMigration)
138+
Logger.info(
139+
"Applied channel migration: ${channelMigration.channelMonitors.size} monitors",
140+
context = "Migration"
141+
)
142+
}
143+
131144
setEntropyBip39Mnemonic(
132145
mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound,
133146
passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name),

0 commit comments

Comments
 (0)