Remove phoenix code
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-196
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
-119
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
-226
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-165
@@ -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
|
||||
}
|
||||
-14
@@ -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()
|
||||
}
|
||||
-90
@@ -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)
|
||||
}
|
||||
}
|
||||
-23
@@ -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))
|
||||
}
|
||||
-39
@@ -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")
|
||||
}
|
||||
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-44
@@ -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)
|
||||
}
|
||||
-31
@@ -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() {}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
-42
@@ -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()
|
||||
}
|
||||
}
|
||||
-172
@@ -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()
|
||||
}
|
||||
-34
@@ -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")
|
||||
}
|
||||
-26
@@ -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()
|
||||
}
|
||||
}
|
||||
-60
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
-64
@@ -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()
|
||||
|
||||
}
|
||||
-38
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
-60
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
-47
@@ -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()
|
||||
}
|
||||
-146
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
-46
@@ -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
|
||||
)
|
||||
}
|
||||
-68
@@ -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>
|
||||
-107
@@ -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>
|
||||
-37
@@ -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,
|
||||
)
|
||||
}
|
||||
-40
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
-353
@@ -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
|
||||
}
|
||||
-100
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
-40
@@ -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
Reference in new issue
Block a user