Remove phoenix code

This commit is contained in:
Kgothatso Ngako committed 2026-08-02 18:58:36 +02:00
1 parent b17901973c
commit e9510c4ea5
205 files changed
-22110

No files matched your search

@@ -1,324 +0,0 @@
/*
* Copyright 2025 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.android
import android.content.Context
import android.text.format.DateUtils
import press.mantra.android.MantraApplication
import press.mantra.compose.AppVersion
import press.mantra.compose.ui.composable.widgets.wallet.WalletAvatars
import fr.acinq.lightning.LiquidityEvents
import fr.acinq.lightning.PaymentEvents
import fr.acinq.lightning.utils.Connection
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.phoenix.BusinessMonitorJobs
import fr.acinq.phoenix.BusinessRunning
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.android.services.InflightPaymentsWatcher
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.data.StartupParams
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.data.inFlightPaymentsCount
import fr.acinq.phoenix.managers.AppConnectionsDaemon
import fr.acinq.phoenix.managers.NodeParamsManager
import fr.acinq.phoenix.managers.PeerManager
import fr.acinq.phoenix.managers.global.CurrencyManager
import fr.acinq.phoenix.utils.MnemonicLanguage
import fr.acinq.phoenix.utils.SystemNotificationHelper
import fr.acinq.phoenix.utils.preferences.InternalPrefs
import fr.acinq.phoenix.utils.preferences.UserPrefs
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.slf4j.LoggerFactory
import kotlin.time.Duration.Companion.hours
object BusinessManager {
private val log = LoggerFactory.getLogger(this::class.java)
private val supervisor = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Default + supervisor)
private val startupMutex = Mutex()
// No memory leaks because this can only contain the application context.
private lateinit var appContext: Context
private val application by lazy { appContext as MantraApplication }
/** A map of (walletId -> active businesses) */
private val _businessFlow = MutableStateFlow<Map<WalletId, BusinessRunning>>(emptyMap())
val businessFlow = _businessFlow.asStateFlow()
/** Map of jobs monitoring events/payments once business starts */
private val eventsMonitoringJobs = mutableMapOf<WalletId, BusinessMonitorJobs>() //List<Job>>()
fun initialize(context: Context) {
appContext = context.applicationContext // TODO: we should be getting this from phoenixGloba...
}
/**
* This method creates and starts a new business from a given [decryptedMnemonics], and adds it to the flow of started businesses.
*
* If a business already exists for that seed, the method does nothing.
*
* @param words bip39 mnemonics
* @param isHeadless true if started from a service (e.g. after a FCM notification), false if started from the UI.
*/
suspend fun startNewBusiness(words: List<String>, isHeadless: Boolean): StartBusinessResult = startupMutex.withLock {
val business = PhoenixBusiness(application.phoenixGlobal)
val walletInfo = try {
log.debug("loading wallet before starting a new business")
val seed = business.walletManager.mnemonicsToSeed(words, wordList = MnemonicLanguage.English.wordlist())
business.walletManager.loadWallet(seed)
} catch (e: Exception) {
log.error("unable to load wallet, likely because of an invalid seed, aborting...")
return StartBusinessResult.Failure.LoadWalletError
}
val walletId = WalletId(walletInfo.nodeIdHash)
val nodeId = walletInfo.nodeId.toHex()
val globalPrefs = application.globalPrefs
val walletMetadata = globalPrefs.getAvailableWalletsMeta.first()[walletId] ?: run {
val metadata = UserWalletMetadata(
walletId = walletId,
name = null,
avatar = WalletAvatars.list.random(),
createdAt = currentTimestampMillis(),
isHidden = false
)
globalPrefs.saveAvailableWalletMeta(metadata)
metadata
}
val dataStoreManager = business.dataStoreManager
val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId)
val internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId)
val businessInFlow = businessFlow.value[walletId]?.business
if (businessInFlow != null) {
log.info("business already exists in flow, ignoring...")
return StartBusinessResult.Success(walletInfo, businessInFlow)
}
return try {
log.info("preparing new business with node_id=$nodeId wallet_id=$walletId...")
// check last used version to display a patch note
val lastVersionUsed = globalPrefs.getLastUsedAppCode.first()
if (lastVersionUsed == null) {
// lastUsedAppCode was added in version 99, and is set up during the wallet creation. So if it's null, this Phoenix was installed prior v99 and we can show a patch note
globalPrefs.saveShowReleaseNoteSinceCode("98")
} else if (lastVersionUsed < AppVersion.versionCode) {
globalPrefs.saveShowReleaseNoteSinceCode(lastVersionUsed)
}
// update app configuration with user preferences
business.appConfigurationManager.updateElectrumConfig(userPrefs.getElectrumServer.first())
val preferredCurrencies = userPrefs.getFiatCurrencies.first()
business.appConfigurationManager.updatePreferredFiatCurrencies(preferredCurrencies)
business.phoenixGlobal.currencyManager.startMonitoringCurrencies(walletId = walletId.nodeIdHash, currencies = preferredCurrencies)
// setup jobs monitoring the business events
eventsMonitoringJobs[walletId] = BusinessMonitorJobs(
monitorHeadlessPaymentsJob = if (isHeadless) {
scope.launch { monitorPaymentsWhenHeadless(walletId, walletMetadata, business.nodeParamsManager, application.phoenixGlobal.currencyManager, userPrefs) }
} else null,
monitorNodeEventsJob = scope.launch { monitorNodeEvents(walletId, business.peerManager, business.nodeParamsManager, internalPrefs) },
monitorFcmTokenJob = scope.launch { monitorFcmToken(business) },
monitorInFlightPaymentsJob = scope.launch { monitorInFlightPayments(business.peerManager, internalPrefs) },
)
// startup params depend user's settings: Tor and liquidity policy
val startupParams = StartupParams(isTorEnabled = userPrefs.getIsTorEnabled.first(), liquidityPolicy = userPrefs.getLiquidityPolicy.first())
delay(1_000)
// actually start the business
log.info("starting new business with node_id=$nodeId...")
_businessFlow.value += walletId to BusinessRunning(business = business, isHeadless = isHeadless)
business.start(startupParams)
// the node has been started, so we can now increment the last-used build code
globalPrefs.saveLastUsedAppCode(AppVersion.versionCode)
// start watching the swap-in wallet
scope.launch {
business.peerManager.getPeer().startWatchSwapInWallet()
}
log.info("business initialisation has successfully completed")
StartBusinessResult.Success(walletInfo, business)
} catch (e: Exception) {
log.error("there was an error when initialising new business: ", e)
stopBusiness(walletId)
StartBusinessResult.Failure.Generic(e)
}
}
/**
* Updates the matching business in the map of active businesses with a non-headless flag. Should be called when the UI starts a given wallet.
* If called improperly, will not have severe effects ; the app will just show incoming payment notifications.
*/
fun updateBusinessActiveInUI(walletId: WalletId) {
val businessMap = _businessFlow.value.toMutableMap()
businessMap[walletId]?.let {
businessMap[walletId] = it.copy(isHeadless = false)
}
eventsMonitoringJobs[walletId]?.monitorHeadlessPaymentsJob?.cancel()
_businessFlow.value = businessMap
}
fun stopAllHeadlessBusinesses() {
val headlessBusinesses = businessFlow.value.filter { it.value.isHeadless }
log.info("stopping all headless businesses (${headlessBusinesses.size})...")
headlessBusinesses.forEach { doStopBusiness(it.key, it.value) }
_businessFlow.value = businessFlow.value.minus(headlessBusinesses.keys)
}
fun stopAllBusinesses() {
log.info("stopping all businesses...")
businessFlow.value.forEach { doStopBusiness(it.key, it.value) }
_businessFlow.value = emptyMap()
}
fun stopBusiness(walletId: WalletId) {
val businessMap = _businessFlow.value.toMutableMap()
businessMap[walletId]?.let { doStopBusiness(walletId, it)}
businessMap.remove(walletId)
_businessFlow.value = businessMap
}
private fun doStopBusiness(walletId: WalletId, running: BusinessRunning) {
running.business.appConnectionsDaemon?.incrementDisconnectCount(AppConnectionsDaemon.ControlTarget.All)
running.business.stop()
eventsMonitoringJobs.remove(walletId)?.let {
it.monitorHeadlessPaymentsJob?.cancel()
it.monitorFcmTokenJob.cancel()
it.monitorNodeEventsJob.cancel()
it.monitorInFlightPaymentsJob.cancel()
}
application.phoenixGlobal.currencyManager.stopMonitoringForWallet(walletId.nodeIdHash)
}
private suspend fun monitorFcmToken(business: PhoenixBusiness) {
val token = application.globalPrefs.getFcmToken.filterNotNull().first()
business.connectionsManager.connections.first { it.peer == Connection.ESTABLISHED }
delay(5000)
log.info("registering fcm token=$token")
business.registerFcmToken(token)
}
private suspend fun monitorNodeEvents(walletId: WalletId, peerManager: PeerManager, nodeParamsManager: NodeParamsManager, internalPrefs: InternalPrefs) {
val monitoringStartedAt = currentTimestampMillis()
combine(
peerManager.swapInNextTimeout,
nodeParamsManager.nodeParams.filterNotNull().first().nodeEvents
) { nextTimeout, nodeEvent ->
nextTimeout to nodeEvent
}.collect { (nextTimeout, event) ->
// TODO: click on notif must deeplink to the notification screen
when (event) {
is LiquidityEvents.Rejected -> {
log.debug("processing liquidity_event={}", event)
if (event.source == LiquidityEvents.Source.OnChainWallet) {
// Check the last time a rejected on-chain swap notification has been shown. If recent, we do not want to trigger a notification every time.
val lastRejectedSwap = internalPrefs.getLastRejectedOnchainSwap.first().takeIf {
// However, if the app started < 2 min ago, we always want to display a notification. So we'll ignore this check ^
currentTimestampMillis() - monitoringStartedAt >= 2 * DateUtils.MINUTE_IN_MILLIS
}
if (lastRejectedSwap != null
&& lastRejectedSwap.first == event.amount
&& currentTimestampMillis() - lastRejectedSwap.second <= 2 * DateUtils.HOUR_IN_MILLIS
) {
log.debug("ignore this liquidity event as a similar notification was recently displayed")
return@collect
} else {
internalPrefs.saveLastRejectedOnchainSwap(event)
}
}
val walletMetadata = application.globalPrefs.getAvailableWalletsMeta.first().getByWalletIdOrDefault(walletId)
when (val reason = event.reason) {
is LiquidityEvents.Rejected.Reason.PolicySetToDisabled -> {
SystemNotificationHelper.notifyPaymentRejectedPolicyDisabled(appContext, walletId, walletMetadata, event.source, event.amount, nextTimeout?.second)
}
is LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee -> {
SystemNotificationHelper.notifyPaymentRejectedOverAbsolute(appContext, walletId, walletMetadata, event.source, event.amount, event.fee, reason.maxAbsoluteFee, nextTimeout?.second)
}
is LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee -> {
SystemNotificationHelper.notifyPaymentRejectedOverRelative(appContext, walletId, walletMetadata, event.source, event.amount, event.fee, reason.maxRelativeFeeBasisPoints, nextTimeout?.second)
}
is LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow -> {
SystemNotificationHelper.notifyPaymentRejectedAmountTooLow(appContext, walletId, walletMetadata, event.source, event.amount)
}
// Temporary errors
is LiquidityEvents.Rejected.Reason.ChannelFundingInProgress,
is LiquidityEvents.Rejected.Reason.NoMatchingFundingRate,
is LiquidityEvents.Rejected.Reason.TooManyParts -> {
SystemNotificationHelper.notifyPaymentRejectedFundingError(appContext, walletId, walletMetadata, event.source, event.amount)
}
}
}
else -> Unit
}
}
}
private suspend fun monitorPaymentsWhenHeadless(walletId: WalletId, walletMetadata: UserWalletMetadata, nodeParamsManager: NodeParamsManager, currencyManager: CurrencyManager, userPrefs: UserPrefs) {
nodeParamsManager.nodeParams.filterNotNull().first().nodeEvents.collect { event ->
when (event) {
is PaymentEvents.PaymentReceived -> {
SystemNotificationHelper.notifyPaymentsReceived(
context = appContext,
userPrefs = userPrefs,
walletId = walletId,
userWalletMetadata = walletMetadata,
paymentId = event.payment.id,
paymentAmount = event.payment.amountReceived,
rates = currencyManager.ratesFlow.value,
)
}
else -> Unit
}
}
}
private suspend fun monitorInFlightPayments(peerManager: PeerManager, internalPrefs: InternalPrefs) {
peerManager.channelsFlow.filterNotNull().collect {
val inFlightPaymentsCount = it.inFlightPaymentsCount()
internalPrefs.saveInFlightPaymentsCount(inFlightPaymentsCount)
if (inFlightPaymentsCount == 0) {
InflightPaymentsWatcher.cancel(appContext)
} else {
InflightPaymentsWatcher.scheduleOnce(appContext, delay = 2.hours)
}
}
}
fun clear() {
supervisor.cancel()
}
}
@@ -1,34 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.android.services
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* This receiver is started when the device has booted, and schedules background jobs.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED == intent.action) {
ChannelsWatcher.schedule(context)
InflightPaymentsWatcher.scheduleOnce(context)
DailyConnect.schedule(context)
}
}
}
@@ -1,196 +0,0 @@
/*
* Copyright 2023 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.android.services
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.Operation
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import press.mantra.android.BuildConfig
import fr.acinq.lightning.channel.states.Closing
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.android.BusinessManager
import fr.acinq.phoenix.data.ChannelsWatcherOutcome
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.data.WatchTowerOutcome
import fr.acinq.phoenix.managers.AppConnectionsDaemon
import fr.acinq.phoenix.managers.DataStoreManager
import fr.acinq.phoenix.managers.NodeParamsManager.Companion.chain
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.utils.PlatformContext
import fr.acinq.phoenix.utils.SystemNotificationHelper
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit
/** Worker that monitors channels in the background. Triggers a user-facing notification when an unexpected spending is detected. */
class ChannelsWatcher(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
log.info("starting $name")
if (BusinessManager.businessFlow.first().isNotEmpty()) {
log.info("there already are active businesses, aborting $name")
return Result.success()
}
val userWallets = SeedManager.loadAndDecryptOrNull(
phoenixGlobal = PhoenixGlobal(
ctx = PlatformContext(
applicationContext
)
)
)
if (userWallets.isNullOrEmpty()) {
log.info("could not load any seed, aborting $name")
return Result.success()
}
val watchResult = userWallets.map { (walletId, wallet) ->
watchWallet(walletId, wallet.words)
}
BusinessManager.stopAllHeadlessBusinesses()
return if (watchResult.all { it }) {
log.info("finished $name, watchers have all terminated successfully")
Result.success()
} else {
log.info("finished $name, one or more watchers encountered an error")
Result.failure()
}
}
/** Watches channels for a given node id ; return false if an error occurred. */
private suspend fun watchWallet(walletId: WalletId, words: List<String>): Boolean {
val res = BusinessManager.startNewBusiness(words = words, isHeadless = true)
val dataStoreManager = DataStoreManager(
ctx = PlatformContext(applicationContext),
chain = chain,
)
val internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId)
if (res is StartBusinessResult.Failure) {
log.info("failed to start business for wallet=$walletId")
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.Unknown(currentTimestampMillis()))
return true
}
val business = BusinessManager.businessFlow.value[walletId]?.business
if (business == null) {
log.info("failed to access business for wallet=$walletId")
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.Unknown(currentTimestampMillis()))
return true
}
val notificationsManager = business.notificationsManager
try {
business.appConnectionsDaemon!!.incrementDisconnectCount(AppConnectionsDaemon.ControlTarget.Peer)
business.appConnectionsDaemon!!.incrementDisconnectCount(AppConnectionsDaemon.ControlTarget.Http)
val peer = withTimeout(5_000) {
business.peerManager.getPeer()
}
val channelsAtBoot = peer.bootChannelsFlow.filterNotNull().first()
if (channelsAtBoot.isEmpty()) {
log.info("no channels found, nothing to watch")
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.Nominal(currentTimestampMillis()))
return true
} else {
log.info("watching ${channelsAtBoot.size} channel(s)")
}
// connect electrum (and only electrum), and wait for the watcher to catch-up
withTimeout(ELECTRUM_TIMEOUT_MILLIS) {
business.electrumWatcher.openUpToDateFlow().first()
}
log.info("electrum watcher is up-to-date")
business.appConnectionsDaemon?.decrementDisconnectCount(AppConnectionsDaemon.ControlTarget.Electrum)
val revokedCommitsBeforeWatching = channelsAtBoot.map { (channelId, state) ->
(state as? Closing)?.revokedCommitPublished?.let { channelId to it }
}.filterNotNull().toMap()
log.info("there were initially ${revokedCommitsBeforeWatching.size} channel(s) with revoked commitments")
log.info("checking for new revoked commitments on ${peer.channels.size} channel(s)")
val unknownRevokedAfterWatching = peer.channels.filter { (channelId, state) ->
state is Closing && state.revokedCommitPublished.any {
val isKnown = revokedCommitsBeforeWatching[channelId]?.contains(it) ?: false
if (!isKnown) {
log.warn("found unknown revoked commit for channel=${channelId.toHex()}, tx=${it.commitTx}")
}
!isKnown
}
}.keys
if (unknownRevokedAfterWatching.isNotEmpty()) {
log.warn("new revoked commits found, notifying user")
notificationsManager.saveWatchTowerOutcome(WatchTowerOutcome.RevokedFound(channels = unknownRevokedAfterWatching))
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.RevokedFound(currentTimestampMillis()))
SystemNotificationHelper.notifyRevokedCommits(applicationContext)
} else {
log.info("no revoked commit found, channels-watcher job completed successfully")
notificationsManager.saveWatchTowerOutcome(WatchTowerOutcome.Nominal(channelsWatchedCount = peer.channels.size))
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.Nominal(currentTimestampMillis()))
}
return true
} catch (e: Exception) {
log.error("failed to run channels-watcher job: ", e)
notificationsManager.saveWatchTowerOutcome(WatchTowerOutcome.Unknown())
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.Unknown(currentTimestampMillis()))
return false
}
}
companion object {
private val log = LoggerFactory.getLogger(ChannelsWatcher::class.java)
private val name = "channels-watcher-job"
const val TAG = BuildConfig.APPLICATION_ID + ".ChannelsWatcher"
private const val ELECTRUM_TIMEOUT_MILLIS = 5 * 60_000L
fun schedule(context: Context) {
log.info("scheduling channels watcher")
val work = PeriodicWorkRequest.Builder(ChannelsWatcher::class.java, 36, TimeUnit.HOURS, 12, TimeUnit.HOURS).addTag(TAG)
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build())
}
fun scheduleASAP(context: Context) {
val work = OneTimeWorkRequest.Builder(ChannelsWatcher::class.java).addTag(TAG).build()
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work)
}
fun cancel(context: Context): Operation {
return WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
}
}
}
@@ -1,119 +0,0 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.android.services
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import press.mantra.android.BuildConfig
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.android.BusinessManager
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.utils.ContactsPhotoHelper
import fr.acinq.phoenix.utils.PlatformContext
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import org.slf4j.LoggerFactory
import java.io.File
import java.util.concurrent.TimeUnit
/** Clean up unused photo files for contacts. */
class ContactsPhotoCleaner(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
private val log = LoggerFactory.getLogger(this::class.java)
override suspend fun doWork(): Result {
log.info("starting $name")
try {
// get the active business map, or create it no business is active
val businessMap = BusinessManager.businessFlow.value.takeIf { it.isNotEmpty() }
?: run {
val userWallets = SeedManager.loadAndDecryptOrNull(
phoenixGlobal = PhoenixGlobal(
ctx = PlatformContext(
applicationContext
)
)
)
if (userWallets.isNullOrEmpty()) {
log.info("no seeds available, terminating $name")
return Result.success()
}
userWallets.map { (walletId, wallet) ->
val res = BusinessManager.startNewBusiness(words = wallet.words, isHeadless = true)
if (res is StartBusinessResult.Failure) {
log.info("failed to start business for wallet=$walletId")
return Result.failure()
}
val business = BusinessManager.businessFlow.value[walletId]
if (business == null) {
log.info("failed to access business for wallet=$walletId")
return Result.success()
}
walletId to business
}.toMap()
}
val contactsPhotoDir = ContactsPhotoHelper.contactsDir(applicationContext)
val toDelete = businessMap.map { (_, running) ->
val contacts = running.business.databaseManager.contactsList.filterNotNull().first()
val contactsPhotoNames = contactsPhotoDir.listFiles()?.map { it.name }?.toSet() ?: emptySet()
contactsPhotoNames.subtract(contacts.map { it.photoUri }.toSet()).filterNotNull()
}.flatten()
log.debug("info ${toDelete.size} unused photo file(s)")
toDelete.forEach { imageName ->
File(contactsPhotoDir, imageName).takeIf { it.exists() && it.isFile && it.canWrite() }?.delete()
}
return Result.success()
} catch (e: Exception) {
log.error("error in $name: ", e)
return Result.failure()
} finally {
log.debug("finished $name")
}
}
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
private const val name = "contacts-photo-cleaner"
private const val TAG = BuildConfig.APPLICATION_ID + ".ContactPhotoCleaner"
/** Schedule [ContactsPhotoCleaner] to run roughly every 2 weeks. */
fun schedule(context: Context) {
val work = PeriodicWorkRequest.Builder(ContactsPhotoCleaner::class.java, 15, TimeUnit.DAYS, 3, TimeUnit.DAYS).addTag(TAG)
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build())
}
fun scheduleASAP(context: Context) {
log.info("scheduling $name once")
val work = OneTimeWorkRequest.Builder(ContactsPhotoCleaner::class.java).addTag(TAG).build()
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work)
}
}
}
@@ -1,159 +0,0 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.android.services
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.Operation
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import press.mantra.android.BuildConfig
import fr.acinq.lightning.utils.Connection
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.android.BusinessManager
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.utils.PlatformContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit
/**
* This worker is scheduled to run roughly every day. It simply connects to the LSP, wait for 1 minute,
* then shuts down. The purpose is to settle pending payments that may have been missed by the
* [InflightPaymentsWatcher], to complete closings properly, etc...
*/
class DailyConnect(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
private val log = LoggerFactory.getLogger(this::class.java)
override suspend fun doWork(): Result {
log.info("starting $name")
if (BusinessManager.businessFlow.first().isNotEmpty()) {
log.info("there already are active businesses, aborting $name")
return Result.success()
}
val userWallets = SeedManager.loadAndDecryptOrNull(
phoenixGlobal = PhoenixGlobal(
ctx = PlatformContext(
applicationContext
)
)
)
if (userWallets.isNullOrEmpty()) {
log.info("could not load any seed, aborting $name")
return Result.success()
}
try {
val businessMap = userWallets.map { (walletId, wallet) ->
val res = BusinessManager.startNewBusiness(words = wallet.words, isHeadless = true)
if (res is StartBusinessResult.Failure) {
log.info("failed to start business for wallet=$walletId")
return Result.success()
}
val business = BusinessManager.businessFlow.value[walletId]
if (business == null) {
log.info("failed to access business for wallet=$walletId")
return Result.success()
}
walletId to business
}.toMap()
withContext(Dispatchers.Default) {
val stopJobSignal = MutableStateFlow(false)
val watchers = businessMap.map { (walletId, running) ->
val business = running.business
launch {
business.appConnectionsDaemon?.forceReconnect()
business.connectionsManager.connections.first { it.global is Connection.ESTABLISHED }
log.debug("connections established for wallet={}", walletId)
business.peerManager.channelsFlow.filterNotNull().collect { channels ->
when {
channels.isEmpty() -> {
log.info("no channels found for wallet=$walletId")
stopJobSignal.value = true
}
else -> {
log.info("${channels.size} channel(s) found for wallet=$walletId, waiting 60s...")
delay(60_000)
stopJobSignal.value = true
}
}
}
}.also {
it.invokeOnCompletion { log.debug("completed watching-channels job for wallet={} ({})", walletId, it?.localizedMessage) }
}
}
stopJobSignal.first { it }
log.debug("stop-job signal detected")
watchers.forEach { it.cancelAndJoin() }
}
return Result.success()
} catch (e: Exception) {
log.error("error in $name: ", e)
return Result.failure()
} finally {
BusinessManager.stopAllHeadlessBusinesses()
log.info("finished $name")
}
}
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
private val name = "daily-connect-job"
const val TAG = BuildConfig.APPLICATION_ID + ".DailyConnect"
/** Schedule [DailyConnect] to run roughly every day. */
fun schedule(context: Context) {
log.info("scheduling $name")
val work = PeriodicWorkRequest.Builder(DailyConnect::class.java, 36, TimeUnit.HOURS, 12, TimeUnit.HOURS).addTag(TAG)
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build())
}
fun scheduleASAP(context: Context) {
log.info("scheduling $name once")
val work = OneTimeWorkRequest.Builder(DailyConnect::class.java).addTag(TAG).build()
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work)
}
/** Cancel all scheduled in-flight payments worker. */
fun cancel(context: Context): Operation {
return WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
}
}
}
@@ -1,198 +0,0 @@
/*
* Copyright 2023 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.android.services
import android.app.Service
import android.content.Intent
import android.nfc.NdefMessage
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
import com.machankura.compose.ui.composable.widgets.nfc.NfcState
import com.machankura.compose.ui.composable.widgets.nfc.NfcStateRepository
import fr.acinq.bitcoin.byteVector
import fr.acinq.phoenix.utils.nfc.ApduCommands
import fr.acinq.phoenix.utils.nfc.ApduCommands.A_ERROR
import fr.acinq.phoenix.utils.nfc.ApduCommands.A_OKAY
import fr.acinq.phoenix.utils.nfc.ApduCommands.NDEF_FILE
import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_CC
import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_CC_RESPONSE
import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_NDEF_BINARY
import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_NDEF_BINARY_LENGTH
import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_AID
import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_AID_LE
import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_CC_FILE
import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_NDEF_FILE
import fr.acinq.phoenix.utils.nfc.NfcHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
/**
* This service emulates a NDEF type 4 tag. See the NFC Forum specs section 5.4 for the expected
* sequence of messages/responses. We hardcode most of them in [ApduCommands].
*
* Uses [NfcStateRepository] to pass the payment request to emit.
*
* The message will be a Ndef text record with TNF WELL-KNOWN.
*/
class HceService : HostApduService() {
private val log = LoggerFactory.getLogger(this::class.java)
private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
private var ndefMessage: HceMessage? = null
override fun onCreate() {
super.onCreate()
log.debug("creating hce service")
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
log.debug("onStartCommand")
serviceScope.launch {
NfcStateRepository.state.collect {
ndefMessage = when (it) {
is NfcState.EmulatingTag -> HceMessage(it.paymentRequest)
else -> null
}
}
}
return START_NOT_STICKY
}
@OptIn(ExperimentalStdlibApi::class)
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
log.debug("processing apdu command={} extras={}", commandApdu?.byteVector()?.toHex(), extras)
if (commandApdu == null) return A_ERROR
val nfcState = NfcStateRepository.state.value
if (nfcState is NfcState.Inactive) {
log.debug("trying to emulate tag in state={}, aborting", nfcState)
return A_ERROR
}
val message = ndefMessage ?: return A_ERROR
return when {
SELECT_AID.contentEquals(commandApdu) or SELECT_AID_LE.contentEquals(commandApdu) -> {
log.debug("selecting ndef tag AID, returning {}", A_OKAY.toHexString())
A_OKAY
}
SELECT_CC_FILE.contentEquals(commandApdu) -> {
log.debug("selecting capability container, returning {}", A_OKAY.toHexString())
A_OKAY
}
READ_CC.contentEquals(commandApdu) -> {
log.debug("selecting capability container file, returning {}", READ_CC_RESPONSE.toHexString())
READ_CC_RESPONSE
}
SELECT_NDEF_FILE.contentEquals(commandApdu) -> {
log.debug("selecting ndef file, returning {}", A_OKAY.toHexString())
A_OKAY
}
READ_NDEF_BINARY_LENGTH.contentEquals(commandApdu) -> {
val response = message.messageLength + A_OKAY
log.debug("reading ndef message length, returning {}", response.toHexString())
response
}
commandApdu.size > 2 && commandApdu.sliceArray(0..1).contentEquals(READ_NDEF_BINARY) -> {
// we may receive several commands in a row if the message is large enough
val response = getBinaryResponse(commandApdu, message)
val expectedResponse = message.message.records[0].payload.toHexString() + A_OKAY.toHexString()
if (expectedResponse.contains(response.toHexString())) {
log.info("done sending payment request via nfc: ${message.paymentRequest}")
NfcStateRepository.updateState(NfcState.Inactive)
}
log.debug("reading ndef message, returning {}", response.toHexString())
response
}
else -> {
log.debug("unhandled apdu command={}", commandApdu)
A_ERROR
}
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun getBinaryResponse(commandApdu: ByteArray, message: HceMessage): ByteArray {
try {
if (commandApdu.size < 5) {
log.error("invalid APDU: too short (${commandApdu.size} bytes)")
return A_ERROR
}
// the full response before applying offset
val response = message.messageLength + message.messageBytes
// the reader may want the data in chunks
val offset = commandApdu.sliceArray(2..3).toHexString().toInt(16)
val chunkSize = commandApdu[4].toInt() and 0xFF
log.debug("offset=$offset length=$chunkSize for full_response=${response.toHexString()} =")
if (offset >= response.size) {
log.error("offset $offset is beyond full response size ${response.size}")
return A_ERROR
}
val responseAfterOffset = response.sliceArray(offset until response.size)
val chunkSizeForOffset = minOf(chunkSize, responseAfterOffset.size)
val chunk = responseAfterOffset.copyOfRange(0, chunkSizeForOffset) + A_OKAY
return chunk
} catch (e: Exception) {
log.error("error when getting binary response: {}", e.localizedMessage)
return A_ERROR
}
}
override fun onDeactivated(reason: Int) {
when (reason) {
DEACTIVATION_DESELECTED -> log.info("deactivation: different AID selected")
DEACTIVATION_LINK_LOSS -> log.info("deactivation: link lost")
else -> log.info("deactivation: code $reason")
}
}
override fun onDestroy() {
super.onDestroy()
log.info("service removed")
if (NfcStateRepository.state.value is NfcState.EmulatingTag) {
NfcStateRepository.updateState(NfcState.Inactive)
}
}
private data class HceMessage(val paymentRequest: String) {
val message by lazy { NdefMessage(NfcHelper.createTextRecord(paymentRequest, NDEF_FILE)) }
val messageBytes: ByteArray by lazy { message.toByteArray() }
val messageLength by lazy {
NfcHelper.fillByteArrayToFixedDimension(messageBytes.size.toBigInteger().toByteArray(), 2)
}
override fun toString(): String {
return "message=$paymentRequest length=$messageLength tnf=${message.records.firstOrNull()?.tnf} type=${message.records.firstOrNull()?.type}"
}
}
}
@@ -1,226 +0,0 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.android.services
import android.content.Context
import android.os.Build
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import press.mantra.android.BuildConfig
import press.mantra.android.MantraApplication
import fr.acinq.lightning.channel.states.Syncing
import fr.acinq.lightning.utils.Connection
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.android.BusinessManager
import fr.acinq.phoenix.data.LocalChannelInfo
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.data.inFlightPaymentsCount
import fr.acinq.phoenix.managers.DataStoreManager
import fr.acinq.phoenix.managers.NodeParamsManager.Companion.chain
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.utils.PlatformContext
import fr.acinq.phoenix.utils.SystemNotificationHelper
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
/**
* This worker starts a node to settle any pending in-flight payments. This will prevent payment timeouts
* (and channels force-close) in case the app is not started regularly and the silent push notifications
* sent by the ACINQ peer are ignored by the device.
*
* Example: devices using GrapheneOS, where FCM is not supported.
*
* This service is scheduled whenever there's a pending htlc in a channel.
* See [LocalChannelInfo.inFlightPaymentsCount].
*/
class InflightPaymentsWatcher(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
private val log = LoggerFactory.getLogger(this::class.java)
override suspend fun doWork(): Result {
log.info("starting $name")
if (BusinessManager.businessFlow.first().isNotEmpty()) {
log.debug("there already are active businesses, aborting $name")
return Result.success()
}
val userWallets = SeedManager.loadAndDecryptOrNull(
phoenixGlobal = PhoenixGlobal(
ctx = PlatformContext(
applicationContext
)
)
)
if (userWallets.isNullOrEmpty()) {
log.debug("could not load any seed, aborting $name")
return Result.success()
}
val watchResult = userWallets.map { (walletId, wallet) ->
val walletMetadata = (applicationContext as MantraApplication).globalPrefs.getAvailableWalletsMeta.first().getByWalletIdOrDefault(walletId)
watchWallet(walletId, walletMetadata, wallet.words)
}
BusinessManager.stopAllHeadlessBusinesses()
return if (watchResult.all { it }) {
log.info("finished $name, watchers have all terminated successfully")
Result.success()
} else {
log.info("finished $name, one or more watchers encountered an error")
Result.failure()
}
}
private suspend fun watchWallet(walletId: WalletId, walletMetadata: UserWalletMetadata, words: List<String>): Boolean {
try {
val dataStoreManager = DataStoreManager(
ctx = PlatformContext(
applicationContext
),
chain = chain,
)
val internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId = walletId)
val inFlightPaymentsCount = internalPrefs.getInFlightPaymentsCount.first()
if (inFlightPaymentsCount == 0) {
log.info("aborting $name: expecting NO in-flight payments")
return true
}
val res = BusinessManager.startNewBusiness(words = words, isHeadless = true)
if (res is StartBusinessResult.Failure) {
log.info("failed to start business for wallet=$walletId")
return false
}
val business = BusinessManager.businessFlow.value[walletId]?.business
if (business == null) {
log.info("failed to access business for wallet=$walletId")
return false
}
withContext(Dispatchers.Default) {
val stopJobs = MutableStateFlow(false)
val jobTimer = launch {
delay(2.minutes)
log.info("stopping $name-$walletId after 2 minutes without resolution - show notification")
scheduleOnce(applicationContext)
SystemNotificationHelper.notifyInFlightHtlc(applicationContext, walletMetadata)
stopJobs.value = true
}
val watcher = launch {
business.appConnectionsDaemon?.forceReconnect()
business.connectionsManager.connections.first { it.global is Connection.ESTABLISHED }
log.debug("watching in-flight payments for wallet={}", walletId)
business.peerManager.channelsFlow.filterNotNull().collectIndexed { index, channels ->
val paymentsCount = channels.inFlightPaymentsCount()
internalPrefs.saveInFlightPaymentsCount(paymentsCount)
when {
channels.isEmpty() -> {
log.info("no channels found, successfully terminating watcher (#$index)")
stopJobs.value = true
}
channels.any { it.value.state is Syncing } -> {
log.debug("channels syncing, pausing 10s before next check (#$index)")
delay(10.seconds)
}
paymentsCount > 0 -> {
log.debug("$paymentsCount payments in-flight, pausing 5s before next check (#$index)...")
delay(5.seconds)
}
else -> {
log.info("$paymentsCount payments in-flight, successfully completing worker (#$index)...")
stopJobs.value = true
}
}
}
}
stopJobs.first { it }
log.debug("stop-job signal detected")
watcher.cancelAndJoin()
jobTimer.cancelAndJoin()
}
return true
} catch (e: Exception) {
log.error("error in $name-$walletId: ", e)
return false
}
}
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
const val name = "inflight-payments-watcher"
const val TAG = BuildConfig.APPLICATION_ID + ".InflightPaymentsWatcher"
/** Schedule a in-flight payments watcher job to start every few hours. */
fun schedulePeriodic(context: Context) {
log.info("scheduling periodic $name")
val work = PeriodicWorkRequest.Builder(InflightPaymentsWatcher::class.java, 2, TimeUnit.HOURS, 3, TimeUnit.HOURS).addTag(TAG)
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build())
}
/** Schedule an in-flight payments job to run once in [delay] from now (by default, 2 hours). Existing schedules are replaced. */
fun scheduleOnce(context: Context, delay: Duration = 2.hours) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
log.info("scheduling $name in $delay from now")
val work = OneTimeWorkRequestBuilder<InflightPaymentsWatcher>().setInitialDelay(delay.toJavaDuration()).build()
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work)
} else {
log.error("Couldn't schedule $name in $delay from now")
}
}
/** Cancel all scheduled in-flight payments worker. */
fun cancel(context: Context): Operation {
return WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
}
}
}
@@ -1,165 +0,0 @@
package fr.acinq.phoenix.android.services
import android.app.Service
import android.content.Intent
import android.os.Handler
import android.os.Looper
import androidx.core.app.NotificationManagerCompat
import press.mantra.android.BuildConfig
import press.mantra.android.MantraApplication
import fr.acinq.lightning.utils.Connection
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.android.BusinessManager
import fr.acinq.phoenix.data.DecryptSeedResult
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.managers.AppConnectionsDaemon
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.utils.PlatformContext
import fr.acinq.phoenix.utils.SystemNotificationHelper
import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* This foreground service starts a [PhoenixBusiness] upon receiving FCM payment messages from the LSP. Being a foreground
* service, it needs to display a foreground notification visible by the user.
*
* This service allows Phoenix to receive payments (or settle pending payments) even when it's closed or in the background.
*/
class PaymentsForegroundService : Service() {
private val log = LoggerFactory.getLogger(this::class.java)
private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
private lateinit var notificationManager: NotificationManagerCompat
override fun onCreate() {
super.onCreate()
log.debug("creating node service...")
notificationManager = NotificationManagerCompat.from(this)
log.debug("service created")
}
override fun onBind(intent: Intent?) = null
private val shutdownHandler = Handler(Looper.getMainLooper())
private val shutdownRunnable: Runnable = Runnable {
log.info("reached scheduled shutdown while headless")
stopForeground(STOP_FOREGROUND_REMOVE)
BusinessManager.stopAllHeadlessBusinesses()
stopSelf()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
log.info("start service from intent [ intent=$intent, flag=$flags, startId=$startId ]")
val reason = intent?.getStringExtra(EXTRA_REASON)
val walletId = intent?.getStringExtra(EXTRA_NODE_ID_HASH)?.let { WalletId(it) }
val businessMap = BusinessManager.businessFlow.value
// the notification for the foreground service depends on whether the business is started or not.
val shouldWeWaitLong: Boolean = when {
walletId != null && businessMap[walletId] != null -> {
log.info("active business found for wallet=$walletId, ignoring background message (reason=$reason)")
businessMap[walletId]?.business?.let {
if (it.connectionsManager.connections.value.peer !is Connection.ESTABLISHED) {
it.appConnectionsDaemon?.forceReconnect(AppConnectionsDaemon.ControlTarget.Peer)
}
}
false
}
walletId == null -> {
log.info("no wallet_id provided, ignoring background message (reason=$reason)")
false
}
else -> {
when (val result = SeedManager.loadAndDecrypt(
phoenixGlobal = PhoenixGlobal(
ctx = PlatformContext(
applicationContext
)
)
)) {
is DecryptSeedResult.Failure.SeedFileNotFound -> {
log.info("seed not found, ignoring background message (reason=$reason)")
false
}
is DecryptSeedResult.Failure -> {
log.info("unable to read seed, ignoring background message (reason=$reason)")
serviceScope.launch {
val walletMetadataMap = (application as MantraApplication).globalPrefs.getAvailableWalletsMeta.first()
val metadata = walletMetadataMap.getByWalletIdOrDefault(walletId)
when (reason) {
"IncomingPayment" -> SystemNotificationHelper.notifyPaymentMissedAppUnavailable(applicationContext, metadata)
"PendingSettlement" -> SystemNotificationHelper.notifyPendingSettlement(applicationContext, metadata)
else -> Unit
}
}
false
}
is DecryptSeedResult.Success -> {
val userWallets = result.userWalletsMap
val wallet = userWallets[walletId]
if (wallet == null) {
log.info("seed not found for node_id=$walletId, ignoring background message (reason=$reason)")
false
} else {
serviceScope.launch(Dispatchers.Default) {
when (val res = BusinessManager.startNewBusiness(wallet.words, isHeadless = true)) {
is StartBusinessResult.Failure -> {
log.error("error when starting wallet=$walletId... from foreground service: $res")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
is StartBusinessResult.Success -> Unit
}
}
true
}
}
}
}
}
// show a notification -- this is mandatory!
SystemNotificationHelper.notifyRunningHeadless(applicationContext)
// startForeground(notif)
// service will automatically shutdown -- delay is short if the message is ignored
shutdownHandler.removeCallbacksAndMessages(null)
shutdownHandler.postDelayed(shutdownRunnable, if (shouldWeWaitLong) 2.minutes.inWholeMilliseconds else 5.seconds.inWholeMilliseconds)
return START_NOT_STICKY
}
// private fun startForeground(notif: Notification) {
// if (Build.VERSION.SDK_INT >= 34) {
// ServiceCompat.startForeground(this, SystemNotificationHelper.HEADLESS_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)
// } else {
// startForeground(SystemNotificationHelper.HEADLESS_NOTIF_ID, notif)
// }
// }
override fun onDestroy() {
super.onDestroy()
log.info("foreground service destroyed")
}
companion object {
const val EXTRA_REASON = "${BuildConfig.APPLICATION_ID}.FCM_MESSAGE.REASON"
const val EXTRA_NODE_ID_HASH = "${BuildConfig.APPLICATION_ID}.FCM_MESSAGE.NODE_ID_HASH"
}
}
@@ -1,6 +0,0 @@
package fr.acinq.phoenix.data
import fr.acinq.lightning.io.TcpSocket
import fr.acinq.lightning.utils.ServerAddress
actual fun platformElectrumRegtestConf(): ServerAddress = ServerAddress(host = "10.0.2.2", port = 51002, tls = TcpSocket.TLS.DISABLED)
@@ -1,52 +0,0 @@
package fr.acinq.phoenix.db
import androidx.sqlite.db.SupportSQLiteDatabase
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import fr.acinq.phoenix.db.migrations.v10.AfterVersion10
import fr.acinq.phoenix.db.migrations.v11.AfterVersion11
import fr.acinq.phoenix.db.sqldelight.AppDatabase
import fr.acinq.phoenix.db.sqldelight.ChannelsDatabase
import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase
import fr.acinq.phoenix.utils.PlatformContext
actual fun createChannelsDbDriver(ctx: PlatformContext, fileName: String): SqlDriver {
return AndroidSqliteDriver(
schema = ChannelsDatabase.Schema,
context = ctx.applicationContext,
name = fileName,
callback = object : AndroidSqliteDriver.Callback(schema = ChannelsDatabase.Schema) {
override fun onConfigure(db: SupportSQLiteDatabase) {
super.onConfigure(db)
db.setForeignKeyConstraintsEnabled(true)
}
}
)
}
actual fun createPaymentsDbDriver(ctx: PlatformContext, fileName: String, onError: (String) -> Unit): SqlDriver {
return AndroidSqliteDriver(
schema = PaymentsDatabase.Schema,
context = ctx.applicationContext,
name = fileName,
callback = object : AndroidSqliteDriver.Callback(
schema = PaymentsDatabase.Schema,
AfterVersion10(onError),
AfterVersion11(onError),
) {
override fun onConfigure(db: SupportSQLiteDatabase) {
super.onConfigure(db)
db.setForeignKeyConstraintsEnabled(true)
}
}
)
}
actual fun createAppDbDriver(ctx: PlatformContext): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, ctx.applicationContext, "appdb.sqlite", callback = object : AndroidSqliteDriver.Callback(schema = AppDatabase.Schema) {
override fun onConfigure(db: SupportSQLiteDatabase) {
super.onConfigure(db)
db.setForeignKeyConstraintsEnabled(true)
}
})
}
@@ -1,16 +0,0 @@
package fr.acinq.phoenix.db
import fr.acinq.lightning.utils.UUID
import fr.acinq.phoenix.db.payments.CloudKitInterface
import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase
actual fun didSaveWalletPayment(id: UUID, database: PaymentsDatabase) {}
actual fun didDeleteWalletPayment(id: UUID, database: PaymentsDatabase) {}
actual fun didUpdateWalletPaymentMetadata(id: UUID, database: PaymentsDatabase) {}
actual fun didSaveContact(contactId: UUID, database: PaymentsDatabase) {}
actual fun didDeleteContact(contactId: UUID, database: PaymentsDatabase) {}
actual fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? {
return null
}
@@ -1,14 +0,0 @@
package fr.acinq.phoenix.managers
import fr.acinq.phoenix.utils.PlatformContext
import okio.Path
import okio.Path.Companion.toOkioPath
actual fun computePreferencePath(
platformContext: PlatformContext,
dataStoreFileName: String,
): Path {
return platformContext.applicationContext.filesDir.resolve(
relative = dataStoreFileName
).toOkioPath()
}
@@ -1,90 +0,0 @@
package fr.acinq.phoenix.managers.global
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.debug
import fr.acinq.lightning.logging.info
import fr.acinq.phoenix.utils.PlatformContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.atomic.AtomicBoolean
actual class NetworkMonitor actual constructor(loggerFactory: LoggerFactory, val ctx: PlatformContext) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
val logger = loggerFactory.newLogger(this::class)
private val isCallbackRegistered = AtomicBoolean(false)
private val _networkState = MutableStateFlow(NetworkState.NotAvailable)
actual val networkState: StateFlow<NetworkState> = _networkState
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
logger.debug { "network is now $network" }
_networkState.value = NetworkState.Available
}
override fun onBlockedStatusChanged(network: Network, blocked: Boolean) {
super.onBlockedStatusChanged(network, blocked)
logger.debug { "block status change to $blocked for network=$network"}
}
override fun onCapabilitiesChanged(network : Network, networkCapabilities : NetworkCapabilities) {
logger.debug { "default network changed capabilities to $networkCapabilities" }
}
override fun onLinkPropertiesChanged(network : Network, linkProperties : LinkProperties) {
logger.debug { "default network changed link properties to $linkProperties" }
}
override fun onLosing(network: Network, maxMsToLive: Int) {
super.onLosing(network, maxMsToLive)
logger.debug { "losing network in ${maxMsToLive}ms..." }
}
override fun onLost(network: Network) {
super.onLost(network)
logger.info { "network has been lost" }
_networkState.value = NetworkState.NotAvailable
}
override fun onUnavailable() {
super.onUnavailable()
logger.info { "network is unavailable" }
_networkState.value = NetworkState.NotAvailable
}
}
actual fun enable() {
}
actual fun disable() {
}
actual fun start() {
val connectivityManager = ctx.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
if (isCallbackRegistered.compareAndSet(false, true)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
connectivityManager.registerDefaultNetworkCallback(networkCallback)
} else {
// TODO: Implement logic to register connectivity monitor
}
}
}
actual fun stop() {
val connectivityManager = ctx.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.unregisterNetworkCallback(networkCallback)
isCallbackRegistered.set(false)
}
}
@@ -1,23 +0,0 @@
package fr.acinq.phoenix.security
import co.touchlab.kermit.Logger
import fr.acinq.phoenix.utils.extensions.tryWith
import java.security.GeneralSecurityException
actual fun keyStoreDecryption(
keyName: String,
iv: ByteArray,
ciphertext: ByteArray
): ByteArray {
Logger.withTag("KeyStoreFunctions").i("IV length: ${iv.size}")
return KeystoreHelper.getDecryptionCipher(keyName, iv).doFinal(ciphertext)
}
actual fun keyStoreEncryption(keyName: String, plainText: ByteArray): Pair<ByteArray, ByteArray> = tryWith(GeneralSecurityException()) {
val cipher = KeystoreHelper.getEncryptionCipher(keyName)
Logger.withTag("KeyStoreFunctions").i("IV for encryption: ${cipher.iv.size}")
return Pair(
cipher.iv,
cipher.doFinal(plainText)
)
}
@@ -1,116 +0,0 @@
/*
* Copyright 2020 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.security
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import co.touchlab.kermit.Logger
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
object KeystoreHelper {
private val log = Logger.withTag("KeystoreHelper")
private val ENC_ALGO = KeyProperties.KEY_ALGORITHM_AES
private val ENC_BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
private val ENC_PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
private val keyStore by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
}
private fun getOrCreateKeyNoAuthRequired(): SecretKey {
keyStore.getKey(KeyStoreNames.KEY_NO_AUTH, null)?.let { return it as SecretKey }
val spec = KeyGenParameterSpec.Builder(KeyStoreNames.KEY_NO_AUTH, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).apply {
setBlockModes(ENC_BLOCK_MODE)
setEncryptionPaddings(ENC_PADDING)
setRandomizedEncryptionRequired(true)
setKeySize(256)
setUserAuthenticationRequired(false)
setInvalidatedByBiometricEnrollment(false)
}
return generateKeyWithSpec(spec)
}
/** Generate key from key gen specs. If possible, store the key in strongbox. */
private fun generateKeyWithSpec(spec: KeyGenParameterSpec.Builder): SecretKey {
val keygen = KeyGenerator.getInstance(ENC_ALGO, keyStore.provider)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// try to use strongbox on Android 9+, which is only supported by a few devices
try {
spec.setIsStrongBoxBacked(true)
keygen.init(spec.build())
keygen.generateKey()
} catch (e: Exception) {
log.w("failed to generate key with strongbox enabled: ${e.javaClass.simpleName}: ${e.localizedMessage}, trying again without strongbox")
spec.setIsStrongBoxBacked(false)
keygen.init(spec.build())
keygen.generateKey()
}
} else {
keygen.init(spec.build())
keygen.generateKey()
}
}
private fun getKeyForName(keyName: String): SecretKey = when (keyName) {
KeyStoreNames.KEY_NO_AUTH -> getOrCreateKeyNoAuthRequired()
KeyStoreNames.KEY_FOR_PINCODE_V1 -> getOrCreateKeyNoAuthRequired()
else -> throw IllegalArgumentException("unhandled key=$keyName")
}
/**
* Get the encryption cipher for the key. If it fails once, delete the key, and try again. This should only be used by the seed fallback to check if the
* keystore is accessible. This seems to (rarely) happen after an OS update that fails to upgrade the key (raising a `upgrade_keyblob_if_required_with`
* error), and might be related to the secure element option?
*/
fun checkEncryptionCipherOrReset(keyName: String) = when (keyName) {
KeyStoreNames.KEY_NO_AUTH -> {
try {
getEncryptionCipher(keyName)
} catch (e: Exception) {
log.e("could not get encryption cipher: ${e.localizedMessage}")
try {
log.e("deleting key=$keyName from keystore")
keyStore.deleteEntry(keyName)
getEncryptionCipher(keyName)
} catch (e: Exception) {
log.e("cannot delete $keyName entry from keystore: ${e.localizedMessage}")
throw e
}
}
}
else -> {
throw IllegalArgumentException("unhandled key_name=$keyName")
}
}
/** Get encryption Cipher for given key. */
internal fun getEncryptionCipher(keyName: String): Cipher = Cipher.getInstance("$ENC_ALGO/$ENC_BLOCK_MODE/$ENC_PADDING").apply {
init(Cipher.ENCRYPT_MODE, getKeyForName(keyName), parameters)
}
/** Get decryption Cipher for given key. */
internal fun getDecryptionCipher(keyName: String, iv: ByteArray): Cipher = Cipher.getInstance("$ENC_ALGO/$ENC_BLOCK_MODE/$ENC_PADDING").apply {
init(Cipher.DECRYPT_MODE, getKeyForName(keyName), IvParameterSpec(iv))
}
}
@@ -1,91 +0,0 @@
package fr.acinq.phoenix.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.content.FileProvider
import press.mantra.android.BuildConfig
import fr.acinq.lightning.utils.currentTimestampMillis
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileOutputStream
object ContactsPhotoHelper {
private val log = LoggerFactory.getLogger(this::class.java)
fun contactsDir(context: Context) = File(context.filesDir, "contacts")
/** Creates a temporary file where the camera activity will store the picture in. File is deleted on app exit. */
fun createTempContactPictureUri(
context: Context,
): Uri? {
val cacheContactsDir = File(context.cacheDir, "contacts") // using cacheDir !
if (!cacheContactsDir.exists()) cacheContactsDir.mkdir()
return try {
val tempFile = File.createTempFile("contact_", ".png", cacheContactsDir)
tempFile.deleteOnExit()
FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tempFile)
} catch (e: Exception) {
log.error("failed to write temporary file for contact: {}", e.localizedMessage)
null
}
}
/** Creates the final picture from the temporary picture URI returned by the camera activity. The picture is resized and compressed. Returns the file name (in filesDir/contacts). */
fun createPermaContactPicture(
context: Context,
tempFileUri: Uri,
): String? {
val contactsDir = contactsDir(context)
if (!contactsDir.exists()) contactsDir.mkdir()
return try {
val orientation = context.contentResolver.openInputStream(tempFileUri)?.use {
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
} ?: ExifInterface.ORIENTATION_UNDEFINED
val bitmap = context.contentResolver.openInputStream(tempFileUri)?.use {
BitmapFactory.decodeStream(it)
} ?: return null
val scale = (480f / bitmap.width.coerceAtLeast(bitmap.height)).coerceAtMost(1f)
val matrix = Matrix().apply {
postScale(scale, scale)
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> postRotate(270f)
}
}
val scaledBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, false)
val photoFile = File(contactsDir, "contact_${currentTimestampMillis()}.jpg")
FileOutputStream(photoFile).use { scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 50, it) }
photoFile.name
} catch (e: Exception) {
log.error("failed to write contact photo to disk: {}", e.localizedMessage)
null
}
}
fun getPhotoForFile(
context: Context,
fileName: String
): ImageBitmap? {
val contactsDir = contactsDir(context)
if (!contactsDir.exists() || !contactsDir.canRead()) return null
return try {
val photoFile = File(contactsDir, fileName)
val content = photoFile.readBytes()
BitmapFactory.decodeByteArray(content, 0, content.size).asImageBitmap()
} catch (e: Exception) {
log.info("could not read contact photo=$fileName: ", e.localizedMessage)
null
}
}
}
@@ -1,62 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.utils
import android.content.Context
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
object Logging {
private const val LOGS_DIR = "logs"
private const val CURRENT_LOG_FILE = "phoenix.log"
private const val ARCHIVED_LOG_FILE = "phoenix.archive-%i.log"
fun exportLogFile(context: Context): File {
val export = File(File(context.filesDir, LOGS_DIR), "phoenix_export.log")
val exportOutputStream = FileOutputStream(export)
val exportChannel = exportOutputStream.channel
// write archive-1 to export file, if available
File(File(context.filesDir, LOGS_DIR), "phoenix.archive-1.log").takeIf {
it.exists() && it.isFile && it.canRead()
}?.let {
FileInputStream(it)
}?.also {
val channel = it.channel
channel.transferTo(0, channel.size(), exportChannel)
channel.close()
}?.close()
// write current log file to export file, if available
File(File(context.filesDir, LOGS_DIR), CURRENT_LOG_FILE).takeIf {
it.exists() && it.isFile && it.canRead()
}?.let {
FileInputStream(it)
}?.also {
val channel = it.channel
channel.transferTo(0, channel.size(), exportChannel)
channel.close()
}?.close()
exportChannel.close()
exportOutputStream.close()
return export
}
}
@@ -1,16 +0,0 @@
package fr.acinq.phoenix.utils
import android.content.Context
actual class PlatformContext(val applicationContext: Context)
actual fun getApplicationFilesDirectoryPath(ctx: PlatformContext): String =
ctx.applicationContext.filesDir.absolutePath
actual fun getDatabaseFilesDirectoryPath(ctx: PlatformContext): String? = null
actual fun getApplicationCacheDirectoryPath(ctx: PlatformContext): String =
ctx.applicationContext.cacheDir.absolutePath
actual fun getTemporaryDirectoryPath(ctx: PlatformContext): String =
ctx.applicationContext.cacheDir.absolutePath
@@ -1,335 +0,0 @@
/*
* Copyright 2021 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.utils
import android.Manifest
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.text.format.DateUtils
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.net.toUri
import press.mantra.android.BuildConfig
import press.mantra.android.MainActivity
import press.mantra.android.R
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.LiquidityEvents
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.phoenix.data.BitcoinUnit
import fr.acinq.phoenix.data.ExchangeRate
import fr.acinq.phoenix.data.FiatCurrency
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.utils.converters.AmountFormatter.toPrettyString
import fr.acinq.phoenix.utils.converters.DateFormatter.toAbsoluteDateString
import fr.acinq.phoenix.utils.preferences.UserPrefs
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import kotlinx.coroutines.flow.first
import org.slf4j.LoggerFactory
import java.text.DecimalFormat
object SystemNotificationHelper {
private const val PAYMENT_FAILED_NOTIF_ID = 354319
private const val PAYMENT_FAILED_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.PAYMENT_FAILED_NOTIF"
private const val PAYMENT_RECEIVED_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.PAYMENT_RECEIVED_NOTIF"
private const val SETTLEMENT_PENDING_NOTIF_ID = 354322
private const val SETTLEMENT_PENDING_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.SETTLEMENT_PENDING_NOTIF"
const val HEADLESS_NOTIF_ID = 354321
const val HEADLESS_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.BACKGROUND_PROCESSING"
private const val CHANNELS_WATCHER_ALERT_ID = 354324
private const val CHANNELS_WATCHER_ALERT_CHANNEL = "${BuildConfig.APPLICATION_ID}.CHANNELS_WATCHER"
private const val SWAP_TIMEOUT_ID = 354325
private const val SWAP_TIMEOUT_CHANNEL = "${BuildConfig.APPLICATION_ID}.SWAP_TIMEOUT"
private val log = LoggerFactory.getLogger(this::class.java)
/** If the remaining blocks count before a swap timeout is lower than this, we should mention it in the notification. */
private const val SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS = 144 * 30 * 2 // ~2 months
fun registerNotificationChannels(context: Context) {
// notification channels (android 8+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.getSystemService(NotificationManager::class.java)?.createNotificationChannels(
listOf(
// TODO: Setup notifications...
// NotificationChannel(HEADLESS_NOTIF_CHANNEL, context.getString(R.string.notification_headless_title), NotificationManager.IMPORTANCE_DEFAULT).apply {
// description = context.getString(R.string.notification_headless_desc)
// },
// NotificationChannel(CHANNELS_WATCHER_ALERT_CHANNEL, context.getString(R.string.notification_channels_watcher_title), NotificationManager.IMPORTANCE_HIGH).apply {
// description = context.getString(R.string.notification_channels_watcher_desc)
// },
// NotificationChannel(SETTLEMENT_PENDING_NOTIF_CHANNEL, context.getString(R.string.notification_pending_settlement_title), NotificationManager.IMPORTANCE_HIGH).apply {
// description = context.getString(R.string.notification_pending_settlement_desc)
// },
// NotificationChannel(PAYMENT_RECEIVED_NOTIF_CHANNEL, context.getString(R.string.notification_received_payment_title), NotificationManager.IMPORTANCE_LOW).apply {
// description = context.getString(R.string.notification_received_payment_desc)
// },
// NotificationChannel(PAYMENT_FAILED_NOTIF_CHANNEL, context.getString(R.string.notification_missed_payment_title), NotificationManager.IMPORTANCE_DEFAULT).apply {
// description = context.getString(R.string.notification_missed_payment_desc)
// },
// NotificationChannel(SWAP_TIMEOUT_CHANNEL, context.getString(R.string.notification_swap_timeout_title), NotificationManager.IMPORTANCE_HIGH).apply {
// description = context.getString(R.string.notification_swap_timeout_desc)
// },
)
)
}
}
fun notifyRunningHeadless(context: Context): Notification {
return NotificationCompat.Builder(context, HEADLESS_NOTIF_CHANNEL).apply {
// TODO: setContentTitle(context.getString(Res.string.notif_headless_title_default))
setContentTitle("Running Headless")
// TODO: setSmallIcon(R.drawable.ic_phoenix_outline)
}.build().also {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(HEADLESS_NOTIF_ID, it)
}
}
}
private fun notifyPaymentFailed(context: Context, walletMetadata: UserWalletMetadata, title: String, message: String, deepLink: String?): Notification {
return NotificationCompat.Builder(context, PAYMENT_FAILED_NOTIF_CHANNEL).apply {
setContentTitle(getTitleForWallet(walletMetadata, title))
setContentText(message)
setStyle(NotificationCompat.BigTextStyle().bigText(message))
// TODO: setSmallIcon(R.drawable.ic_phoenix_outline)
val intent = deepLink?.let {
Intent(Intent.ACTION_VIEW, it.toUri(), context, MainActivity::class.java)
} ?: Intent(context, MainActivity::class.java)
setContentIntent(
TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE)
}
)
setAutoCancel(true)
}.build().also {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(PAYMENT_FAILED_NOTIF_ID, it)
}
}
}
private fun getTitleForWallet(walletMetadata: UserWalletMetadata, title: String): String {
return "${walletMetadata.avatar} $title"
}
fun notifyPaymentRejectedPolicyDisabled(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, nextTimeoutRemainingBlocks: Int?): Notification {
return notifyPaymentFailed(
context = context,
title = "Payment rejected policy disabled",
// TODO: title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title,
// amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)),
message = when {
source == LiquidityEvents.Source.OnChainWallet && nextTimeoutRemainingBlocks != null && nextTimeoutRemainingBlocks < SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS -> {
nextTimeoutRemainingBlocks * 10 * DateUtils.MINUTE_IN_MILLIS
// TODO: context.getString(R.string.notif_rejected_policy_disabled_timeout, (currentTimestampMillis() + remainingTimeMillis).toAbsoluteDateString())
"Rejected policy disabled timeout"
}
else -> {
// TODO: context.getString(R.string.notif_rejected_policy_disabled)
"Rejected policy disabled"
}
},
walletMetadata = walletMetadata,
deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId",
)
}
fun notifyPaymentRejectedOverAbsolute(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, fee: MilliSatoshi, absoluteMax: Satoshi, nextTimeoutRemainingBlocks: Int?): Notification {
return notifyPaymentFailed(
context = context,
title = getTitleForWallet(walletMetadata, context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title,
amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true))),
message = when {
source == LiquidityEvents.Source.OnChainWallet && nextTimeoutRemainingBlocks != null && nextTimeoutRemainingBlocks < SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS -> {
val remainingTimeMillis = nextTimeoutRemainingBlocks * 10 * DateUtils.MINUTE_IN_MILLIS
context.getString(
R.string.notif_rejected_over_absolute_timeout, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true),
absoluteMax.toPrettyString(BitcoinUnit.Sat, withUnit = true), (currentTimestampMillis() + remainingTimeMillis).toAbsoluteDateString()
)
}
else -> {
context.getString(R.string.notif_rejected_over_absolute, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true),
absoluteMax.toPrettyString(BitcoinUnit.Sat, withUnit = true))
}
},
walletMetadata = walletMetadata,
deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId",
)
}
fun notifyPaymentRejectedOverRelative(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, fee: MilliSatoshi, percentMax: Int, nextTimeoutRemainingBlocks: Int?): Notification {
return notifyPaymentFailed(
context = context,
title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title,
amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)),
message = when {
source == LiquidityEvents.Source.OnChainWallet && nextTimeoutRemainingBlocks != null && nextTimeoutRemainingBlocks < SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS -> {
val remainingTimeMillis = nextTimeoutRemainingBlocks * 10 * DateUtils.MINUTE_IN_MILLIS
context.getString(R.string.notif_rejected_over_relative_timeout, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true),
DecimalFormat("0.##").format(percentMax.toDouble() / 100), (currentTimestampMillis() + remainingTimeMillis).toAbsoluteDateString()
)
}
else -> {
context.getString(R.string.notif_rejected_over_relative, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true),
DecimalFormat("0.##").format(percentMax.toDouble() / 100))
}
},
walletMetadata = walletMetadata,
deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId",
)
}
fun notifyPaymentRejectedAmountTooLow(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi): Notification {
return notifyPaymentFailed(
context = context,
title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title,
amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)),
message = context.getString(R.string.notif_rejected_amount_too_low),
walletMetadata = walletMetadata,
deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId",
)
}
fun notifyPaymentRejectedFundingError(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi): Notification {
return notifyPaymentFailed(
context = context,
title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title,
amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)),
message = context.getString(R.string.notif_rejected_generic_error),
walletMetadata = walletMetadata,
deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId",
)
}
fun notifyPaymentMissedAppUnavailable(context: Context, walletMetadata: UserWalletMetadata): Notification {
return notifyPaymentFailed(
context = context,
title = context.getString(R.string.notif_missed_title),
message = context.getString(R.string.notif_missed_unavailable),
walletMetadata = walletMetadata,
deepLink = null
)
}
fun notifyPendingSettlement(context: Context, walletMetadata: UserWalletMetadata): Notification {
return NotificationCompat.Builder(context, SETTLEMENT_PENDING_NOTIF_CHANNEL).apply {
setContentTitle(getTitleForWallet(walletMetadata, context.getString(R.string.notif_pending_settlement_title)))
setContentText(context.getString(R.string.notif_pending_settlement_message))
setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notif_pending_settlement_message)))
setSmallIcon(R.drawable.ic_launcher_foreground)
setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE))
setAutoCancel(true)
}.build().also {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(SETTLEMENT_PENDING_NOTIF_ID, it)
}
}
}
fun notifyInFlightHtlc(context: Context, walletMetadata: UserWalletMetadata): Notification {
return NotificationCompat.Builder(context, SETTLEMENT_PENDING_NOTIF_CHANNEL).apply {
setContentTitle(getTitleForWallet(walletMetadata, context.getString(R.string.notif_inflight_payment_title)))
setContentText(context.getString(R.string.notif_inflight_payment_message))
setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notif_inflight_payment_message)))
setSmallIcon(R.drawable.ic_launcher_foreground)
setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE))
setAutoCancel(true)
}.build().also {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(SETTLEMENT_PENDING_NOTIF_ID, it)
}
}
}
suspend fun notifyPaymentsReceived(
context: Context,
userPrefs: UserPrefs,
walletId: WalletId,
userWalletMetadata: UserWalletMetadata,
paymentId: UUID,
paymentAmount: MilliSatoshi,
rates: List<ExchangeRate>,
): Notification {
val isFiat = userPrefs.getIsAmountInFiat.first() && rates.isNotEmpty()
val unit = if (isFiat) {
userPrefs.getFiatCurrencies.first().primary
} else {
userPrefs.getBitcoinUnits.first().primary
}
val rate = if (isFiat) {
when (val rate = rates.find { it.fiatCurrency == unit }) {
is ExchangeRate.BitcoinPriceRate -> rate
is ExchangeRate.UsdPriceRate -> {
(rates.find { it.fiatCurrency == FiatCurrency.USD } as? ExchangeRate.BitcoinPriceRate)?.let { usdRate ->
// create a BTC/Fiat price rate using the USD/BTC rate and the Fiat/USD rate.
ExchangeRate.BitcoinPriceRate(
fiatCurrency = rate.fiatCurrency,
price = rate.price * usdRate.price,
source = rate.source,
timestampMillis = rate.timestampMillis
)
}
}
else -> null
}
} else null
return NotificationCompat.Builder(context, PAYMENT_RECEIVED_NOTIF_CHANNEL).apply {
setContentTitle(context.getString(R.string.notif_headless_received, userWalletMetadata.avatar, paymentAmount.toPrettyString(unit, rate, withUnit = true)))
setSmallIcon(R.drawable.ic_launcher_foreground)
val intent = Intent(Intent.ACTION_VIEW,"phoenix:payments/$walletId/$paymentId".toUri(), context, MainActivity::class.java).apply {
Intent.FLAG_ACTIVITY_SINGLE_TOP
}
setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT))
setAutoCancel(true)
}.build().also {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(currentTimestampMillis().toInt(), it)
}
}
}
fun notifyRevokedCommits(context: Context) {
NotificationCompat.Builder(context, CHANNELS_WATCHER_ALERT_CHANNEL).apply {
setContentTitle(context.getString(R.string.notif_watcher_revoked_commit_title))
setContentText(context.getString(R.string.notif_watcher_revoked_commit_message))
setSmallIcon(R.drawable.ic_launcher_foreground)
setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE))
setAutoCancel(true)
}.let {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(CHANNELS_WATCHER_ALERT_ID, it.build())
}
}
}
}
@@ -1,55 +0,0 @@
/*
* Copyright 2025 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.utils.converters
import android.annotation.SuppressLint
import android.text.format.DateUtils
import androidx.compose.runtime.Composable
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import kotlin.math.abs
object DateFormatter {
/** Converts this millis timestamp into a relative string date. */
@Composable
fun Long.toRelativeDateString(): String {
val now = System.currentTimeMillis()
val delay: Long = this - now
return if (abs(delay) < 60 * 1000L) { // less than 1 minute ago
// TODO: stringResource(id = R.string.utils_date_just_now)
"Just now"
} else {
DateUtils.getRelativeTimeSpanString(this, now, delay).toString()
}
}
/** Converts this millis timestamp into a pretty, absolute string date time using the locale format. */
fun Long.toAbsoluteDateTimeString(): String = DateFormat.getDateTimeInstance().format(Date(this))
/** Converts this millis timestamp into a pretty, absolute string date using the locale format. */
fun Long.toAbsoluteDateString(): String = DateFormat.getDateInstance().format(Date(this))
/** Converts this millis timestamp into an year-month-day string. */
@SuppressLint("SimpleDateFormat")
fun Long.toBasicAbsoluteDateString(): String = SimpleDateFormat("yyyy-MM-dd").format(Date(this))
/** Converts this millis timestamp into an year-month-day string. */
@SuppressLint("SimpleDateFormat")
fun Long.toBasicAbsoluteDateTimeString(): String = SimpleDateFormat("yyyyMMdd-HHmmss").format(Date(this))
}
@@ -1,39 +0,0 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.utils.extensions
import android.content.Context
import android.content.ContextWrapper
import press.mantra.android.MainActivity
fun Context.findActivitySafe(): MainActivity? {
var context = this
while (context is ContextWrapper) {
if (context is MainActivity) return context
context = context.baseContext
}
return null
}
fun Context.findActivity(): MainActivity {
var context = this
while (context is ContextWrapper) {
if (context is MainActivity) return context
context = context.baseContext
}
throw IllegalStateException("not in the context of the main Phoenix activity")
}
@@ -1,35 +0,0 @@
package fr.acinq.phoenix.utils.extensions
import co.touchlab.kermit.Logger
import fr.acinq.phoenix.data.DecryptSeedResult
import kotlinx.serialization.SerializationException
import java.security.KeyStoreException
actual inline fun gracefulSingleSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult = try {
action.invoke()
} catch (e: Exception) {
return when (e) {
is KeyStoreException -> DecryptSeedResult.Failure.KeyStoreFailure(e)
else -> DecryptSeedResult.Failure.DecryptionError(e)
}
}
actual inline fun gracefulMultiSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult = try {
action.invoke()
} catch (e: Exception) {
val log = Logger.withTag("gracefulMultiSeedDecryption")
return when (e) {
is SerializationException, is IllegalArgumentException -> {
log.e("failed to decrypt [V2.MultipleSeed]: ${e.javaClass.simpleName}")
DecryptSeedResult.Failure.SerializationError
}
is KeyStoreException -> {
log.e("failed to decrypt [V2.MultipleSeed]: ", e)
DecryptSeedResult.Failure.KeyStoreFailure(e)
}
else -> {
log.e("failed to decrypt [V2.MultipleSeed]: ", e)
DecryptSeedResult.Failure.DecryptionError(e)
}
}
}
@@ -1,44 +0,0 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.utils.extensions
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
/**
* Utility method rebinding any exceptions thrown by a method into another exception, using the origin exception as the root cause.
* Helps with pattern matching.
*/
inline fun <T> tryWith(exception: Exception, action: () -> T): T = try {
action.invoke()
} catch (t: Exception) {
exception.initCause(t)
throw exception
}
inline fun <T1 : Any, T2 : Any, R : Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2) -> R?): R? {
return if (p1 != null && p2 != null) block(p1, p2) else null
}
@OptIn(ExperimentalContracts::class)
inline fun <T, R> T.ifLet(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}
@@ -1,31 +0,0 @@
package fr.acinq.phoenix.utils.logger
import co.touchlab.kermit.LogWriter
import co.touchlab.kermit.Severity
import co.touchlab.kermit.Severity.Assert
import co.touchlab.kermit.Severity.Debug
import co.touchlab.kermit.Severity.Info
import co.touchlab.kermit.Severity.Verbose
import co.touchlab.kermit.Severity.Warn
import fr.acinq.phoenix.utils.PlatformContext
import org.slf4j.LoggerFactory
/**
* Use SLF4J writer on Android. Note that writing logs to Logcat is already done in
* phoenix-android SLF4J configuration.
*/
actual fun phoenixLogWriters(ctx: PlatformContext): List<LogWriter> = listOf(Slf4jLogWriter())
class Slf4jLogWriter : LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val logger = LoggerFactory.getLogger(tag)
when (severity) {
Verbose -> logger.trace(message, throwable)
Debug -> logger.debug(message, throwable)
Info -> logger.info(message, throwable)
Warn -> logger.warn(message, throwable)
Severity.Error -> logger.error(message, throwable)
Assert -> logger.error(message, throwable)
}
}
}
@@ -1,48 +0,0 @@
/*
* Copyright 2025 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.utils.nfc
import fr.acinq.bitcoin.ByteVector
object ApduCommands {
private const val SELECT_FILE = "00A4000C02"
// structure is CLA-INS-P1-P2 -- NDEF AID -- LE field
// This AID is also the one defined in res/xml/apduservice.xml
val SELECT_AID = ByteVector("00a4040007" + "d2760000850101")
// LE field is optional, we should accept the command if LE is set
val SELECT_AID_LE = SELECT_AID.concat(0x00)
// file id of the CC file is 0xe103
val SELECT_CC_FILE = ByteVector(SELECT_FILE + "E103")
// file id of the ndef file is e104
val SELECT_NDEF_FILE = ByteVector(SELECT_FILE + "E104")
// file identifier = ndef
// maximum ndef file size = 1024b
// read/write access 00
val READ_CC = ByteVector("00B000000F")
val READ_CC_RESPONSE = ByteVector("000F20003B00340406" + "E104" + "4000" + "0000" + "9000").toByteArray()
val READ_NDEF_BINARY_LENGTH = ByteVector("00B0000002")
val READ_NDEF_BINARY = ByteVector("00B0").toByteArray()
val A_OKAY = ByteVector("9000").toByteArray()
val A_ERROR = ByteVector("6A82").toByteArray()
val NDEF_FILE = ByteVector("E104").toByteArray()
}
@@ -1,50 +0,0 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.utils.nfc
import android.nfc.NdefRecord
object NfcHelper {
fun createTextRecord(text: String, id: ByteArray): NdefRecord {
val languageBytes = "en".toByteArray(Charsets.US_ASCII)
val langLength = languageBytes.size and 0x3F
val textBytes = text.toByteArray(Charsets.UTF_8)
val payload = byteArrayOf(langLength.toByte()) + languageBytes.copyOfRange(0, langLength) + textBytes
return NdefRecord(NdefRecord.TNF_WELL_KNOWN, NdefRecord.RTD_TEXT, id, payload)
}
fun fillByteArrayToFixedDimension(array: ByteArray, fixedSize: Int): ByteArray =
if (array.size >= fixedSize) {
array.copyOfRange(0, fixedSize)
} else {
fillByteArrayToFixedDimension(byteArrayOf(0x00) + array, fixedSize)
}
fun intToByteArray(value: Int): ByteArray {
if (value == 0) return byteArrayOf(0)
val bytes = mutableListOf<Byte>()
var temp = value
while (temp != 0) {
bytes.add(0, (temp and 0xFF).toByte()) // prepend to preserve big-endian order
temp = temp ushr 8
}
return bytes.toByteArray()
}
}
@@ -1,72 +0,0 @@
/*
* Copyright 2025 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.utils.nfc
import android.nfc.NdefRecord
import org.slf4j.LoggerFactory
object NdefParser {
private val log = LoggerFactory.getLogger(this::class.java)
@OptIn(ExperimentalStdlibApi::class)
fun parseNdefRecord(record: NdefRecord): String? {
val tnf = record.tnf
val type = record.type
val payload = record.payload
log.info("parsing ndef record tnf=$tnf type=${type.toHexString()} payload=${payload.decodeToString()}")
return try {
when (tnf) {
NdefRecord.TNF_WELL_KNOWN -> {
if (type.contentEquals(NdefRecord.RTD_TEXT)) {
parseTextRecord(payload)
} else if (type.contentEquals(NdefRecord.RTD_URI)) {
parseUriRecord(payload)
} else {
log.debug("unhandled well-known record with type={}", type)
null
}
}
NdefRecord.TNF_ABSOLUTE_URI -> {
parseAbsoluteUriRecord(payload)
}
else -> {
log.debug("unhandled tnf={}", tnf)
null
}
}
} catch (e: Exception) {
log.warn("failed to parse ndef record: ${e.message}")
null
}
}
private fun parseTextRecord(payload: ByteArray): String {
val textEncoding = if (((payload[0].toInt() and 0x80) == 0)) "UTF-8" else "UTF-16"
val languageCodeLength = payload[0].toInt() and 0x3F
return String(payload, languageCodeLength + 1, payload.size - languageCodeLength - 1, charset(textEncoding))
}
private fun parseUriRecord(payload: ByteArray): String {
// URI records have a prefix byte followed by the URI
return String(payload, 1, payload.size - 1, Charsets.UTF_8)
}
private fun parseAbsoluteUriRecord(payload: ByteArray): String {
return String(payload, Charsets.UTF_8)
}
}
@@ -1,141 +0,0 @@
/*
* Copyright 2025 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.utils.nfc
import android.nfc.NdefMessage
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.IsoDep
import com.machankura.compose.ui.composable.widgets.nfc.NfcState
import com.machankura.compose.ui.composable.widgets.nfc.NfcStateRepository
import fr.acinq.lightning.utils.toByteVector
import fr.acinq.phoenix.android.services.HceService
import org.slf4j.LoggerFactory
/**
* This class retrieves the first NDEF message in an NFC tag.
*
* Sends a sequence of APDU commands to the tag, mirroring what's done in [HceService].
*/
class NfcReaderCallback(val onFoundData: (String) -> Unit) : NfcAdapter.ReaderCallback {
private val log = LoggerFactory.getLogger(this::class.java)
@OptIn(ExperimentalStdlibApi::class)
override fun onTagDiscovered(tag: Tag?) {
log.info("discovered tag=$tag")
val isoDep = IsoDep.get(tag)
if (isoDep == null) {
log.info("aborting tag discovery: tag does not support iso-dep")
return
}
if (!NfcStateRepository.isReading()) {
log.info("aborting tag discovery: nfc_state=${NfcStateRepository.state.value}")
isoDep.close()
return
}
try {
log.debug("connecting to tag={}", tag)
isoDep.connect()
log.info("nfc reader connected, starting communication...")
val selectCommand = ApduCommands.SELECT_AID
log.debug("SEND select aid: ${selectCommand.toByteArray().toHexString()}")
val responseSelect = isoDep.transceive(selectCommand.toByteArray())
log.debug("RECV select aid: ${responseSelect.toByteVector().toHex()}")
if (!responseSelect.contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for select aid: ${responseSelect.toHexString()}")
}
log.debug("SEND select cc: ${ApduCommands.SELECT_CC_FILE.toByteArray().toHexString()}")
val selectCC = isoDep.transceive(ApduCommands.SELECT_CC_FILE.toByteArray())
log.debug("RECV select cc: ${selectCC.toByteVector().toHex()}")
if (!selectCC.contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for select cc: ${selectCC.toHexString()}")
}
log.debug("SEND read cc: ${ApduCommands.READ_CC.toByteArray().toHexString()}")
val responseReadCC = isoDep.transceive(ApduCommands.READ_CC.toByteArray())
log.debug("RECV read cc: ${responseReadCC.toByteVector().toHex()}")
if (!responseReadCC.takeLast(2).toByteArray().contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for read cc: ${responseReadCC.toByteVector().toHex()}")
}
log.debug("SEND select ndef: ${ApduCommands.SELECT_NDEF_FILE.toByteArray().toHexString()}")
val selectNdefFile = isoDep.transceive(ApduCommands.SELECT_NDEF_FILE.toByteArray())
log.debug("RECV select ndef: ${selectNdefFile.toByteVector().toHex()}")
if (!selectNdefFile.contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for select ndef: ${selectNdefFile.toByteVector().toHex()}")
}
log.debug("SEND select bin len: ${ApduCommands.READ_NDEF_BINARY_LENGTH.toByteArray().toHexString()}")
val readBinaryLengthResponse = isoDep.transceive(ApduCommands.READ_NDEF_BINARY_LENGTH.toByteArray())
log.debug("RECV select bin len: ${readBinaryLengthResponse.toByteVector().toHex()}")
val (c,s) = readBinaryLengthResponse.dropLast(2) to readBinaryLengthResponse.takeLast(2)
if (!s.toByteArray().contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for bin length: ${s.toByteArray().toHexString()}")
}
val messageLength = c.toByteArray().toHexString().toInt(16)
log.debug("binary ndef message length=$messageLength")
// depending on the length of the message contained in the tag, we send several commands to retrieve the
// data in chunks
var offset = 2
val chunkSize = 59
var result = byteArrayOf()
while (offset < messageLength) {
val size = if (offset + chunkSize > messageLength) {
messageLength - offset + 2
} else {
chunkSize
}
val b = NfcHelper.intToByteArray(offset) + NfcHelper.intToByteArray(size)
val padded = ApduCommands.READ_NDEF_BINARY + NfcHelper.fillByteArrayToFixedDimension(b, 3)
log.debug("SEND read bin (o=$offset, len=$size): ${padded.toHexString()}")
val r = isoDep.transceive(padded)
log.debug("RECV read bin: ${r.toHexString()}")
if (!r.takeLast(2).toByteArray().contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for bin read (o=$offset len=$size): ${r.toHexString()}")
}
result += r.dropLast(2) // must drop A_OKAY suffix
offset += chunkSize
}
val m = NdefMessage(result)
log.info("successfully read tag, found ndef_message=$m")
val d = m.records.mapNotNull {
NdefParser.parseNdefRecord(it)
}
d.firstOrNull()?.let {
onFoundData(it)
}
} catch (e: Exception) {
log.error("failed to read tag: ", e)
} finally {
isoDep.close()
if (NfcStateRepository.isReading()) {
NfcStateRepository.updateState(NfcState.Inactive)
}
log.info("terminated nfc reader callback")
}
}
}
@@ -1,62 +0,0 @@
package fr.acinq.phoenixd.conf
import fr.acinq.bitcoin.Chain
import fr.acinq.bitcoin.PublicKey
import fr.acinq.lightning.CltvExpiryDelta
import fr.acinq.lightning.DefaultSwapInParams
import fr.acinq.lightning.InvoiceDefaultRoutingFees
import fr.acinq.lightning.NodeUri
import fr.acinq.lightning.SwapInParams
import fr.acinq.lightning.TrampolineFees
import fr.acinq.lightning.WalletParams
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
data class LSP(val walletParams: WalletParams, val swapInXpub: String) {
companion object {
private val trampolineFees = listOf(
TrampolineFees(
feeBase = 4.sat,
feeProportional = 4_000,
cltvExpiryDelta = CltvExpiryDelta(576)
)
)
private val invoiceDefaultRoutingFees = InvoiceDefaultRoutingFees(
feeBase = 1_000.msat,
feeProportional = 100,
cltvExpiryDelta = CltvExpiryDelta(144)
)
private val swapInParams = SwapInParams(
minConfirmations = DefaultSwapInParams.MinConfirmations,
maxConfirmations = DefaultSwapInParams.MaxConfirmations,
refundDelay = DefaultSwapInParams.RefundDelay,
)
fun from(chain: Chain) = when (chain) {
is Chain.Mainnet -> LSP(
swapInXpub = "xpub69q3sDXXsLuHVbmTrhqmEqYqTTsXJKahdfawXaYuUt6muf1PbZBnvqzFcwiT8Abpc13hY8BFafakwpPbVkatg9egwiMjed1cRrPM19b2Ma7",
walletParams = WalletParams(
trampolineNode = NodeUri(PublicKey.fromHex("03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"), "3.33.236.230", 9735),
trampolineFees,
invoiceDefaultRoutingFees,
swapInParams
)
)
is Chain.Testnet3 -> LSP(
swapInXpub = "tpubDAmCFB21J9ExKBRPDcVxSvGs9jtcf8U1wWWbS1xTYmnUsuUHPCoFdCnEGxLE3THSWcQE48GHJnyz8XPbYUivBMbLSMBifFd3G9KmafkM9og",
walletParams = WalletParams(
trampolineNode = NodeUri(PublicKey.fromHex("03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134"), "13.248.222.197", 9735),
trampolineFees,
invoiceDefaultRoutingFees,
swapInParams
)
)
else -> error("unsupported chain $chain")
}
}
}
@@ -1,36 +0,0 @@
package fr.acinq.phoenixd.conf
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.MnemonicCode
import fr.acinq.conf.SeedSpec
import fr.acinq.lightning.Lightning.randomBytes
import fr.acinq.lightning.utils.toByteVector
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readString
import kotlinx.io.writeString
data class PhoenixSeed(val seed: ByteVector, val isNew: Boolean, val path: Path?)
/**
* @return a pair with the seed and a boolean indicating whether the seed was newly generated
*/
fun SeedSpec.SeedPath.getOrGenerateSeed(): PhoenixSeed {
try {
val (mnemonics, isNew) = if (SystemFileSystem.exists(path)) {
val contents = SystemFileSystem.source(path).buffered().use { it.readString() }
val mnemonics = Regex("[a-z]+").findAll(contents).map { it.value }.toList()
mnemonics to false
} else {
val entropy = randomBytes(16)
val mnemonics = MnemonicCode.toMnemonics(entropy)
SystemFileSystem.sink(path).buffered().use { it.writeString(mnemonics.joinToString(" ")) }
mnemonics to true
}
MnemonicCode.validate(mnemonics)
return PhoenixSeed(seed = MnemonicCode.toSeed(mnemonics, "").toByteVector(), isNew = isNew, path = path)
} catch (t: Throwable) {
throw Throwable(t.message)
}
}
@@ -1,9 +0,0 @@
package fr.acinq.conf
import fr.acinq.bitcoin.ByteVector
import kotlinx.io.files.Path
sealed class SeedSpec {
data class Manual(val seed: ByteVector) : SeedSpec()
data class SeedPath(val path: Path) : SeedSpec()
}
@@ -1,60 +0,0 @@
/*
* Copyright 2020 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.navigation.NavController
import fr.acinq.phoenix.controllers.ControllerFactory
import fr.acinq.phoenix.data.BitcoinUnit
import fr.acinq.phoenix.data.CurrencyUnit
import fr.acinq.phoenix.data.ExchangeRate
import fr.acinq.phoenix.data.FiatCurrency
import fr.acinq.phoenix.data.PreferredFiatCurrencies
import fr.acinq.phoenix.data.UserTheme
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.utils.preferences.InternalPrefs
import fr.acinq.phoenix.utils.preferences.PreferredBitcoinUnits
import fr.acinq.phoenix.utils.preferences.UserPrefs
typealias CF = ControllerFactory
val LocalTheme = staticCompositionLocalOf { UserTheme.SYSTEM }
val LocalWalletId = compositionLocalOf<WalletId?> { null }
val LocalBusiness = compositionLocalOf<PhoenixBusiness?> { null }
val LocalUserPrefs = staticCompositionLocalOf<UserPrefs?> { null }
val LocalInternalPrefs = staticCompositionLocalOf<InternalPrefs?> { null }
val LocalControllerFactory = staticCompositionLocalOf<ControllerFactory?> { null }
val LocalNavController = staticCompositionLocalOf<NavController?> { null }
val LocalBitcoinUnits = compositionLocalOf { PreferredBitcoinUnits(primary = BitcoinUnit.Sat) }
val LocalFiatCurrencies = compositionLocalOf { PreferredFiatCurrencies(primary = FiatCurrency.USD, others = emptyList()) }
val LocalExchangeRatesMap = compositionLocalOf<Map<FiatCurrency, ExchangeRate.BitcoinPriceRate>> { emptyMap() }
val LocalShowInFiat = compositionLocalOf { false }
val isDarkTheme: Boolean
@Composable
get() = LocalTheme.current.let { it == UserTheme.DARK || (it == UserTheme.SYSTEM && isSystemInDarkTheme()) }
val preferredAmountUnit: CurrencyUnit
@Composable
get() = if (LocalShowInFiat.current) LocalFiatCurrencies.current.primary else LocalBitcoinUnits.current.primary
val primaryFiatRate: ExchangeRate.BitcoinPriceRate?
@Composable
get() = LocalFiatCurrencies.current.primary.let { prefFiat -> LocalExchangeRatesMap.current[prefFiat] }
@@ -1,185 +0,0 @@
/*
* Copyright 2022 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix
import fr.acinq.bitcoin.Chain
import fr.acinq.lightning.blockchain.electrum.ElectrumClient
import fr.acinq.lightning.blockchain.electrum.ElectrumWatcher
import fr.acinq.lightning.io.TcpSocket
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.debug
import fr.acinq.lightning.logging.info
import fr.acinq.phoenix.controllers.CloseChannelsConfigurationController
import fr.acinq.phoenix.controllers.ConfigurationController
import fr.acinq.phoenix.controllers.ContentController
import fr.acinq.phoenix.controllers.ControllerFactory
import fr.acinq.phoenix.controllers.ElectrumConfigurationController
import fr.acinq.phoenix.controllers.HomeController
import fr.acinq.phoenix.controllers.InitializationController
import fr.acinq.phoenix.controllers.ReceiveController
import fr.acinq.phoenix.controllers.RestoreWalletController
import fr.acinq.phoenix.controllers.config.AppCloseChannelsConfigurationController
import fr.acinq.phoenix.controllers.config.AppConfigurationController
import fr.acinq.phoenix.controllers.config.AppElectrumConfigurationController
import fr.acinq.phoenix.controllers.init.AppInitController
import fr.acinq.phoenix.controllers.init.AppRestoreWalletController
import fr.acinq.phoenix.controllers.main.AppContentController
import fr.acinq.phoenix.controllers.main.AppHomeController
import fr.acinq.phoenix.controllers.payments.AppReceiveController
import fr.acinq.phoenix.data.StartupParams
import fr.acinq.phoenix.managers.AppConfigurationManager
import fr.acinq.phoenix.managers.AppConnectionsDaemon
import fr.acinq.phoenix.managers.BalanceManager
import fr.acinq.phoenix.managers.ConnectionsManager
import fr.acinq.phoenix.managers.DataStoreManager
import fr.acinq.phoenix.managers.DatabaseManager
import fr.acinq.phoenix.managers.LnurlManager
import fr.acinq.phoenix.managers.NodeParamsManager
import fr.acinq.phoenix.managers.NotificationsManager
import fr.acinq.phoenix.managers.PaymentsManager
import fr.acinq.phoenix.managers.PeerManager
import fr.acinq.phoenix.managers.SendManager
import fr.acinq.phoenix.managers.WalletManager
import fr.acinq.phoenix.managers.global.CurrencyManager
import fr.acinq.phoenix.managers.global.FeerateManager
import fr.acinq.phoenix.utils.BlockchainExplorer
import fr.acinq.phoenix.utils.logger.PhoenixLoggerConfig
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlin.time.Duration.Companion.seconds
data class BusinessRunning(
val business: PhoenixBusiness,
val isHeadless: Boolean,
)
data class BusinessMonitorJobs(
val monitorHeadlessPaymentsJob: Job?, // null if the business is not headless, which is something that can change over time
val monitorNodeEventsJob: Job,
val monitorFcmTokenJob: Job,
val monitorInFlightPaymentsJob: Job,
)
class PhoenixBusiness(
val phoenixGlobal: PhoenixGlobal,
) {
// this logger factory will be used throughout the project (including dependencies like lightning-kmp) to
// create new [Logger] instances, and output logs to platform dependent writers.
val loggerFactory = LoggerFactory(PhoenixLoggerConfig(phoenixGlobal.ctx))
private val logger = loggerFactory.newLogger(this::class)
private val tcpSocketBuilder = TcpSocket.Builder()
internal val tcpSocketBuilderFactory = suspend {
tcpSocketBuilder
}
val chain: Chain = NodeParamsManager.chain
val electrumClient by lazy { ElectrumClient(scope = MainScope(), loggerFactory = loggerFactory, pingInterval = 30.seconds, rpcTimeout = 10.seconds) }
val electrumWatcher by lazy { ElectrumWatcher(electrumClient, MainScope(), loggerFactory) }
var appConnectionsDaemon: AppConnectionsDaemon? = null
val walletManager by lazy { WalletManager(chain) }
val nodeParamsManager by lazy { NodeParamsManager(this) }
val databaseManager by lazy { DatabaseManager(this) }
val dataStoreManager by lazy { DataStoreManager(this) }
val peerManager by lazy { PeerManager(this) }
val paymentsManager by lazy { PaymentsManager(this) }
val balanceManager by lazy { BalanceManager(this) }
val appConfigurationManager by lazy { AppConfigurationManager(this) }
val connectionsManager by lazy { ConnectionsManager(this) }
val lnurlManager by lazy { LnurlManager(this) }
val notificationsManager by lazy { NotificationsManager(this) }
val blockchainExplorer by lazy { BlockchainExplorer(chain) }
val sendManager by lazy { SendManager(this) }
val feerateManager by lazy { FeerateManager(loggerFactory) }
val currencyManager by lazy { CurrencyManager(loggerFactory, phoenixGlobal.appDb) }
fun start(startupParams: StartupParams) {
logger.debug { "starting with params=$startupParams" }
if (appConnectionsDaemon == null) {
logger.debug { "start business" }
appConfigurationManager.setStartupParams(startupParams)
appConnectionsDaemon = AppConnectionsDaemon(this)
}
}
/**
* Cancels the CoroutineScope of all managers, and closes all database connections.
* It's recommended that you close the network connections (electrum + peer)
* BEFORE invoking this function, to ensure a clean disconnect from the server.
*/
fun stop() {
logger.info { "stopping business" }
electrumClient.stop()
electrumWatcher.stop()
electrumWatcher.cancel()
appConnectionsDaemon?.cancel()
notificationsManager.cancel()
paymentsManager.cancel()
walletManager.cancel()
nodeParamsManager.cancel()
peerManager.peerState.value?.cancel()
peerManager.cancel()
appConfigurationManager.cancel()
databaseManager.close()
databaseManager.cancel()
lnurlManager.cancel()
logger.info { "stopped business" }
}
// The (node_id, fcm_token) tuple only needs to be registered once.
// And after that, only if the tuple changes (e.g. different fcm_token).
suspend fun registerFcmToken(token: String?) {
logger.debug { "registering token=$token" }
peerManager.getPeer().registerFcmToken(token)
}
private val _this = this
val controllers: ControllerFactory = object : ControllerFactory {
override fun content(): ContentController =
AppContentController(_this)
override fun initialization(): InitializationController =
AppInitController(_this)
override fun home(): HomeController =
AppHomeController(_this)
override fun receive(): ReceiveController =
AppReceiveController(_this)
override fun restoreWallet(): RestoreWalletController =
AppRestoreWalletController(_this)
override fun configuration(): ConfigurationController =
AppConfigurationController(_this)
override fun electrumConfiguration(): ElectrumConfigurationController =
AppElectrumConfigurationController(_this)
override fun closeChannelsConfiguration(): CloseChannelsConfigurationController =
AppCloseChannelsConfigurationController(_this, isForceClose = false)
override fun forceCloseChannelsConfiguration(): CloseChannelsConfigurationController =
AppCloseChannelsConfigurationController(_this, isForceClose = true)
}
}
@@ -1,61 +0,0 @@
/*
* Copyright 2025 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.info
import fr.acinq.phoenix.db.SqliteAppDb
import fr.acinq.phoenix.db.createAppDbDriver
import fr.acinq.phoenix.managers.AppConnectionsDaemon
import fr.acinq.phoenix.managers.global.CurrencyManager
import fr.acinq.phoenix.managers.global.FeerateManager
import fr.acinq.phoenix.managers.global.NetworkMonitor
import fr.acinq.phoenix.managers.global.WalletContextManager
import fr.acinq.phoenix.utils.PlatformContext
import fr.acinq.phoenix.utils.logger.PhoenixLoggerConfig
class PhoenixGlobal(val ctx: PlatformContext) {
val loggerFactory = LoggerFactory(PhoenixLoggerConfig(ctx))
private val logger = loggerFactory.newLogger(this::class)
val appDb by lazy { SqliteAppDb(createAppDbDriver(ctx)) }
val networkMonitor by lazy { NetworkMonitor(loggerFactory, ctx) }
val currencyManager by lazy { CurrencyManager(loggerFactory, appDb) }
val feerateManager by lazy { FeerateManager(loggerFactory) }
val walletContextManager by lazy { WalletContextManager(loggerFactory) }
init {
logger.info { "init PhoenixGlobal..." }
}
/** Called by [AppConnectionsDaemon] when internet is available. */
internal fun enableNetworkAccess() {
feerateManager.startMonitoringFeerate()
walletContextManager.stopJobs()
currencyManager.enableNetworkAccess()
}
/** Called by [AppConnectionsDaemon] when no connection is available. */
internal fun disableNetworkAccess() {
feerateManager.stopMonitoringFeerate()
walletContextManager.stopJobs()
currencyManager.disableNetworkAccess()
}
}
@@ -1,78 +0,0 @@
package fr.acinq.phoenix.controllers
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.debug
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
abstract class AppController<M : MVI.Model, I : MVI.Intent>(
loggerFactory: LoggerFactory,
firstModel: M
) : MVI.Controller<M, I>(firstModel), CoroutineScope {
private val job = Job()
override val coroutineContext = MainScope().coroutineContext + job
protected val logger = loggerFactory.newLogger(this::class)
internal val models = MutableStateFlow(firstModel)
private val modelChanges = Channel<M.() -> M>()
init {
fun truncateLog(m: M): String {
val s = m.toString().lines().joinToString(" ")
return if (s.length > 100) {
"${s.take(100)} (truncated)"
} else {
s
}
}
logger.debug { "initial model=${truncateLog(firstModel)}" }
launch {
modelChanges.consumeEach { change ->
val newModel = models.value.change()
logger.debug { "model=${truncateLog(newModel)}" }
models.value = newModel
}
}
}
final override fun subscribe(onModel: (M) -> Unit): () -> Unit {
val subscription = launch {
models.collect { onModel(it) }
}
return ({ subscription.cancel() })
}
protected suspend fun model(change: M.() -> M) {
modelChanges.send(change)
}
protected suspend fun model(model: M) {
modelChanges.send { model }
}
protected abstract fun process(intent: I)
final override fun intent(intent: I) {
logger.debug { "intent=$intent" }
process(intent)
}
final override fun stop() {
job.cancel()
}
}
@@ -1,33 +0,0 @@
package fr.acinq.phoenix.controllers
import fr.acinq.phoenix.controllers.config.CloseChannelsConfiguration
import fr.acinq.phoenix.controllers.config.Configuration
import fr.acinq.phoenix.controllers.config.ElectrumConfiguration
import fr.acinq.phoenix.controllers.init.Initialization
import fr.acinq.phoenix.controllers.init.RestoreWallet
import fr.acinq.phoenix.controllers.main.Content
import fr.acinq.phoenix.controllers.main.Home
import fr.acinq.phoenix.controllers.payments.Receive
typealias ContentController = MVI.Controller<Content.Model, Content.Intent>
typealias HomeController = MVI.Controller<Home.Model, Home.Intent>
typealias InitializationController = MVI.Controller<Initialization.Model, Initialization.Intent>
typealias ReceiveController = MVI.Controller<Receive.Model, Receive.Intent>
typealias RestoreWalletController = MVI.Controller<RestoreWallet.Model, RestoreWallet.Intent>
typealias CloseChannelsConfigurationController = MVI.Controller<CloseChannelsConfiguration.Model, CloseChannelsConfiguration.Intent>
typealias ConfigurationController = MVI.Controller<Configuration.Model, Configuration.Intent>
typealias ElectrumConfigurationController = MVI.Controller<ElectrumConfiguration.Model, ElectrumConfiguration.Intent>
/** Lets us define different implementation for the controllers, which is useful for mocks. */
interface ControllerFactory {
fun content(): ContentController
fun initialization(): InitializationController
fun home(): HomeController
fun receive(): ReceiveController
fun restoreWallet(): RestoreWalletController
fun configuration(): ConfigurationController
fun electrumConfiguration(): ElectrumConfigurationController
fun closeChannelsConfiguration(): CloseChannelsConfigurationController
fun forceCloseChannelsConfiguration(): CloseChannelsConfigurationController
}
@@ -1,32 +0,0 @@
package fr.acinq.phoenix.controllers
object MVI {
abstract class Data {
override fun toString(): String = this::class.simpleName ?: super.toString()
}
abstract class Model : Data()
abstract class Intent : Data()
abstract class Controller<M : Model, I : Intent>(val firstModel: M) {
abstract fun subscribe(onModel: (M) -> Unit): () -> Unit
abstract fun intent(intent: I)
abstract fun stop()
open class Mock<M : Model, I : Intent>(val model: M) : Controller<M, I>(model) {
override fun subscribe(onModel: (M) -> Unit): () -> Unit {
onModel(model)
return ({})
}
override fun intent(intent: I) {}
override fun stop() {}
}
}
}
@@ -1,42 +0,0 @@
package fr.acinq.phoenix.controllers.config
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.phoenix.controllers.MVI
object CloseChannelsConfiguration {
sealed class Model : MVI.Model() {
object Loading : Model()
data class Ready(
val channels: List<ChannelInfo>,
val address: String // this wallet's bitcoin address
) : Model()
data class ChannelsClosed(
val channels: List<ChannelInfo>,
val closing: Set<ByteVector32> // list of channel ids to close
) : Model()
data class ChannelInfo(
val id: ByteVector32,
val balance: Satoshi?,
val status: ChannelInfoStatus
)
enum class ChannelInfoStatus {
Normal,
Offline,
Syncing,
Closing,
Closed,
Aborted
}
}
sealed class Intent : MVI.Intent() {
data class MutualCloseAllChannels(val address: String, val feerate: FeeratePerKw) : Intent()
object ForceCloseAllChannels : Intent()
}
}
@@ -1,172 +0,0 @@
package fr.acinq.phoenix.controllers.config
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Chain
import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.channel.ChannelCommand
import fr.acinq.lightning.channel.states.Aborted
import fr.acinq.lightning.channel.states.ChannelState
import fr.acinq.lightning.channel.states.Closed
import fr.acinq.lightning.channel.states.Closing
import fr.acinq.lightning.channel.states.Normal
import fr.acinq.lightning.channel.states.Offline
import fr.acinq.lightning.channel.states.Syncing
import fr.acinq.lightning.io.WrappedChannelCommand
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.info
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.controllers.AppController
import fr.acinq.phoenix.controllers.config.CloseChannelsConfiguration.Model.ChannelInfoStatus
import fr.acinq.phoenix.managers.AppConfigurationManager
import fr.acinq.phoenix.managers.PeerManager
import fr.acinq.phoenix.managers.phoenixFinalWallet
import fr.acinq.phoenix.utils.Parser
import fr.acinq.phoenix.utils.extensions.localBalance
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
class AppCloseChannelsConfigurationController(
loggerFactory: LoggerFactory,
private val peerManager: PeerManager,
private val appConfigurationManager: AppConfigurationManager,
private val chain: Chain,
private val isForceClose: Boolean
) : AppController<CloseChannelsConfiguration.Model, CloseChannelsConfiguration.Intent>(
loggerFactory = loggerFactory,
firstModel = CloseChannelsConfiguration.Model.Loading
) {
constructor(business: PhoenixBusiness, isForceClose: Boolean): this(
loggerFactory = business.loggerFactory,
peerManager = business.peerManager,
appConfigurationManager = business.appConfigurationManager,
chain = business.chain,
isForceClose = isForceClose
)
private var closingChannelIds: Set<ByteVector32>? = null
private fun channelInfoStatus(channel: ChannelState): ChannelInfoStatus? = when (channel) {
is Normal -> ChannelInfoStatus.Normal
is Offline -> ChannelInfoStatus.Offline
is Syncing -> ChannelInfoStatus.Syncing
is Closing -> ChannelInfoStatus.Closing
is Closed -> ChannelInfoStatus.Closed
is Aborted -> ChannelInfoStatus.Aborted
else -> null
}
private fun isMutualClosable(channelInfoStatus: ChannelInfoStatus): Boolean = when (channelInfoStatus) {
ChannelInfoStatus.Normal -> true
else -> false
}
private fun isForceClosable(channelInfoStatus: ChannelInfoStatus): Boolean = when (channelInfoStatus) {
ChannelInfoStatus.Normal -> true
ChannelInfoStatus.Offline -> true
ChannelInfoStatus.Syncing -> true
else -> false
}
private fun isClosable(channelInfoStatus: ChannelInfoStatus): Boolean = if (isForceClose) {
isForceClosable(channelInfoStatus)
} else {
isMutualClosable(channelInfoStatus)
}
private fun isClosable(channel: ChannelState): Boolean = channelInfoStatus(channel)?.let {
isClosable(it)
} ?: false
init {
launch {
val peer = peerManager.getPeer()
peer.channelsFlow.collect { channels ->
val closingChannelIdsCopy = closingChannelIds?.toSet()
val updatedChannelsList = channels.filter {
closingChannelIdsCopy?.contains(it.key) ?: true
}.mapNotNull {
channelInfoStatus(it.value)?.let { mappedStatus ->
CloseChannelsConfiguration.Model.ChannelInfo(
id = it.key,
balance = it.value.localBalance()?.truncateToSatoshi(),
status = mappedStatus
)
}
}
if (closingChannelIdsCopy != null) {
model(CloseChannelsConfiguration.Model.ChannelsClosed(
channels = updatedChannelsList,
closing = closingChannelIdsCopy
))
} else {
val closableChannelsList = updatedChannelsList.filter {
isClosable(it.status)
}
val address = peer.phoenixFinalWallet.finalAddress
model(CloseChannelsConfiguration.Model.Ready(
channels = closableChannelsList,
address = address
))
}
}
}
}
override fun process(intent: CloseChannelsConfiguration.Intent) {
when (intent) {
is CloseChannelsConfiguration.Intent.MutualCloseAllChannels -> process_mutualClose(intent)
is CloseChannelsConfiguration.Intent.ForceCloseAllChannels -> process_forceClose(intent)
}
}
fun process_mutualClose(intent: CloseChannelsConfiguration.Intent.MutualCloseAllChannels) {
val scriptPubKey: ByteVector?
try {
scriptPubKey = Parser.parseBip21Uri(chain, intent.address).right!!.script
} catch (e: Exception) {
throw IllegalArgumentException("Address is invalid. Caller MUST validate user input via readBitcoinAddress")
}
launch {
val peer = peerManager.getPeer()
val filteredChannels = peer.channels.filter {
isClosable(it.value)
}
closingChannelIds = closingChannelIds?.plus(filteredChannels.keys) ?: filteredChannels.keys
filteredChannels.keys.forEach { channelId ->
logger.info { "(mutual) closing channel=${channelId.toHex()} with feerate=${FeeratePerByte(intent.feerate)}" }
val command = ChannelCommand.Close.MutualClose(
replyTo = CompletableDeferred(),
scriptPubKey = scriptPubKey,
feerate = intent.feerate
)
val peerEvent = WrappedChannelCommand(channelId, command)
peer.send(peerEvent)
}
}
}
fun process_forceClose(intent: CloseChannelsConfiguration.Intent.ForceCloseAllChannels) {
launch {
val peer = peerManager.getPeer()
val filteredChannels = peer.channels.filter {
isClosable(it.value)
}
closingChannelIds = closingChannelIds?.plus(filteredChannels.keys) ?: filteredChannels.keys
filteredChannels.keys.forEach { channelId ->
logger.info { "(force) closing channel=${channelId.toHex()}" }
val command = ChannelCommand.Close.ForceClose
val peerEvent = WrappedChannelCommand(channelId, command)
peer.send(peerEvent)
}
}
}
}
@@ -1,13 +0,0 @@
package fr.acinq.phoenix.controllers.config
import fr.acinq.phoenix.controllers.MVI
object Configuration {
sealed class Model : MVI.Model() {
object SimpleMode : Model()
object FullMode : Model()
}
sealed class Intent : MVI.Intent()
}
@@ -1,34 +0,0 @@
package fr.acinq.phoenix.controllers.config
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.controllers.AppController
import fr.acinq.phoenix.managers.WalletManager
import kotlinx.coroutines.launch
class AppConfigurationController(
loggerFactory: LoggerFactory,
private val walletManager: WalletManager
) : AppController<Configuration.Model, Configuration.Intent>(
loggerFactory = loggerFactory,
firstModel = Configuration.Model.SimpleMode
) {
constructor(business: PhoenixBusiness): this(
loggerFactory = business.loggerFactory,
walletManager = business.walletManager
)
init {
launch {
model(
if (!walletManager.isLoaded())
Configuration.Model.SimpleMode
else
Configuration.Model.FullMode
)
}
}
override fun process(intent: Configuration.Intent) = error("Nothing to process")
}
@@ -1,26 +0,0 @@
package fr.acinq.phoenix.controllers.config
import fr.acinq.lightning.utils.Connection
import fr.acinq.lightning.utils.ServerAddress
import fr.acinq.phoenix.controllers.MVI
import fr.acinq.phoenix.data.ElectrumConfig
object ElectrumConfiguration {
data class Model(
val configuration: ElectrumConfig? = null,
val currentServer: ServerAddress? = null,
val connection: Connection = Connection.CLOSED(reason = null),
val feeRate: Long = 0,
val blockHeight: Int = 0,
val tipTimestamp: Long = 0,
val walletIsInitialized: Boolean = false,
val error: Error? = null
) : MVI.Model() {
fun isCustom() = configuration != null && configuration is ElectrumConfig.Custom
}
sealed class Intent : MVI.Intent() {
data class UpdateElectrumServer(val config: ElectrumConfig.Custom?) : Intent()
}
}
@@ -1,60 +0,0 @@
package fr.acinq.phoenix.controllers.config
import fr.acinq.lightning.blockchain.electrum.ElectrumClient
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.controllers.AppController
import fr.acinq.phoenix.managers.AppConfigurationManager
import fr.acinq.phoenix.managers.AppConnectionsDaemon
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class AppElectrumConfigurationController(
loggerFactory: LoggerFactory,
private val configurationManager: AppConfigurationManager,
private val electrumClient: ElectrumClient,
private val appConnectionsDaemon: AppConnectionsDaemon?
) : AppController<ElectrumConfiguration.Model, ElectrumConfiguration.Intent>(
loggerFactory = loggerFactory,
firstModel = ElectrumConfiguration.Model()
) {
constructor(business: PhoenixBusiness): this(
loggerFactory = business.loggerFactory,
configurationManager = business.appConfigurationManager,
electrumClient = business.electrumClient,
appConnectionsDaemon = business.appConnectionsDaemon
)
init {
launch {
if (appConnectionsDaemon != null) {
combine(
configurationManager.electrumConfig,
appConnectionsDaemon.lastElectrumServerAddress,
electrumClient.connectionStatus,
configurationManager.electrumMessages,
transform = { configState, currentServer, connectionStatus, message ->
ElectrumConfiguration.Model(
configuration = configState,
currentServer = currentServer,
connection = connectionStatus.toConnectionState(),
blockHeight = message?.blockHeight ?: 0,
tipTimestamp = message?.header?.time ?: 0,
)
}
).collect {
model(it)
}
}
}
}
override fun process(intent: ElectrumConfiguration.Intent) {
when (intent) {
is ElectrumConfiguration.Intent.UpdateElectrumServer -> {
configurationManager.updateElectrumConfig(intent.config)
}
}
}
}
@@ -1,34 +0,0 @@
package fr.acinq.phoenix.controllers.init
import fr.acinq.bitcoin.MnemonicCode
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.controllers.AppController
import kotlinx.coroutines.launch
class AppInitController(
loggerFactory: LoggerFactory
) : AppController<Initialization.Model, Initialization.Intent>(
loggerFactory = loggerFactory,
firstModel = Initialization.Model.Ready
) {
constructor(business: PhoenixBusiness): this(
loggerFactory = business.loggerFactory
)
override fun process(intent: Initialization.Intent) {
when (intent) {
is Initialization.Intent.GenerateWallet -> {
launch {
val mnemonics = MnemonicCode.toMnemonics(
entropy = intent.entropy,
wordlist = intent.language.wordlist()
)
val seed = MnemonicCode.toSeed(mnemonics, "")
model(Initialization.Model.GeneratedWallet(mnemonics, intent.language, seed))
}
}
}
}
}
@@ -1,29 +0,0 @@
package fr.acinq.phoenix.controllers.init
import fr.acinq.phoenix.controllers.MVI
import fr.acinq.phoenix.utils.MnemonicLanguage
object Initialization {
sealed class Model : MVI.Model() {
object Ready : Model()
data class GeneratedWallet(
val mnemonics: List<String>,
val language: MnemonicLanguage,
val seed: ByteArray
) : Model() {
override fun toString() = "GeneratedWallet"
}
}
sealed class Intent : MVI.Intent() {
data class GenerateWallet(
val entropy: ByteArray,
val language: MnemonicLanguage
) : Intent() {
override fun toString() = "GenerateWallet"
}
}
}
@@ -1,78 +0,0 @@
package fr.acinq.phoenix.controllers.init
import fr.acinq.phoenix.controllers.MVI
import fr.acinq.phoenix.utils.MnemonicLanguage
object RestoreWallet {
sealed class Model : MVI.Model() {
object Ready : Model()
data class FilteredWordlist(
val uuid: String,
val predicate: String,
val words: List<String>
) : Model() {
override fun toString(): String = "FilteredWordlist"
}
object InvalidMnemonics : Model()
data class ValidMnemonics(
val mnemonics: List<String>,
val language: MnemonicLanguage,
val seed: ByteArray
) : Model() {
// Kotlin recommends equals & hashCode for data classes with array props
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as ValidMnemonics
return seed.contentEquals(other.seed)
}
override fun hashCode(): Int {
return seed.contentHashCode()
}
override fun toString() = "ValidMnemonics"
}
}
sealed class Intent : MVI.Intent() {
data class FilterWordList(
val predicate: String,
val language: MnemonicLanguage,
val uuid: String = "" // See note below
) : Intent() {
// We are using StateFlow to handle model changes.
// The problem is that StateFlow is conflated,
// so it will silently drop notifications if the model doesn't change.
// As per issue #109, we encountered problems with this.
// For example, if the user pastes in a seed such as "hammer hammer ...",
// then what happens is:
//
// - UI calls intent with FilterWordList(hammer) // 1st word
// - model is updated to FilteredWordlist([hammer])
// - UI is notified
// - UI calls intent with FilterWordList(hammer) // 2nd word
// - model is updated to FilteredWordlist([hammer])
// - UI is NOT updated, because the model didn't change !
//
// So the uuid is a workaround, to force a model change everytime.
//
// Another possible solution is to switch from MutableStateFlow to MutableShareFlow.
// However, doing so would affect AppController.kt, which effects every MVI.
// So we're reserving that as a potential future change.
override fun toString() = ".".repeat(predicate.length)
}
data class Validate(
val mnemonics: List<String>,
val language: MnemonicLanguage
) : Intent() {
override fun toString() = "Validate"
}
}
}
@@ -1,64 +0,0 @@
package fr.acinq.phoenix.controllers.init
import fr.acinq.bitcoin.MnemonicCode
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.controllers.AppController
import kotlinx.coroutines.launch
class AppRestoreWalletController(
loggerFactory: LoggerFactory
) : AppController<RestoreWallet.Model, RestoreWallet.Intent>(
loggerFactory = loggerFactory,
firstModel = RestoreWallet.Model.Ready
) {
constructor(business: PhoenixBusiness): this(
loggerFactory = business.loggerFactory
)
override fun process(intent: RestoreWallet.Intent) {
when (intent) {
is RestoreWallet.Intent.FilterWordList -> launch {
processIntent(intent)
}
is RestoreWallet.Intent.Validate -> launch {
processIntent(intent)
}
}
}
private suspend fun processIntent(
intent: RestoreWallet.Intent.FilterWordList
) {
when {
intent.predicate.length > 1 -> {
val words = intent.language.matches(intent.predicate)
model(RestoreWallet.Model.FilteredWordlist(
uuid = intent.uuid,
predicate = intent.predicate,
words = words
))
}
else -> {
model(RestoreWallet.Model.FilteredWordlist(
uuid = intent.uuid,
predicate = intent.predicate,
words = emptyList()
))
}
}
}
private suspend fun processIntent(
intent: RestoreWallet.Intent.Validate
) {
try {
MnemonicCode.validate(intent.mnemonics, intent.language.wordlist())
val seed = MnemonicCode.toSeed(intent.mnemonics, passphrase = "")
model(RestoreWallet.Model.ValidMnemonics(intent.mnemonics, intent.language, seed))
} catch (e: IllegalArgumentException) {
model(RestoreWallet.Model.InvalidMnemonics)
}
}
}
@@ -1,15 +0,0 @@
package fr.acinq.phoenix.controllers.main
import fr.acinq.phoenix.controllers.MVI
object Content {
sealed class Model : MVI.Model() {
object Waiting : Model()
object IsInitialized : Model()
object NeedInitialization : Model()
}
sealed class Intent : MVI.Intent()
}
@@ -1,38 +0,0 @@
package fr.acinq.phoenix.controllers.main
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.controllers.AppController
import fr.acinq.phoenix.managers.WalletManager
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class AppContentController(
loggerFactory: LoggerFactory,
private val walletManager: WalletManager
) : AppController<Content.Model, Content.Intent>(
loggerFactory = loggerFactory,
firstModel = Content.Model.Waiting
) {
constructor(business: PhoenixBusiness): this(
loggerFactory = business.loggerFactory,
walletManager = business.walletManager
)
init {
launch {
if (walletManager.isLoaded()) {
model(Content.Model.IsInitialized)
} else {
model(Content.Model.NeedInitialization)
// Suspends until a wallet is created
walletManager.keyManager.filterNotNull().first()
model(Content.Model.IsInitialized)
}
}
}
override fun process(intent: Content.Intent) = error("Nothing to process")
}
@@ -1,17 +0,0 @@
package fr.acinq.phoenix.controllers.main
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.phoenix.controllers.MVI
object Home {
data class Model(
val balance: MilliSatoshi?,
) : MVI.Model()
val emptyModel = Model(
balance = null,
)
sealed class Intent : MVI.Intent()
}
@@ -1,31 +0,0 @@
package fr.acinq.phoenix.controllers.main
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.controllers.AppController
import fr.acinq.phoenix.managers.BalanceManager
import kotlinx.coroutines.launch
class AppHomeController(
loggerFactory: LoggerFactory,
private val balanceManager: BalanceManager
) : AppController<Home.Model, Home.Intent>(
loggerFactory = loggerFactory,
firstModel = Home.emptyModel
) {
constructor(business: PhoenixBusiness): this(
loggerFactory = business.loggerFactory,
balanceManager = business.balanceManager
)
init {
launch {
balanceManager.balance.collect {
model { copy(balance = it) }
}
}
}
override fun process(intent: Home.Intent) {}
}
@@ -1,22 +0,0 @@
package fr.acinq.phoenix.controllers.payments
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.phoenix.controllers.MVI
object Receive {
sealed class Model : MVI.Model() {
object Awaiting : Model()
object Generating: Model()
data class Generated(val request: String, val paymentHash: String, val amount: MilliSatoshi?, val desc: String?): Model()
}
sealed class Intent : MVI.Intent() {
data class Ask(
val amount: MilliSatoshi?,
val desc: String?,
val expirySeconds: Long = 3600 * 24 * 7 // 7 days
) : Intent()
}
}
@@ -1,60 +0,0 @@
/*
* Copyright 2022 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.controllers.payments
import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.controllers.AppController
import fr.acinq.phoenix.managers.PeerManager
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
class AppReceiveController(
loggerFactory: LoggerFactory,
private val peerManager: PeerManager,
) : AppController<Receive.Model, Receive.Intent>(
loggerFactory = loggerFactory,
firstModel = Receive.Model.Awaiting
) {
constructor(business: PhoenixBusiness) : this(
loggerFactory = business.loggerFactory,
peerManager = business.peerManager,
)
private val Receive.Intent.Ask.description: String get() = desc?.takeIf { it.isNotBlank() } ?: ""
override fun process(intent: Receive.Intent) {
when (intent) {
is Receive.Intent.Ask -> {
launch {
model(Receive.Model.Generating)
val paymentRequest = peerManager.getPeer().createInvoice(
paymentPreimage = randomBytes32(),
amount = intent.amount,
description = Either.Left(intent.description),
expiry = intent.expirySeconds.seconds
)
model(Receive.Model.Generated(paymentRequest.write(), paymentRequest.paymentHash.toHex(), paymentRequest.amount, paymentRequest.description))
}
}
}
}
}
@@ -1,86 +0,0 @@
/*
* Copyright 2025 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.csv
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.phoenix.data.ExchangeRate
import kotlin.math.absoluteValue
open class CsvWriter {
private val sb: StringBuilder = StringBuilder()
fun addRow(vararg fields: String) {
val cleanFields = fields.map { processField(it) }
sb.append(cleanFields.joinToString(separator = ",", postfix = "\n"))
}
fun addRow(fields: List<String>) {
addRow(*fields.toTypedArray())
}
/**
* On Android & iOS we are currently handling the file IO separately (using native code).
* This method allows us to dump the buffer in batches to perform file IO during the export process.
*/
fun dumpAndClear(): String {
val content = sb.toString()
sb.clear()
return content
}
private fun processField(str: String): String {
return str.findAnyOf(listOf(",", "\"", "\n"))?.let {
// - field must be enclosed in double-quotes
// - a double-quote appearing inside the field must be
// escaped by preceding it with another double quote
"\"${str.replace("\"", "\"\"")}\""
} ?: str
}
/**
* Convert and format an amount to fiat using the provided exchange rate. The amount is displayed like this: "1.2345 EUR".
* Note, the result will not be negative, even if [amount] is (which happens for outgoing payments, as a way to represent in the CSV money leaving the wallet).
*/
fun convertToFiat(amount: MilliSatoshi?, exchangeRate: ExchangeRate.BitcoinPriceRate?): String {
if (amount == null || exchangeRate == null) return ""
val msatsPerBitcoin = 100_000_000_000.toDouble()
val amtFiat = (amount.msat.absoluteValue / msatsPerBitcoin) * exchangeRate.price
val currencyName = exchangeRate.fiatCurrency.name
return "${formatFiatValue(amtFiat)} $currencyName"
}
/**
* Format a Double amount as a String. We always display 4 decimal places.
*
* The extra precision can be truncated / rounded / ignored by the reader, who has more insight into how they wish
* to use the exported information.
*
* Note: we can't use Java's String.format function on KMM. Also, Double.toString() might produce something like this: "7.900441605000001E-4".
* So we're stuck rolling our own solution.
*/
private fun formatFiatValue(amt: Double): String {
val integerPart = amt.toLong().toString()
val fractionPart = ((amt % 1) * 10_000).toLong().toString()
.take(4).padStart(4, '0')
val formattedStr = "${integerPart}.${fractionPart}"
return formattedStr
}
}
@@ -1,288 +0,0 @@
/*
* Copyright 2025 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.csv
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Satoshi
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.db.AutomaticLiquidityPurchasePayment
import fr.acinq.lightning.db.Bolt11IncomingPayment
import fr.acinq.lightning.db.ChannelCloseOutgoingPayment
import fr.acinq.lightning.db.LegacyPayToOpenIncomingPayment
import fr.acinq.lightning.db.LegacySwapInIncomingPayment
import fr.acinq.lightning.db.LightningIncomingPayment
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.db.ManualLiquidityPurchasePayment
import fr.acinq.lightning.db.OnChainIncomingPayment
import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment
import fr.acinq.lightning.db.SpliceOutgoingPayment
import fr.acinq.lightning.db.WalletPayment
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.sum
import fr.acinq.lightning.utils.toMilliSatoshi
import fr.acinq.phoenix.data.WalletPaymentMetadata
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
class WalletPaymentCsvWriter(val configuration: Configuration) : CsvWriter() {
data class Configuration(
val includesFiat: Boolean,
val includesDescription: Boolean,
val includesNotes: Boolean,
val includesOriginDestination: Boolean,
)
private val FIELD_DATE = "date"
private val FIELD_ID = "id"
private val FIELD_TYPE = "type"
private val FIELD_AMOUNT_MSAT = "amount_msat"
private val FIELD_AMOUNT_FIAT = "amount_fiat"
private val FIELD_FEE_CREDIT_MSAT = "fee_credit_msat"
private val FIELD_MINING_FEE_SAT = "mining_fee_sat"
private val FIELD_MINING_FEE_FIAT = "mining_fee_fiat"
private val FIELD_SERVICE_FEE_MSAT = "service_fee_msat"
private val FIELD_SERVICE_FEE_FIAT = "service_fee_fiat"
private val FIELD_PAYMENT_HASH = "payment_hash"
private val FIELD_TX_ID = "tx_id"
private val FIELD_DESTINATION = "destination"
private val FIELD_DESCRIPTION = "description"
init {
addRow(
FIELD_DATE,
FIELD_ID,
FIELD_TYPE,
FIELD_AMOUNT_MSAT,
FIELD_AMOUNT_FIAT,
FIELD_FEE_CREDIT_MSAT,
FIELD_MINING_FEE_SAT,
FIELD_MINING_FEE_FIAT,
FIELD_SERVICE_FEE_MSAT,
FIELD_SERVICE_FEE_FIAT,
FIELD_PAYMENT_HASH,
FIELD_TX_ID,
FIELD_DESTINATION,
FIELD_DESCRIPTION
)
}
@Suppress("EnumEntryName")
enum class Type {
legacy_swap_in,
legacy_swap_out,
legacy_pay_to_open,
swap_in,
swap_out,
fee_bumping,
lightning_received,
lightning_sent,
liquidity_purchase,
channel_close,
}
data class Details(
val type: Type,
val amount: MilliSatoshi,
val feeCredit: MilliSatoshi,
val miningFee: Satoshi,
val serviceFee: MilliSatoshi,
val paymentHash: ByteVector32?,
val txId: TxId?,
val destination: String? = null,
val description: String? = null,
)
@OptIn(ExperimentalTime::class)
private fun addRow(
timestamp: Long,
id: UUID,
details: Details,
metadata: WalletPaymentMetadata?,
) {
val dateStr = Instant.fromEpochMilliseconds(timestamp).toString() // ISO-8601 format
val originalFiat = metadata?.originalFiat
addRow(
dateStr,
id.toString(),
details.type.toString(),
details.amount.msat.toString(),
if (configuration.includesFiat) convertToFiat(details.amount, originalFiat) else "",
details.feeCredit.msat.toString(),
details.miningFee.sat.toString(),
if (configuration.includesFiat) convertToFiat(details.miningFee.toMilliSatoshi(), originalFiat) else "",
details.serviceFee.msat.toString(),
if (configuration.includesFiat) convertToFiat(details.serviceFee, originalFiat) else "",
details.paymentHash?.toHex() ?: "",
if (configuration.includesOriginDestination) details.txId?.toString() ?: "" else "",
if (configuration.includesOriginDestination) details.destination ?: "" else "",
if (configuration.includesDescription) listOf(
details.description, metadata?.userDescription, metadata?.userNotes, metadata?.lnurl?.pay?.metadata?.longDesc
).mapNotNull { it.takeIf { !it.isNullOrBlank() } }.joinToString("\n---\n") else "",
)
}
@Suppress("DEPRECATION")
fun add(payment: WalletPayment, metadata: WalletPaymentMetadata?) {
val timestamp = payment.completedAt ?: payment.createdAt
val id = payment.id
val details: Details? = when (payment) {
is LightningIncomingPayment -> Details(
type = Type.lightning_received,
amount = payment.amountReceived,
feeCredit = payment.parts.filterIsInstance<LightningIncomingPayment.Part.FeeCredit>().map { it.amountReceived }.sum() - (payment.liquidityPurchaseDetails?.feeCreditUsed ?: 0.msat),
miningFee = payment.liquidityPurchaseDetails?.miningFee ?: 0.sat,
serviceFee = payment.liquidityPurchaseDetails?.purchase?.fees?.serviceFee?.toMilliSatoshi() ?: 0.msat,
paymentHash = payment.paymentHash,
txId = payment.liquidityPurchaseDetails?.txId,
description = (payment as? Bolt11IncomingPayment)?.paymentRequest?.description
)
is LegacySwapInIncomingPayment -> Details(
type = Type.legacy_swap_in,
amount = payment.amount,
feeCredit = 0.msat,
miningFee = payment.fees.truncateToSatoshi(),
serviceFee = 0.msat,
paymentHash = null,
txId = null,
destination = payment.address
)
is LegacyPayToOpenIncomingPayment -> Details(
type = Type.legacy_pay_to_open,
amount = payment.amount,
feeCredit = 0.msat,
miningFee = payment.parts.filterIsInstance<LegacyPayToOpenIncomingPayment.Part.OnChain>().map { it.miningFee }.sum(),
serviceFee = payment.parts.filterIsInstance<LegacyPayToOpenIncomingPayment.Part.OnChain>().map { it.serviceFee }.sum(),
paymentHash = payment.paymentHash,
txId = payment.parts.filterIsInstance<LegacyPayToOpenIncomingPayment.Part.OnChain>().map { it.txId }.firstOrNull(),
description = (payment.origin as? LegacyPayToOpenIncomingPayment.Origin.Invoice)?.paymentRequest?.description
)
is OnChainIncomingPayment -> Details(
type = Type.swap_in,
amount = payment.amount,
feeCredit = 0.msat,
miningFee = payment.miningFee,
serviceFee = payment.serviceFee,
paymentHash = null,
txId = payment.txId
)
is LightningOutgoingPayment -> when (val details = payment.details) {
is LightningOutgoingPayment.Details.Normal -> Details(
type = Type.lightning_sent,
amount = -payment.amount,
feeCredit = 0.msat,
miningFee = 0.sat,
serviceFee = payment.fees,
paymentHash = payment.paymentHash,
txId = null,
destination = details.paymentRequest.nodeId.toHex(),
description = details.paymentRequest.description
)
is LightningOutgoingPayment.Details.SwapOut -> Details(
type = Type.legacy_swap_out,
amount = -payment.amount,
feeCredit = 0.msat,
miningFee = details.swapOutFee,
serviceFee = 0.msat,
paymentHash = null,
txId = null,
destination = details.address
)
is LightningOutgoingPayment.Details.Blinded -> Details(
type = Type.lightning_sent,
amount = -payment.amount,
feeCredit = 0.msat,
miningFee = 0.sat,
serviceFee = payment.fees,
paymentHash = payment.paymentHash,
txId = null,
description = details.paymentRequest.description
)
}
is SpliceOutgoingPayment -> Details(
type = Type.swap_out,
amount = -payment.amount,
feeCredit = 0.msat,
miningFee = payment.miningFee,
serviceFee = 0.msat,
paymentHash = null,
txId = payment.txId,
destination = payment.address
)
is ChannelCloseOutgoingPayment -> Details(
type = Type.channel_close,
amount = -payment.amount,
feeCredit = 0.msat,
miningFee = payment.miningFee,
serviceFee = 0.msat,
paymentHash = null,
txId = payment.txId,
destination = payment.address
)
is SpliceCpfpOutgoingPayment -> Details(
type = Type.fee_bumping,
amount = -payment.amount,
feeCredit = 0.msat,
miningFee = payment.miningFee,
serviceFee = 0.msat,
paymentHash = null,
txId = payment.txId
)
is AutomaticLiquidityPurchasePayment -> if (payment.incomingPaymentReceivedAt == null) {
Details(
type = Type.liquidity_purchase,
amount = -payment.amount,
feeCredit = -payment.liquidityPurchaseDetails.feeCreditUsed,
miningFee = payment.miningFee,
serviceFee = payment.serviceFee,
paymentHash = null,
txId = payment.txId
)
} else {
// If the corresponding Lightning payment was received, then liquidity fees will be included in the Lightning payment
null
}
is ManualLiquidityPurchasePayment -> Details(
type = Type.liquidity_purchase,
amount = -payment.amount,
feeCredit = -payment.liquidityPurchaseDetails.feeCreditUsed,
miningFee = payment.miningFee,
serviceFee = payment.serviceFee,
paymentHash = null,
txId = payment.txId
)
}
details?.let { addRow(timestamp, id, it, metadata) }
}
}
@@ -1,296 +0,0 @@
package fr.acinq.phoenix.data
import fr.acinq.lightning.io.TcpSocket
import fr.acinq.lightning.payment.LiquidityPolicy
import fr.acinq.lightning.utils.ServerAddress
import fr.acinq.phoenix.utils.extensions.isOnion
import kotlinx.serialization.Serializable
import org.jetbrains.compose.resources.getString
import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.currency_ars_bm
import mantra.composeapp.generated.resources.currency_ars_official
import mantra.composeapp.generated.resources.currency_cup_fm
import mantra.composeapp.generated.resources.currency_cup_official
import mantra.composeapp.generated.resources.currency_lbp_bm
import mantra.composeapp.generated.resources.currency_lbp_official
sealed interface CurrencyUnit {
/** Code that should be displayed in the UI. */
val displayCode: String
}
@Serializable
enum class BitcoinUnit(override val displayCode: String) : CurrencyUnit {
Sat("sat"), Bit("bit"), MBtc("mbtc"), Btc("btc");
override fun toString(): String {
return super.toString().lowercase()
}
companion object {
val values = entries
fun valueOfOrNull(code: String): BitcoinUnit? = try {
valueOf(code)
} catch (e: Exception) {
null
}
}
}
/**
* @param flag when multiple countries use that currency, use the flag of the country with highest GDP
*/
@Serializable
enum class FiatCurrency(override val displayCode: String, val flag: String = "🏳️") : CurrencyUnit {
AED(displayCode = "AED", flag = "🇦🇪"), // United Arab Emirates Dirham
AFN(displayCode = "AFN", flag = "🇦🇫"), // Afghan Afghani
ALL(displayCode = "ALL", flag = "🇦🇱"), // Albanian Lek
AMD(displayCode = "AMD", flag = "🇦🇲"), // Armenian Dram
ANG(displayCode = "ANG", flag = "🇳🇱"), // Netherlands Antillean Guilder
AOA(displayCode = "AOA", flag = "🇦🇴"), // Angolan Kwanza
ARS_BM(displayCode = "ARS", flag = "🇦🇷"), // Argentine Peso (blue market)
ARS(displayCode = "ARS_OFF", flag = "🇦🇷"), // Argentine Peso (official rate)
AUD(displayCode = "AUD", flag = "🇦🇺"), // Australian Dollar
AWG(displayCode = "AWG", flag = "🇦🇼"), // Aruban Florin
AZN(displayCode = "AZN", flag = "🇦🇿"), // Azerbaijani Manat
BAM(displayCode = "BAM", flag = "🇧🇦"), // Bosnia-Herzegovina Convertible Mark
BBD(displayCode = "BBD", flag = "🇧🇧"), // Barbadian Dollar
BDT(displayCode = "BDT", flag = "🇧🇩"), // Bangladeshi Taka
BGN(displayCode = "BGN", flag = "🇧🇬"), // Bulgarian Lev
BHD(displayCode = "BHD", flag = "🇧🇭"), // Bahraini Dinar
BIF(displayCode = "BIF", flag = "🇧🇮"), // Burundian Franc
BMD(displayCode = "BMD", flag = "🇧🇲"), // Bermudan Dollar
BND(displayCode = "BND", flag = "🇧🇳"), // Brunei Dollar
BOB(displayCode = "BOB", flag = "🇧🇴"), // Bolivian Boliviano
BRL(displayCode = "BRL", flag = "🇧🇷"), // Brazilian Real
BSD(displayCode = "BSD", flag = "🇧🇸"), // Bahamian Dollar
BTN(displayCode = "BTN", flag = "🇧🇹"), // Bhutanese Ngultrum
BWP(displayCode = "BWP", flag = "🇧🇼"), // Botswanan Pula
BZD(displayCode = "BZD", flag = "🇧🇿"), // Belize Dollar
CAD(displayCode = "CAD", flag = "🇨🇦"), // Canadian Dollar
CDF(displayCode = "CDF", flag = "🇨🇩"), // Congolese Franc
CHF(displayCode = "CHF", flag = "🇨🇭"), // Swiss Franc
CLP(displayCode = "CLP", flag = "🇨🇱"), // Chilean Peso
CNH(displayCode = "CNH", flag = "🇨🇳"), // Chinese Yuan (offshore)
CNY(displayCode = "CNY", flag = "🇨🇳"), // Chinese Yuan (onshore)
COP(displayCode = "COP", flag = "🇨🇴"), // Colombian Peso
CRC(displayCode = "CRC", flag = "🇨🇷"), // Costa Rican Colón
CUP_FM(displayCode = "CUP", flag = "🇨🇺"), // Cuban Peso (free market)
CUP(displayCode = "CUP_OFF", flag = "🇨🇺"), // Cuban Peso (official rate)
CVE(displayCode = "CVE", flag = "🇨🇻"), // Cape Verdean Escudo
CZK(displayCode = "CZK", flag = "🇨🇿"), // Czech Republic Koruna
DJF(displayCode = "DJF", flag = "🇩🇯"), // Djiboutian Franc
DKK(displayCode = "DKK", flag = "🇩🇰"), // Danish Krone
DOP(displayCode = "DOP", flag = "🇩🇴"), // Dominican Peso
DZD(displayCode = "DZD", flag = "🇩🇿"), // Algerian Dinar
EGP(displayCode = "EGP", flag = "🇪🇬"), // Egyptian Pound
ERN(displayCode = "ERN", flag = "🇪🇷"), // Eritrean Nakfa
ETB(displayCode = "ETB", flag = "🇪🇹"), // Ethiopian Birr
EUR(displayCode = "EUR", flag = "🇪🇺"), // Euro
FJD(displayCode = "FJD", flag = "🇫🇯"), // Fijian Dollar
FKP(displayCode = "FKP", flag = "🇫🇰"), // Falkland Islands Pound
GBP(displayCode = "GBP", flag = "🇬🇧"), // British Pound Sterling
GEL(displayCode = "GEL", flag = "🇬🇪"), // Georgian Lari
GHS(displayCode = "GHS", flag = "🇬🇭"), // Ghanaian Cedi
GIP(displayCode = "GIP", flag = "🇬🇮"), // Gibraltar Pound
GMD(displayCode = "GMD", flag = "🇬🇲"), // Gambian Dalasi
GNF(displayCode = "GNF", flag = "🇬🇳"), // Guinean Franc
GTQ(displayCode = "GTQ", flag = "🇬🇹"), // Guatemalan Quetzal
GYD(displayCode = "GYD", flag = "🇬🇾"), // Guyanaese Dollar
HKD(displayCode = "HKD", flag = "🇭🇰"), // Hong Kong Dollar
HNL(displayCode = "HNL", flag = "🇭🇳"), // Honduran Lempira
HRK(displayCode = "HRK", flag = "🇭🇷"), // Croatian Kuna
HTG(displayCode = "HTG", flag = "🇭🇹"), // Haitian Gourde
HUF(displayCode = "HUF", flag = "🇭🇺"), // Hungarian Forint
IDR(displayCode = "IDR", flag = "🇮🇩"), // Indonesian Rupiah
ILS(displayCode = "ILS", flag = "🇮🇱"), // Israeli New Sheqel
INR(displayCode = "INR", flag = "🇮🇳"), // Indian Rupee
IQD(displayCode = "IQD", flag = "🇮🇶"), // Iraqi Dinar
IRR(displayCode = "IRR", flag = "🇮🇷"), // Iranian Rial
ISK(displayCode = "ISK", flag = "🇮🇸"), // Icelandic Króna
JEP(displayCode = "JEP", flag = "🇯🇪"), // Jersey Pound
JMD(displayCode = "JMD", flag = "🇯🇲"), // Jamaican Dollar
JOD(displayCode = "JOD", flag = "🇯🇴"), // Jordanian Dinar
JPY(displayCode = "JPY", flag = "🇯🇵"), // Japanese Yen
KES(displayCode = "KES", flag = "🇰🇪"), // Kenyan Shilling
KGS(displayCode = "KGS", flag = "🇰🇬"), // Kyrgystani Som
KHR(displayCode = "KHR", flag = "🇰🇭"), // Cambodian Riel
KMF(displayCode = "KMF", flag = "🇰🇲"), // Comorian Franc
KPW(displayCode = "KPW", flag = "🇰🇵"), // North Korean Won
KRW(displayCode = "KRW", flag = "🇰🇷"), // South Korean Won
KWD(displayCode = "KWD", flag = "🇰🇼"), // Kuwaiti Dinar
KYD(displayCode = "KYD", flag = "🇰🇾"), // Cayman Islands Dollar
KZT(displayCode = "KZT", flag = "🇰🇿"), // Kazakhstani Tenge
LAK(displayCode = "LAK", flag = "🇱🇦"), // Laotian Kip
LBP_BM(displayCode = "LBP", flag = "🇱🇧"), // Lebanese Pound (black market)
LBP(displayCode = "LBP_OFF", flag = "🇱🇧"), // Lebanese Pound (official rate)
LKR(displayCode = "LKR", flag = "🇱🇰"), // Sri Lankan Rupee
LRD(displayCode = "LRD", flag = "🇱🇷"), // Liberian Dollar
LSL(displayCode = "LSL", flag = "🇱🇸"), // Lesotho Loti
LYD(displayCode = "LYD", flag = "🇱🇾"), // Libyan Dinar
MAD(displayCode = "MAD", flag = "🇲🇦"), // Moroccan Dirham
MDL(displayCode = "MDL", flag = "🇲🇩"), // Moldovan Leu
MGA(displayCode = "MGA", flag = "🇲🇬"), // Malagasy Ariary
MKD(displayCode = "MKD", flag = "🇲🇰"), // Macedonian Denar
MMK(displayCode = "MMK", flag = "🇲🇲"), // Myanma Kyat
MNT(displayCode = "MNT", flag = "🇲🇳"), // Mongolian Tugrik
MOP(displayCode = "MOP", flag = "🇲🇴"), // Macanese Pataca
MUR(displayCode = "MUR", flag = "🇲🇺"), // Mauritian Rupee
MVR(displayCode = "MVR", flag = "🇲🇻"), // Maldivian Rufiyaa
MWK(displayCode = "MWK", flag = "🇲🇼"), // Malawian Kwacha
MXN(displayCode = "MXN", flag = "🇲🇽"), // Mexican Peso
MYR(displayCode = "MYR", flag = "🇲🇾"), // Malaysian Ringgit
MZN(displayCode = "MZN", flag = "🇲🇿"), // Mozambican Metical
NAD(displayCode = "NAD", flag = "🇳🇦"), // Namibian Dollar
NGN(displayCode = "NGN", flag = "🇳🇬"), // Nigerian Naira
NIO(displayCode = "NIO", flag = "🇳🇮"), // Nicaraguan Córdoba
NOK(displayCode = "NOK", flag = "🇳🇴"), // Norwegian Krone
NPR(displayCode = "NPR", flag = "🇳🇵"), // Nepalese Rupee
NZD(displayCode = "NZD", flag = "🇳🇿"), // New Zealand Dollar
OMR(displayCode = "OMR", flag = "🇴🇲"), // Omani Rial
PAB(displayCode = "PAB", flag = "🇵🇦"), // Panamanian Balboa
PEN(displayCode = "PEN", flag = "🇵🇪"), // Peruvian Sol
PGK(displayCode = "PGK", flag = "🇵🇬"), // Papua New Guinean Kina
PHP(displayCode = "PHP", flag = "🇵🇭"), // Philippine Peso
PKR(displayCode = "PKR", flag = "🇵🇰"), // Pakistani Rupee
PLN(displayCode = "PLN", flag = "🇵🇱"), // Polish Zloty
PYG(displayCode = "PYG", flag = "🇵🇾"), // Paraguayan Guarani
QAR(displayCode = "QAR", flag = "🇶🇦"), // Qatari Rial
RON(displayCode = "RON", flag = "🇷🇴"), // Romanian Leu
RSD(displayCode = "RSD", flag = "🇷🇸"), // Serbian Dinar
RUB(displayCode = "RUB", flag = "🇷🇺"), // Russian Ruble
RWF(displayCode = "RWF", flag = "🇷🇼"), // Rwandan Franc
SAR(displayCode = "SAR", flag = "🇸🇦"), // Saudi Riyal
SBD(displayCode = "SBD", flag = "🇸🇧"), // Solomon Islands Dollar
SCR(displayCode = "SCR", flag = "🇸🇨"), // Seychellois Rupee
SDG(displayCode = "SDG", flag = "🇸🇩"), // Sudanese Pound
SEK(displayCode = "SEK", flag = "🇸🇪"), // Swedish Krona
SGD(displayCode = "SGD", flag = "🇸🇬"), // Singapore Dollar
SHP(displayCode = "SHP", flag = "🇸🇭"), // Saint Helena Pound
SLL(displayCode = "SLL", flag = "🇸🇱"), // Sierra Leonean Leone
SOS(displayCode = "SOS", flag = "🇸🇴"), // Somali Shilling
SRD(displayCode = "SRD", flag = "🇸🇷"), // Surinamese Dollar
SYP(displayCode = "SYP", flag = "🇸🇾"), // Syrian Pound
SZL(displayCode = "SZL", flag = "🇸🇿"), // Swazi Lilangeni
THB(displayCode = "THB", flag = "🇹🇭"), // Thai Baht
TJS(displayCode = "TJS", flag = "🇹🇯"), // Tajikistani Somoni
TMT(displayCode = "TMT", flag = "🇹🇲"), // Turkmenistani Manat
TND(displayCode = "TND", flag = "🇹🇳"), // Tunisian Dinar
TOP(displayCode = "TOP", flag = "🇹🇴"), // Tongan Paʻanga
TRY(displayCode = "TRY", flag = "🇹🇷"), // Turkish Lira
TTD(displayCode = "TTD", flag = "🇹🇹"), // Trinidad and Tobago Dollar
TWD(displayCode = "TWD", flag = "🇹🇼"), // Taiwan Dollar
TZS(displayCode = "TZS", flag = "🇹🇿"), // Tanzanian Shilling
UAH(displayCode = "UAH", flag = "🇺🇦"), // Ukrainian Hryvnia
UGX(displayCode = "UGX", flag = "🇺🇬"), // Ugandan Shilling
USD(displayCode = "USD", flag = "🇺🇸"), // United States Dollar
UYU(displayCode = "UYU", flag = "🇺🇾"), // Uruguayan Peso
UZS(displayCode = "UZS", flag = "🇺🇿"), // Uzbekistan Som
VND(displayCode = "VND", flag = "🇻🇳"), // Vietnamese Dong
VUV(displayCode = "VUV", flag = "🇻🇺"), // Vanuatu Vatu
WST(displayCode = "WST", flag = "🇼🇸"), // Samoan Tala
XAF(displayCode = "XAF", flag = "🇨🇲"), // CFA Franc BEAC
XCD(displayCode = "XCD", flag = "🇱🇨"), // East Caribbean Dollar
XOF(displayCode = "XOF", flag = "🇨🇮"), // CFA Franc BCEAO
XPF(displayCode = "XPF", flag = "🇳🇨"), // CFP Franc
YER(displayCode = "YER", flag = "🇾🇪"), // Yemeni Rial
ZAR(displayCode = "ZAR", flag = "🇿🇦"), // South African Rand
ZMW(displayCode = "ZMW", flag = "🇿🇲"); // Zambian Kwacha
/** Returns a pair of string (short name, full name) for this [fiatCurrency]. */
suspend fun getLabel(): Pair<String, String> {
val fullName = when {
// use the free market rates as default. Name for official rates gets a special tag, as those rates are usually inaccurate.
this == ARS -> getString(Res.string.currency_ars_official)
this == ARS_BM -> getString(Res.string.currency_ars_bm)
this == CUP -> getString(Res.string.currency_cup_official)
this == CUP_FM -> getString(Res.string.currency_cup_fm)
this == LBP -> getString(Res.string.currency_lbp_official)
this == LBP_BM -> getString(Res.string.currency_lbp_bm)
// use the JVM API otherwise to get the name
this.displayCode.length == 3 -> try {
this.name
} catch (e: Exception) {
"N/A"
}
else -> "N/A"
}
return "${this.flag} ${this.displayCode}" to fullName
}
companion object {
val values = entries
fun valueOfOrNull(code: String): FiatCurrency? = try {
valueOf(code)
} catch (e: Exception) {
null
}
}
}
sealed class ElectrumConfig {
/**
* Note : constructor is private because we want to enforce a disabled tls policy on onion hosts.
*
* @param requireOnionIfTorEnabled if this option is true, Phoenix will require this custom server to use an onion address when Tor is enabled, otherwise
* it will not connect to it. This parameter should be true in the normal case. However, the user may want to override this requirement, for
* example, if he's connecting to his own server and does not care about leaking his IP if the Tor proxy fails.
*/
class Custom private constructor(val server: ServerAddress, val requireOnionIfTorEnabled: Boolean) : ElectrumConfig() {
companion object {
fun create(server: ServerAddress, requireOnionIfTorEnabled: Boolean) = Custom(
server = if (server.isOnion) server.copy(tls = TcpSocket.TLS.DISABLED) else server,
requireOnionIfTorEnabled = requireOnionIfTorEnabled
)
}
}
object Random : ElectrumConfig()
override operator fun equals(other: Any?): Boolean {
if (other !is ElectrumConfig) {
return false
}
return when (this) {
is Custom -> {
when (other) {
is Custom -> this === other // custom =?= custom
is Random -> false // custom != random
}
}
is Random -> {
when (other) {
is Custom -> false // random != custom
is Random -> true // random == random
}
}
}
}
}
data class StartupParams(
/** If true, we'll use onion addresses when connecting to the peer and to Electrum servers. */
val isTorEnabled: Boolean,
/** The liquidity policy must be injected into the node params manager. */
val liquidityPolicy: LiquidityPolicy,
// TODO: add custom electrum address, fiat currencies, ...
)
@Serializable
data class PreferredFiatCurrencies(
val primary: FiatCurrency,
val others: Set<FiatCurrency>
) {
constructor(primary: FiatCurrency, others: List<FiatCurrency>) :
this(primary = primary, others = others.toSet())
val all: Set<FiatCurrency>
get() {
return if (others.contains(primary)) {
others
} else {
others.toMutableSet().apply { add(primary) }
}
}
}
@@ -1,66 +0,0 @@
/*
* Copyright 2022 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.data
import fr.acinq.bitcoin.BitcoinError
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.Chain
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.payment.Bolt11Invoice
import fr.acinq.lightning.wire.OfferTypes
import io.ktor.http.Parameters
import io.ktor.http.formUrlEncode
import io.ktor.http.plus
data class BitcoinUri(
val chain: Chain,
/** Actual Bitcoin address; may be different than the source, e.g. if the source is an URI like "bitcoin:xyz?param=123". */
val address: String,
val script: ByteVector?,
// Bip-21 parameters
val label: String? = null,
val message: String? = null,
/** Amount requested in the URI. */
val amount: Satoshi? = null,
/** A Bitcoin URI may contain a Bolt11 invoice or an offer as an alternative way to make the payment. */
val paymentRequest: Bolt11Invoice? = null,
val offer: OfferTypes.Offer? = null,
/** Other bip-21 parameters in the URI that we do not handle. */
val ignoredParams: Parameters = Parameters.Empty,
) {
fun write(): String {
val params = Parameters.build {
label?.let { append("label", it) }
message?.let { append("message", it) }
// amount field is converted to BTC
amount?.sat?.toString()?.padStart(9, '0')?.let {
val satPart = it.takeLast(8)
val btcPart = it.substring(0, it.length - 8)
append("amount", "${btcPart}.${satPart}")
}
paymentRequest?.let { append("lightning", it.write()) }
offer?.let { append("lno", it.encode()) }
}
return "bitcoin:$address?${(params + ignoredParams).formUrlEncode()}"
}
}
sealed class BitcoinUriError {
data class InvalidScript(val error: BitcoinError): BitcoinUriError()
data class UnhandledRequiredParams(val parameters: List<Pair<String, String>>): BitcoinUriError()
object InvalidUri: BitcoinUriError()
}
@@ -1,17 +0,0 @@
package fr.acinq.phoenix.data
import kotlinx.serialization.Serializable
@Serializable
sealed class ChannelsWatcherOutcome {
abstract val timestamp: Long
@Serializable
data class Unknown(override val timestamp: Long) : ChannelsWatcherOutcome()
@Serializable
data class Nominal(override val timestamp: Long) : ChannelsWatcherOutcome()
@Serializable
data class RevokedFound(override val timestamp: Long) : ChannelsWatcherOutcome()
}
@@ -1,115 +0,0 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.data
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Crypto
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.byteVector32
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.lightning.wire.OfferTypes
import io.ktor.utils.io.charsets.Charsets
import io.ktor.utils.io.core.toByteArray
sealed class ContactPaymentCode {
abstract val id: ByteVector32
abstract val label: String?
abstract val createdAt: Long
abstract val paymentCode: String
}
data class ContactOffer(
override val id: ByteVector32,
val offer: OfferTypes.Offer,
override val label: String?,
override val createdAt: Long
) : ContactPaymentCode() {
override val paymentCode: String by lazy { offer.encode() }
constructor(offer: OfferTypes.Offer, label: String?, createdAt: Long? = null) : this(
id = offer.offerId, // see note below
offer = offer,
label = label,
createdAt = createdAt ?: currentTimestampMillis()
)
// We purposefully store the calculated `offer.offerId` as a property,
// because the `offerId` property itself is actually a result of hashing and
// other calculations. In other words it's not cheap to compute.
// And it's a value we reference regularly within the UI.
}
data class ContactAddress(
override val id: ByteVector32,
val address: String,
override val label: String?,
override val createdAt: Long
) : ContactPaymentCode() {
override val paymentCode: String = address
constructor(address: String, label: String?, createdAt: Long? = null) : this(
id = hash(address), // see note below
address = address,
label = label,
createdAt = createdAt ?: currentTimestampMillis()
)
// We purposefully store the calculated `hash(address)` as a property,
// because the value is a result of hashing. So it's not cheap to compute.
// And it's a value we reference regularly within the UI.
companion object {
fun hash(address: String): ByteVector32 {
val input = address.lowercase().toByteArray(charset = Charsets.UTF_8)
return Crypto.sha256(input).byteVector32()
}
}
}
data class ContactInfo(
val id: UUID,
val name: String,
val photoUri: String?,
val useOfferKey: Boolean,
val offers: List<ContactOffer>,
val addresses: List<ContactAddress>,
val publicKeys: List<PublicKey>,
) {
constructor(
id: UUID,
name: String,
photoUri: String?,
useOfferKey: Boolean,
offers: List<ContactOffer>,
addresses: List<ContactAddress>
) : this(
id = id,
name = name,
photoUri = photoUri,
useOfferKey = useOfferKey,
offers = offers,
addresses = addresses,
publicKeys = offers.map { it.offer.contactInfos.map { it.nodeId } }.flatten()
)
/** List the offers and LN addresses attached to the contact, ordered by creation date (most recent on top). */
val paymentCodes: List<ContactPaymentCode> by lazy { (offers + addresses).sortedByDescending { it.createdAt } }
}
@@ -1,13 +0,0 @@
package fr.acinq.phoenix.data
sealed class DecryptSeedResult {
data class Success(val userWalletsMap: Map<WalletId, UserWallet>): DecryptSeedResult()
sealed class Failure: DecryptSeedResult() {
data object SeedFileNotFound: Failure()
data object SerializationError: Failure()
data class KeyStoreFailure(val cause: Throwable): Failure()
data class DecryptionError(val cause: Throwable): Failure()
data object SeedFileUnreadable: Failure()
data object SeedInvalid: Failure()
}
}
@@ -1,32 +0,0 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.data
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.lightning.wire.OfferTypes
/**
* @param defaultOffer The default offer for a node.
* @param payerKey A private key attached to a node. It can be used to sign payments to offers of
* third parties and prove the origin of that payment. The recipient of that payment can then
* decide that this origin is trusted, and show/hide the `payerNote` attached to that payment.
*
*/
data class OfferData(
val defaultOffer: OfferTypes.Offer,
val payerKey: PrivateKey
)
@@ -1,184 +0,0 @@
package fr.acinq.phoenix.data
import fr.acinq.lightning.io.TcpSocket
import fr.acinq.lightning.utils.ServerAddress
private fun electrumServer(host: String, port: Int = 50002): ServerAddress =
ServerAddress(host = host, port = port, tls = TcpSocket.TLS.TRUSTED_CERTIFICATES())
private fun electrumServer(host: String, port: Int = 50002, publicKey: String): ServerAddress =
ServerAddress(host = host, port = port, tls = TcpSocket.TLS.PINNED_PUBLIC_KEY(publicKey))
private fun electrumServerOnion(host: String, port: Int = 50002): ServerAddress =
ServerAddress(host = host, port = port, tls = TcpSocket.TLS.DISABLED)
val mainnetElectrumServers = listOf(
electrumServer(host = "electrum.acinq.co"),
electrumServer(
host = "E-X.not.fyi", publicKey =
"MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA8wmp3hyau0aAOjszUUJY" +
"YcMlDqlQ0/Gi7xYf0id1CG+e0yjU2pHuPXgnEmtXdsLIF5GleU7LP5L1xPrzGQD3" +
"LZb8CGKcl7Ve9H167wt5kiehJ/AaF4xcL96uaGQ8ykZMxZrz01AD72mT7u9S7IJt" +
"ypdHbiSq9YiTQj/lscYw318woRV/VLf9qaPfANileffEDRuOJB4OT6FizB+1CDoD" +
"ayI8J7sEiPPYuV7/ttNIEGH6wQCQLxQHQAP6fkAAQ+WMuwl2UeG7NvDocHZJp5hA" +
"L0LJkgquH4LaoFzzA2Yh61Ep1uWeRH7KKlXQnhPRUkgUKfrgovhT5kyszIIpkZiZ" +
"Z2g15fXTRVp2WBxJSa9qPEgg310T37KFXwaV08XdnEEa7pz5oHYUcUPGlRWuYDVQ" +
"X7HAUYvwT84eRvEj+E6L3FhsI0EulzvaHUO8SvKjSK94yoG6FepFi95eNdAwIUXg" +
"LgOWKu/zsdCoDWbaA9nihIJw9ZPESbb8q1WDAOV+M6YLcAyE0hLWDzra3euxUAuB" +
"vIc/tP8RkJ1tzrHE+3KosNAO7y8mP4XlnPvkY5ZS01VXL6a+NoPcDL3+tZr1jjb4" +
"XBjxhaQKn4EhlvwTURL9VOoZADOnVU+DmGiTRJbKNIL84yio/nIubKrD4eONggUh" +
"QDEcaySiP9R+yKz5C9o/WlUCAwEAAQ=="
),
electrumServer(
host = "btc.cihar.com", publicKey =
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs0gd2ZsghxUZNwjY6cAD" +
"eZRRvk4sGUvkp5SEENNotiwCFPWXdNxCWxh1aiXpLc/h1+1NmwDHDhFXZDZNGFEW" +
"GPjW92uZWlcGVZffJWqc8XAvVmTKXUgCDv5daEtyTxk/69NDmmDWSeltV8020ykD" +
"FcU5cE/xEmBCfFRoR6yIGwIsCQAIX7XnfbDg1+JdN2N3ZSOOlY4B9r7n3Pm0Q0MW" +
"kRykSFk8EEQYmtk383aFZVDuvUkgLLFsBb0zmkWEVrm6Jy1hXyfWqdsrLaipqhy7" +
"2n62mHT9vfKhTGIoOXR989v6FA+EIYAklIL2ptX3vLqqvOnRjB122b9eT5ZpZhNi" +
"uwIDAQAB"
),
electrumServer(host = "e.keff.org"), // certificate has expired
electrumServer(
host = "tardis.bauerj.eu", publicKey =
"MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxLHijYCSNS9SDOgcMPo3" +
"ldVzRTo3LYwozUOUI2P5P8ip7sLFmXPjLbRdzSaKi6YA1J56muie5MJgAimqPo8F" +
"vclOGQrArpU/mQEHbWBZJyPQiftldILRLGAN5OpZnAilLtNuPOtbqbEn5KtX7hyz" +
"K4Xq+RZd32PMpehVVpG9LZTL/QCB5m99iffUl5uR3BX36siOJIWpahPMizzJKdP0" +
"RNZcAKrx5YdWStUYtfprjfBDKXQN6SB5tOVHxVLPpQz3+Iv1mab2nBbQxqiuPTyW" +
"8KC1ZsaLfvnQdBgnWxSPcSuLmlc4hsjloC0oinUnH3j4MvqkaTrTsokUMF4ROHiX" +
"ks9UcbvzdTXHh9c+8Ia/fVLLAIZitKrc9glFKI1hkjFRAQeGlc3m4TFcsT8ue8a6" +
"C6btxW1XZ/BPhznpk9FdUtU0BjZKtwg12cuqSfBcqdFgIwN60jM5N16n1hQDrMHN" +
"eZuW5DcVBIR4gq8eZUZ15460Ck4qufliWFD/M6G7rO+hLOIxu9MEe6r5CF1bGaNw" +
"mNJhIZeg3JGN9fn1h7kX4VW5H+9v7YVpYGB/vGXCDEsOLjHlZVjgbtungBVJ1UML" +
"uOixmccwKWZecT5jXCypj671lzOxF6edUbRsD2xXFjkG5RoifqyNY6zM08bNTyVo" +
"BbQJhHwGCCFBV/e5RgVATIUCAwEAAQ=="
),
electrumServer(
host = "VPS.hsmiths.com", publicKey =
"MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwaNR7gST8KEAwc7tGTZP" +
"1T6qVhg+KjqY1LpvkZ8m1QBPh+j6HsTjB4+RiH1MerqMWVLD9dEZEYZsEUREySyb" +
"G3szQ1nf+GZkn3XorKyvX29B0aZow3ZdAKwTXF51jwCE/G7j3UpteKIYoX/egoUq" +
"G8k/N3q0jxUZXGEY33BhHGAdiuRAk70QDIUjh1zuPHVED9tE9fc/qeJ/ZlQbQHzK" +
"Y820JcqR1JingTufVps26SyiOHO+jzlRGsO3qHpAmtc1ikEJt1dPS53Rcu8LojF0" +
"kgKZ5oCgGSCWU/+T+8xffp/y5GrV7lxlFNNwM2HFLvx24r2ovYujMzd45VgQ+rIG" +
"V1FcAVDXsarNtSV2R5YmyldScRnzR5IEHcPWPFXMuMvZR31COtD99pXsoS60JO9S" +
"Bd7DMcbU/aXxlyfltla58JS3RV27Uem01YkIfx9d0QW07TXPRmWfQNQ1aZV4Iyfn" +
"oWJnYRR36BOI3TIZKGBSFYvvojNh6noeRVp/BCDRmsi4lL3PLAAH3jKJ2jGjXnZ7" +
"uQNlxsC6ZDL9yezndEjpRYQPYZLrUGOOuNKJReSHtWw83U6wd/5TyI8NRGkHZlCi" +
"585YChjthLYR8fdfHR2sAvMf0pfqBQgV8sNQ3qIZdpaVZIr0wXucsKDGhsZO3Zym" +
"FlzYsP0snim4LMNIlQ+W8N8CAwEAAQ=="
),
electrumServer(
host = "electrum.qtornado.com", publicKey =
"MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAm78Y1pLds3BzsHpo9Bz2" +
"2lzu9tS/7loMcdL6AVJ3zVgGycI5whOWuaQntG0aYSiHammZNgGzjv44oU/PluzX" +
"PhMxzPlNgSEHnVi2K9mzG7HuGMQh5tEJfvt1zoMmnV4qTaZSLgKKcvrG9112LGQF" +
"UZBhV2J3gN21c8rqj/b6NfEqtItKVU173nqYchYu1GBGrHK1nDWWwTVrtHzY4Qos" +
"etyCPPnj/JWkO6I8kW2CIevpbXh5PROb+YvdbIsqvRODFgo1oLHmwf09Y7ZxE/nS" +
"LZ8yI67U1O39EcRQnoob6pbaqbaoNr0KZSR3xXcJTz2RkhlErMVNH850QGxlXMOr" +
"wNKgVrHhUFEBklbsk11Mx+mYWoeHrvZn55xwpTYGaZmZmAVwwUvherz/Tg490XD2" +
"2+2T78Zu3mmfmHKfD9uhC+ewyn+REHiz9vrvmMeh+YMBOEwf8lp1Jqte7/8xgdfq" +
"kDguOkN6azG64+LyzgWrB79Dfql858Rwn+ezpBZKcSyIL7o1r0T/WeCuR6vLiQ8q" +
"YbwAm61pUM9aVshda2WGRRUkhpy7Uj5OHpEQsnXqtoeEzTuiCr1y19VJgwabcUQH" +
"MVN1HGHiBl5eU4xBhDUG0l/268Ulk2lcRSI0udRtu7jjQzhSnKQL6HUhCm7PCdXg" +
"SqOSwa1yPuHtg/rNBXcqK8cCAwEAAQ=="
),
electrumServer(
host = "electrum.emzy.de", publicKey =
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAufqDv0nJICJPoP86wOPY" +
"M/XIfFs6vmVrGEeBZmy9MmMNubulhnyE+sWuFhnIX+0uuFlJ18LJYFOIg0fAaFdN" +
"5xh4vagBNXP9dCcxfVfMGfv9xBQZfWhKrDjC4DCJ82n3K+Q4RqpK/yS9GIZIcqrG" +
"3rxELBZ8NHVPIXveW0PnagGeQ31NDdOAq9MBKiKRcmfKem5daUEo/xM4nTt+tOTx" +
"l5sHicdQSCePHXewMXzmkM/Vw1rMJZKeTwnLX44TsppEi47fXUFcduB2+A1xHQIg" +
"E9wa4Bqc2ZoUtKKBayeeU02C2SBFgVxtAWT6YESdcPP8u+pR7lADA7QZNUVNKMvM" +
"NwIDAQAB"
),
electrumServer(
host = "ecdsa.net", port = 110, publicKey =
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzGHtaM37N0tgS7xcWmSq" +
"S2eOHBOtsZIGkkccbQbdmkgSpMGZSJgr23OofrlhmcdzFIzlqK2nXii7+5KW3Lgs" +
"zHKt9pArroFRRRFvoWHp5ijWYRATo7GKVPCngCPbg4fl4wxPYp9yg14BPe7BJfSN" +
"Stz6gV+akcCmKMVfqN5JFqPuuzOmSib270TgHCtIUccgDqHdP1muPQWZjCCxjePT" +
"f6e6J478Bh+Eop4lqvpEiLGeU/6Qj2oZ2tmO7j09J6Ycp0FHBISuCWWUCuZmIEk/" +
"NGOIUagggRU2tVFeW6wjSm1T3Q/z/b6G5oIaldZnklqo//79d3B4Fjj+C8lnFhpP" +
"/wIDAQAB"
),
electrumServer(host = "electrum.hodlister.co"),
electrumServer(host = "electrum3.hodlister.co"),
electrumServer(host = "electrum5.hodlister.co"),
electrumServer(
host = "fortress.qtornado.com", publicKey =
"MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAy7xI+hVU3rUSAyMMX8OI" +
"LdCs5m5Az1rzFL/8MTSnCBUnXY+YjmN8vFg8ktc/ebwSnjxCfjjXjPPcf0qSS2Xa" +
"WZ3M7nAlzYLpZCTuGBWE2ZbQ30QzXiMzh3Ucum4EJUeGOTEDtisdCrv3jjDs/P96" +
"UgR8MMA1fJqIpxAKnbEj5I9FX9t4eFVhYl6aiykJUFpV3aUZiDsqH8v5MiXCWHAc" +
"EGx1nfzY/os5xYYm/w0+VJiyX6HN0AViA2zF1wnWZFRqwCWpffaZAAPQdYaJ+bGl" +
"zR2PQneczw01EfJwMgb8QfMy3GbZFga7gT5NcZGzEAztL8Y7EzhtjcLksvhm5TB7" +
"iZbvNlCTmyv6F/jm90sZZAwYsg+9Dzl5ciJTKigQnMbPlDaOiRUKXkdw114Q2emO" +
"XKxs74fBl2g/XNO+3Jt7LSDrHEN38ygrHROxPbCTJmUawQ29KUBDULlnsWgzb9Ni" +
"uWMdaPJTXPUQSEYcux1jkVED1l5vANds0bTgM3fipb1MhvFb2GYJiVJkwu342fiG" +
"ADGpTzzjMqhsxevph8zh72MdaxyHCLlvk5OswTEoGNdo6ZFRBpmWDJF9zMnXCPEM" +
"vk2PS8RRlECPNORxSDsu79uXVm0+VmhLshcLOEEb+9qhLid063YLZrwEqZJshbm9" +
"Gcc9FdI7SUP85+RR9XnyL9cCAwEAAQ=="
),
electrumServer(host = "electrum.blockstream.info"),
electrumServer(host = "blockstream.info", port = 700)
)
val testnetElectrumServers = listOf(
electrumServer(
host = "testnet.qtornado.com", port = 51002, publicKey =
"MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwkLgqNkkTbwpV3gMdgDA" +
"+jJFdzrOp8vIDT/qxVIox8NZ53pxPc2N44aeY1NJx4TyfpHUGcI7l+gxZfLr8a13" +
"o3CIQSotDbZZdJhS6Ir5tT4iRqwZJch+HayTQf9rztv8OQWgrflWDzCiYtBA5PGx" +
"6LEQWyah/xPPUbeANe/ndEzlfAhXjNcynSfrkikzTgFNBqnc5CcTkHjYgzCXqMwy" +
"ZCD6kQTQG+eqIHSHul21dwUougfCWCR+P0zFA7LeUfPz2mLZktmGXjqTyYZ+0ZTU" +
"gJz/MMZt9PDWGJZsHQzoFSCMicukKtnvZ4Q0gbPOoYp8+WjD4SH+WmC3MZdLagsi" +
"05hUDdm7PHIM1VHQTALLGRnW3yTOaqhsvYGAM5UOkDcmgUqIr6IztHGWCKldfbhS" +
"c4l7BIgvwW2M6FxYlSAcavIodNfvEC1ythdMzl8bZsBjGIOZ39WtiM0grgcg7bb8" +
"W5ovZpLOXpzZBjS0zB0sZJnumjS+3jCSjy9rZXGUn3JmMdqtTV8RQxkB8OBJhFf5" +
"qtMSZXiJIr9RH71VoJKjnds/hoILHuCKU3HOJeo0+4KSD8+q4g3tZLr/haIrsHg5" +
"uifT9db6tDML1PTKpbHkW+f3w9PdhSmsNUUXrgNmQ0MoBhxV7U2Qcug3jX3xaf1P" +
"gwWDg3nZZizhuvBceY0IYLECAwEAAQ=="
),
electrumServer(host = "blockstream.info", port = 993),
electrumServer(
host = "testnet.aranguren.org", port = 51002, publicKey =
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+RL/AH7wn08YKlRCswER" +
"M0JBdGycRuGBgQbM0guzDxi7Ov01WB9AM0DX7GC09pAOefbRP8QzXEhyKO0qpnin" +
"+5Vz4jKS+xv4zPGx2MpTqjJxzom/6v13cumZxWXMzVSeNUTjVp2sOPQ1JQaHqqjs" +
"2lTgShWu+pAgKUH1KPWxMSz21cI+AQkT8NuuXe0USYYIeiXzyTpciIaBf50j6185" +
"u+4bUwA3hvdPZyrkJDtSluJ0HiJzCSFlmNYNHLqbvZNAYrgUM3qJRTsvmD0JK6mm" +
"8m7iXW4m6mKX22VgR93meD/3rdcrJ8FbMbVlkS3wimzcYezls9JytaXupyeRKhQj" +
"TQIDAQAB"
),
)
val mainnetElectrumServersOnion: List<ServerAddress> by lazy {
listOf(
electrumServerOnion(host = "22mgr2fndslabzvx4sj7ialugn2jv3cfqjb3dnj67a6vnrkp7g4l37ad.onion", port = 50001),
electrumServerOnion(host = "bejqtnc64qttdempkczylydg7l3ordwugbdar7yqbndck53ukx7wnwad.onion", port = 50001),
electrumServerOnion(host = "egyh5mutxwcvwhlvjubf6wytwoq5xxvfb2522ocx77puc6ihmffrh6id.onion", port = 50001),
electrumServerOnion(host = "explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion", port = 110),
electrumServerOnion(host = "kittycp2gatrqhlwpmbczk5rblw62enrpo2rzwtkfrrr27hq435d4vid.onion", port = 50001),
electrumServerOnion(host = "qly7g5n5t3f3h23xvbp44vs6vpmayurno4basuu5rcvrupli7y2jmgid.onion", port = 50001),
electrumServerOnion(host = "rzspa374ob3hlyjptkdgz6a62wim2mpanuw6m3shlwn2cxg2smy3p7yd.onion", port = 50003),
electrumServerOnion(host = "ty6cgwaf2pbc244gijtmpfvte3wwfp32wgz57eltjkgtsel2q7jufjyd.onion", port = 50001),
electrumServerOnion(host = "udfpzbte2hommnvag5f3qlouqkhvp3xybhlus2yvfeqdwlhjroe4bbyd.onion", port = 60001),
electrumServerOnion(host = "v7gtzf7nua6hdmb2wtqaqioqmesdb4xrlly4zwr7bvayxv2bpg665pqd.onion", port = 50001),
electrumServerOnion(host = "v7o2hkemnt677k3jxcbosmjjxw3p5khjyu7jwv7orfy6rwtkizbshwqd.onion", port = 57001),
electrumServerOnion(host = "venmrle3xuwkgkd42wg7f735l6cghst3sdfa3w3ryib2rochfhld6lid.onion", port = 50001),
electrumServerOnion(host = "wsw6tua3xl24gsmi264zaep6seppjyrkyucpsmuxnjzyt3f3j6swshad.onion", port = 50001),
)
}
val testnetElectrumServersOnion by lazy {
listOf(
electrumServerOnion(host = "explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion", port = 143)
)
}
expect fun platformElectrumRegtestConf(): ServerAddress
@@ -1,174 +0,0 @@
package fr.acinq.phoenix.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
sealed class ExchangeRate {
abstract val fiatCurrency: FiatCurrency
abstract val timestampMillis: Long
/** An exchange rate may be between a fiat currency and Bitcoin, or between a fiat currency and the US Dollar. */
enum class Type {
BTC,
USD
}
data class Row(
val fiat: String,
val price: Double,
val type: Type,
val source: String,
val updated_at: Long
)
/**
* Price: 1 BTC = $price FIAT
*/
data class BitcoinPriceRate(
override val fiatCurrency: FiatCurrency,
/** The price of 1 BTC in this currency */
val price: Double,
val source: String,
override val timestampMillis: Long
): ExchangeRate() {
fun toRow() = Row(
fiat = fiatCurrency.name,
price = price,
type = Type.BTC,
source = source,
updated_at = timestampMillis
)
}
/**
* Price: 1 USD = $price FIAT
*/
data class UsdPriceRate(
override val fiatCurrency: FiatCurrency,
/** The price of one US Dollar in this currency */
val price: Double,
val source: String,
override val timestampMillis: Long
): ExchangeRate() {
fun toRow() = Row(
fiat = fiatCurrency.name,
price = price,
type = Type.USD,
source = source,
updated_at = timestampMillis
)
}
}
/**
* Blockchain.info example:
* {
* "ARS": {
* "15m": 5453151.01,
* "last": 5453151.01,
* "buy": 5453151.01,
* "sell": 5453151.01,
* "symbol": "ARS"
* },
* "AUD": {
* "15m": 24869.41,
* "last": 24869.41,
* "buy": 24869.41,
* "sell": 24869.41,
* "symbol": "AUD"
* },
* ...
* }
*/
@Serializable
data class BlockchainInfoPriceObject(
val last: Double
)
typealias BlockchainInfoResponse = Map<String, BlockchainInfoPriceObject>
/**
* Coinbase example:
* {
* "data": {
* "currency": "USD",
* "rates": {
* "AED": "3.6726399999999999",
* "AFN": "89.2213",
* ...
* }
* }
* }
*/
@Serializable
data class CoinbaseResponse(
val data: Data,
) {
@Serializable
data class Data(
val rates: Map<String, String>
)
}
/**
* Bluelytics example:
* {
* "oficial":{
* "value_avg":110.92,
* "value_sell":113.92,
* "value_buy":107.92
* },
* "blue":{
* "value_avg":198.50,
* "value_sell":200.50,
* "value_buy":196.50
* },
* "oficial_euro":{
* "value_avg":119.27,
* "value_sell":122.49,
* "value_buy":116.04
* },
* "blue_euro":{
* "value_avg":213.44,
* "value_sell":215.59,
* "value_buy":211.29
* },
* "last_update":"2022-03-07T15:25:32.816374-03:00"
* }
*/
@Serializable
data class BluelyticsResponse(
val blue: Rate,
val blue_euro: Rate
) {
@Serializable
data class Rate(
val value_avg: Double,
val value_sell: Double,
val value_buy: Double
)
}
/**
* Yadio example:
* {
* "BTC": 16640.86,
* "USD": {
* "CUP": 170,
* "IRR": 4063500,
* ...
* },
* "base": "USD",
* "timestamp": 1672776301979
* }
*/
@Serializable
data class YadioResponse(
@SerialName("USD")
val usdRates: Map<String, Double>
)
@@ -1,194 +0,0 @@
/*
* Copyright 2023 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.data
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Satoshi
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.channel.states.Aborted
import fr.acinq.lightning.channel.states.ChannelState
import fr.acinq.lightning.channel.states.ChannelStateWithCommitments
import fr.acinq.lightning.channel.states.Closed
import fr.acinq.lightning.channel.states.Closing
import fr.acinq.lightning.channel.states.Normal
import fr.acinq.lightning.channel.states.Syncing
import fr.acinq.lightning.json.JsonSerializers
import fr.acinq.lightning.utils.msat
import fr.acinq.phoenix.managers.PeerManager
import fr.acinq.phoenix.utils.extensions.isTerminated
import fr.acinq.phoenix.utils.extensions.localBalance
/**
* This class exposes a channel's more important information in an easy-to-consume format.
*
* @param channelId the channel's identifier as a hexadecimal string.
* @param state the channel's state that may contain commitments information.
* @param isBooting if true, this data comes from the local database and the channel has not yet been reestablished
* with the peer. As such, the data may be obsolete (for example, if the peer actually force-closed the channel
* while Phoenix was disconnected). If false, this data is the live view of the channel as negotiated with the
* peer and can be considered up-to-date (but there's no guarantee, as the channel may actually be disconnected!).
*/
data class LocalChannelInfo(
val channelId: String,
val state: ChannelState,
val isBooting: Boolean,
) {
/** True if the channel is terminated and will never be usable again. */
val isTerminated by lazy { state.isTerminated() }
/** True if the channel can be used to send/receive payments. */
val isUsable by lazy { state is Normal && !isBooting }
/** True if the channel is `LegacyWaitForFundingConfirmed`, i.e., it may be a zombie channel. */
// val isLegacyWait by lazy { state.isLegacyWait() }
/** A string version of the state's class. */
val stateName by lazy { state.stateName }
// FIXME: we should also expose the raw channel's balance, which is what should be used in the channel's details screen, rather than the "smart" spendable balance returned by `localBalance()`
/** The channel's spendable balance, as seen in [ChannelState.localBalance]. */
val localBalance by lazy { state.localBalance() }
/**
* The channel's receive capacity - should be accurate but still depends on the network feerate.
*
* @return null if the channel is not NORMAL, otherwise the receive capacity.
*/
val availableForReceive by lazy {
when (state) {
is Normal -> state.commitments.availableBalanceForReceive()
else -> null
}
}
/** The channel's current capacity. It actually is the funding capacity of the latest commitment. */
val currentFundingAmount by lazy { if (state is ChannelStateWithCommitments) state.commitments.latest.fundingAmount else null }
/** A channel may have several active commitments. */
val commitmentsInfo: List<CommitmentInfo> by lazy {
when (state) {
is ChannelStateWithCommitments -> {
val params = state.commitments.channelParams
val changes = state.commitments.changes
state.commitments.active.map {
CommitmentInfo(
fundingTxId = it.fundingTxId,
fundingTxIndex = it.fundingTxIndex,
fundingAmount = it.fundingAmount,
balanceForSend = it.availableBalanceForSend(params, changes)
)
}.sortedByDescending { it.fundingTxIndex }
}
else -> emptyList()
}
}
/** A channel may have several inactive commitments. */
val inactiveCommitmentsInfo: List<CommitmentInfo> by lazy {
when (state) {
is ChannelStateWithCommitments -> {
val params = state.commitments.channelParams
val changes = state.commitments.changes
state.commitments.inactive.map {
CommitmentInfo(
fundingTxId = it.fundingTxId,
fundingTxIndex = it.fundingTxIndex,
fundingAmount = it.fundingAmount,
balanceForSend = it.availableBalanceForSend(params, changes)
)
}.sortedByDescending { it.fundingTxIndex }
}
else -> emptyList()
}
}
/** Returns the count of payments being sent or received by this channel. */
val inFlightPaymentsCount: Int by lazy {
when (state) {
is Closing, is Closed, is Aborted -> 0
is ChannelStateWithCommitments -> {
buildSet {
state.commitments.latest.localCommit.spec.htlcs.forEach { add(it.add.paymentHash) }
state.commitments.latest.remoteCommit.spec.htlcs.forEach { add(it.add.paymentHash) }
state.commitments.latest.nextRemoteCommit?.spec?.htlcs?.forEach { add(it.add.paymentHash) }
}.size
}
else -> 0
}
}
/** The channel's data serialized in a json string. */
val json: String by lazy { JsonSerializers.json.encodeToString(state) }
/** Stripped-down commitment, easier to consume from the frontend. */
data class CommitmentInfo(
val fundingTxId: TxId,
val fundingTxIndex: Long,
val fundingAmount: Satoshi,
val balanceForSend: MilliSatoshi,
)
companion object { /* allow companion extensions; see PhoenixExposure.kt */ }
}
/**
* Helper method that returns the **relevant** receive balance of channels exposed in the [PeerManager]'s channels flow.
*
* The point is to avoid taking into account the liquidity of non-usable channels - for example, syncing channels, or channels being reestablished.
*
* @return Null if:
* - the node is not yet fully initialized and the channels flow is in limbo.
* - the node is initialized, but channels are still local (not yet reestablished with the peer)
* 0 msat if:
* - the map is empty (no active channels in the node);
* - the map is not empty, but all active channels are in a non-NORMAL state. For example, channels being closed.
* the actual receive balance (that may be 0) if:
* - there's at least 1 NORMAL channel.
*/
fun Map<ByteVector32, LocalChannelInfo>?.availableForReceive(): MilliSatoshi? {
return this?.values?.availableForReceive()
}
fun Collection<LocalChannelInfo>.availableForReceive(): MilliSatoshi? {
return when {
this.isEmpty() -> 0.msat
this.all { it.isBooting } -> null
this.all { it.state is Syncing } -> null
else -> this.map {
if (it.state is Syncing || it.isBooting) {
null
} else {
it.availableForReceive ?: 0.msat
}
}.reduce { a, b ->
when {
a == null && b == null -> null
a != null && b != null -> a + b
a == null && b != null -> b
else -> a
}
}
}
}
/** Liquidity can be requested if you have at least 1 usable channel. */
fun Map<ByteVector32, LocalChannelInfo>?.canRequestLiquidity(): Boolean {
return this?.values?.canRequestLiquidity() ?: false
}
fun Collection<LocalChannelInfo>.canRequestLiquidity(): Boolean {
return this.any { it.isUsable }
}
fun Map<ByteVector32, LocalChannelInfo>?.inFlightPaymentsCount(): Int {
return this?.values?.inFlightPaymentsCount() ?: 0
}
fun Collection<LocalChannelInfo>.inFlightPaymentsCount(): Int {
return this.sumOf { it.inFlightPaymentsCount }
}
@@ -1,54 +0,0 @@
/*
* Copyright 2023 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.data
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.transactions.Transactions
import fr.acinq.lightning.utils.sat
import fr.acinq.phoenix.managers.NodeParamsManager
/** Inspired from https://mempool.space/api/v1/fees/recommended */
data class MempoolFeerate(
val fastest: FeeratePerByte,
val halfHour: FeeratePerByte,
val hour: FeeratePerByte,
val economy: FeeratePerByte,
val minimum: FeeratePerByte,
val timestamp: Long,
) {
/**
* Estimates roughly the cost of a dual-funded splice, using the current feerate and an arbitrary tx weight.
*
* An additional service fee is expected if there's no channels already.
*/
fun swapEstimationFee(hasNoChannels: Boolean): Satoshi {
return Transactions.weight2fee(feerate = FeeratePerKw(hour), weight = DualFundingPayToSpliceWeight) + if (hasNoChannels) 1000.sat else 0.sat
}
fun payToOpenEstimationFee(amount: MilliSatoshi, hasNoChannels: Boolean): Satoshi {
return swapEstimationFee(hasNoChannels) + (amount * NodeParamsManager.payToOpenFeeBase / 10_000).truncateToSatoshi()
}
companion object {
/** Spending a channel output and adding funds from a wpkh wallet with one change output: 2-inputs (wpkh+wsh)/2-outputs (wpkh+wsh) */
const val DualFundingPayToSpliceWeight = 992
}
}
@@ -1,93 +0,0 @@
/*
* Copyright 2023 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.data
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.LiquidityEvents
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampMillis
/** Notification object, typically regarding missed payments. */
sealed class Notification {
abstract val id: UUID
abstract val createdAt: Long
abstract val readAt: Long?
val hasBeenRead by lazy { readAt != null }
sealed class PaymentRejected : Notification() {
abstract val amount: MilliSatoshi
abstract val source: LiquidityEvents.Source
}
data class OverAbsoluteFee(
override val id: UUID,
override val createdAt: Long,
override val readAt: Long?,
override val amount: MilliSatoshi,
val fee: MilliSatoshi,
override val source: LiquidityEvents.Source,
val maxAbsoluteFee: Satoshi,
) : PaymentRejected()
data class OverRelativeFee(
override val id: UUID,
override val createdAt: Long,
override val readAt: Long?,
override val amount: MilliSatoshi,
val fee: MilliSatoshi,
override val source: LiquidityEvents.Source,
val maxRelativeFeeBasisPoints: Int,
) : PaymentRejected()
data class FeePolicyDisabled(
override val id: UUID,
override val createdAt: Long,
override val readAt: Long?,
override val amount: MilliSatoshi,
override val source: LiquidityEvents.Source,
) : PaymentRejected()
data class MissingOffChainAmountTooLow(
override val id: UUID,
override val createdAt: Long,
override val readAt: Long?,
override val amount: MilliSatoshi,
override val source: LiquidityEvents.Source,
) : PaymentRejected()
data class GenericError(
override val id: UUID,
override val createdAt: Long,
override val readAt: Long?,
override val amount: MilliSatoshi,
override val source: LiquidityEvents.Source,
) : PaymentRejected()
}
sealed class WatchTowerOutcome : Notification() {
data class Unknown(override val id: UUID, override val createdAt: Long, override val readAt: Long?): WatchTowerOutcome() {
constructor() : this(UUID.randomUUID(), currentTimestampMillis(), null)
}
data class Nominal(override val id: UUID, override val createdAt: Long, override val readAt: Long?, val channelsWatchedCount: Int): WatchTowerOutcome() {
constructor(channelsWatchedCount: Int) : this(UUID.randomUUID(), currentTimestampMillis(), null, channelsWatchedCount)
}
data class RevokedFound(override val id: UUID, override val createdAt: Long, override val readAt: Long?, val channels: Set<ByteVector32>): WatchTowerOutcome() {
constructor(channels: Set<ByteVector32>) : this(UUID.randomUUID(), currentTimestampMillis(), null, channels)
}
}
@@ -1,12 +0,0 @@
package fr.acinq.phoenix.data
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.managers.WalletManager
sealed class StartBusinessResult {
data class Success(val walletInfo: WalletManager.WalletInfo, val business: PhoenixBusiness): StartBusinessResult()
sealed class Failure : StartBusinessResult() {
data class Generic(val cause: Throwable): Failure()
data object LoadWalletError: Failure()
}
}
@@ -1,14 +0,0 @@
package fr.acinq.phoenix.data
enum class UserTheme {
LIGHT, DARK, SYSTEM;
companion object {
fun safeValueOf(value: String?): UserTheme = when (value) {
LIGHT.name -> LIGHT
DARK.name -> DARK
SYSTEM.name -> SYSTEM
else -> SYSTEM
}
}
}
@@ -1,55 +0,0 @@
package fr.acinq.phoenix.data
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.byteVector
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.utils.preferences.InternalPrefs
import fr.acinq.phoenix.utils.preferences.UserPrefs
import kotlinx.serialization.Serializable
sealed class ListWalletState {
data object Init: ListWalletState()
data object Success: ListWalletState()
sealed class Error: ListWalletState() {
data class Generic(val cause: Throwable?): Error()
data object Serialization: Error()
sealed class DecryptionError : Error() {
data class GeneralException(val cause: Throwable): DecryptionError()
data class KeystoreFailure(val cause: Throwable): DecryptionError()
}
}
}
sealed class BaseWalletId
data object EmptyWalletId: BaseWalletId()
/** Wraps a nodeIdHash (hash160 of a nodeId). Easier to maintain and upgrade than a plain String. */
@Serializable
data class WalletId(val nodeIdHash: String): BaseWalletId() {
constructor(nodeId: PublicKey) : this(
nodeIdHash = nodeId.hash160().byteVector().toHex()
)
override fun toString() = nodeIdHash
override fun hashCode() = nodeIdHash.hashCode()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is WalletId) return false
return nodeIdHash == other.nodeIdHash
}
}
data class UserWallet(
val walletId: WalletId,
val nodeId: String,
val words: List<String>,
) {
override fun toString(): String = "UserWallet[ wallet_id=$walletId, words=*** ]"
}
data class ActiveWallet(
val id: WalletId,
val business: PhoenixBusiness?,
val userPrefs: UserPrefs,
val internalPrefs: InternalPrefs,
)
@@ -1,8 +0,0 @@
package fr.acinq.phoenix.data
/** Contains contextual information for the wallet, fetched from https://acinq.co/phoenix/walletcontext.json. */
data class WalletContext(
val isMempoolFull: Boolean,
val androidLatestVersion: Int,
val androidLatestCriticalVersion: Int,
)
@@ -1,3 +0,0 @@
package fr.acinq.phoenix.data
data class WalletNotice(val message: String, val index: Int)
@@ -1,152 +0,0 @@
package fr.acinq.phoenix.data
import androidx.compose.runtime.Composable
import fr.acinq.lightning.db.AutomaticLiquidityPurchasePayment
import fr.acinq.lightning.db.Bolt11IncomingPayment
import fr.acinq.lightning.db.Bolt12IncomingPayment
import fr.acinq.lightning.db.ChannelCloseOutgoingPayment
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.lightning.db.LegacyPayToOpenIncomingPayment
import fr.acinq.lightning.db.LegacySwapInIncomingPayment
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.db.ManualLiquidityPurchasePayment
import fr.acinq.lightning.db.OnChainIncomingPayment
import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment
import fr.acinq.lightning.db.SpliceOutgoingPayment
import fr.acinq.lightning.db.WalletPayment
import fr.acinq.phoenix.data.lnurl.LnurlPay
import fr.acinq.phoenix.utils.converters.AmountFormatter.toPrettyString
import fr.acinq.phoenix.utils.extensions.desc
import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.paymentdetails_desc_closing_channel
import mantra.composeapp.generated.resources.paymentdetails_desc_cpfp
import mantra.composeapp.generated.resources.paymentdetails_desc_liquidity_automated
import mantra.composeapp.generated.resources.paymentdetails_desc_liquidity_manual
import mantra.composeapp.generated.resources.paymentdetails_desc_splice_out
import mantra.composeapp.generated.resources.paymentdetails_desc_swapin
import mantra.composeapp.generated.resources.paymentdetails_desc_swapout
/**
* Represents a payment & its associated metadata.
*/
data class WalletPaymentInfo(
val payment: WalletPayment,
val metadata: WalletPaymentMetadata,
val contact: ContactInfo?
) {
val id get() = payment.id
}
/** Returns true if the payment is a channel-close made by the legacy app to the node's swap-in address. */
fun WalletPayment.isLegacyMigration(metadata: WalletPaymentMetadata): Boolean? {
return when {
this !is ChannelCloseOutgoingPayment -> false
metadata.userDescription == "kmp-migration-override" -> true
else -> false
}
}
/**
* Returns a trimmed, localized description of the payment, based on the type and information available. May be null!
*
* For example, a payment closing a channel has no description, and it's up to us to create one. Others like a LN
* payment with an invoice do have a description baked in, and that's what is returned.
*/
@Composable
fun WalletPayment.smartDescription(): String? = when (this) {
is LightningOutgoingPayment -> smartDescription()
is IncomingPayment -> smartDescription()
is ChannelCloseOutgoingPayment -> smartDescription()
is SpliceOutgoingPayment -> smartDescription()
is SpliceCpfpOutgoingPayment -> smartDescription()
is ManualLiquidityPurchasePayment -> smartDescription()
is AutomaticLiquidityPurchasePayment -> smartDescription()
}
@Composable
fun LightningOutgoingPayment.smartDescription(): String? = when (val details = this.details) {
is LightningOutgoingPayment.Details.Normal -> details.paymentRequest.desc
is LightningOutgoingPayment.Details.SwapOut -> stringResource(Res.string.paymentdetails_desc_swapout, details.address)
is LightningOutgoingPayment.Details.Blinded -> details.paymentRequest.description
}?.takeIf { it.isNotBlank() }
@Composable
fun SpliceOutgoingPayment.smartDescription(): String = stringResource(Res.string.paymentdetails_desc_splice_out)
@Composable
fun SpliceCpfpOutgoingPayment.smartDescription(): String = stringResource(Res.string.paymentdetails_desc_cpfp)
@Composable
fun ChannelCloseOutgoingPayment.smartDescription(): String = stringResource(Res.string.paymentdetails_desc_closing_channel)
@Composable
fun ManualLiquidityPurchasePayment.smartDescription(): String =
stringResource(
Res.string.paymentdetails_desc_liquidity_manual,
liquidityPurchase.amount.toPrettyString(BitcoinUnit.Sat, withUnit = true)
)
@Composable
fun AutomaticLiquidityPurchasePayment.smartDescription(): String =
stringResource(
Res.string.paymentdetails_desc_liquidity_automated,
liquidityPurchase.amount.toPrettyString(BitcoinUnit.Sat, withUnit = true)
)
@Suppress("DEPRECATION")
@Composable
fun IncomingPayment.smartDescription() : String? = when (this) {
is Bolt11IncomingPayment -> paymentRequest.description
is Bolt12IncomingPayment -> null
is OnChainIncomingPayment -> stringResource(Res.string.paymentdetails_desc_swapin)
is LegacySwapInIncomingPayment -> stringResource(Res.string.paymentdetails_desc_swapin)
is LegacyPayToOpenIncomingPayment -> when (val origin = origin) {
is LegacyPayToOpenIncomingPayment.Origin.Invoice -> origin.paymentRequest.description
is LegacyPayToOpenIncomingPayment.Origin.Offer -> null
}
}?.takeIf { it.isNotBlank() }
@Suppress("DEPRECATION")
fun WalletPayment.basicDescription(): String? = when (this) {
is Bolt11IncomingPayment -> paymentRequest.description?.takeIf { it.isNotBlank() }
is LegacyPayToOpenIncomingPayment -> when (val origin = origin) {
is LegacyPayToOpenIncomingPayment.Origin.Invoice -> origin.paymentRequest.description
is LegacyPayToOpenIncomingPayment.Origin.Offer -> null
}
is IncomingPayment -> null
is LightningOutgoingPayment -> when (val details = this.details) {
is LightningOutgoingPayment.Details.Normal -> details.paymentRequest.desc
is LightningOutgoingPayment.Details.SwapOut -> null
is LightningOutgoingPayment.Details.Blinded -> details.paymentRequest.description
}
is ChannelCloseOutgoingPayment -> null
is SpliceOutgoingPayment -> null
is SpliceCpfpOutgoingPayment -> null
is ManualLiquidityPurchasePayment -> null
is AutomaticLiquidityPurchasePayment -> null
}?.takeIf { it.isNotBlank() }
/**
* Represents information from the `payments_metadata` table.
*/
data class WalletPaymentMetadata(
val lnurl: LnurlPayMetadata? = null,
val originalFiat: ExchangeRate.BitcoinPriceRate? = null,
val userDescription: String? = null,
val userNotes: String? = null,
val lightningAddress: String? = null,
val modifiedAt: Long? = null
)
data class LnurlPayMetadata(
val pay: LnurlPay.Intent,
val description: String,
val successAction: LnurlPay.Invoice.SuccessAction?
) {
companion object { /* allow companion extensions */ }
}
@@ -1,264 +0,0 @@
/*
* Copyright 2021 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.data.lnurl
import co.touchlab.kermit.Logger
import fr.acinq.bitcoin.Bech32
import fr.acinq.lightning.logging.debug
import fr.acinq.lightning.logging.error
import fr.acinq.lightning.logging.info
import fr.acinq.lightning.utils.msat
import fr.acinq.phoenix.utils.Parser
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.client.statement.request
import io.ktor.http.URLBuilder
import io.ktor.http.URLProtocol
import io.ktor.http.Url
import io.ktor.http.encodedPath
import io.ktor.http.isSecure
import io.ktor.http.isSuccess
import io.ktor.utils.io.charsets.Charsets
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.longOrNull
/**
* This class describes the various types of Lnurls supported by phoenix:
* - auth
* - pay
* - withdraw
*
* It also contains the possible errors related to the Lnurl flow:
* errors that break the specs, or errors raised when the data returned
* by the Lnurl service are not valid.
*
* A companion object contains the utility methods that parse the urls, read a response
* from a Lnurl service, and transform this response into a valid Lnurl object.
*/
sealed interface Lnurl {
/**
* Some lnurls must be executed to be of any use, they don't contain any info by themselves. Those
* lnurls are usually not visible to the user and are called immediately.
*/
data class Request(override val initialUrl: Url, val tag: Tag?) : Lnurl
/**
* Qualified lnurls objects contain all the necessary data needed from the lnurl service for the user
* to decide how to proceed.
*/
sealed interface Qualified : Lnurl
val initialUrl: Url
enum class Tag(val label: String) {
Auth("login"),
Withdraw("withdrawRequest"),
Pay("payRequest")
}
companion object {
internal val format: Json = Json { ignoreUnknownKeys = true }
/**
* Attempts to extract a [Lnurl] from a string.
*
* @param source can be a bech32 lnurl, a non-bech32 lnurl, or a lightning address.
* @return a [LnurlAuth] if the source is a login lnurl, or an [Url] if it is a payment/withdrawal lnurl.
*
* Throws an exception if the source is malformed or invalid.
*/
fun extractLnurl(source: String, logger: Logger): Lnurl {
val input = Parser.trimMatchingPrefix(source, Parser.lightningPrefixes + Parser.bitcoinPrefixes + Parser.lnurlPrefixes)
val url: Url = try {
logger.debug { "parsing as lnurl source=$source" }
parseBech32Url(input)
} catch (bech32Ex: Exception) {
try {
if (lud17Schemes.any { input.startsWith(it, ignoreCase = true) }) {
parseNonBech32Lud17(input, logger)
} else {
parseNonBech32Http(input)
}
} catch (nonBech32Ex: Exception) {
logger.info { "cannot parse source as non-bech32 lnurl: ${nonBech32Ex.message ?: nonBech32Ex::class} or as a bech32 lnurl: ${bech32Ex.message ?: bech32Ex::class}" }
throw LnurlError.Invalid(cause = nonBech32Ex)
}
}
val tag = url.parameters["tag"]?.let {
when (it) {
Tag.Auth.label -> Tag.Auth
Tag.Withdraw.label -> Tag.Withdraw
Tag.Pay.label -> Tag.Pay
else -> null // ignore unknown tags and handle the lnurl as a `request` to be executed immediately
}
}
return when (tag) {
Tag.Auth -> {
val k1 = url.parameters["k1"]
if (k1.isNullOrBlank()) {
throw LnurlError.Auth.MissingK1()
} else {
LnurlAuth(url, k1)
}
}
else -> Request(url, tag)
}
}
/** Lnurls are originally bech32 encoded. If unreadable, throw an exception. */
private fun parseBech32Url(source: String): Url {
val (_, data) = Bech32.decode(source)
val payload = Bech32.five2eight(data, 0).decodeToString()
val url = URLBuilder(payload).build()
if (!url.protocol.isSecure()) throw LnurlError.UnsafeResource()
return url
}
/** Lnurls sometimes hide in regular http urls, under the lightning parameter. */
private fun parseNonBech32Http(source: String): Url {
val urlBuilder = URLBuilder(source)
val lightningParam = urlBuilder.parameters["lightning"]
return if (!lightningParam.isNullOrBlank()) {
// this url contains a lnurl fallback which takes priority - and must be bech32 encoded
parseBech32Url(lightningParam)
} else {
if (!urlBuilder.protocol.isSecure()) throw LnurlError.UnsafeResource()
urlBuilder.build()
}
}
private val lud17Schemes = listOf(
"phoenix:lnurlp://", "phoenix:lnurlp:",
"lnurlp://", "lnurlp:",
"phoenix:lnurlw://", "phoenix:lnurlw:",
"lnurlw://", "lnurlw:",
"phoenix:keyauth://", "phoenix:keyauth:",
"keyauth://", "keyauth:",
)
/** Converts LUD-17 lnurls (using a custom lnurl scheme like lnurlc, lnurlp, keyauth) into a regular http url. */
private fun parseNonBech32Lud17(source: String, logger: Logger): Url {
val matchingPrefix = lud17Schemes.firstOrNull { source.startsWith(it, ignoreCase = true) }
val stripped = if (matchingPrefix != null) {
source.drop(matchingPrefix.length)
} else {
throw IllegalArgumentException("source does not use a lud17 scheme: $source")
}
logger.debug { "lud-17 scheme found - transforming input into an http request" }
return URLBuilder(stripped).apply {
encodedPath.split("/", ignoreCase = true, limit = 2).let {
this.host = it.first()
this.encodedPath = "/${it.drop(1).joinToString()}"
}
protocol = if (this.host.endsWith(".onion")) {
URLProtocol.HTTP
} else {
URLProtocol.HTTPS
}
}.build()
}
/**
* Processes a HTTP response replied by a lnurl service and returns a [JsonObject].
*
* Throw:
* - [LnurlError.RemoteFailure.Code] if service returns a non-2XX code
* - [LnurlError.RemoteFailure.Unreadable] if response is not valid JSON
* - [LnurlError.RemoteFailure.Detailed] if service reports an internal error message (`{ status: "error", reason: "..." }`)
*/
suspend fun processLnurlResponse(response: HttpResponse, logger: Logger): JsonObject {
val url = response.request.url
val json: JsonObject = try {
// From the LUD-01 specs:
// > HTTP Status Codes and Content-Type:
// > Neither status codes or any HTTP Header has any meaning. Servers may use
// > whatever they want. Clients should ignore them [...] and just parse the
// > response body as JSON, then interpret it accordingly.
Json.decodeFromString(response.bodyAsText(Charsets.UTF_8))
} catch (e: Exception) {
logger.error { "unhandled response from url=$url: ${e.message}" }
throw LnurlError.RemoteFailure.Unreadable(url.host)
}
logger.debug { "lnurl service=${url.host} returned response=${json.toString().take(100)}" }
return if (json["status"]?.jsonPrimitive?.content?.trim()?.equals("error", true) == true) {
val errorMessage = json["reason"]?.jsonPrimitive?.content?.trim() ?: ""
if (errorMessage.isNotEmpty()) {
logger.error { "lnurl service=${url.host} returned error=$errorMessage" }
throw LnurlError.RemoteFailure.Detailed(url.host, errorMessage.take(90).replace("<", ""))
} else if (!response.status.isSuccess()) {
throw LnurlError.RemoteFailure.Code(url.host, response.status)
} else {
throw LnurlError.RemoteFailure.Unreadable(url.host)
}
} else {
json
}
}
/** Converts a lnurl JSON response to a [Lnurl] object. */
fun parseLnurlJson(url: Url, json: JsonObject): Lnurl {
val callback = URLBuilder(json["callback"]?.jsonPrimitive?.content ?: throw LnurlError.MissingCallback()).build()
if (!callback.protocol.isSecure()) throw LnurlError.UnsafeResource()
val tag = json["tag"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: throw LnurlError.NoTag()
return when (tag) {
Tag.Withdraw.label -> {
val k1 = json["k1"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: throw LnurlError.Withdraw.MissingK1()
val minWithdrawable = json["minWithdrawable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat
?: json["minWithdrawable"]?.jsonPrimitive?.long?.takeIf { it > 0 }?.msat
?: 0.msat
val maxWithdrawable = json["maxWithdrawable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat
?: json["maxWithdrawable"]?.jsonPrimitive?.long?.takeIf { it > 0 }?.msat
?: minWithdrawable
val dDesc = json["defaultDescription"]?.jsonPrimitive?.content ?: ""
LnurlWithdraw(
initialUrl = url,
callback = callback,
k1 = k1,
defaultDescription = dDesc,
minWithdrawable = minWithdrawable.coerceAtMost(maxWithdrawable),
maxWithdrawable = maxWithdrawable
)
}
Tag.Pay.label -> {
val minSendable = json["minSendable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat
?: json["minSendable"]?.jsonPrimitive?.longOrNull?.takeIf { it > 0 }?.msat
?: throw LnurlError.Pay.Intent.InvalidMin()
val maxSendable = json["maxSendable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat
?: json["maxSendable"]?.jsonPrimitive?.longOrNull?.coerceAtLeast(minSendable.msat)?.msat
?: throw LnurlError.Pay.Intent.MissingMax()
val metadata = LnurlPay.parseMetadata(json["metadata"]?.jsonPrimitive?.content ?: throw LnurlError.Pay.Intent.MissingMetadata())
val maxCommentLength = json["commentAllowed"]?.jsonPrimitive?.longOrNull?.takeIf { it > 0 }
LnurlPay.Intent(
initialUrl = url,
callback = callback,
minSendable = minSendable,
maxSendable = maxSendable,
metadata = metadata,
maxCommentLength = maxCommentLength
)
}
else -> throw LnurlError.UnhandledTag(tag)
}
}
}
}
@@ -1,177 +0,0 @@
/*
* Copyright 2022 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.data.lnurl
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Crypto
import fr.acinq.bitcoin.DeterministicWallet
import fr.acinq.bitcoin.KeyPath
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.crypto.Digest
import fr.acinq.bitcoin.crypto.Pack
import fr.acinq.bitcoin.crypto.hmac
import fr.acinq.lightning.crypto.LocalKeyManager
import io.ktor.http.Url
data class LnurlAuth(
override val initialUrl: Url,
val k1: String
) : Lnurl.Qualified {
enum class Action {
Register, Login, Link, Auth
}
val action = initialUrl.parameters["action"]?.let { action ->
when (action.lowercase()) {
"register" -> Action.Register
"login" -> Action.Login
"link" -> Action.Link
"auth" -> Action.Auth
else -> null
}
}
/**
* Early versions of our lnurl-auth implementation used non-standard keys on Android. We define a legacy-friendly
* scheme to let the user switch to that old behaviour when needed. The default scheme is the recommended
* one and is compliant with the specifications.
*
* Note: this does **not** affect the [LnurlAuth.LegacyDomain.filterDomain] method for the key derivation path,
* which will apply in both cases.
*/
sealed class Scheme(val id: Int) {
/**
* The default scheme is spec compliant and that's what should be used on new service. The hashing key and
* the linking key are computed by deriving the master key. The iOS app should always use that scheme.
*/
data object DEFAULT_SCHEME : Scheme(0)
/**
* This is the scheme used by the legacy android wallet. The hashing key is derived from the node key, and
* the linking key derived from the hashing key. Only use this when needed.
*/
data object ANDROID_LEGACY_SCHEME : Scheme(1)
}
companion object {
/** Signs the challenge with the key provided and returns the public key and the DER-encoded signed data. */
fun signChallenge(
challenge: String,
key: PrivateKey
): Pair<PublicKey, ByteVector> {
return key.publicKey() to Crypto.compact2der(Crypto.sign(data = ByteVector32.fromValidHex(challenge), privateKey = key))
}
/**
* Returns a key to sign a lnurl-auth challenge. This key is derived from the wallet's master key. The derivation
* path depends on the domain provided and the type of the key.
*
* @param scheme This type helps with backward compatibility. We use compatibility keys on some domains, see [LegacyDomain].
*/
fun getAuthLinkingKey(
localKeyManager: LocalKeyManager,
serviceUrl: Url,
scheme: Scheme
): PrivateKey {
// no need to use the legacy scheme on non-legacy domain
val useAndroidLegacyScheme = scheme == Scheme.ANDROID_LEGACY_SCHEME && LegacyDomain.isEligible(serviceUrl)
val hashingKeyPath = KeyPath("m/138'/0")
val hashingKey = if (useAndroidLegacyScheme) {
DeterministicWallet.derivePrivateKey(localKeyManager.nodeKeys.legacyNodeKey, hashingKeyPath)
} else {
localKeyManager.derivePrivateKey(hashingKeyPath)
}
// the domain used for the derivation path may not be the full domain name.
val path = getDerivationPathForDomain(
domain = LegacyDomain.filterDomain(serviceUrl),
hashingKey = hashingKey.privateKey.value.toByteArray()
)
return if (useAndroidLegacyScheme) {
DeterministicWallet.derivePrivateKey(hashingKey, path).privateKey
} else {
localKeyManager.derivePrivateKey(path).privateKey
}
}
/**
* Returns lnurl-auth path derivation, as described in spec:
* https://github.com/fiatjaf/lnurl-rfc/blob/luds/05.md
*
* Test vectors exist for path derivation.
*/
fun getDerivationPathForDomain(
domain: String,
hashingKey: ByteArray
): KeyPath {
val fullHash = Digest.sha256().hmac(
key = hashingKey,
data = domain.encodeToByteArray(),
blockSize = 64
)
require(fullHash.size >= 16) { "domain hash must be at least 16 bytes" }
val path1 = fullHash.sliceArray(IntRange(0, 3)).let { Pack.int32BE(it, 0) }.toUInt()
val path2 = fullHash.sliceArray(IntRange(4, 7)).let { Pack.int32BE(it, 0) }.toUInt()
val path3 = fullHash.sliceArray(IntRange(8, 11)).let { Pack.int32BE(it, 0) }.toUInt()
val path4 = fullHash.sliceArray(IntRange(12, 15)).let { Pack.int32BE(it, 0) }.toUInt()
return KeyPath("m/138'/$path1/$path2/$path3/$path4")
}
}
/**
* Domains where we should use a legacy path instead of the regular full domain, for
* backward compatibility reasons.
*
* Those services are listed as using LUD-04 on the lnurl specs:
* https://github.com/fiatjaf/lnurl-rfc/tree/38d8baa6f8e3b3dfd13649bfa79e2175d6ca42ff#services
*/
enum class LegacyDomain(val host: String, val legacyCompatDomain: String) {
GEYSER("auth.geyser.fund", "geyser.fund"),
KOLLIDER("api.kollider.xyz", "kollider.xyz"),
LNMARKETS("api.lnmarkets.com", "lnmarkets.com"),
// LNBITS("", ""),
GETALBY("getalby.com", "getalby.com"),
LIGHTNING_VIDEO("lightning.video", "lightning.video"),
LOFT("api.loft.trade", "loft.trade"),
// WHEEL_OF_FORTUNE("", ""),
// COINOS("", ""),
LNSHORT("lnshort.it", "lnshort.it"),
STACKERNEWS("stacker.news", "stacker.news"),
BOLTFUN("auth.bolt.fun", "bolt.fun")
;
companion object {
/** Return true if this host is eligible to use legacy keys, false otherwise. */
fun isEligible(url: Url): Boolean {
return entries.any { it.host == url.host }
}
/** Get the legacy domain for the given [Url] if eligible, or the full domain name otherwise (i.e. specs compliant). */
fun filterDomain(url: Url): String {
return entries.firstOrNull { it.host == url.host }?.legacyCompatDomain ?: url.host
}
}
}
override fun toString(): String {
return "LnurlAuth(action=$action, initialUrl=$initialUrl)".take(100)
}
}
@@ -1,68 +0,0 @@
/*
* Copyright 2022 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.data.lnurl
import io.ktor.http.HttpStatusCode
sealed class LnurlError(override val message: String? = null) : RuntimeException(message) {
val details: String by lazy { "Lnurl error=${message ?: this::class.simpleName ?: "N/A"} in url=" }
sealed class Auth(override val message: String?) : LnurlError(message) {
class MissingK1 : Auth("missing k1 parameter")
}
sealed class Withdraw(override val message: String?) : LnurlError(message) {
class MissingK1 : Withdraw("missing k1 parameter in auth metadata")
}
sealed class Pay : LnurlError() {
sealed class Intent(override val message: String?) : LnurlError(message) {
class InvalidMin : Intent("invalid minimum amount")
class MissingMax : Intent("missing maximum amount parameter")
class MissingMetadata : Intent("missing metadata parameter")
data class InvalidMetadata(val meta: String) : Intent("invalid meta=$meta")
}
sealed class Invoice(override val message: String?) : LnurlError(message) {
abstract val origin: String
data class Malformed(
override val origin: String,
val context: String
) : Invoice("malformed: $context")
data class InvalidAmount(override val origin: String) : Invoice("paymentRequest.amount doesn't match user input")
}
}
data class Invalid(override val cause: Throwable?) : LnurlError("cannot be parsed as a bech32 or as a human readable lnurl")
class NoTag : LnurlError("no tag field found")
data class UnhandledTag(val tag: String) : LnurlError("unhandled tag=$tag")
class UnsafeResource : LnurlError("resource should be https")
class MissingCallback : LnurlError("missing callback in metadata response")
sealed class RemoteFailure(override val message: String) : LnurlError(message) {
abstract val origin: String
data class IsWebsite(override val origin: String) : RemoteFailure("this appears to just be a website")
data class LightningAddressError(override val origin: String) : RemoteFailure("service $origin doesn't support lightning addresses, or doesn't know this user")
data class CouldNotConnect(override val origin: String) : RemoteFailure("could not connect to $origin")
data class Unreadable(override val origin: String) : RemoteFailure("unreadable response from $origin")
data class Detailed(override val origin: String, val reason: String) : RemoteFailure("error=$reason from $origin")
data class Code(override val origin: String, val code: HttpStatusCode) : RemoteFailure("error code=$code from $origin")
}
}
@@ -1,226 +0,0 @@
/*
* Copyright 2022 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.data.lnurl
import co.touchlab.kermit.Logger
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.utils.Try
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.payment.Bolt11Invoice
import fr.acinq.phoenix.data.lnurl.Lnurl.Companion.format
import fr.acinq.phoenix.db.cloud.b64Decode
import io.ktor.http.Url
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
sealed class LnurlPay : Lnurl.Qualified {
/**
* Response from a lnurl service to describe what kind of payment is expected.
* First step of the lnurl-pay flow.
*/
data class Intent(
override val initialUrl: Url,
val callback: Url,
val minSendable: MilliSatoshi,
val maxSendable: MilliSatoshi,
val metadata: Metadata,
val maxCommentLength: Long?
) : LnurlPay() {
data class Metadata(
val raw: String,
val plainText: String,
val longDesc: String?,
val imagePng: String?, // base64 encoded png
val imageJpg: String?, // base64 encoded jpg
val identifier: String?,
val email: String?,
val unknown: JsonArray?
) {
val lnid: String? by lazy { email ?: identifier }
override fun toString(): String {
return "Metadata(plainText=$plainText, longDesc=${longDesc?.take(50)}, identifier=$identifier, email=$email, imagePng=${imagePng?.take(10)}, imageJpg=${imageJpg?.take(10)})"
}
}
override fun toString(): String {
return "Intent(minSendable=$minSendable, maxSendable=$maxSendable, metadata=$metadata, maxCommentLength=$maxCommentLength, initialUrl=$initialUrl, callback=$callback)".take(100)
}
}
/**
* Invoice returned by a lnurl service after user states what they want to pay.
* Second step of the lnurl-payment flow.
*/
data class Invoice(
override val initialUrl: Url,
val invoice: Bolt11Invoice,
val successAction: SuccessAction?
) : LnurlPay() {
sealed class SuccessAction {
data class Message(
val message: String
) : SuccessAction()
data class Url(
val description: String,
val url: io.ktor.http.Url
) : SuccessAction()
data class Aes(
val description: String,
val ciphertext: ByteVector,
val iv: ByteVector
) : SuccessAction() {
data class Decrypted(
val description: String,
val plaintext: String
)
}
enum class Tag(val label: String) {
Message("message"),
Url("url"),
Aes("aes")
}
}
}
companion object {
/** Parses json into a [LnurlPay.Invoice] object. Throws an [LnurlError.PayInvoice] exception if unreadable. */
fun parseLnurlPayInvoice(
intent: Intent,
origin: String,
json: JsonObject
): Invoice {
try {
val pr = json["pr"]?.jsonPrimitive?.content ?: throw LnurlError.Pay.Invoice.Malformed(origin, "missing pr")
val invoice = when (val res = Bolt11Invoice.read(pr)) {
is Try.Success -> res.result
is Try.Failure -> throw LnurlError.Pay.Invoice.Malformed(origin, res.error.message ?: res.error::class.toString())
}
val successAction = parseSuccessAction(origin, json)
return Invoice(intent.initialUrl, invoice, successAction)
} catch (t: Throwable) {
when (t) {
is LnurlError.Pay.Invoice -> throw t
else -> throw LnurlError.Pay.Invoice.Malformed(origin, "unknown error")
}
}
}
private fun parseSuccessAction(
origin: String,
json: JsonObject
): Invoice.SuccessAction? {
val obj = try {
json["successAction"]?.jsonObject // throws on Non-JsonObject (e.g. JsonNull)
} catch (t: Throwable) {
null
} ?: return null
return when (obj["tag"]?.jsonPrimitive?.content) {
Invoice.SuccessAction.Tag.Message.label -> {
val message = obj["message"]?.jsonPrimitive?.content ?: return null
if (message.isBlank() || message.length > 144) {
throw LnurlError.Pay.Invoice.Malformed(origin, "success.message: bad length")
}
Invoice.SuccessAction.Message(message)
}
Invoice.SuccessAction.Tag.Url.label -> {
val description = obj["description"]?.jsonPrimitive?.content ?: return null
if (description.length > 144) {
throw LnurlError.Pay.Invoice.Malformed(origin, "success.url.description: bad length")
}
val urlStr = obj["url"]?.jsonPrimitive?.content ?: return null
val url = Url(urlStr)
Invoice.SuccessAction.Url(description, url)
}
Invoice.SuccessAction.Tag.Aes.label -> {
val description = obj["description"]?.jsonPrimitive?.content ?: return null
if (description.length > 144) {
throw LnurlError.Pay.Invoice.Malformed(origin, "success.aes.description: bad length")
}
val ciphertextStr = obj["ciphertext"]?.jsonPrimitive?.content ?: return null
val ciphertext = ByteVector(ciphertextStr.b64Decode())
if (ciphertext.size() > (4 * 1024)) {
throw LnurlError.Pay.Invoice.Malformed(origin, "success.aes.ciphertext: bad length")
}
val ivStr = obj["iv"]?.jsonPrimitive?.content ?: return null
if (ivStr.length != 24) {
throw LnurlError.Pay.Invoice.Malformed(origin, "success.aes.iv: bad length")
}
val iv = ByteVector(ivStr.b64Decode())
Invoice.SuccessAction.Aes(description, ciphertext = ciphertext, iv = iv)
}
else -> null
}
}
/** Decode a serialized [Lnurl.Pay.Metadata] object. */
fun parseMetadata(raw: String): Intent.Metadata = try {
val array = format.decodeFromString<JsonArray>(raw)
var plainText: String? = null
var longDesc: String? = null
var imagePng: String? = null
var imageJpg: String? = null
var identifier: String? = null
var email: String? = null
val unknown = mutableListOf<JsonElement>()
array.forEach {
try {
when (it.jsonArray[0].jsonPrimitive.content) {
"text/plain" -> plainText = it.jsonArray[1].jsonPrimitive.content
"text/long-desc" -> longDesc = it.jsonArray[1].jsonPrimitive.content
"image/png;base64" -> imagePng = it.jsonArray[1].jsonPrimitive.content
"image/jpeg;base64" -> imageJpg = it.jsonArray[1].jsonPrimitive.content
"text/identifier" -> identifier = it.jsonArray[1].jsonPrimitive.content
"text/email" -> email = it.jsonArray[1].jsonPrimitive.content
else -> unknown.add(it)
}
} catch (e: Exception) {
Logger.w("LnurlPay") { "could not decode raw lnurlpay-meta=$it: ${e.message}" }
}
}
Intent.Metadata(
raw = raw,
plainText = plainText!!,
longDesc = longDesc,
imagePng = imagePng,
imageJpg = imageJpg,
identifier = identifier,
email = email,
unknown = unknown.takeIf { it.isNotEmpty() }?.let {
JsonArray(it.toList())
}
)
} catch (e: Exception) {
Logger.e("LnurlPay") { "could not decode raw lnurlpay-meta=$raw: ${e.message}" }
throw LnurlError.Pay.Intent.InvalidMetadata(raw)
}
}
}
@@ -1,33 +0,0 @@
/*
* Copyright 2022 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.data.lnurl
import fr.acinq.lightning.MilliSatoshi
import io.ktor.http.Url
data class LnurlWithdraw(
override val initialUrl: Url,
val callback: Url,
val k1: String,
val defaultDescription: String,
val minWithdrawable: MilliSatoshi,
val maxWithdrawable: MilliSatoshi
) : Lnurl.Qualified {
override fun toString(): String {
return "LnurlWithdraw(defaultDescription='$defaultDescription', minWithdrawable=$minWithdrawable, maxWithdrawable=$maxWithdrawable, initialUrl=$initialUrl, callback=$callback)".take(100)
}
}
@@ -1,26 +0,0 @@
/*
* Copyright 2020 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.db
import app.cash.sqldelight.db.SqlDriver
import fr.acinq.phoenix.utils.PlatformContext
expect fun createChannelsDbDriver(ctx: PlatformContext, fileName: String): SqlDriver
expect fun createPaymentsDbDriver(ctx: PlatformContext, fileName: String, onError: (String) -> Unit): SqlDriver
expect fun createAppDbDriver(ctx: PlatformContext): SqlDriver
@@ -1,52 +0,0 @@
package fr.acinq.phoenix.db
import fr.acinq.lightning.utils.UUID
import fr.acinq.phoenix.db.payments.CloudKitInterface
import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase
/**
* Implement this function to execute platform specific code when a payment is saved to the database.
* For example, on iOS this is used to enqueue the (encrypted) payment for upload to CloudKit.
*
* This function is invoked inside the same transaction used to add/modify the row.
* This means any database operations performed in this function are atomic,
* with respect to the referenced row.
*/
expect fun didSaveWalletPayment(id: UUID, database: PaymentsDatabase)
/**
* Implement this function to execute platform specific code when a payment is deleted.
* For example, on iOS this is used to enqueue an operation to delete the payment from CloudKit.
*/
expect fun didDeleteWalletPayment(id: UUID, database: PaymentsDatabase)
/**
* Implement this function to execute platform specific code when a payment's metadata is updated.
* For example: the user modifies the payment description.
*
* This function is invoked inside the same transaction used to add/modify the row.
* This means any database operations performed in this function are atomic,
* with respect to the referenced row.
*/
expect fun didUpdateWalletPaymentMetadata(id: UUID, database: PaymentsDatabase)
/**
* Implement this function to execute platform specific code when a contact is saved to the database.
* For example, on iOS this is used to enqueue the (encrypted) contact for upload to CloudKit.
*
* This function is invoked inside the same transaction used to add/modify the row.
* This means any database operations performed in this function are atomic,
* with respect to the referenced row.
*/
expect fun didSaveContact(contactId: UUID, database: PaymentsDatabase)
/**
* Implement this function to execute platform specific code when a contact is deleted.
* For example, on iOS this is used to enqueue an operation to delete the contact from CloudKit.
*/
expect fun didDeleteContact(contactId: UUID, database: PaymentsDatabase)
/**
* Implemented on Apple platforms with support for CloudKit.
*/
expect fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface?
@@ -1,146 +0,0 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalStdlibApi::class)
package fr.acinq.phoenix.db
import app.cash.sqldelight.ColumnAdapter
import app.cash.sqldelight.EnumColumnAdapter
import app.cash.sqldelight.db.SqlDriver
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.channel.states.PersistedChannelState
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.lightning.db.OutgoingPayment
import fr.acinq.lightning.db.WalletPayment
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.error
import fr.acinq.lightning.utils.UUID
import fr.acinq.phoenix.data.ContactInfo
import fr.acinq.phoenix.db.sqldelight.ChannelsDatabase
import fr.acinq.phoenix.db.sqldelight.Cloudkit_payments_metadata
import fr.acinq.phoenix.db.sqldelight.Cloudkit_payments_queue
import fr.acinq.phoenix.db.sqldelight.Contacts
import fr.acinq.phoenix.db.sqldelight.Htlc_infos
import fr.acinq.phoenix.db.sqldelight.Link_lightning_outgoing_payment_parts
import fr.acinq.phoenix.db.sqldelight.Local_channels
import fr.acinq.phoenix.db.sqldelight.On_chain_txs
import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase
import fr.acinq.phoenix.db.sqldelight.Payments_incoming
import fr.acinq.phoenix.db.sqldelight.Payments_metadata
import fr.acinq.phoenix.db.sqldelight.Payments_outgoing
import fr.acinq.phoenix.managers.PaymentMetadataQueue
import fr.acinq.phoenix.utils.extensions.toByteArray
fun createSqliteChannelsDb(driver: SqlDriver): SqliteChannelsDb {
return SqliteChannelsDb(
driver = driver,
database = ChannelsDatabase(
driver = driver,
htlc_infosAdapter = Htlc_infos.Adapter(ByteVector32Adapter, ByteVector32Adapter),
local_channelsAdapter = Local_channels.Adapter(ByteVector32Adapter, PersistedChannelStateAdapter)
)
)
}
fun createSqlitePaymentsDb(driver: SqlDriver, metadataQueue: PaymentMetadataQueue?, loggerFactory: LoggerFactory): SqlitePaymentsDb {
val onError: (String) -> Unit = { loggerFactory.newLogger(SqlitePaymentsDb::class).error { it } }
return SqlitePaymentsDb(
driver = driver,
database = PaymentsDatabase(
driver = driver,
payments_incomingAdapter = Payments_incoming.Adapter(UUIDAdapter, ByteVector32Adapter, TxIdAdapter, IncomingPaymentAdapter(onError)),
payments_outgoingAdapter = Payments_outgoing.Adapter(UUIDAdapter, ByteVector32Adapter, TxIdAdapter, OutgoingPaymentAdapter(onError)),
link_lightning_outgoing_payment_partsAdapter = Link_lightning_outgoing_payment_parts.Adapter(UUIDAdapter, UUIDAdapter),
on_chain_txsAdapter = On_chain_txs.Adapter(UUIDAdapter, TxIdAdapter),
payments_metadataAdapter = Payments_metadata.Adapter(UUIDAdapter, EnumColumnAdapter(), EnumColumnAdapter(), EnumColumnAdapter()),
cloudkit_payments_queueAdapter = Cloudkit_payments_queue.Adapter(UUIDAdapter),
cloudkit_payments_metadataAdapter = Cloudkit_payments_metadata.Adapter(UUIDAdapter),
contactsAdapter = Contacts.Adapter(UUIDAdapter, ContactInfoAdapter)
),
paymentMetadataQueue = metadataQueue,
loggerFactory = loggerFactory
)
}
object UUIDAdapter : ColumnAdapter<UUID, ByteArray> {
override fun decode(databaseValue: ByteArray): UUID = UUID.fromBytes(databaseValue)
override fun encode(value: UUID): ByteArray = value.toByteArray()
}
object ByteVector32Adapter : ColumnAdapter<ByteVector32, ByteArray> {
override fun decode(databaseValue: ByteArray) = ByteVector32(databaseValue)
override fun encode(value: ByteVector32): ByteArray = value.toByteArray()
}
object TxIdAdapter : ColumnAdapter<TxId, ByteArray> {
override fun decode(databaseValue: ByteArray) = TxId(databaseValue)
override fun encode(value: TxId): ByteArray = value.value.toByteArray()
}
object PersistedChannelStateAdapter : ColumnAdapter<PersistedChannelState, ByteArray> {
override fun decode(databaseValue: ByteArray): PersistedChannelState = when(val res = fr.acinq.lightning.serialization.channel.Serialization.deserialize(databaseValue)) {
is fr.acinq.lightning.serialization.channel.Serialization.DeserializationResult.Success -> res.state
is fr.acinq.lightning.serialization.channel.Serialization.DeserializationResult.UnknownVersion -> error("unknown channel version ${res.version}")
}
override fun encode(value: PersistedChannelState): ByteArray = fr.acinq.lightning.serialization.channel.Serialization.serialize(value)
}
class IncomingPaymentAdapter(val onError: (String) -> Unit) : ColumnAdapter<IncomingPayment, ByteArray> {
@OptIn(ExperimentalStdlibApi::class)
override fun decode(databaseValue: ByteArray): IncomingPayment {
return fr.acinq.lightning.serialization.payment.Serialization.deserialize(databaseValue).getOrNull()
?.let { it as IncomingPayment }
?: onError("cannot deserialize ${databaseValue.toHexString()}").let {
throw RuntimeException("cannot deserialize ${databaseValue.toHexString()}")
}
}
override fun encode(value: IncomingPayment): ByteArray = fr.acinq.lightning.serialization.payment.Serialization.serialize(value)
}
class OutgoingPaymentAdapter(val onError: (String) -> Unit) : ColumnAdapter<OutgoingPayment, ByteArray> {
override fun decode(databaseValue: ByteArray): OutgoingPayment =
fr.acinq.lightning.serialization.payment.Serialization.deserialize(databaseValue).getOrNull()
?.let { it as OutgoingPayment }
?: onError("cannot deserialize ${databaseValue.toHexString()}").let {
throw RuntimeException("cannot deserialize ${databaseValue.toHexString()}")
}
override fun encode(value: OutgoingPayment): ByteArray = fr.acinq.lightning.serialization.payment.Serialization.serialize(value)
}
object WalletPaymentAdapter : ColumnAdapter<WalletPayment, ByteArray> {
override fun decode(databaseValue: ByteArray): WalletPayment =
fr.acinq.lightning.serialization.payment.Serialization.deserialize(databaseValue).getOrNull()
?: throw RuntimeException("cannot deserialize ${databaseValue.toHexString()}")
override fun encode(value: WalletPayment): ByteArray = fr.acinq.lightning.serialization.payment.Serialization.serialize(value)
}
object ContactInfoAdapter : ColumnAdapter<ContactInfo, ByteArray> {
override fun decode(databaseValue: ByteArray): ContactInfo =
fr.acinq.phoenix.db.serialization.contacts.Serialization.deserialize(databaseValue).getOrNull()
?: throw RuntimeException("cannot deserialize ${databaseValue.toHexString()}")
override fun encode(value: ContactInfo): ByteArray = fr.acinq.phoenix.db.serialization.contacts.Serialization.serialize(value)
}
@@ -1,175 +0,0 @@
package fr.acinq.phoenix.db
import app.cash.sqldelight.EnumColumnAdapter
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.db.SqlDriver
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.phoenix.data.ExchangeRate
import fr.acinq.phoenix.data.FiatCurrency
import fr.acinq.phoenix.data.Notification
import fr.acinq.phoenix.db.notifications.NotificationsQueries
import fr.acinq.phoenix.db.sqldelight.AppDatabase
import fr.acinq.phoenix.db.sqldelight.Exchange_rates
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class SqliteAppDb(val driver: SqlDriver) {
internal val database = AppDatabase(
driver = driver,
exchange_ratesAdapter = Exchange_rates.Adapter(
typeAdapter = EnumColumnAdapter()
),
)
private val priceQueries = database.exchangeRatesQueries
private val keyValueStoreQueries = database.keyValueStoreQueries
private val notificationsQueries = NotificationsQueries(database)
/**
* Save a list of [ExchangeRate] items to the database.
* Inserts new items, and updates existing items.
*/
suspend fun saveExchangeRates(rates: List<ExchangeRate>) {
if (rates.isEmpty()) return
withContext(Dispatchers.Default) {
database.transaction {
for (rate in rates) {
val row = when (rate) {
is ExchangeRate.BitcoinPriceRate -> rate.toRow()
is ExchangeRate.UsdPriceRate -> rate.toRow()
}
priceQueries.get(row.fiat).executeAsOneOrNull()?.run {
priceQueries.update(
price = row.price,
type = row.type,
source = row.source,
updated_at = row.updated_at,
fiat = row.fiat
)
} ?: run {
priceQueries.insert(
fiat = row.fiat,
price = row.price,
type = row.type,
source = row.source,
updated_at = row.updated_at
)
}
}
}
}
}
/**
* Emits the list of exchange rates as a flow,
* and emits a new result everytime the data changes in the database.
*/
fun listBitcoinRates(): Flow<List<ExchangeRate>> {
// Here's what we want:
// - we should be able to **REMOVE** fiat currencies from the codebase in the future
// (e.g. after it collapses due to hyperinflation)
// - however, after we do, the corresponding row will remain in the user's database
// - attempting to force-decode it via FiatCurrency.valueOf(code) will throw an exception
// - so we use FiatCurrency.valueOfOrNull, and workaround potential null values
//
return priceQueries.list(mapper = { fiat, price, type, source, updated_at ->
ExchangeRate.Row(fiat, price, type, source, updated_at)
})
.asFlow()
.map {
withContext(Dispatchers.Default) {
database.transactionWithResult {
it.executeAsList()
}
}
}
.map {
it.mapNotNull { row ->
FiatCurrency.valueOfOrNull(row.fiat)?.let { fiatCurrency ->
when (row.type) {
ExchangeRate.Type.BTC -> {
ExchangeRate.BitcoinPriceRate(
fiatCurrency = fiatCurrency,
price = row.price,
source = row.source,
timestampMillis = row.updated_at
)
}
ExchangeRate.Type.USD -> {
ExchangeRate.UsdPriceRate(
fiatCurrency = fiatCurrency,
price = row.price,
source = row.source,
timestampMillis = row.updated_at
)
}
}
}
}
}
}
suspend fun deleteBitcoinRate(fiat: String) {
withContext(Dispatchers.Default) {
priceQueries.delete(fiat)
}
}
suspend fun getValue(key: String): Pair<ByteArray, Long>? {
return keyValueStoreQueries.get(key).executeAsOneOrNull()?.let {
Pair(it.value_, it.updated_at)
}
}
suspend fun <T> getValue(key: String, transform: (ByteArray) -> T): Pair<T, Long>? {
return keyValueStoreQueries.get(key).executeAsOneOrNull()?.let {
val tValue = transform(it.value_)
Pair(tValue, it.updated_at)
}
}
suspend fun setValue(value: ByteArray, key: String): Long {
return database.transactionWithResult {
val exists = keyValueStoreQueries.exists(key).executeAsOne() > 0
val now = currentTimestampMillis()
if (exists) {
keyValueStoreQueries.update(key = key, value_ = value, updated_at = now)
} else {
keyValueStoreQueries.insert(key = key, value_ = value, updated_at = now)
}
now
}
}
suspend fun getNotification(id: UUID): Notification? = withContext(Dispatchers.Default) {
notificationsQueries.get(id)
}
suspend fun saveNotification(notification: Notification, nodeIdHash: String) = withContext(Dispatchers.Default) {
notificationsQueries.save(notification, nodeIdHash)
}
suspend fun dismissNotifications(ids: Set<UUID>) = withContext(Dispatchers.Default) {
notificationsQueries.markAsRead(ids)
}
suspend fun dismissAllNotifications() = withContext(Dispatchers.Default) {
notificationsQueries.markAllAsRead()
}
fun listUnreadNotification(nodeIdHash: String): Flow<List<Pair<Set<UUID>, Notification>>> {
return notificationsQueries.listUnread(nodeIdHash)
}
suspend fun initializeNodeIdHashColumn(nodeIdHash: String) = withContext(Dispatchers.Default) {
notificationsQueries.initializeNodeIdHashColumn(nodeIdHash)
}
fun close() {
driver.close()
}
}
@@ -1,85 +0,0 @@
/*
* Copyright 2020 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.db
import app.cash.sqldelight.db.SqlDriver
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.lightning.CltvExpiry
import fr.acinq.lightning.channel.states.PersistedChannelState
import fr.acinq.lightning.db.ChannelsDb
import fr.acinq.phoenix.db.sqldelight.ChannelsDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class SqliteChannelsDb(val driver: SqlDriver, database: ChannelsDatabase) : ChannelsDb {
private val queries = database.channelsDatabaseQueries
override suspend fun addOrUpdateChannel(state: PersistedChannelState) {
withContext(Dispatchers.Default) {
queries.transaction {
queries.getChannel(state.channelId).executeAsOneOrNull()?.run {
queries.updateChannel(channel_id = state.channelId, data_ = state)
} ?: run {
queries.insertChannel(channel_id = state.channelId, data_ = state)
}
}
}
}
suspend fun getChannel(channelId: ByteVector32): Triple<ByteVector32, PersistedChannelState, Boolean>? {
return withContext(Dispatchers.Default) {
queries.getChannel(channelId, mapper = { channelId, data, isClosed ->
Triple(channelId, data, isClosed)
}).executeAsOneOrNull()
}
}
override suspend fun removeChannel(channelId: ByteVector32) {
withContext(Dispatchers.Default) {
queries.deleteHtlcInfo(channel_id = channelId)
queries.closeLocalChannel(channel_id = channelId)
}
}
override suspend fun listLocalChannels(): List<PersistedChannelState> = withContext(Dispatchers.Default) {
queries.listLocalChannels().executeAsList()
}
override suspend fun addHtlcInfo(channelId: ByteVector32, commitmentNumber: Long, paymentHash: ByteVector32, cltvExpiry: CltvExpiry) {
withContext(Dispatchers.Default) {
queries.insertHtlcInfo(
channel_id = channelId,
commitment_number = commitmentNumber,
payment_hash = paymentHash,
cltv_expiry = cltvExpiry.toLong()
)
}
}
override suspend fun listHtlcInfos(channelId: ByteVector32, commitmentNumber: Long): List<Pair<ByteVector32, CltvExpiry>> {
return withContext(Dispatchers.Default) {
queries.listHtlcInfos(channel_id = channelId, commitment_number = commitmentNumber, mapper = { payment_hash, cltv_expiry ->
payment_hash to CltvExpiry(cltv_expiry)
}).executeAsList()
}
}
override fun close() {
driver.close()
}
}
@@ -1,342 +0,0 @@
/*
* Copyright 2020 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.db
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import app.cash.sqldelight.db.SqlDriver
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.db.AutomaticLiquidityPurchasePayment
import fr.acinq.lightning.db.Bolt11IncomingPayment
import fr.acinq.lightning.db.Bolt12IncomingPayment
import fr.acinq.lightning.db.IncomingPaymentsDb
import fr.acinq.lightning.db.LegacyPayToOpenIncomingPayment
import fr.acinq.lightning.db.LegacySwapInIncomingPayment
import fr.acinq.lightning.db.LightningIncomingPayment
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.db.ManualLiquidityPurchasePayment
import fr.acinq.lightning.db.NewChannelIncomingPayment
import fr.acinq.lightning.db.OnChainIncomingPayment
import fr.acinq.lightning.db.OnChainOutgoingPayment
import fr.acinq.lightning.db.OutgoingPaymentsDb
import fr.acinq.lightning.db.PaymentsDb
import fr.acinq.lightning.db.WalletPayment
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.error
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.lightning.wire.LiquidityAds
import fr.acinq.phoenix.data.ContactAddress
import fr.acinq.phoenix.data.WalletPaymentInfo
import fr.acinq.phoenix.data.WalletPaymentMetadata
import fr.acinq.phoenix.db.contacts.SqliteContactsDb
import fr.acinq.phoenix.db.payments.LnurlBase
import fr.acinq.phoenix.db.payments.LnurlMetadata
import fr.acinq.phoenix.db.payments.LnurlSuccessAction
import fr.acinq.phoenix.db.payments.PaymentsMetadataQueries
import fr.acinq.phoenix.db.payments.SqliteIncomingPaymentsDb
import fr.acinq.phoenix.db.payments.SqliteOutgoingPaymentsDb
import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase
import fr.acinq.phoenix.managers.PaymentMetadataQueue
import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata
import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.withContext
class SqlitePaymentsDb(
val driver: SqlDriver,
val database: PaymentsDatabase,
val paymentMetadataQueue: PaymentMetadataQueue?,
val loggerFactory: LoggerFactory
) : IncomingPaymentsDb by SqliteIncomingPaymentsDb(database, paymentMetadataQueue),
OutgoingPaymentsDb by SqliteOutgoingPaymentsDb(database, paymentMetadataQueue),
PaymentsDb {
val metadataQueries = PaymentsMetadataQueries(database)
val contacts = SqliteContactsDb(driver, database, loggerFactory)
val log = loggerFactory.newLogger(SqlitePaymentsDb::class)
override suspend fun getInboundLiquidityPurchase(txId: TxId): LiquidityAds.LiquidityTransactionDetails? {
val payment = buildList {
addAll(database.paymentsIncomingQueries.listByTxId(txId).executeAsList())
addAll(database.paymentsOutgoingQueries.listByTxId(txId).executeAsList())
}.firstOrNull()
@Suppress("DEPRECATION")
return when (payment) {
is LightningIncomingPayment -> payment.liquidityPurchaseDetails
is OnChainIncomingPayment -> payment.liquidityPurchaseDetails
is LegacyPayToOpenIncomingPayment -> null
is LegacySwapInIncomingPayment -> null
is LightningOutgoingPayment -> null
is OnChainOutgoingPayment -> payment.liquidityPurchaseDetails
null -> null
}
}
override suspend fun setLocked(txId: TxId) {
database.transaction {
val lockedAt = currentTimestampMillis()
database.onChainTransactionsQueries.setLocked(tx_id = txId, locked_at = lockedAt)
database.paymentsIncomingQueries.listByTxId(txId).executeAsList().filterIsInstance<OnChainIncomingPayment>().forEach { payment ->
val payment1 = payment.setLocked(lockedAt)
database.paymentsIncomingQueries.update(id = payment1.id, data = payment1, txId = payment1.txId, receivedAt = payment1.lockedAt)
didSaveWalletPayment(payment1.id, database)
}
database.paymentsOutgoingQueries.listByTxId(txId).executeAsList().filterIsInstance<OnChainOutgoingPayment>().forEach { payment ->
val payment1 = payment.setLocked(lockedAt)
database.paymentsOutgoingQueries.update(id = payment1.id, data = payment1, completed_at = payment1.completedAt, succeeded_at = payment1.succeededAt)
didSaveWalletPayment(payment1.id, database)
}
}
}
suspend fun setConfirmed(txId: TxId) = withContext(Dispatchers.Default) {
database.transaction {
val confirmedAt = currentTimestampMillis()
database.onChainTransactionsQueries.setConfirmed(tx_id = txId, confirmed_at = confirmedAt)
database.paymentsIncomingQueries.listByTxId(txId).executeAsList().filterIsInstance<OnChainIncomingPayment>().forEach { payment ->
val payment1 = payment.setConfirmed(confirmedAt)
// receivedAt must still set to lockedAt, and not confirmedAt.
database.paymentsIncomingQueries.update(id = payment1.id, data = payment1, txId = payment1.txId, receivedAt = payment1.lockedAt)
didSaveWalletPayment(payment1.id, database)
}
database.paymentsOutgoingQueries.listByTxId(txId).executeAsList().filterIsInstance<OnChainOutgoingPayment>().forEach { payment ->
val payment1 = payment.setConfirmed(confirmedAt)
database.paymentsOutgoingQueries.update(id = payment1.id, data = payment1, completed_at = payment1.completedAt, succeeded_at = payment1.succeededAt)
didSaveWalletPayment(payment1.id, database)
}
}
}
suspend fun getPayment(id: UUID): Pair<WalletPayment, WalletPaymentMetadata?>? = withContext(Dispatchers.Default) {
_getPayment(id)
}
fun _getPayment(id: UUID): Pair<WalletPayment, WalletPaymentMetadata?>? = database.transactionWithResult {
(database.paymentsIncomingQueries.get(id).executeAsOneOrNull() ?: database.paymentsOutgoingQueries.get(id).executeAsOneOrNull())?.let { payment ->
val metadata = metadataQueries.get(id)
payment to metadata
}
}
fun listUnconfirmedTransactions(): Flow<List<TxId>> {
return database.onChainTransactionsQueries.listUnconfirmed()
.asFlow()
.mapToList(Dispatchers.Default)
}
suspend fun listPaymentsForTxId(txId: TxId): List<WalletPayment> = withContext(Dispatchers.Default) {
database.paymentsIncomingQueries.listByTxId(txId).executeAsList() + database.paymentsOutgoingQueries.listByTxId(txId).executeAsList()
}
fun listPaymentsAsFlow(count: Long, skip: Long): Flow<List<WalletPaymentInfo>> {
return combine(
database.paymentsQueries.list(limit = count, offset = skip, mapper = ::mapPaymentsAndMetadata).asFlow().mapToList(Dispatchers.Default),
contacts.indexesFlow,
transform = ::combinePaymentAndContact
)
}
fun listOutgoingInFlightPaymentsAsFlow(count: Long, skip: Long): Flow<List<WalletPaymentInfo>> {
return combine(
database.paymentsQueries.listInFlight(limit = count, offset = skip, mapper = ::mapPaymentsAndMetadata).asFlow().mapToList(Dispatchers.Default),
contacts.indexesFlow,
transform = ::combinePaymentAndContact
)
}
// Recent payments includes in-flight (not completed) payments.
fun listRecentPaymentsAsFlow(count: Long, skip: Long, sinceDate: Long): Flow<List<WalletPaymentInfo>> {
return combine(
database.paymentsQueries.listRecent(min_ts = sinceDate, limit = count, offset = skip, mapper = ::mapPaymentsAndMetadata).asFlow().mapToList(Dispatchers.Default),
contacts.indexesFlow,
transform = ::combinePaymentAndContact
)
}
suspend fun listCompletedPayments(count: Long, skip: Long, startDate: Long, endDate: Long): List<WalletPaymentInfo> {
return withContext(Dispatchers.Default) {
database.paymentsQueries.listSuccessful(succeeded_at_from = startDate, succeeded_at_to = endDate, limit = count, offset = skip, mapper = ::mapPaymentsAndMetadata)
.executeAsList()
}
}
private fun combinePaymentAndContact(paymentInfoList: List<WalletPaymentInfo>, indexes: SqliteContactsDb.ContactIndexes): List<WalletPaymentInfo> = paymentInfoList.map { paymentInfo ->
val payment = paymentInfo.payment
val metadata = paymentInfo.metadata
val contactId: UUID? = when (payment) {
is Bolt12IncomingPayment -> payment.incomingOfferMetadata()?.let { indexes.publicKeysMap[it.payerKey] }
is LightningOutgoingPayment -> payment.outgoingInvoiceRequest()?.let { indexes.offersMap[it.offer.offerId] }
else -> metadata.lightningAddress?.let { indexes.addressesMap[ContactAddress.hash(it)] }
}
contactId?.let { indexes.contactsMap[it] }?.let {
paymentInfo.copy(contact = it)
} ?: paymentInfo
}
@Suppress("UNUSED_PARAMETER")
private fun mapPaymentsAndMetadata(
data_: ByteArray,
payment_id: UUID?,
lnurl_base_type: LnurlBase.TypeVersion?,
lnurl_base_blob: ByteArray?,
lnurl_description: String?,
lnurl_metadata_type: LnurlMetadata.TypeVersion?,
lnurl_metadata_blob: ByteArray?,
lnurl_successAction_type: LnurlSuccessAction.TypeVersion?,
lnurl_successAction_blob: ByteArray?,
user_description: String?,
user_notes: String?,
modified_at: Long?,
original_fiat_type: String?,
original_fiat_rate: Double?,
lightning_address: String?
): WalletPaymentInfo {
val payment = try {
WalletPaymentAdapter.decode(data_)
} catch (e: Exception) {
log.error(e) { "failed to deserialize payment: ${e.message}" }
throw e
}
val metadata = PaymentsMetadataQueries.mapAll(
id = payment.id,
lnurl_base_type = lnurl_base_type,
lnurl_base_blob = lnurl_base_blob,
lnurl_description = lnurl_description,
lnurl_metadata_type = lnurl_metadata_type,
lnurl_metadata_blob = lnurl_metadata_blob,
lnurl_successAction_type = lnurl_successAction_type,
lnurl_successAction_blob = lnurl_successAction_blob,
user_description = user_description,
user_notes = user_notes,
modified_at = modified_at,
original_fiat_type = original_fiat_type,
original_fiat_rate = original_fiat_rate,
lightning_address = lightning_address
)
return WalletPaymentInfo(payment, metadata, null)
}
suspend fun getOldestCompletedDate(): Long? = withContext(Dispatchers.Default) {
database.paymentsQueries.getOldestCompletedAt().executeAsOneOrNull()?.completed_at
}
suspend fun countCompletedInRange(startDate: Long, endDate: Long): Long = withContext(Dispatchers.Default) {
database.paymentsQueries.countCompletedInRange(completed_at_from = startDate, completed_at_to = endDate).executeAsOne()
}
suspend fun updateUserInfo(id: UUID, userDescription: String?, userNotes: String?) = withContext(Dispatchers.Default) {
metadataQueries.updateUserInfo(id = id, userDescription = userDescription, userNotes = userNotes)
}
/**
* @param notify Set to false if `didDeleteWalletPayment` should not be invoked.
*/
suspend fun deletePayment(paymentId: UUID, notify: Boolean = true): Unit = withContext(Dispatchers.Default) {
database.transaction {
database.paymentsIncomingQueries.deleteById(id = paymentId)
if (database.paymentsIncomingQueries.changes().executeAsOne() == 0L) {
database.paymentsOutgoingQueries.deleteById(id = paymentId)
}
if (notify) {
didDeleteWalletPayment(paymentId, database)
}
}
}
/**
* Cloudkit operates on a record-by-record basis. When a database migration involves merging
* records, it has to be done in a separate post-processing step.
*
* This particular function merges liquidity-related records, into other records.
*/
suspend fun finishCloudkitRestore(): Unit = withContext(Dispatchers.Default) {
database.transaction {
database.paymentsIncomingQueries
.listSuccessful(
received_at_from = 0,
received_at_to = Long.MAX_VALUE,
limit = Long.MAX_VALUE,
offset = 0
)
.executeAsList()
.forEach {
when (val incomingPayment = it) {
is NewChannelIncomingPayment -> if (incomingPayment.liquidityPurchase == null) {
val manualLiquidityPayment = database.paymentsOutgoingQueries.listByTxId(incomingPayment.txId)
.executeAsOneOrNull() as? ManualLiquidityPurchasePayment
manualLiquidityPayment?.let {
val incomingPayment1 = incomingPayment.copy(liquidityPurchase = manualLiquidityPayment.liquidityPurchase)
database.paymentsIncomingQueries.update(
receivedAt = incomingPayment1.completedAt,
txId = incomingPayment1.txId,
data = incomingPayment1,
id = incomingPayment1.id
)
database.paymentsOutgoingQueries.deleteById(manualLiquidityPayment.id)
didSaveWalletPayment(incomingPayment.id, database)
didDeleteWalletPayment(manualLiquidityPayment.id, database)
}
}
is LightningIncomingPayment -> if (incomingPayment.liquidityPurchaseDetails == null) {
val txId = incomingPayment.parts.filterIsInstance<LightningIncomingPayment.Part.Htlc>().firstNotNullOfOrNull { it.fundingFee?.fundingTxId }
txId?.let {
val autoLiquidityPayment =
database.paymentsOutgoingQueries.listByTxId(txId)
.executeAsOneOrNull() as? AutomaticLiquidityPurchasePayment
autoLiquidityPayment?.let {
val incomingPayment1 = when(incomingPayment) {
is Bolt11IncomingPayment -> incomingPayment.copy(liquidityPurchaseDetails = autoLiquidityPayment.liquidityPurchaseDetails)
is Bolt12IncomingPayment -> incomingPayment.copy(liquidityPurchaseDetails = autoLiquidityPayment.liquidityPurchaseDetails)
}
database.paymentsIncomingQueries.update(
id = incomingPayment1.id,
data = incomingPayment1,
receivedAt = incomingPayment1.completedAt,
txId = incomingPayment1.liquidityPurchaseDetails?.txId
)
val autoLiquidityPayment1 = autoLiquidityPayment.copy(incomingPaymentReceivedAt = incomingPayment1.completedAt)
database.paymentsOutgoingQueries.update(
id = autoLiquidityPayment.id,
completed_at = autoLiquidityPayment1.completedAt,
succeeded_at = autoLiquidityPayment1.succeededAt,
data = autoLiquidityPayment1
)
didSaveWalletPayment(incomingPayment.id, database)
didSaveWalletPayment(autoLiquidityPayment.id, database)
}
}
}
else -> Unit
}
}
}
}
fun close() {
contacts.cancel()
driver.close()
}
}
@@ -1,18 +0,0 @@
package fr.acinq.phoenix.db.cloud
import io.ktor.util.decodeBase64Bytes
import io.ktor.util.encodeBase64
// Kotlin wants to encode a ByteArray like this: {
// "fail": [123,34,112,97,121,109,101,110,116,82,101,113,117]
// }
//
// Lol. If we don't use Cbor, then we should at least use Base64.
fun ByteArray.b64Encode(): String {
return this.encodeBase64() // io.ktor.util
}
fun String.b64Decode(): ByteArray {
return this.decodeBase64Bytes() // io.ktor.util
}
@@ -1,148 +0,0 @@
package fr.acinq.phoenix.db.cloud
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.utils.Try
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.wire.OfferTypes
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
// Notes from the field:
//
// Consider the following JSON:
// {
// "preimage":"JuO9VOOW/5pzCKsaCO7a9E/ETS7Bef5yyWVRBJBYmOQ=",
// "origin":{
// "type":"INVOICE_V0",
// "blob":"eyJwYXltZW50UmVxdWVzdCI6ImxudGIxMDB1MXBzMDNmc3VwcDVnbmh4NmR4NTA4cnM3OG1kcnB3Y3U3cWgwNGZja3hjbmRhdXdueXp1NjRuM3V5dzRqZHRzZHE1ZmFjeDJtM3F2ZDV4em1ud3Y0a3FjcXBqc3A1aHIyZDg1cDdkNm5xcGtyZXVtNGo5czZycjBwaG1weGhzdzVqYXJyNTI5ZGxsYXY5MGZ3cTlxdHpxcXFxcXF5c2dxeHF5anc1cXJ6anF3Zm4zcDkyNzh0dHp6cGUwZTAwdWh5eGhuZWQzajVkOWFjcWFrNWVtd2ZwZmxwOHoyY25mbGNmemNhODA1NmsweXFxcXFsZ3FxcXFxZXFxanFmOTN4M3YyM3IwZTg1a3p5cXJlaDY4ZGhxbGQwY2w3and4OTdwZDZwemF4Y2N1Y3l3NzhxZGc1ZzJsdzhrZnlmdWQzMnN4d2NtNDlhY2wwNXdxd3phajA4djdyeHQ5cWZ4Z2E3MDNzcGhrcmZhdCJ9"
// },
// "received":{
// "ts":1626908236089,
// "type":"MULTIPARTS_V0",
// "blob":"W3sidHlwZSI6ImZyLmFjaW5xLnBob2VuaXguZGIucGF5bWVudHMuSW5jb21pbmdSZWNlaXZlZFdpdGhEYXRhLlBhcnQuTmV3Q2hhbm5lbC5WMCIsImFtb3VudCI6eyJtc2F0IjoxMDAwMDAwMH0sImZlZXMiOnsibXNhdCI6MzAwMDAwMH0sImNoYW5uZWxJZCI6bnVsbH1d"
// },
// "createdAt":1626908189069
// }
//
// Now there are 4 different ways in which we can encode this data.
//
// 1. Use JSON serialization, and encode the data as Base64.
// The output looks exactly like the above.
// ```
// @Serializable(with = ByteVector32JsonSerializer::class)
// val preimage: ByteVector32
// ```
//
// 2. Use CBOR serialization, and encode the data as Base64.
// ```
// @Serializable(with = ByteVector32JsonSerializer::class)
// val preimage: ByteVector32
// ```
//
// 3. Use CBOR serialization, and encode the data as ByteArray.
// Since CBOR supports raw data, this should encode smaller.
// ```
// val preimage: ByteArray
// ```
//
// 4. Use CBOR serialization, and encode the data as ByteArray w/ByteString.
// The docs mention that we can opt-in to use CBOR major type 2.
// ```
// @ByteString
// val preimage: ByteArray
// ```
//
// After attempting all the above options, here are the results:
// 1. 915 bytes
// 2. 883 bytes
// 3. 1,256 bytes (huh?)
// 4. 690 bytes !
//
// The winner is CBOR with @ByteString.
/**
* Standard Cbor instance for serialization.
*/
@OptIn(ExperimentalSerializationApi::class)
fun cborSerializer() = Cbor { ignoreUnknownKeys = true }
/**
* Serializer that uses base64 encoding.
* (this is more compact than hexadecimal encoding)
*/
object ByteVectorJsonSerializer : KSerializer<ByteVector> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("ByteVector", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: ByteVector) {
return encoder.encodeString(value.toByteArray().b64Encode())
}
override fun deserialize(decoder: Decoder): ByteVector {
return ByteVector(decoder.decodeString().b64Decode())
}
}
/**
* Serializer that uses base64 encoding.
* (this is more compact than hexadecimal encoding)
*/
object ByteVector32JsonSerializer : KSerializer<ByteVector32> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("ByteVector32", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: ByteVector32) {
return encoder.encodeString(value.toByteArray().b64Encode())
}
override fun deserialize(decoder: Decoder): ByteVector32 {
return ByteVector32(decoder.decodeString().b64Decode())
}
}
/**
* The old version (fr.acinq.phoenix.db.serializers.v1.UUIDSerializer)
* serializes a UUID like this: {
* "mostSignificantBits":-1321539888342873580,
* "leastSignificantBits":-7509590717981962141
* }
*
* This version is more compact.
*/
object UUIDSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) {
return encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): UUID {
return UUID.fromString(decoder.decodeString())
}
}
object OfferSerializer : KSerializer<OfferTypes.Offer> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("Offer", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: OfferTypes.Offer) {
return encoder.encodeString(value.encode())
}
override fun deserialize(decoder: Decoder): OfferTypes.Offer {
val offerStr = decoder.decodeString()
return when (val result = OfferTypes.Offer.decode(offerStr)) {
is Try.Success -> result.result
is Try.Failure -> throw SerializationException(message = "invalid offer")
}
}
}
@@ -1,109 +0,0 @@
package fr.acinq.phoenix.db.cloud.contacts
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.lightning.wire.OfferTypes
import fr.acinq.phoenix.data.ContactInfo
import fr.acinq.phoenix.data.ContactOffer
import fr.acinq.phoenix.db.cloud.OfferSerializer
import fr.acinq.phoenix.db.cloud.UUIDSerializer
import fr.acinq.phoenix.db.cloud.cborSerializer
import fr.acinq.phoenix.db.serialization.contacts.Serialization
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromByteArray
sealed class CloudContact {
enum class Version(val value: Int) {
// Initial version
V0(0)
}
@Serializable
data class VersionSwitch(
@SerialName("v")
val version: Int
)
@Serializable
data class V0(
@SerialName("v")
val version: Int,
@Serializable(with = UUIDSerializer::class)
val id: UUID,
val name: String,
val useOfferKey: Boolean,
val offers: List<@Serializable(OfferSerializer::class) OfferTypes.Offer>,
): CloudContact() {
@Throws(Exception::class)
fun unwrap(photoUri: String?): ContactInfo {
val now = currentTimestampMillis()
val mappedOffers: List<ContactOffer> = this.offers.map {
ContactOffer(offer = it, label = "", createdAt = now)
}
return ContactInfo(
id = this.id,
name = this.name,
photoUri = photoUri,
useOfferKey = this.useOfferKey,
offers = mappedOffers,
addresses = listOf()
)
}
companion object
}
data class V1(
val contact: ContactInfo
): CloudContact() {
fun serialize(): ByteArray {
val cleanContact = contact.copy(photoUri = null)
val cloudVersion = byteArrayOf(1.toByte())
val serializedData = Serialization.serialize(cleanContact)
return cloudVersion + serializedData
}
}
companion object {
@OptIn(ExperimentalSerializationApi::class)
@Throws(Exception::class)
private fun cborDeserializeAndUnwrap(
blob: ByteArray,
photoUri: String?
): ContactInfo? {
val serializer = cborSerializer()
val header = serializer.decodeFromByteArray<VersionSwitch>(blob)
return when (header.version) {
Version.V0.value -> {
serializer.decodeFromByteArray<V0>(blob).unwrap(photoUri)
}
else -> null
}
}
fun deserialize(
blob: ByteArray,
photoUri: String?
): ContactInfo? {
return kotlin.runCatching {
when (val version = blob.first()) {
1.toByte() -> {
val serializedData = blob.sliceArray(1..<blob.size)
Serialization.deserialize(serializedData).getOrThrow()
}
else -> {
throw IllegalArgumentException("unknown version: $version")
}
}.copy(photoUri = photoUri)
}.recoverCatching {
cborDeserializeAndUnwrap(blob, photoUri)
}.getOrNull()
}
}
}
@@ -1,47 +0,0 @@
package fr.acinq.phoenix.db.cloud.payments
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.db.ChannelCloseOutgoingPayment
import fr.acinq.lightning.db.ChannelCloseOutgoingPayment.ChannelClosingType
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toByteVector32
import fr.acinq.phoenix.db.cloud.UUIDSerializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class ChannelClosePaymentWrapper(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
val amountSat: Long,
val address: String,
val isSentToDefaultAddress: Boolean,
val miningFeeSat: Long,
@ByteString val txId: ByteArray,
val createdAt: Long,
val confirmedAt: Long?,
val lockedAt: Long?,
@ByteString val channelId: ByteArray,
val closingType: ChannelClosingType,
) {
@Throws(Exception::class)
fun unwrap() = ChannelCloseOutgoingPayment(
id = id,
recipientAmount = amountSat.sat,
address = address,
isSentToDefaultAddress = isSentToDefaultAddress,
miningFee = miningFeeSat.sat,
txId = TxId(txId),
createdAt = createdAt,
confirmedAt = confirmedAt,
lockedAt = lockedAt,
channelId = channelId.toByteVector32(),
closingType = closingType
)
companion object
}
@@ -1,139 +0,0 @@
package fr.acinq.phoenix.db.cloud.payments
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.phoenix.db.cloud.cborSerializer
import fr.acinq.phoenix.db.payments.LnurlBase
import fr.acinq.phoenix.db.payments.LnurlMetadata
import fr.acinq.phoenix.db.payments.LnurlSuccessAction
import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
enum class CloudAssetVersion(val value: Int) {
// Initial version
V0(0),
// V1:
// - added `original_fiat`
V1(1)
// Future versions go here
}
// Upgrade notes:
// If needed in the future, you can use code like this to extract only the version:
//
// data class CloudAssetVersion(
// @SerialName("v")
// val version: Int
// )
// val version = try {
// cborSerializer().decodeFromByteArray<CloudAssetVersion>(blob)
// } catch (e: Throwable) { null }
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class CloudAsset(
@SerialName("v")
val version: Int,
val lnurl_base: LnurlBaseWrapper?,
val lnurl_metadata: LnurlMetadataWrapper?,
val lnurl_successAction: LnurlSuccessActionWrapper?,
val lnurl_description: String?,
val user_description: String?,
val user_notes: String?,
val original_fiat: OriginalFiatWrapper? = null // added in V1
) {
constructor(row: WalletPaymentMetadataRow) : this(
version = CloudAssetVersion.V1.value,
lnurl_base = row.lnurl_base?.let {
LnurlBaseWrapper(it.first.name, it.second)
},
lnurl_metadata = row.lnurl_metadata?.let {
LnurlMetadataWrapper(it.first.name, it.second)
},
lnurl_successAction = row.lnurl_successAction?.let {
LnurlSuccessActionWrapper(it.first.name, it.second)
},
lnurl_description = row.lnurl_description,
user_description = row.user_description,
user_notes = row.user_notes,
original_fiat = row.original_fiat?.let {
OriginalFiatWrapper(it.first, it.second)
}
)
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class LnurlBaseWrapper(
val type: String,
@ByteString
val blob: ByteArray
) {
var typeVersion = LnurlBase.TypeVersion.valueOf(type)
}
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class LnurlMetadataWrapper(
val type: String,
@ByteString
val blob: ByteArray
) {
var typeVersion = LnurlMetadata.TypeVersion.valueOf(type)
}
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class LnurlSuccessActionWrapper(
val type: String,
@ByteString
val blob: ByteArray
) {
var typeVersion = LnurlSuccessAction.TypeVersion.valueOf(type)
}
@Serializable
data class OriginalFiatWrapper(
val type: String,
val rate: Double
)
@Throws(Exception::class)
fun unwrap() = WalletPaymentMetadataRow(
lnurl_base = lnurl_base?.let {
Pair(it.typeVersion, it.blob)
},
lnurl_metadata = lnurl_metadata?.let {
Pair(it.typeVersion, it.blob)
},
lnurl_successAction = lnurl_successAction?.let {
Pair(it.typeVersion, it.blob)
},
lnurl_description = lnurl_description,
user_description = user_description,
user_notes = user_notes,
original_fiat = original_fiat?.let {
Pair(it.type, it.rate)
},
modified_at = currentTimestampMillis()
)
companion object
}
@OptIn(ExperimentalSerializationApi::class)
fun CloudAsset.cborSerialize(): ByteArray {
return Cbor.encodeToByteArray(this)
}
@OptIn(ExperimentalSerializationApi::class)
@Throws(Exception::class)
fun CloudAsset.Companion.cborDeserialize(
blob: ByteArray
): CloudAsset {
return cborSerializer().decodeFromByteArray(blob)
}
@@ -1,157 +0,0 @@
package fr.acinq.phoenix.db.cloud.payments
import fr.acinq.lightning.db.WalletPayment
import fr.acinq.lightning.serialization.payment.Serialization
import fr.acinq.phoenix.db.cloud.cborSerializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.json.Json
// Architecture & Notes:
//
// We make every attempt to re-use code from the database serialization routines.
// However, cloud serialization is a bit different from database serialization.
//
// DIFFERENCE #1:
//
// SqlDelight allows us to version the database, and make changes to the database structure.
// For example, when upgrading the app from v1.5 to v1.6,
// we might upgrade the database from v2 to v3.
// This upgrade mechanism allows us to write code that **only** supports database v3,
// since SqlDelight handles the upgrade mechanics.
//
// This upgrade mechanism isn't available for the cloud.
// Thus, in the cloud we might have serialized objects in v1, v2, v3...
// And we will need to support deserializing all these versions.
//
// DIFFERENCE #2:
//
// For the local system, space is cheap, and the disk is fast.
// But for the cloud, space is really expensive.
// On iOS, the user only has 5 GB. But that's NOT per app.
// That 5GB is meant to be shared by every app on their phone.
// Which means we're expected to be a good steward of their cloud space.
//
// So we make certain changes.
//
// OPTIMIZATION #1:
//
// We use CBOR instead of JSON.
// This allows us to very efficiently encode ByteArray's.
//
// For a discussion of the space savings in practice,
// see the comments in CloudSerializers.kt.
//
// OPTIMIZATION #2:
//
// We use custom serializers when the other versions are found to
// be inefficient (in terms of space).
// For example, we supply an alternative UUIDSerializer.
enum class CloudDataVersion(val value: Int) {
// Initial version
V0(0)
// Future versions go here
}
sealed class CloudData {
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class V0(
@SerialName("i")
val incoming: IncomingPaymentWrapperV10Legacy? = null,
@ByteString
@SerialName("o")
val outgoing: LightningOutgoingPaymentWrapper? = null,
@SerialName("so")
val spliceOutgoing: SpliceOutgoingPaymentWrapper? = null,
@SerialName("cc")
val channelClose: ChannelClosePaymentWrapper? = null,
@SerialName("sc")
val spliceCpfp: SpliceCpfpPaymentWrapper? = null,
@SerialName("il")
val inboundLegacyLiquidity: InboundLiquidityLegacyWrapper? = null,
@SerialName("ip")
val inboundPurchaseLiquidity: InboundLiquidityPaymentWrapper? = null,
@SerialName("v")
val version: Int,
@ByteString
@SerialName("p")
val padding: ByteArray?,
) : CloudData() {
/**
* This function exists because the Kotlin-generated
* copy function doesn't translate to iOS very well.
*/
fun copyWithPadding(padding: ByteArray): V0 {
return this.copy(padding = padding)
}
@Throws(Exception::class)
fun unwrap(): WalletPayment? = when {
incoming != null -> incoming.unwrap()
outgoing != null -> outgoing.unwrap()
spliceOutgoing != null -> spliceOutgoing.unwrap()
channelClose != null -> channelClose.unwrap()
spliceCpfp != null -> spliceCpfp.unwrap()
inboundLegacyLiquidity != null -> inboundLegacyLiquidity.unwrap()
inboundPurchaseLiquidity != null -> inboundPurchaseLiquidity.unwrap()
else -> null
}
override fun serialize(): ByteArray = throw NotImplementedError("cannot create V0 cloud data anymore")
companion object {
@OptIn(ExperimentalSerializationApi::class)
private fun cborDeserialize(blob: ByteArray): CloudData {
return cborSerializer().decodeFromByteArray(blob)
}
}
}
data class V1(val payment: WalletPayment) : CloudData() {
override fun serialize(): ByteArray {
val version = byteArrayOf(1.toByte())
val serializedDate = Serialization.serialize(payment)
return version + serializedDate
}
}
abstract fun serialize(): ByteArray
companion object {
@OptIn(ExperimentalSerializationApi::class)
fun deserialize(data: ByteArray): CloudData? {
return kotlin.runCatching {
when (val version = data.first()) {
1.toByte() -> {
val serializedData = data.sliceArray(1..<data.size)
V1(Serialization.deserialize(serializedData).getOrThrow())
}
else -> {
throw IllegalArgumentException("unknown version: $version")
}
}
}.recoverCatching {
cborSerializer().decodeFromByteArray<V0>(data)
}.getOrNull()
}
}
}
/**
* For DEBUGGING:
*
* You can use the jsonSerializer to see what the data looks like.
* Just keep in mind that the ByteArray's will be encoded super-inefficiently.
* That's because we're optimizing for Cbor.
* To optimize for JSON, you would use ByteVector's,
* and encode the data as Base64 via ByteVectorJsonSerializer.
*/
fun CloudData.jsonSerialize(): ByteArray {
return Json.encodeToString(this).encodeToByteArray()
}
@@ -1,146 +0,0 @@
package fr.acinq.phoenix.db.cloud.payments
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.db.AutomaticLiquidityPurchasePayment
import fr.acinq.lightning.db.ManualLiquidityPurchasePayment
import fr.acinq.lightning.db.WalletPayment
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toByteVector32
import fr.acinq.lightning.wire.LiquidityAds
import fr.acinq.phoenix.db.cloud.UUIDSerializer
import fr.acinq.phoenix.db.migrations.v11.types.liquidityads.PurchaseData
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
/** New inbound liquidity wrapper that uses the [LiquidityAds.Purchase] object. */
@Suppress("ArrayInDataClass")
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class InboundLiquidityPaymentWrapper(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
@ByteString val channelId: ByteArray,
@ByteString val txId: ByteArray,
val miningFeesSat: Long,
val purchase: LiquidityAdsPurchaseWrapper,
val createdAt: Long,
val confirmedAt: Long?,
val lockedAt: Long?,
) {
@Throws(Exception::class)
fun unwrap(): WalletPayment {
val purchase = this.purchase.unwrap()
return when (purchase.paymentDetails) {
is LiquidityAds.PaymentDetails.FromFutureHtlc, is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage, is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> {
AutomaticLiquidityPurchasePayment(
id = this.id,
channelId = this.channelId.toByteVector32(),
txId = TxId(this.txId),
miningFee = this.miningFeesSat.sat,
liquidityPurchase = purchase,
createdAt = this.createdAt,
confirmedAt = this.confirmedAt,
lockedAt = this.lockedAt,
incomingPaymentReceivedAt = this.confirmedAt
)
}
is LiquidityAds.PaymentDetails.FromChannelBalance -> {
ManualLiquidityPurchasePayment(
id = this.id,
channelId = this.channelId.toByteVector32(),
txId = TxId(this.txId),
miningFee = this.miningFeesSat.sat,
liquidityPurchase = purchase,
createdAt = this.createdAt,
confirmedAt = this.confirmedAt,
lockedAt = this.lockedAt,
)
}
}
}
@Serializable
data class LiquidityAdsPurchaseWrapper(@ByteString val blob: ByteArray) {
fun unwrap(): LiquidityAds.Purchase {
return PurchaseData.decodeAsCanonical("", blob)
}
}
}
/** This is the legacy wrapper for inbound liquidity, that used a Lease object to represent the liquidity purchase. Used only for deserialization now. */
@Serializable
@Suppress("ArrayInDataClass")
@OptIn(ExperimentalSerializationApi::class)
data class InboundLiquidityLegacyWrapper(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
@ByteString val channelId: ByteArray,
@ByteString val txId: ByteArray,
val miningFeesSat: Long,
val lease: LiquidityAdsLeaseWrapper,
val createdAt: Long,
val confirmedAt: Long?,
val lockedAt: Long?,
) {
@Throws(Exception::class)
fun unwrap(): WalletPayment {
val purchase = this.lease.unwrap()
return when (purchase.paymentDetails) {
is LiquidityAds.PaymentDetails.FromFutureHtlc, is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage, is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> {
AutomaticLiquidityPurchasePayment(
id = this.id,
channelId = this.channelId.toByteVector32(),
txId = TxId(this.txId),
miningFee = this.miningFeesSat.sat,
liquidityPurchase = purchase,
createdAt = this.createdAt,
confirmedAt = this.confirmedAt,
lockedAt = this.lockedAt,
incomingPaymentReceivedAt = this.confirmedAt
)
}
is LiquidityAds.PaymentDetails.FromChannelBalance -> {
ManualLiquidityPurchasePayment(
id = this.id,
channelId = this.channelId.toByteVector32(),
txId = TxId(this.txId),
miningFee = this.miningFeesSat.sat,
liquidityPurchase = purchase,
createdAt = this.createdAt,
confirmedAt = this.confirmedAt,
lockedAt = this.lockedAt,
)
}
}
}
@Serializable
data class LiquidityAdsLeaseWrapper(
val amountSat: Long,
val fees: LiquidityAdsLeaseFeesWrapper,
) {
@Throws(Exception::class)
fun unwrap(): LiquidityAds.Purchase{
return LiquidityAds.Purchase.Standard(
amount = this.amountSat.sat,
fees = this.fees.unwrap().let { LiquidityAds.Fees(miningFee = it.miningFee, serviceFee = it.serviceFee) },
paymentDetails = LiquidityAds.PaymentDetails.FromChannelBalance
)
}
}
@Serializable
data class LiquidityAdsLeaseFeesWrapper(
val miningFeeSat: Long,
val serviceFeeSat: Long
) {
@Throws(Exception::class)
fun unwrap() = LiquidityAds.Fees(
miningFee = this.miningFeeSat.sat,
serviceFee = this.serviceFeeSat.sat
)
}
}
@@ -1,46 +0,0 @@
package fr.acinq.phoenix.db.cloud.payments
import fr.acinq.bitcoin.byteVector32
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.phoenix.db.migrations.v10.types.mapIncomingPaymentFromV10
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class IncomingPaymentWrapperV10Legacy(
@ByteString val preimage: ByteArray,
val origin: OriginWrapper,
val received: ReceivedWrapper?,
val createdAt: Long
) {
fun unwrap(): IncomingPayment {
return mapIncomingPaymentFromV10(
preimage = preimage,
payment_hash = preimage.byteVector32().sha256().toByteArray(),
created_at = createdAt,
origin_type = origin.type,
origin_blob = origin.blob,
received_amount_msat = null,
received_at = received?.ts,
received_with_type = received?.type,
received_with_blob = received?.blob,
)
}
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class OriginWrapper(
val type: String,
@ByteString val blob: ByteArray
)
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class ReceivedWrapper(
val ts: Long, // timestamp / receivedAt
val type: String,
@ByteString val blob: ByteArray
)
}
@@ -1,68 +0,0 @@
package fr.acinq.phoenix.db.cloud.payments
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.utils.UUID
import fr.acinq.phoenix.db.cloud.UUIDSerializer
import fr.acinq.phoenix.db.migrations.v11.queries.LightningOutgoingQueries
import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartStatusData
import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartStatusTypeVersion
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
/** Legacy object used when channel closing were stored as outgoing-payments parts. */
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class LightningOutgoingClosingTxPartWrapper(
@Serializable(with = UUIDSerializer::class) val id: UUID,
@ByteString val txId: ByteArray,
val sat: Long,
val info: ClosingInfoWrapper,
val createdAt: Long
) {
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class ClosingInfoWrapper(
val type: String,
@ByteString val blob: ByteArray
)
}
@Serializable
data class LightningOutgoingPartWrapper(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
val msat: Long,
val route: String,
val status: StatusWrapper?,
val createdAt: Long
) {
fun unwrap() = LightningOutgoingPayment.Part(
id = id,
amount = MilliSatoshi(msat = msat),
route = LightningOutgoingQueries.hopDescAdapter.decode(route),
status = status?.unwrap() ?: LightningOutgoingPayment.Part.Status.Pending,
createdAt = createdAt
)
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class StatusWrapper(
val ts: Long, // timestamp: completedAt
val type: String,
@ByteString
val blob: ByteArray
) {
fun unwrap(): LightningOutgoingPayment.Part.Status {
return OutgoingPartStatusData.deserialize(
typeVersion = OutgoingPartStatusTypeVersion.valueOf(type),
blob = blob,
completedAt = ts
)
}
} // </StatusWrapper>
} // </OutgoingPartData>
@@ -1,107 +0,0 @@
package fr.acinq.phoenix.db.cloud.payments
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.byteVector32
import fr.acinq.lightning.db.ChannelCloseOutgoingPayment
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.db.OutgoingPayment
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import fr.acinq.phoenix.db.cloud.UUIDSerializer
import fr.acinq.phoenix.db.migrations.v11.types.OutgoingDetailsData
import fr.acinq.phoenix.db.migrations.v11.types.OutgoingDetailsTypeVersion
import fr.acinq.phoenix.db.migrations.v11.types.OutgoingStatusData
import fr.acinq.phoenix.db.migrations.v11.types.OutgoingStatusTypeVersion
import fr.acinq.phoenix.utils.migrations.LegacyChannelCloseHelper
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class LightningOutgoingPaymentWrapper(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
val msat: Long,
@ByteString
val recipient: ByteArray,
val details: DetailsWrapper,
val parts: List<LightningOutgoingPartWrapper>,
// these parts are now obsolete, we now use a dedicated object for channels closing
val closingTxsParts: List<LightningOutgoingClosingTxPartWrapper> = emptyList(),
val status: StatusWrapper?,
val createdAt: Long
) {
/**
* Unwraps a cbor-serialized outgoing payment. Should return a [LightningOutgoingPayment], but on may also return a
* [ChannelCloseOutgoingPayment] in case the data are legacy and actually contain data for a channel closing.
*/
@Throws(Exception::class)
fun unwrap(): OutgoingPayment? {
val details = details.unwrap()
return if (details != null) {
val status = status?.unwrap() ?: LightningOutgoingPayment.Status.Pending
val parts = parts.map { it.unwrap() }
LightningOutgoingPayment(
id = id,
recipientAmount = msat.msat,
recipient = PublicKey.parse(recipient),
status = status,
parts = parts,
details = details,
createdAt = createdAt
)
} else {
try {
LegacyChannelCloseHelper.convertLegacyToChannelClose(
id = id,
recipientAmount = msat.msat,
partsAmount = closingTxsParts.takeIf { it.isNotEmpty() }?.sumOf { it.sat }?.sat,
partsTxId = closingTxsParts.firstOrNull()?.txId?.byteVector32(),
detailsBlob = this.details.blob,
statusBlob = this.status?.blob,
partsClosingTypeBlob = closingTxsParts.firstOrNull()?.info?.blob,
createdAt = createdAt,
confirmedAt = this.status?.ts ?: createdAt,
)
} catch (e: Exception) {
null
}
}
}
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class DetailsWrapper(
val type: String,
@ByteString
val blob: ByteArray
) {
fun unwrap(): LightningOutgoingPayment.Details? {
return OutgoingDetailsData.deserialize(
typeVersion = OutgoingDetailsTypeVersion.valueOf(type),
blob = blob
)
}
} // </DetailsWrapper>
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class StatusWrapper(
val ts: Long,
val type: String,
@ByteString
val blob: ByteArray
) {
fun unwrap(): LightningOutgoingPayment.Status {
return OutgoingStatusData.deserialize(
typeVersion = OutgoingStatusTypeVersion.valueOf(type),
blob = blob,
completedAt = ts
)
}
} // </StatusWrapper>
} // </OutgoingPaymentWrapper>
@@ -1,37 +0,0 @@
package fr.acinq.phoenix.db.cloud.payments
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toByteVector32
import fr.acinq.phoenix.db.cloud.UUIDSerializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class SpliceCpfpPaymentWrapper(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
val miningFeeSat: Long,
@ByteString
val channelId: ByteArray,
@ByteString
val txId: ByteArray,
val createdAt: Long,
val confirmedAt: Long?,
val lockedAt: Long?
) {
@Throws(Exception::class)
fun unwrap() = SpliceCpfpOutgoingPayment(
id = id,
miningFee = miningFeeSat.sat,
channelId = channelId.toByteVector32(),
txId = TxId(txId),
createdAt = createdAt,
confirmedAt = confirmedAt,
lockedAt = lockedAt,
)
}
@@ -1,40 +0,0 @@
package fr.acinq.phoenix.db.cloud.payments
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.db.SpliceOutgoingPayment
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toByteVector32
import fr.acinq.phoenix.db.cloud.UUIDSerializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class SpliceOutgoingPaymentWrapper(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
val amountSat: Long,
val address: String,
val miningFeeSat: Long,
@ByteString val txId: ByteArray,
@ByteString val channelId: ByteArray,
val createdAt: Long,
val confirmedAt: Long?,
val lockedAt: Long?,
) {
@Throws(Exception::class)
fun unwrap() = SpliceOutgoingPayment(
id = id,
recipientAmount = amountSat.sat,
address = address,
miningFee = miningFeeSat.sat,
channelId = channelId.toByteVector32(),
txId = TxId(txId),
liquidityPurchase = null,
createdAt = createdAt,
confirmedAt = confirmedAt,
lockedAt = lockedAt,
)
}
@@ -1,75 +0,0 @@
package fr.acinq.phoenix.db.contacts
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.phoenix.data.ContactInfo
import fr.acinq.phoenix.db.didDeleteContact
import fr.acinq.phoenix.db.didSaveContact
import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase
import kotlinx.coroutines.flow.Flow
import kotlin.coroutines.CoroutineContext
class ContactQueries(val database: PaymentsDatabase) {
val queries = database.contactsQueries
fun saveContact(contact: ContactInfo, notify: Boolean = true) {
database.transaction {
val contactExists = queries.existsContact(
id = contact.id
).executeAsOne() > 0
if (contactExists) {
updateExistingContact(contact)
} else {
saveNewContact(contact)
}
if (notify) {
didSaveContact(contact.id, database)
}
}
}
private fun saveNewContact(contact: ContactInfo) {
queries.insertContact(
id = contact.id,
data = contact,
createdAt = currentTimestampMillis(),
updatedAt = null
)
}
private fun updateExistingContact(contact: ContactInfo) {
queries.updateContact(
data = contact,
updatedAt = currentTimestampMillis(),
contactId = contact.id
)
}
fun existsContact(contactId: UUID): Boolean {
return queries.existsContact(
id = contactId
).executeAsOne() > 0
}
fun getContact(contactId: UUID): ContactInfo? {
return queries.getContact(contactId).executeAsOneOrNull()
}
fun listContacts(): List<ContactInfo> {
return queries.listContacts().executeAsList()
}
fun monitorContactsFlow(context: CoroutineContext): Flow<List<ContactInfo>> {
return queries.listContacts().asFlow().mapToList(context)
}
fun deleteContact(contactId: UUID) {
database.transaction { // transaction required: `didDeleteContact` MUST run in same tx
queries.deleteContact(contactId)
didDeleteContact(contactId, database)
}
}
}
@@ -1,171 +0,0 @@
package fr.acinq.phoenix.db.contacts
import app.cash.sqldelight.db.SqlDriver
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PublicKey
import fr.acinq.lightning.db.Bolt12IncomingPayment
import fr.acinq.lightning.db.WalletPayment
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.wire.OfferTypes
import fr.acinq.phoenix.data.ContactAddress
import fr.acinq.phoenix.data.ContactInfo
import fr.acinq.phoenix.data.WalletPaymentMetadata
import fr.acinq.phoenix.db.SqliteAppDb
import fr.acinq.phoenix.db.migrations.appDb.v7.AfterVersion7Result
import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase
import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata
import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds
class SqliteContactsDb(
val driver: SqlDriver,
val database: PaymentsDatabase,
val loggerFactory: LoggerFactory
): CoroutineScope by MainScope() {
private val log = loggerFactory.newLogger(this::class)
val contactQueries = ContactQueries(database)
private val _contactsList = MutableStateFlow<List<ContactInfo>>(emptyList())
val contactsList = _contactsList.asStateFlow()
data class ContactIndexes(
val contactsMap: Map<UUID, ContactInfo>,
val offersMap: Map<ByteVector32, UUID>,
val publicKeysMap: Map<PublicKey, UUID>,
val addressesMap: Map<ByteVector32, UUID>,
)
@OptIn(ExperimentalCoroutinesApi::class)
val indexesFlow = contactsList.mapLatest { list ->
ContactIndexes(
contactsMap = list.associateBy { it.id },
offersMap = list.flatMap { contact ->
contact.offers.map { it.id to contact.id }
}.toMap(),
publicKeysMap = list.flatMap { contact ->
contact.publicKeys.map { it to contact.id }
}.toMap(),
addressesMap = list.flatMap { contact ->
contact.addresses.map { it.id to contact.id }
}.toMap()
)
}.stateIn(
scope = this,
started = SharingStarted.Eagerly,
initialValue = ContactIndexes(emptyMap(), emptyMap(), emptyMap(), emptyMap())
)
init {
launch {
contactQueries.monitorContactsFlow(Dispatchers.Default).collect { list ->
_contactsList.value = list
}
}
}
suspend fun saveContact(contact: ContactInfo) = withContext(Dispatchers.Default) {
contactQueries.saveContact(contact)
}
suspend fun deleteContact(contactId: UUID) = withContext(Dispatchers.Default) {
contactQueries.deleteContact(contactId)
}
/**
* There's generally no need to query the database since we have everything in memory.
*/
fun contactForPayment(payment: WalletPayment, metadata: WalletPaymentMetadata?): ContactInfo? {
return contactIdForPayment(payment, metadata)?.let { contactId ->
contactForId(contactId)
}
}
fun contactForOffer(offer: OfferTypes.Offer): ContactInfo? {
return contactForOfferId(offer.offerId)
}
fun contactForPayerPubKey(payerPubKey: PublicKey): ContactInfo? {
return contactIdForPayerPubKey(payerPubKey)?.let { contactId ->
contactForId(contactId)
}
}
fun contactForLightningAddress(address: String): ContactInfo? {
return contactIdForLightningAddress(address)?.let { contactId ->
contactForId(contactId)
}
}
private fun contactForId(contactId: UUID): ContactInfo? {
return indexesFlow.value.contactsMap[contactId]
}
private fun contactForOfferId(offerId: ByteVector32): ContactInfo? {
return contactIdForOfferId(offerId)?.let { contactId ->
contactForId(contactId)
}
}
private fun contactIdForOfferId(offerId: ByteVector32): UUID? {
return indexesFlow.value.offersMap[offerId]
}
private fun contactIdForPayerPubKey(payerPubKey: PublicKey): UUID? {
return indexesFlow.value.publicKeysMap[payerPubKey]
}
private fun contactIdForLightningAddress(address: String): UUID? {
return indexesFlow.value.addressesMap[ContactAddress.hash(address)]
}
private fun contactIdForPayment(payment: WalletPayment, metadata: WalletPaymentMetadata?): UUID? {
return if (payment is Bolt12IncomingPayment) {
payment.incomingOfferMetadata()?.let { offerMetadata ->
contactIdForPayerPubKey(offerMetadata.payerKey)
}
} else {
metadata?.lightningAddress?.let { address ->
contactIdForLightningAddress(address)
} ?: payment.outgoingInvoiceRequest()?.let { invoiceRequest ->
contactIdForOfferId(invoiceRequest.offer.offerId)
}
}
}
/**
* Run this to migrate the contacts from the appDb to the paymentsDb.
* This function can be run everytime the app is launched.
*/
internal suspend fun migrateContactsIfNeeded(appDb: SqliteAppDb) = withContext(Dispatchers.Default) {
val result = fr.acinq.phoenix.db.migrations.appDb.v7.AfterVersion7(
appDbDriver = appDb.driver,
paymentsDbDriver = driver,
loggerFactory = loggerFactory
)
if (result == AfterVersion7Result.MigrationNowCompleted) {
delay(5.seconds)
// We updated the database directly, which skips the SqlDelight hooks.
// Which means things like `monitorContactsFlow()` won't get triggered.
// So we need to manually update the contactsList.
_contactsList.value = contactQueries.listContacts()
}
}
}
@@ -1,353 +0,0 @@
package fr.acinq.phoenix.db.migrations.appDb.v7
import app.cash.sqldelight.TransacterImpl
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import fr.acinq.bitcoin.byteVector32
import fr.acinq.bitcoin.utils.Try
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.debug
import fr.acinq.lightning.logging.info
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.lightning.wire.OfferTypes
import fr.acinq.phoenix.data.ContactInfo
import fr.acinq.phoenix.data.ContactOffer
import fr.acinq.phoenix.db.serialization.contacts.Serialization
import fr.acinq.phoenix.utils.extensions.toByteArray
enum class AfterVersion7Result {
MigrationWasAlreadyCompleted,
MigrationNowCompleted
}
fun AfterVersion7(
appDbDriver: SqlDriver,
paymentsDbDriver: SqlDriver,
loggerFactory: LoggerFactory
): AfterVersion7Result {
data class MetadataRow(
val id: String,
val recordCreation: Long,
val recordBlob: ByteArray
)
fun fetchMetadataBatch(): List<MetadataRow> {
return appDbDriver.executeQuery(
identifier = null,
sql = "SELECT * FROM cloudkit_contacts_metadata_old LIMIT 10;",
mapper = { cursor ->
val result = buildList {
while (cursor.next().value) {
val o = MetadataRow(
id = cursor.getString(0)!!,
recordCreation = cursor.getLong(1)!!,
recordBlob = cursor.getBytes(2)!!
)
add(o)
}
}
QueryResult.Value(result)
},
parameters = 0
).value
}
fun insertMetadataBatch(list: List<MetadataRow>) {
val driver: SqlDriver = paymentsDbDriver // avoid typos; always refer to correct driver
val transacter = object : TransacterImpl(driver) {}
transacter.transaction {
list.forEach { metadata ->
val exists = driver.executeQuery(
identifier = null,
sql = "SELECT COUNT(*) FROM cloudkit_contacts_metadata WHERE id = ?;",
mapper = { cursor ->
val count: Long = if (cursor.next().value) {
cursor.getLong(0) ?: 0
} else {
0
}
QueryResult.Value(count)
},
parameters = 1,
binders = {
bindString(0, metadata.id)
}
).value > 0
if (!exists) {
driver.execute(
identifier = null,
sql = "INSERT INTO cloudkit_contacts_metadata(id, record_creation, record_blob)\n" +
" VALUES (?, ?, ?);",
parameters = 4,
binders = {
bindString(0, metadata.id)
bindLong(1, metadata.recordCreation)
bindBytes(2, metadata.recordBlob)
}
)
}
}
}
}
fun deleteMetadataBatch(list: List<MetadataRow>) {
val driver: SqlDriver = appDbDriver // avoid typos; always refer to correct driver
val transacter = object : TransacterImpl(driver) {}
transacter.transaction {
list.forEach { metadata ->
driver.execute(
identifier = null,
sql = "DELETE FROM cloudkit_contacts_metadata_old WHERE id = ?;",
parameters = 1,
binders = {
bindString(0, metadata.id)
}
)
}
}
}
@Suppress("UNUSED_PARAMETER")
fun mapContact(
id: String,
name: String,
photo_uri: String?,
use_offer_key: Boolean,
created_at: Long,
updated_at: Long?
): ContactInfo {
val contactId = UUID.fromString(id)
return ContactInfo(
id = contactId,
name = name,
photoUri = photo_uri,
useOfferKey = use_offer_key,
offers = listOf(),
addresses = listOf()
)
}
@Suppress("UNUSED_PARAMETER")
fun mapOffer(
offer_id: ByteArray,
contact_id: String,
offer: String,
created_at: Long
): ContactOffer? {
return when (val result = OfferTypes.Offer.decode(offer)) {
is Try.Success -> ContactOffer(
id = offer_id.byteVector32(),
offer = result.get(),
label = null,
createdAt = created_at
)
is Try.Failure -> null
}
}
fun fetchContactsBatch(): List<ContactInfo> {
val driver: SqlDriver = appDbDriver // avoid typos; always refer to correct driver
val transacter = object : TransacterImpl(driver) {}
return transacter.transactionWithResult {
val batch = driver.executeQuery(
identifier = null,
sql = "SELECT id, name, photo_uri, use_offer_key, created_at, updated_at FROM contacts_old LIMIT 10;",
parameters = 0,
mapper = { cursor ->
val result = buildList {
while (cursor.next().value) {
val o = mapContact(
id = cursor.getString(0)!!,
name = cursor.getString(1)!!,
photo_uri = cursor.getString(2),
use_offer_key = cursor.getBoolean(3)!!,
created_at = cursor.getLong(4)!!,
updated_at = cursor.getLong(5)
)
add(o)
}
}
QueryResult.Value(result)
}
).value
batch.map { contact ->
val offers = driver.executeQuery(
identifier = null,
sql = "SELECT * FROM contact_offers_old WHERE contact_id = ?;",
mapper = { cursor ->
val result = buildList {
while (cursor.next().value) {
val o = mapOffer(
offer_id = cursor.getBytes(0)!!,
contact_id = cursor.getString(1)!!,
offer = cursor.getString(2)!!,
created_at = cursor.getLong(3)!!
)
if (o != null) {
add(o)
}
}
}
QueryResult.Value(result)
},
parameters = 1,
binders = {
bindString(0, contact.id.toString())
}
).value
contact.copy(offers = offers)
}
}
}
fun insertContactsBatch(contacts: List<ContactInfo>) {
val driver: SqlDriver = paymentsDbDriver // avoid typos; always refer to correct driver
val transacter = object : TransacterImpl(driver) {}
transacter.transaction {
contacts.forEach { contact ->
val exists = driver.executeQuery(
identifier = null,
sql = "SELECT COUNT(*) FROM contacts WHERE id = ?;",
mapper = { cursor ->
val count: Long = if (cursor.next().value) {
cursor.getLong(0) ?: 0
} else {
0
}
QueryResult.Value(count)
},
parameters = 1,
binders = {
bindBytes(0, contact.id.toByteArray())
}
).value > 0
if (!exists) {
driver.execute(
identifier = null,
sql = "INSERT INTO contacts(id, data, created_at, updated_at)\n" +
" VALUES (?, ?, ?, ?);",
parameters = 4,
binders = {
bindBytes(0, contact.id.toByteArray())
bindBytes(1, Serialization.serialize(contact))
bindLong(2, currentTimestampMillis())
// bindNull(3)
}
)
}
}
}
}
fun deleteContactsBatch(contacts: List<ContactInfo>) {
val driver: SqlDriver = appDbDriver // avoid typos; always refer to correct driver
val transacter = object : TransacterImpl(driver) {}
transacter.transaction {
contacts.forEach { contact ->
driver.execute(
identifier = null,
sql = "DELETE FROM contacts_old WHERE id = ?;",
parameters = 1,
binders = {
bindString(0, contact.id.toString())
}
)
}
}
}
fun existsTables(): Boolean {
return appDbDriver.executeQuery(
identifier = null,
sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='contacts_old';",
mapper = { cursor ->
val exists = cursor.next().value
QueryResult.Value(exists)
},
parameters = 0
).value
}
fun dropTables() {
val driver: SqlDriver = appDbDriver // avoid typos; always refer to correct driver
val transacter = object : TransacterImpl(driver) {}
return transacter.transaction {
driver.execute(
identifier = null,
sql = "DROP TABLE IF EXISTS cloudkit_contacts_metadata_old;",
parameters = 0
)
driver.execute(
identifier = null,
sql = "DROP TABLE IF EXISTS contact_offers_old;",
parameters = 0
)
driver.execute(
identifier = null,
sql = "DROP TABLE IF EXISTS contacts_old;",
parameters = 0
)
}
}
val log = loggerFactory.newLogger("migrations.appDb.AfterVersion7")
log.debug { "Checking tables..." }
if (!existsTables()) {
log.debug { "Tables already dropped. Migration must have previously completed." }
return AfterVersion7Result.MigrationWasAlreadyCompleted
}
while (true) {
log.debug { "Fetching metadata batch..." }
val metadataBatch = fetchMetadataBatch()
if (metadataBatch.isEmpty()) {
break
}
log.info { "Migrating metadata batch of ${metadataBatch.size}..." }
insertMetadataBatch(metadataBatch)
log.debug { "Deleting metadata batch of ${metadataBatch.size}..." }
deleteMetadataBatch(metadataBatch)
}
while (true) {
log.info { "Fetching contacts batch..." }
val contactsBatch = fetchContactsBatch()
if (contactsBatch.isEmpty()) {
break
}
log.info { "Migrating contacts batch of ${contactsBatch.size}..." }
insertContactsBatch(contactsBatch)
log.debug { "Deleting contacts batch of ${contactsBatch.size}..." }
deleteContactsBatch(contactsBatch)
}
log.debug { "Dropping tables..." }
dropTables()
log.info { "Completed AppDb v7 migration" }
return AfterVersion7Result.MigrationNowCompleted
}
@@ -1,100 +0,0 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.db.migrations.v10
import app.cash.sqldelight.db.AfterVersion
import app.cash.sqldelight.db.QueryResult
import fr.acinq.lightning.db.LegacyPayToOpenIncomingPayment
import fr.acinq.lightning.db.LegacySwapInIncomingPayment
import fr.acinq.lightning.db.LightningIncomingPayment
import fr.acinq.lightning.db.OnChainIncomingPayment
import fr.acinq.lightning.serialization.payment.Serialization
import fr.acinq.phoenix.db.migrations.v10.types.mapIncomingPaymentFromV10
import fr.acinq.phoenix.utils.extensions.deriveUUID
import fr.acinq.phoenix.utils.extensions.toByteArray
@OptIn(ExperimentalStdlibApi::class)
fun AfterVersion10(onError: (String) -> Unit) = AfterVersion(10) { driver ->
val payments = driver.executeQuery(
identifier = null,
sql = "SELECT * FROM incoming_payments",
parameters = 0,
mapper = { cursor ->
val result = buildList {
while (cursor.next().value) {
try {
val o = mapIncomingPaymentFromV10(
payment_hash = cursor.getBytes(0)!!,
preimage = cursor.getBytes(1)!!,
created_at = cursor.getLong(2)!!,
origin_type = cursor.getString(3)!!,
origin_blob = cursor.getBytes(4)!!,
received_amount_msat = cursor.getLong(5),
received_at = cursor.getLong(6),
received_with_type = cursor.getString(7),
received_with_blob = cursor.getBytes(8),
)
add(o)
} catch (e: Exception) {
onError("(v10) cannot migrate legacy incoming data: ${e.message}" +
"\n payment_hash=${cursor.getBytes(0)?.toHexString()} preimage=${cursor.getBytes(1)?.toHexString()} received=${cursor.getLong(5)}" +
"\n origin_type=${cursor.getString(3)} origin_blob=${cursor.getBytes(4)?.toHexString()}" +
"\n rec_w_type=${cursor.getString(7)} rec_w_blob=${cursor.getBytes(8)?.toHexString()}")
}
}
}
QueryResult.Value(result)
}
).value
driver.execute(identifier = null, sql = "DROP TABLE incoming_payments", parameters = 0)
payments
.forEach { payment ->
driver.execute(
identifier = null,
sql = "INSERT INTO payments_incoming (id, payment_hash, tx_id, created_at, received_at, data) VALUES (?, ?, ?, ?, ?, ?)",
parameters = 6
) {
when (payment) {
is LightningIncomingPayment -> {
bindBytes(0, payment.paymentHash.deriveUUID().toByteArray())
bindBytes(1, payment.paymentHash.toByteArray())
bindBytes(2, null)
}
is @Suppress("DEPRECATION") LegacyPayToOpenIncomingPayment -> {
bindBytes(0, payment.paymentHash.deriveUUID().toByteArray())
bindBytes(1, payment.paymentHash.toByteArray())
bindBytes(2, null)
}
is @Suppress("DEPRECATION") LegacySwapInIncomingPayment -> {
bindBytes(0, payment.id.toByteArray())
bindBytes(1, null)
bindBytes(2, null)
}
is OnChainIncomingPayment -> {
bindBytes(0, payment.id.toByteArray())
bindBytes(1, null)
bindBytes(2, payment.txId.value.toByteArray())
}
}
bindLong(3, payment.createdAt)
bindLong(4, payment.completedAt)
bindBytes(5, Serialization.serialize(payment))
}
}
}
@@ -1,40 +0,0 @@
/*
* Copyright 2022 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.phoenix.db.migrations.v10.json
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
abstract class AbstractStringSerializer<T>(
name: String,
private val toString: (T) -> String,
private val fromString: (String) -> T
) : KSerializer<T> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(name, PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: T) {
encoder.encodeString(toString(value))
}
override fun deserialize(decoder: Decoder): T {
return fromString(decoder.decodeString())
}
}
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff. Show more