Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ android {
applicationId = "to.bitkit"
minSdk = 28
targetSdk = 36
versionCode = 17
versionCode = 162
versionName = "0.0.17"
testInstrumentationRunner = "to.bitkit.test.HiltTestRunner"
vectorDrawables {
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,18 @@ internal object Env {
const val BITREFILL_APP = "Bitkit"
const val BITREFILL_REF = "AL6dyZYt"

val rnBackupServerHost: String
get() = when (network) {
Network.BITCOIN -> "https://blocktank.synonym.to/backups-ldk"
else -> "https://bitkit.stag0.blocktank.to/backups-ldk"
}

val rnBackupServerPubKey: String
get() = when (network) {
Network.BITCOIN -> "0236efd76e37f96cf2dced9d52ff84c97e5b3d4a75e7d494807291971783f38377"
else -> "02c03b8b8c1b5500b622646867d99bf91676fac0f38e2182c91a9ff0d053a21d6d"
}

// endregion
}

Expand Down
22 changes: 17 additions & 5 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,14 @@ class ActivityRepo @Inject constructor(
/**
* Syncs `ldk-node` [PaymentDetails] list to `bitkit-core` [Activity] items.
*/
private suspend fun syncLdkNodePayments(payments: List<PaymentDetails>): Result<Unit> = runCatching {
val channelIdsByTxId = findChannelsForPayments(payments)
coreService.activity.syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = channelIdsByTxId)
}.onFailure { e ->
Logger.error("Error syncing LDK payments:", e, context = TAG)
suspend fun syncLdkNodePayments(payments: List<PaymentDetails>): Result<Unit> = withContext(bgDispatcher) {
return@withContext runCatching {
val channelIdsByTxId = findChannelsForPayments(payments)
coreService.activity.syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = channelIdsByTxId)
notifyActivitiesChanged()
}.onFailure { e ->
Logger.error("Error syncing LDK payments:", e, context = TAG)
}
}

private suspend fun findChannelsForPayments(
Expand Down Expand Up @@ -666,6 +669,15 @@ class ActivityRepo @Inject constructor(
}
}

suspend fun markAllUnseenActivitiesAsSeen(): Result<Unit> = withContext(bgDispatcher) {
return@withContext runCatching {
coreService.activity.markAllUnseenActivitiesAsSeen()
notifyActivitiesChanged()
}.onFailure { e ->
Logger.error("Failed to mark all activities as seen: $e", e, context = TAG)
}
}

// MARK: - Development/Testing Methods

/**
Expand Down
37 changes: 37 additions & 0 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -21,6 +23,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import to.bitkit.R
import to.bitkit.data.AppDb
import to.bitkit.data.CacheStore
Expand Down Expand Up @@ -49,6 +53,7 @@ import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime

/**
Expand Down Expand Up @@ -536,6 +541,37 @@ class BackupRepo @Inject constructor(
}
}

suspend fun getLatestBackupTime(): ULong? = withContext(ioDispatcher) {
runCatching {
withTimeout(VSS_TIMESTAMP_TIMEOUT) {
vssBackupClient.setup()
coroutineScope {
BackupCategory.entries
.filter { it != BackupCategory.LIGHTNING_CONNECTIONS }
.map { category -> async { getRemoteBackupTimestamp(category) } }
.mapNotNull { it.await() }
.filter { it > 0uL }
.maxOrNull()
}
}
}.onFailure { e ->
Logger.warn("Failed to get VSS backup timestamp: $e", context = TAG)
}.getOrNull()
}

private suspend fun getRemoteBackupTimestamp(category: BackupCategory): ULong? {
val item = vssBackupClient.getObject(category.name).getOrNull() ?: return null
val data = item.value ?: return null

@Serializable
data class BackupWithCreatedAt(val createdAt: Long? = null)

return runCatching {
val millis = json.decodeFromString<BackupWithCreatedAt>(String(data)).createdAt ?: return@runCatching null
(millis / 1000).toULong()
}.getOrNull()
}

fun scheduleFullBackup() {
Logger.debug("Scheduling backups for all categories", context = TAG)
BackupCategory.entries
Expand Down Expand Up @@ -578,5 +614,6 @@ class BackupRepo @Inject constructor(
private const val FAILED_BACKUP_CHECK_TIME = 30 * 60 * 1000L // 30 minutes
private const val FAILED_BACKUP_NOTIFICATION_INTERVAL = 10 * 60 * 1000L // 10 minutes
private const val SYNC_STATUS_DEBOUNCE = 500L // 500ms debounce for sync status updates
private val VSS_TIMESTAMP_TIMEOUT = 60.seconds
}
}
21 changes: 19 additions & 2 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import org.lightningdevkit.ldknode.Address
import org.lightningdevkit.ldknode.BalanceDetails
import org.lightningdevkit.ldknode.BestBlock
import org.lightningdevkit.ldknode.ChannelConfig
import org.lightningdevkit.ldknode.ChannelDataMigration
import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.ClosureReason
import org.lightningdevkit.ldknode.Event
Expand Down Expand Up @@ -167,10 +168,11 @@ class LightningRepo @Inject constructor(
walletIndex: Int,
customServerUrl: String? = null,
customRgsServerUrl: String? = null,
channelMigration: ChannelDataMigration? = null,
) = withContext(bgDispatcher) {
return@withContext try {
val trustedPeers = getTrustedPeersFromBlocktank()
lightningService.setup(walletIndex, customServerUrl, customRgsServerUrl, trustedPeers)
lightningService.setup(walletIndex, customServerUrl, customRgsServerUrl, trustedPeers, channelMigration)
Result.success(Unit)
} catch (e: Throwable) {
Logger.error("Node setup error", e, context = TAG)
Expand All @@ -196,6 +198,7 @@ class LightningRepo @Inject constructor(
customServerUrl: String? = null,
customRgsServerUrl: String? = null,
eventHandler: NodeEventHandler? = null,
channelMigration: ChannelDataMigration? = null,
): Result<Unit> = withContext(bgDispatcher) {
if (_isRecoveryMode.value) {
return@withContext Result.failure(RecoveryModeException())
Expand All @@ -214,7 +217,7 @@ class LightningRepo @Inject constructor(

// Setup if needed
if (lightningService.node == null) {
val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl)
val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration)
if (setupResult.isFailure) {
_lightningState.update {
it.copy(
Expand Down Expand Up @@ -264,6 +267,7 @@ class LightningRepo @Inject constructor(
shouldRetry = false,
customServerUrl = customServerUrl,
customRgsServerUrl = customRgsServerUrl,
channelMigration = channelMigration,
)
} else {
Logger.error("Node start error", e, context = TAG)
Expand Down Expand Up @@ -311,6 +315,19 @@ class LightningRepo @Inject constructor(
}
}

suspend fun restart(): Result<Unit> = withContext(bgDispatcher) {
stop().onFailure {
Logger.error("Failed to stop node during restart", it, context = TAG)
return@withContext Result.failure(it)
}
delay(500)
start(shouldRetry = false).onFailure {
Logger.error("Failed to start node during restart", it, context = TAG)
return@withContext Result.failure(it)
}
Result.success(Unit)
}

suspend fun sync(): Result<Unit> = executeWhenNodeRunning("sync") {
// If sync is in progress, mark pending and skip
if (!syncMutex.tryLock()) {
Expand Down
51 changes: 42 additions & 9 deletions app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -586,19 +586,15 @@ class ActivityService(
timestamp: ULong,
): ConfirmationData {
var isConfirmed = false
var confirmedTimestamp: ULong? = null
var blockTimestamp: ULong? = null

val status = kind.status
if (status is ConfirmationStatus.Confirmed) {
isConfirmed = true
confirmedTimestamp = status.timestamp
blockTimestamp = status.timestamp
}

if (isConfirmed && confirmedTimestamp != null && confirmedTimestamp < timestamp) {
confirmedTimestamp = timestamp
}

return ConfirmationData(isConfirmed, confirmedTimestamp, timestamp)
return ConfirmationData(isConfirmed, blockTimestamp, timestamp)
}

private fun buildUpdatedOnchainActivity(
Expand Down Expand Up @@ -636,6 +632,14 @@ class ActivityService(
channelId: String? = null,
): OnchainActivity {
val isTransfer = channelId != null
val paymentTimestamp = confirmationData.timestamp
val blockTimestamp = confirmationData.confirmedTimestamp

val activityTimestamp = if (blockTimestamp != null && blockTimestamp < paymentTimestamp) {
blockTimestamp
} else {
paymentTimestamp
}

return OnchainActivity.create(
id = payment.id,
Expand All @@ -644,10 +648,10 @@ class ActivityService(
value = payment.amountSats ?: 0u,
fee = (payment.feePaidMsat ?: 0u) / 1000u,
address = resolvedAddress ?: "Loading...",
timestamp = confirmationData.timestamp,
timestamp = activityTimestamp,
confirmed = confirmationData.isConfirmed,
isTransfer = isTransfer,
confirmTimestamp = confirmationData.confirmedTimestamp,
confirmTimestamp = blockTimestamp,
channelId = channelId,
seenAt = null,
)
Expand Down Expand Up @@ -1112,6 +1116,35 @@ class ActivityService(
markActivityAsSeen(activity.id, seenAt)
}

suspend fun markAllUnseenActivitiesAsSeen() = ServiceQueue.CORE.background {
val timestamp = (System.currentTimeMillis() / 1000).toULong()
val activities = getActivities(
filter = ActivityFilter.ALL,
txType = null,
tags = null,
search = null,
minDate = null,
maxDate = null,
limit = null,
sortDirection = null,
)

for (activity in activities) {
val isSeen = when (activity) {
is Activity.Onchain -> activity.v1.seenAt != null
is Activity.Lightning -> activity.v1.seenAt != null
}

if (!isSeen) {
val activityId = when (activity) {
is Activity.Onchain -> activity.v1.id
is Activity.Lightning -> activity.v1.id
}
markActivityAsSeen(activityId, timestamp)
}
}
}

suspend fun getBoostTxDoesExist(boostTxIds: List<String>): Map<String, Boolean> {
return ServiceQueue.CORE.background {
val doesExistMap = mutableMapOf<String, Boolean>()
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import org.lightningdevkit.ldknode.Bolt11InvoiceDescription
import org.lightningdevkit.ldknode.BuildException
import org.lightningdevkit.ldknode.Builder
import org.lightningdevkit.ldknode.ChannelConfig
import org.lightningdevkit.ldknode.ChannelDataMigration
import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.CoinSelectionAlgorithm
import org.lightningdevkit.ldknode.Config
Expand Down Expand Up @@ -79,6 +80,7 @@ class LightningService @Inject constructor(
customServerUrl: String? = null,
customRgsServerUrl: String? = null,
trustedPeers: List<PeerDetails>? = null,
channelMigration: ChannelDataMigration? = null,
) {
Logger.debug("Building node…")

Expand All @@ -88,6 +90,7 @@ class LightningService @Inject constructor(
customServerUrl,
customRgsServerUrl,
config,
channelMigration,
)

Logger.info("LDK node setup")
Expand Down Expand Up @@ -123,11 +126,21 @@ class LightningService @Inject constructor(
customServerUrl: String?,
customRgsServerUrl: String?,
config: Config,
channelMigration: ChannelDataMigration? = null,
): Node = ServiceQueue.LDK.background {
val builder = Builder.fromConfig(config).apply {
setCustomLogger(LdkLogWriter())
configureChainSource(customServerUrl)
configureGossipSource(customRgsServerUrl)

if (channelMigration != null) {
setChannelDataMigration(channelMigration)
Logger.info(
"Applied channel migration: ${channelMigration.channelMonitors.size} monitors",
context = "Migration"
)
}

setEntropyBip39Mnemonic(
mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound,
passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name),
Expand Down
Loading
Loading