Skip to content
Open
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
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