diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index af71e2bcb..4b1481efa 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -1,5 +1,6 @@ package to.bitkit.env +import org.lightningdevkit.ldknode.LogLevel import org.lightningdevkit.ldknode.Network import to.bitkit.BuildConfig import to.bitkit.ext.ensureDir @@ -16,7 +17,6 @@ internal object Env { val defaultWalletWordCount = 12 val walletSyncIntervalSecs = 10_uL // TODO review val ldkNodeSyncIntervalSecs = 60_uL // TODO review - val esploraParallelRequests = 6 val trustedLnPeers get() = when (network) { Network.REGTEST -> listOf( @@ -83,6 +83,14 @@ internal object Env { appStoragePath = path } + fun ldkLogFilePath(walletIndex: Int): String { + val logPath = Path(ldkStoragePath(walletIndex), "ldk_node_latest.log").toFile().absolutePath + Logger.info("LDK-node log path: $logPath") + return logPath + } + + val ldkLogLevel = LogLevel.TRACE + fun ldkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ldk") fun bitkitCoreStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "core") diff --git a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt index f13c716fb..e02e50714 100644 --- a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt +++ b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt @@ -55,5 +55,8 @@ fun mockChannelDetails( forceCloseAvoidanceMaxFeeSatoshis = 0uL, acceptUnderpayingHtlcs = false, ), + shortChannelId = 1234uL, + outboundScidAlias = 2345uL, + inboundScidAlias = 3456uL, ) } diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 25fb13bd0..8b5376b7d 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -170,6 +170,7 @@ class WakeNodeWorker @AssistedInject constructor( is Event.PaymentSuccessful -> Unit is Event.PaymentClaimable -> Unit + is Event.PaymentForwarded -> Unit is Event.PaymentFailed -> { self.bestAttemptContent?.title = "Payment failed" diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index a501a1650..45c1f543d 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -3,9 +3,11 @@ package to.bitkit.services import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.http.HttpStatusCode +import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus import to.bitkit.async.ServiceQueue import to.bitkit.env.Env @@ -46,6 +48,7 @@ import uniffi.bitkitcore.openChannel import uniffi.bitkitcore.removeTags import uniffi.bitkitcore.updateActivity import uniffi.bitkitcore.updateBlocktankUrl +import uniffi.bitkitcore.upsertActivity import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @@ -164,47 +167,110 @@ class ActivityService( ServiceQueue.CORE.background { var addedCount = 0 var updatedCount = 0 - - for (payment in payments) { // Skip pending inbound payments, just means they created an invoice - if (payment.status == PaymentStatus.PENDING && payment.direction == PaymentDirection.INBOUND) { - continue - } - - val state = when (payment.status) { - PaymentStatus.FAILED -> PaymentState.FAILED - PaymentStatus.PENDING -> PaymentState.PENDING - PaymentStatus.SUCCEEDED -> PaymentState.SUCCEEDED + var latestCaughtError: Throwable? = null + + for (payment in payments) { + try { + val state = when (payment.status) { + PaymentStatus.FAILED -> PaymentState.FAILED + PaymentStatus.PENDING -> PaymentState.PENDING + PaymentStatus.SUCCEEDED -> PaymentState.SUCCEEDED + } + + when (val kind = payment.kind) { + is PaymentKind.Onchain -> { + var isConfirmed = false + var confirmedTimestamp: ULong? = null + + val status = kind.status + if (status is ConfirmationStatus.Confirmed) { + isConfirmed = true + confirmedTimestamp = status.timestamp + } + + // Ensure confirmTimestamp is at least equal to timestamp when confirmed + val timestamp = payment.latestUpdateTimestamp + + if (isConfirmed && confirmedTimestamp != null && confirmedTimestamp < timestamp) { + confirmedTimestamp = timestamp + } + + val onchain = OnchainActivity( + id = payment.id, + txType = payment.direction.toPaymentType(), + txId = kind.txid, + value = payment.amountSats ?: 0u, + fee = (payment.feePaidMsat ?: 0u) / 1000u, + feeRate = 1u, // TODO: get from somewhere + address = "todo_find_address", // TODO: find address + confirmed = isConfirmed, + timestamp = timestamp, + isBoosted = false, // TODO: handle + isTransfer = false, // TODO: handle when paying for order + doesExist = true, + confirmTimestamp = confirmedTimestamp, + channelId = null, // TODO: get from linked order + transferTxId = null, // TODO: get from linked order + createdAt = timestamp, + updatedAt = timestamp, + ) + + if (getActivityById(payment.id) != null) { + updateActivity(payment.id, Activity.Onchain(onchain)) + updatedCount++ + } else { + upsertActivity(Activity.Onchain(onchain)) + addedCount++ + } + } + + is PaymentKind.Bolt11 -> { + // Skip pending inbound payments, just means they created an invoice + if (payment.status == PaymentStatus.PENDING && payment.direction == PaymentDirection.INBOUND) { + continue + } + + val ln = LightningActivity( + id = payment.id, + txType = payment.direction.toPaymentType(), + status = state, + value = payment.amountSats ?: 0u, + fee = null, // TODO + invoice = "lnbc123", // TODO + message = "", + timestamp = payment.latestUpdateTimestamp, + preimage = null, + createdAt = payment.latestUpdateTimestamp, + updatedAt = payment.latestUpdateTimestamp, + ) + + if (getActivityById(payment.id) != null) { + updateActivity(payment.id, Activity.Lightning(ln)) + updatedCount++ + } else { + upsertActivity(Activity.Lightning(ln)) + addedCount++ + } + } + + else -> Unit // Handle spontaneous payments if needed + } + } catch (e: Throwable) { + Logger.error("Error syncing LDK payment:", e, context = "CoreService") + latestCaughtError = e } - - val ln = LightningActivity( - id = payment.id, - txType = if (payment.direction == PaymentDirection.OUTBOUND) PaymentType.SENT else PaymentType.RECEIVED, - status = state, - value = payment.amountSats ?: 0u, - fee = null, // TODO - invoice = "lnbc123", // TODO - message = "", - timestamp = payment.latestUpdateTimestamp, - preimage = null, - createdAt = payment.latestUpdateTimestamp, - updatedAt = payment.latestUpdateTimestamp, - ) - - if (getActivityById(payment.id) != null) { - updateActivity(payment.id, Activity.Lightning(ln)) - updatedCount++ - } else { - insertActivity(Activity.Lightning(ln)) - addedCount++ - } - - // TODO: handle onchain activity when it comes in ldk-node } - Logger.info("Synced LDK payments - Added: $addedCount, Updated: $updatedCount") + // If any of the inserts failed, we want to throw the error up + latestCaughtError?.let { throw it } + + Logger.info("Synced LDK payments - Added: $addedCount - Updated: $updatedCount", context = "CoreService") } } + private fun PaymentDirection.toPaymentType(): PaymentType = + if (this == PaymentDirection.OUTBOUND) PaymentType.SENT else PaymentType.RECEIVED + suspend fun getActivity(id: String): Activity? { return ServiceQueue.CORE.background { getActivityById(id) @@ -240,7 +306,7 @@ class ActivityService( // MARK: - Tag Methods - suspend fun appendTags(toActivityId: String, tags: List) : Result{ + suspend fun appendTags(toActivityId: String, tags: List): Result { return try { ServiceQueue.CORE.background { addTags(toActivityId, tags) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 7676e9405..02a5f2e6f 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -11,14 +11,16 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.AnchorChannelsConfig +import org.lightningdevkit.ldknode.BackgroundSyncConfig import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.Bolt11InvoiceDescription import org.lightningdevkit.ldknode.BuildException import org.lightningdevkit.ldknode.Builder import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.EsploraSyncConfig import org.lightningdevkit.ldknode.Event -import org.lightningdevkit.ldknode.LogLevel +import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException @@ -68,9 +70,7 @@ class LightningService @Inject constructor( .fromConfig( defaultConfig().apply { storageDirPath = dirPath - logDirPath = dirPath network = Env.network - logLevel = LogLevel.TRACE trustedPeers0conf = Env.trustedLnPeers.map { it.nodeId } anchorChannelsConfig = AnchorChannelsConfig( @@ -79,12 +79,15 @@ class LightningService @Inject constructor( ) }) .apply { + setFilesystemLogger(Env.ldkLogFilePath(walletIndex), Env.ldkLogLevel) setChainSourceEsplora( serverUrl = Env.esploraServerUrl, config = EsploraSyncConfig( - onchainWalletSyncIntervalSecs = Env.walletSyncIntervalSecs, - lightningWalletSyncIntervalSecs = Env.walletSyncIntervalSecs, - feeRateCacheUpdateIntervalSecs = Env.walletSyncIntervalSecs, + BackgroundSyncConfig( + onchainWalletSyncIntervalSecs = Env.walletSyncIntervalSecs, + lightningWalletSyncIntervalSecs = Env.walletSyncIntervalSecs, + feeRateCacheUpdateIntervalSecs = Env.walletSyncIntervalSecs, + ), ), ) if (Env.ldkRgsServerUrl != null) { @@ -266,7 +269,7 @@ class LightningService @Inject constructor( peer: LnPeer, channelAmountSats: ULong, pushToCounterpartySats: ULong? = null, - ) : Result { + ): Result { val node = this.node ?: throw ServiceError.NodeNotSetup return ServiceQueue.LDK.background { @@ -311,10 +314,23 @@ class LightningService @Inject constructor( return ServiceQueue.LDK.background { if (sat != null) { Logger.debug("Creating bolt11 for $sat sats") - node.bolt11Payment().receive(sat.millis, description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, expirySecs) + node.bolt11Payment() + .receive( + amountMsat = sat.millis, + description = Bolt11InvoiceDescription.Direct( + description = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE } + ), + expirySecs = expirySecs, + ) } else { Logger.debug("Creating bolt11 for variable amount") - node.bolt11Payment().receiveVariableAmount(description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, expirySecs) + node.bolt11Payment() + .receiveVariableAmount( + description = Bolt11InvoiceDescription.Direct( + description = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE } + ), + expirySecs = expirySecs, + ) } } } @@ -338,13 +354,18 @@ class LightningService @Inject constructor( return true } - suspend fun send(address: Address, sats: ULong): Txid { + //TODO: get feeRate from real source + suspend fun send(address: Address, sats: ULong, satKwu: ULong = 250uL * 5uL): Txid { val node = this.node ?: throw ServiceError.NodeNotSetup Logger.info("Sending $sats sats to $address") return ServiceQueue.LDK.background { - node.onchainPayment().sendToAddress(address, sats) + node.onchainPayment().sendToAddress( + address = address, + amountSats = sats, + feeRate = FeeRate.fromSatPerKwu(satKwu) + ) } } @@ -373,8 +394,12 @@ class LightningService @Inject constructor( } val event = node.nextEventAsync() - Logger.debug("LDK eventHandled: $event") - node.eventHandled() + try { + node.eventHandled() + Logger.debug("LDK eventHandled: $event") + } catch (e: NodeException) { + Logger.error("LDK eventHandled error", LdkError(e)) + } logEvent(event) onEvent?.invoke(event) @@ -411,6 +436,8 @@ class LightningService @Inject constructor( Logger.info("🫰 Payment claimable: paymentId: $paymentId paymentHash: $paymentHash claimableAmountMsat: $claimableAmountMsat") } + is Event.PaymentForwarded -> Unit + is Event.ChannelPending -> { val channelId = event.channelId val userChannelId = event.userChannelId diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 1e874e42e..0a58284de 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -113,6 +113,6 @@ class MigrationService @Inject constructor( private const val KEY = "key" private const val VALUE = "value" private const val LDK_DB_NAME = "$LDK_NODE_DATA.sqlite" - private const val LDK_DB_VERSION = 2 + private const val LDK_DB_VERSION = 2 // Should match SCHEMA_USER_VERSION from ldk-node } } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index b21491b49..eaa08cddb 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -122,7 +122,6 @@ fun ContentView( Lifecycle.Event.ON_START -> { try { walletViewModel.start() - activityListViewModel.syncLdkNodePayments() } catch (e: Throwable) { Logger.error("Failed to start wallet", e) } @@ -222,6 +221,11 @@ fun ContentView( val balance by walletViewModel.balanceState.collectAsStateWithLifecycle() val currencies by currencyViewModel.uiState.collectAsState() + LaunchedEffect(balance) { + // Anytime we receive a balance update, we should sync the payments to activity list + activityListViewModel.syncLdkNodePayments() + } + CompositionLocalProvider( LocalAppViewModel provides appViewModel, LocalWalletViewModel provides walletViewModel, @@ -233,7 +237,7 @@ fun ContentView( LocalCurrencies provides currencies, ) { NavHost(navController, startDestination = Routes.Home) { - home(walletViewModel, appViewModel, navController) + home(walletViewModel, appViewModel, activityListViewModel, navController) settings(walletViewModel, navController) nodeState(walletViewModel, navController) generalSettings(navController) @@ -448,12 +452,14 @@ fun ContentView( private fun NavGraphBuilder.home( viewModel: WalletViewModel, appViewModel: AppViewModel, + activityListViewModel: ActivityListViewModel, navController: NavHostController, ) { composable { HomeScreen( walletViewModel = viewModel, appViewModel = appViewModel, + activityListViewModel = activityListViewModel, rootNavController = navController, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 9ea130655..9aad1d8fa 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -70,6 +70,7 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.screenSlideIn import to.bitkit.ui.utils.screenSlideOut import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.WalletViewModel @@ -78,6 +79,7 @@ import to.bitkit.viewmodels.WalletViewModel fun HomeScreen( walletViewModel: WalletViewModel, appViewModel: AppViewModel, + activityListViewModel: ActivityListViewModel, rootNavController: NavController, ) { val uiState: MainUiState by walletViewModel.uiState.collectAsState() @@ -118,7 +120,10 @@ fun HomeScreen( uiState = uiState, rootNavController = rootNavController, walletNavController = walletNavController, - onRefresh = walletViewModel::onPullToRefresh, + onRefresh = { + walletViewModel.onPullToRefresh() + activityListViewModel.syncLdkNodePayments() + }, ) } composable( diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index e42caca31..203ca9f5e 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -65,6 +65,9 @@ class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") is BuildException.KvStoreSetupFailed -> "KV store setup failed." is BuildException.WalletSetupFailed -> "Wallet setup failed." is BuildException.LoggerSetupFailed -> "Logger setup failed." + is BuildException.InvalidAnnouncementAddresses -> "Invalid announcement addresses" + is BuildException.InvalidNodeAlias -> "Invalid node alias" + is BuildException.NetworkMismatch -> "Network mismatch" else -> exception.message }?.let { "LDK Build error: $it" } } @@ -120,6 +123,9 @@ class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") is NodeException.InvalidUri -> "Invalid URI." is NodeException.InvalidQuantity -> "Invalid quantity." is NodeException.InvalidNodeAlias -> "Invalid node alias." + is NodeException.InvalidCustomTlvs -> "Invalid custom TLVs" + is NodeException.InvalidDateTime -> "Invalid date time" + is NodeException.InvalidFeeRate -> "Invalid fee rate" else -> exception.message }?.let { "LDK Node error: $it" } } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityViewModel.kt index f517aa789..83498e487 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityViewModel.kt @@ -164,14 +164,23 @@ class ActivityListViewModel @Inject constructor( _selectedTags.value = mutableSetOf() } + var isSyncingLdkNodePayments = false fun syncLdkNodePayments() { + if (isSyncingLdkNodePayments) { + Logger.warn("LDK-node payments are already being synced, skipping") + return + } + viewModelScope.launch { - lightningRepo.getPayments().onSuccess { - coreService.activity.syncLdkNodePayments(it) - syncState() - }.onFailure { e -> - Logger.error("Failed to sync ldk-node payments", e) - } + isSyncingLdkNodePayments = true + lightningRepo.getPayments() + .onSuccess { + coreService.activity.syncLdkNodePayments(it) + syncState() + }.onFailure { e -> + Logger.error("Failed to sync ldk-node payments", e) + } + isSyncingLdkNodePayments = false } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e3be24ff3..99856a89a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -275,6 +275,7 @@ class AppViewModel @Inject constructor( is Event.PaymentClaimable -> Unit is Event.PaymentFailed -> Unit + is Event.PaymentForwarded -> Unit } } catch (e: Exception) { Logger.error("LDK event handler error", e) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 001b3e1d3..5e9fb3504 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,6 @@ kotlin = "2.1.10" kotlinxDatetime = "0.6.2" ksp = "2.1.10-1.0.31" ktor = "3.1.1" -ldkNode = "0.4.2" # LDK_DB_VERSION in MigrationService should match lib's sqlite DB version lifecycle = "2.8.7" material = "1.12.0" mockitoKotlin = "5.4.0" @@ -80,7 +79,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "org.lightningdevkit:ldk-node-android", version.ref = "ldkNode" } +ldk-node-android = { module = "org.lightningdevkit:ldk-node-android", version = "0.5.0" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }